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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ When editing UI under `packages/owletto-web`, follow the design rules in @packag
#### Platform
All chat platforms (Telegram, Slack, Discord, WhatsApp, Teams) run through Chat SDK adapters in `packages/gateway/src/connections/`. Connections are created via the `/agents` admin UI or the connections CRUD API — no per-platform env vars. Each connection has a typed config schema (bot token for Telegram, signing secret + bot token for Slack, etc.). Gateway also exposes a public endpoint that triggers an agent run. Settings-page provider order is drag-sortable, with per-provider model selection inline.

**One transport per platform: webhooks via the Chat SDK adapter.** Don't add per-platform alternative transports (Slack Socket Mode, Telegram long-polling, Discord Gateway WebSocket bridges, etc.) or extra runtime SDKs to support them. Local dev for webhook-only platforms uses a tunnel (cloudflared / ngrok / Tailscale Funnel); Lobu Cloud users get a public URL for free. Sticking to the Chat SDK keeps one delivery story, one set of retries, and zero extra dependencies.

#### Orchestration
- **Embedded-only deployment.** Gateway, workers, embeddings, and the Owletto memory backend run in a single Node process (`lobu run`, or `bun run dev` in the monorepo). Workers spawn as `child_process.spawn` subprocesses on the same host; on Linux the spawn path uses `systemd-run --user --scope` for cgroup limits + IPAddressDeny + capability drops. There is no Docker or Kubernetes deployment manager.
- Postgres (with pgvector) is the only user-provided external. The Node process connects out via `DATABASE_URL`. Runtime state that previously lived in Redis (queues, chat connection rows, grant cache, MCP proxy sessions) is now in dedicated Postgres tables.
Expand Down
728 changes: 65 additions & 663 deletions bun.lock

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions db/migrations/20260503000000_agent_secrets_org_scope.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- migrate:up

