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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (170 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (171 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1244,7 +1244,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 170 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 171 migrations</td></tr>
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
Expand Down
8 changes: 4 additions & 4 deletions llm.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo

- **Runtime:** Node.js `>=22.22.2 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 170 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 171 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
Expand Down Expand Up @@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
│ │ │ ├── secrets.ts # Secrets management
│ │ │ ├── stateReset.ts # State reset utilities
│ │ │ ├── migrationRunner.ts # Schema migration runner
│ │ │ └── migrations/ # 170 versioned SQL migration files
│ │ │ └── migrations/ # 171 versioned SQL migration files
│ │ ├── evals/ # Eval runner and scheduler
│ │ ├── memory/ # Persistent conversational memory
│ │ │ ├── extraction.ts # Memory extraction from conversations
Expand Down Expand Up @@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool

8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages.

9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 170 SQL migrations.
9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 171 SQL migrations.

10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`.

Expand Down Expand Up @@ -433,7 +433,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool

4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.

5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 170 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (122 domain-specific files, 171 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.

6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.

Expand Down
90 changes: 90 additions & 0 deletions open-sse/ag-ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* AG-UI — contrato de eventos agente→UI (Fase 7, Agent Console). Tipos + encoder SSE +
* validação de sequência. Puro (sem I/O). Permite replay/reconexão determinísticos: a UI
* reconstrói o estado a partir do fluxo de eventos ordenado.
*
* Segue o espírito do protocolo AG-UI (ciclo de run, deltas de texto, tool calls, estado).
*/

export type AgUiEventType =
| "RUN_STARTED"
| "TEXT_MESSAGE_START"
| "TEXT_MESSAGE_CONTENT"
| "TEXT_MESSAGE_END"
| "TOOL_CALL_START"
| "TOOL_CALL_ARGS"
| "TOOL_CALL_END"
| "STATE_SNAPSHOT"
| "STATE_DELTA"
| "RUN_FINISHED"
| "RUN_ERROR";

interface Base {
readonly seq: number; // ordem monotônica (para replay/reconexão)
readonly runId: string;
}

export type AgUiEvent =
| (Base & { type: "RUN_STARTED" })
| (Base & { type: "TEXT_MESSAGE_START"; messageId: string; role: "assistant" | "tool" })
| (Base & { type: "TEXT_MESSAGE_CONTENT"; messageId: string; delta: string })
| (Base & { type: "TEXT_MESSAGE_END"; messageId: string })
| (Base & { type: "TOOL_CALL_START"; toolCallId: string; name: string })
| (Base & { type: "TOOL_CALL_ARGS"; toolCallId: string; delta: string })
| (Base & { type: "TOOL_CALL_END"; toolCallId: string })
| (Base & { type: "STATE_SNAPSHOT"; state: Record<string, unknown> })
| (Base & { type: "STATE_DELTA"; patch: Record<string, unknown> })
| (Base & { type: "RUN_FINISHED" })
| (Base & { type: "RUN_ERROR"; message: string });

export const TERMINAL_EVENTS: ReadonlySet<AgUiEventType> = new Set(["RUN_FINISHED", "RUN_ERROR"]);

/** Codifica um evento como frame SSE (event: <type>\ndata: <json>\n\n). */
export function encodeSse(event: AgUiEvent): string {
return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
}

export interface SequenceValidation {
readonly ok: boolean;
readonly errors: string[];
}

/**
* Valida invariantes do fluxo (para catch de bugs de emissão, não de segurança):
* - `seq` estritamente crescente;
* - primeiro evento = RUN_STARTED e RUN_STARTED aparece SÓ no índice 0 (nunca reinicia no meio);
* - todos os eventos do MESMO runId (fluxo de um run só);
* - exatamente um terminal (RUN_FINISHED|RUN_ERROR), e ele é o último;
* - nenhum evento após o terminal.
*/
export function validateEventSequence(events: ReadonlyArray<AgUiEvent>): SequenceValidation {
const errors: string[] = [];
if (events.length === 0) return { ok: false, errors: ["fluxo vazio"] };

if (events[0].type !== "RUN_STARTED") errors.push("primeiro evento deve ser RUN_STARTED");
const runId = events[0].runId;

let lastSeq = -Infinity;
let terminalAt = -1;
events.forEach((e, i) => {
if (e.seq <= lastSeq) errors.push(`seq não crescente em ${i} (${e.seq})`);
lastSeq = e.seq;
if (e.type === "RUN_STARTED" && i !== 0) errors.push(`RUN_STARTED duplicado no índice ${i}`);
if (e.runId !== runId) errors.push(`runId inconsistente no índice ${i} (${e.runId})`);
if (TERMINAL_EVENTS.has(e.type)) {
if (terminalAt !== -1) errors.push(`múltiplos eventos terminais (índice ${i})`);
terminalAt = i;
}
});

if (terminalAt === -1) errors.push("faltou evento terminal (RUN_FINISHED|RUN_ERROR)");
else if (terminalAt !== events.length - 1) errors.push("há eventos após o terminal");

return { ok: errors.length === 0, errors };
}

/** Fábrica incremental de seq — ajuda emissores a produzir fluxos válidos. */
export function createSeq(start = 0): () => number {
let n = start;
return () => n++;
}
108 changes: 108 additions & 0 deletions open-sse/browser-guard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Browser Guard — política DETERMINÍSTICA para automação de navegador (Fase 7, "Browser Use").
*
* Desligado por padrão. Allowlist de domínio. Efeito externo (enviar form, download, upload,
* compra) exige aprovação humana. E — crucial contra prompt injection — uma ação cuja ORIGEM é a
* PÁGINA (texto lido) NUNCA escala permissão: efeitos externos originados na página são negados.
* O CÓDIGO decide (não a IA). Puro, sem I/O; o driver real (Playwright) fica fora deste módulo.
*/

export type BrowserActionKind =
| "navigate"
| "read"
| "click"
| "type"
| "submit" // enviar formulário
| "download"
| "upload"
| "purchase";

/** Origem da intenção: o usuário/agente (confiável) ou conteúdo lido da página (NÃO confiável). */
export type BrowserActionOrigin = "user" | "page";

export interface BrowserAction {
readonly kind: BrowserActionKind;
/** URL alvo (quando aplicável). Usada para checar a allowlist de domínio. */
readonly url?: string;
readonly origin: BrowserActionOrigin;
}

export interface BrowserPolicy {
/** Browser Use está ligado? Padrão do produto: OFF. */
readonly enabled: boolean;
/** Domínios permitidos (host exato ou sufixo, ex.: "example.com"). Vazio = nada permitido. */
readonly allowedDomains: ReadonlyArray<string>;
}

export type BrowserDecision = "allow" | "require_approval" | "deny";

export interface BrowserVerdict {
readonly decision: BrowserDecision;
readonly reason: string;
}

/** Ações com efeito externo — nunca automáticas: exigem humano (e nunca se originam da página). */
export const EXTERNAL_EFFECT_KINDS: ReadonlySet<BrowserActionKind> = new Set([
"submit",
"download",
"upload",
"purchase",
]);

function hostOf(url: string): string | null {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return null;
}
}

/** host casa a allowlist se for igual ou subdomínio de algum domínio permitido. */
function domainAllowed(host: string, allowed: ReadonlyArray<string>): boolean {
return allowed.some((d) => {
const dom = d.toLowerCase().replace(/^\.+/, "");
return host === dom || host.endsWith("." + dom);
});
}

/**
* Decide uma ação de navegador. Fail-closed:
* - browser OFF → deny;
* - URL fora da allowlist → deny;
* - efeito externo originado na PÁGINA → deny (prompt injection não escala);
* - efeito externo (origem usuário) → require_approval (humano);
* - navegação/leitura em domínio permitido → allow.
*/
export function decideBrowserAction(action: BrowserAction, policy: BrowserPolicy): BrowserVerdict {
if (!policy.enabled) {
return { decision: "deny", reason: "Browser Use está desligado (padrão)" };
}

if (action.url !== undefined) {
const host = hostOf(action.url);
if (!host) return { decision: "deny", reason: "URL inválida" };
if (!domainAllowed(host, policy.allowedDomains)) {
return { decision: "deny", reason: `domínio fora da allowlist: ${host}` };
}
}

const isExternal = EXTERNAL_EFFECT_KINDS.has(action.kind);

if (isExternal && action.origin === "page") {
// Instrução vinda do conteúdo da página tentando um efeito externo → bloqueio absoluto.
return {
decision: "deny",
reason: "efeito externo originado na página (possível prompt injection) — negado",
};
}

if (isExternal) {
return {
decision: "require_approval",
reason: `efeito externo (${action.kind}) requer aprovação humana`,
};
}

// Leitura/navegação/clique/digitação em domínio permitido.
return { decision: "allow", reason: "ação de leitura/navegação em domínio permitido" };
}
55 changes: 55 additions & 0 deletions open-sse/buzz-bridge/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Buzz Bridge — adaptador desabilitado + guarda de autorização.
*
* Enquanto a flag `buzz_hub` estiver OFF (ou o relay não estiver rodando), usamos o
* DisabledBuzzAdapter: ele NÃO conecta e NÃO publica — só reporta que está inerte. As
* entradas ficam no outbox até haver relay + flag ON e o WebSocketBuzzAdapter (futuro).
*/
import type {
BuzzAdapter,
BuzzEvent,
BuzzIdentityMapping,
BuzzSubscriptionFilter,
OutboxEntry,
} from "./types.ts";

export class DisabledBuzzAdapter implements BuzzAdapter {
readonly enabled = false;
async connect(): Promise<void> {
/* inerte: sem relay, sem conexão */
}
async publish(_entry: OutboxEntry): Promise<boolean> {
return false; // nada é publicado enquanto desabilitado
}
async subscribe(
_filter: BuzzSubscriptionFilter,
_onEvent: (e: BuzzEvent) => void
): Promise<void> {
/* inerte */
}
async close(): Promise<void> {
/* inerte */
}
}

/**
* Regra de segurança inegociável: uma chave Nostr (buzz_pubkey) NUNCA autoriza, por si só,
* uma ação no OmniRoute. A autorização real vem SEMPRE das políticas/aprovações do OmniRoute
* para o (tenant, workspace, user/agent) mapeado — nunca do fato de o evento estar assinado.
*
* Esta função é deliberadamente fail-closed: ela apenas confirma que existe um mapeamento
* de identidade; a decisão de permitir o efeito é do Policy Engine, fora daqui.
*/
export function buzzIdentityIsMapped(
mapping: BuzzIdentityMapping | undefined,
buzzPubkey: string
): boolean {
if (!mapping) return false;
if (!mapping.buzzPubkey || mapping.buzzPubkey !== buzzPubkey) return false;
return Boolean(mapping.tenantId && mapping.workspaceId);
}

/** Uma chave Nostr, sozinha, jamais autoriza. Documenta a regra em código executável. */
export function nostrKeyAuthorizes(): false {
return false;
}
36 changes: 36 additions & 0 deletions open-sse/buzz-bridge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Buzz Bridge — API pública (ponte OmniRoute ↔ Block Buzz), atrás da flag `buzz_hub` (OFF).
*
* Buzz é serviço SEPARADO (relay Nostr). Este módulo é a ponte tipada + idempotente; nada
* conecta enquanto desabilitado. Configuração e controle ficam no PAINEL ÚNICO do OmniRoute.
*/
export * from "./types.ts";
export { Outbox, Inbox } from "./outbox.ts";
export { DisabledBuzzAdapter, buzzIdentityIsMapped, nostrKeyAuthorizes } from "./adapter.ts";
export {
finalizeEvent,
verifyEvent,
getPublicKey,
generateSecretKey,
type SignedNostrEvent,
type UnsignedNostrEvent,
} from "./nostr.ts";
export { WebSocketBuzzAdapter, type WebSocketBuzzConfig } from "./wsAdapter.ts";

import { DisabledBuzzAdapter } from "./adapter.ts";
import type { BuzzAdapter } from "./types.ts";
import { WebSocketBuzzAdapter, type WebSocketBuzzConfig } from "./wsAdapter.ts";

/**
* Resolve o adaptador: inerte quando `buzz_hub` OFF ou sem config; WebSocketBuzzAdapter real
* (contra o buzz-relay) quando a flag está ON e há config (relayUrl + secretKey).
*/
export function resolveBuzzAdapter(
flagEnabled: boolean,
config?: WebSocketBuzzConfig
): BuzzAdapter {
if (!flagEnabled || !config?.relayUrl || !config?.secretKeyHex) {
return new DisabledBuzzAdapter();
}
return new WebSocketBuzzAdapter(config);
}
Loading
Loading