-- agent_secrets used a global `(name)` namespace, so two organizations
-- storing the same key (e.g. `ANTHROPIC_API_KEY`) would silently overwrite
-- each other. Scope rows by organization while keeping legacy rows
-- addressable: empty-string organization_id means "global" (used by
-- system env-store and any pre-existing rows from the global era).
--
-- ROLLOUT NOTE: this migration replaces the primary key on `(name)` with
-- `(organization_id, name)` non-atomically. Lobu's deployment model is a
-- single embedded Node process with atomic swap (see "Embedded-only
-- deployment" in AGENTS.md), so old + new processes never run concurrently
-- against the same database. If you're running Lobu under a rolling-deploy
-- supervisor, stop traffic before applying this migration — old pods using
-- `ON CONFLICT (name)` will fail writes against the new schema.

ALTER TABLE public.agent_secrets
ADD COLUMN IF NOT EXISTS organization_id text NOT NULL DEFAULT '';

ALTER TABLE public.agent_secrets
DROP CONSTRAINT IF EXISTS agent_secrets_pkey;

ALTER TABLE public.agent_secrets
ADD CONSTRAINT agent_secrets_pkey PRIMARY KEY (organization_id, name);

CREATE INDEX IF NOT EXISTS agent_secrets_org_id_idx
ON public.agent_secrets (organization_id);

-- migrate:down

ALTER TABLE public.agent_secrets
DROP CONSTRAINT IF EXISTS agent_secrets_pkey;

DROP INDEX IF EXISTS public.agent_secrets_org_id_idx;

DELETE FROM public.agent_secrets a
USING public.agent_secrets b
WHERE a.name = b.name
AND a.organization_id <> ''
AND b.organization_id = '';

DELETE FROM public.agent_secrets a
USING public.agent_secrets b
WHERE a.name = b.name
AND a.organization_id > b.organization_id;

ALTER TABLE public.agent_secrets
ADD CONSTRAINT agent_secrets_pkey PRIMARY KEY (name);

ALTER TABLE public.agent_secrets
DROP COLUMN IF EXISTS organization_id;
12 changes: 10 additions & 2 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ CREATE TABLE public.agent_secrets (
ciphertext text NOT NULL,
expires_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
updated_at timestamp with time zone DEFAULT now() NOT NULL,
organization_id text DEFAULT ''::text NOT NULL
);


Expand Down Expand Up @@ -2508,7 +2509,7 @@ ALTER TABLE ONLY public.agent_grants
--

ALTER TABLE ONLY public.agent_secrets
ADD CONSTRAINT agent_secrets_pkey PRIMARY KEY (name);
ADD CONSTRAINT agent_secrets_pkey PRIMARY KEY (organization_id, name);


--
Expand Down Expand Up @@ -3166,6 +3167,13 @@ CREATE INDEX agent_grants_agent_id_idx ON public.agent_grants USING btree (agent
CREATE INDEX agent_secrets_expires_at_idx ON public.agent_secrets USING btree (expires_at) WHERE (expires_at IS NOT NULL);


--
-- Name: agent_secrets_org_id_idx; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX agent_secrets_org_id_idx ON public.agent_secrets USING btree (organization_id);


--
-- Name: agent_secrets_name_prefix_idx; Type: INDEX; Schema: public; Owner: -
--
Expand Down
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
"@scalar/hono-api-reference": "^0.9.39",
"@sentry/node": "^10.23.0",
"@sinclair/typebox": "^0.34.41",
"@slack/socket-mode": "^2.0.6",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"better-auth": "^1.4.10",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* AgentStore — unified interface for agent configuration storage.
*
* Implementations:
* - InMemoryAgentStore (default, populated from files or API)
* - Host-provided store (embedded mode, e.g. PostgresAgentStore in Owletto)
* - InMemoryAgentStore (SDK-embedded mode, populated from `GatewayConfig.agents`)
* - Host-provided store (embedded backend, e.g. PostgresAgentStore in Owletto)
*/

import type { PluginsConfig } from "./plugin-types";
Expand Down
1 change: 0 additions & 1 deletion packages/owletto-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
"@scalar/hono-api-reference": "^0.9.39",
"@sentry/node": "^9.0.0",
"@sinclair/typebox": "^0.34.41",
"@slack/socket-mode": "^2.0.6",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"better-auth": "^1.4.10",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,9 @@ describe("BaseDeploymentManager.syncNetworkConfigGrants", () => {
});

test("invalidateGrantSyncCache forces the next call to re-sync", async () => {
// Simulates the reload-from-files flow: an operator changes
// `networkConfig.allowedDomains` on disk, calls `reloadFromFiles`, and
// the next message should re-grant even if the cached hash says the
// set is unchanged.
// An operator changes `networkConfig.allowedDomains` for an agent (via
// `lobu apply` or the web UI) — the next message should re-grant even
// if the cached hash says the set is unchanged.
const grantSpy = spyOn(grantStore, "grant");

const payload = buildPayload({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,45 +217,3 @@ describe("CoreServices store selection", () => {
});
});

describe("CoreServices.reloadFromFiles listeners", () => {
test("invokes registered reload listeners with the reloaded agent ids", async () => {
const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import(
"node:fs"
);
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");

const projectDir = mkdtempSync(join(tmpdir(), "lobu-reload-listener-"));
try {
mkdirSync(join(projectDir, "agents", "bot"), { recursive: true });
writeFileSync(
join(projectDir, "lobu.toml"),
`
[agents.bot]
name = "bot"
dir = "./agents/bot"
`,
"utf-8"
);

const coreServices = new CoreServices(createGatewayConfig());
// Minimally prime reloadFromFiles: it only needs projectPath +
// the file-loaded agents slot. It tolerates missing store/settings
// manager — the inner `if` branches guard each step.
(coreServices as any).projectPath = projectDir;

const received: string[][] = [];
coreServices.onReloadFromFiles((agentIds) => {
received.push(agentIds);
});

const result = await coreServices.reloadFromFiles();

expect(result.reloaded).toBe(true);
expect(result.agents).toEqual(["bot"]);
expect(received).toEqual([["bot"]]);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
buildRegistryMap,
DeclaredAgentRegistry,
entryFromAgentConfig,
entryFromFileLoadedAgent,
} from "../services/declared-agent-registry.js";

describe("DeclaredAgentRegistry", () => {
Expand Down Expand Up @@ -45,29 +44,6 @@ describe("DeclaredAgentRegistry", () => {
});
});

describe("entryFromFileLoadedAgent", () => {
test("preserves settings and credentials from file loader", () => {
const entry = entryFromFileLoadedAgent({
agentId: "careops",
settings: {
installedProviders: [{ providerId: "gemini", installedAt: 5 }],
},
credentials: [
{ provider: "gemini", key: "k1" },
{ provider: "openai", secretRef: "vault://openai/key" },
],
} as any);

expect(entry.settings.installedProviders).toEqual([
{ providerId: "gemini", installedAt: 5 },
]);
expect(entry.credentials).toEqual([
{ provider: "gemini", key: "k1" },
{ provider: "openai", secretRef: "vault://openai/key" },
]);
});
});

describe("entryFromAgentConfig", () => {
test("expands providers into installed list, credentials, and model preferences", () => {
const entry = entryFromAgentConfig({
Expand Down Expand Up @@ -102,34 +78,26 @@ describe("entryFromAgentConfig", () => {
});

describe("buildRegistryMap", () => {
test("merges file and config sources, with config overriding on shared id", () => {
const map = buildRegistryMap(
[
{
agentId: "shared",
settings: {
installedProviders: [{ providerId: "z-ai", installedAt: 1 }],
},
credentials: [],
} as any,
{
agentId: "file-only",
settings: {},
credentials: [],
} as any,
],
[
{
id: "shared",
name: "Shared",
providers: [{ id: "openai", key: "sk-2" }],
} as any,
]
);
test("populates entries from SDK config agents", () => {
const map = buildRegistryMap([
{
id: "agent-a",
name: "Agent A",
providers: [{ id: "openai", key: "sk-1" }],
} as any,
{
id: "agent-b",
name: "Agent B",
providers: [{ id: "anthropic", key: "sk-2" }],
} as any,
]);

expect(map.get("file-only")).toBeDefined();
const shared = map.get("shared");
expect(shared?.settings.installedProviders?.[0]?.providerId).toBe("openai");
expect(shared?.credentials).toEqual([{ provider: "openai", key: "sk-2" }]);
expect(map.size).toBe(2);
expect(map.get("agent-a")?.credentials).toEqual([
{ provider: "openai", key: "sk-1" },
]);
expect(map.get("agent-b")?.credentials).toEqual([
{ provider: "anthropic", key: "sk-2" },
]);
});
});
Loading
Loading