Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
17e36c3
feat(noema): route in-process decisions through contextual-orchestrator
cursoragent Aug 16, 2026
f59ab2d
test(noema): lock orchestrator-only LLM routing
cursoragent Aug 16, 2026
15ec8c8
fix(noema): keep first-slice orchestrator routing only
cursoragent Aug 16, 2026
20a95af
test(noema): prove gateway client failures fail closed
seonghobae Aug 16, 2026
38f4e97
fix(noema): fail closed when orchestrator URL is rejected
cursoragent Aug 17, 2026
a8eec44
merge(develop): refresh Noema orchestrator slice
cursoragent Aug 17, 2026
67e7231
Merge branch 'develop' into cursor/noema-orchestrator-decision-agent-…
seonghobae Aug 17, 2026
5f374ff
test(noema): cover rejected gateway client paths
seonghobae Aug 19, 2026
1fc2733
feat(noema): add signed gateway settings (#1425)
seonghobae Aug 20, 2026
7f18343
fix(noema): classify encryption configuration errors
seonghobae Aug 20, 2026
2660132
test(noema): cover wrapped encryption failures
seonghobae Aug 20, 2026
c8890a7
test(secrets): assert typed encryption configuration error
seonghobae Aug 20, 2026
7c29c9e
chore: sync Noema gateway branch with develop
seonghobae Aug 20, 2026
93dcfb9
fix(noema): handle encryption errors during config reads
seonghobae Aug 20, 2026
414cb60
style(noema): remove trailing blank line
seonghobae Aug 20, 2026
c0fbc32
fix(noema): keep gateway DNS validation off event loop
seonghobae Aug 20, 2026
4b4e7ac
fix: remove unreachable noema response
seonghobae Aug 20, 2026
0fd3301
Merge remote-tracking branch 'origin/develop' into codex/pr1384-recon…
seonghobae Aug 21, 2026
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
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,35 @@ in this repo.
add further `os.getenv` secret reads, and migrate toward the KV pattern as it
is adopted.

### Noema LLM routing (orchestrator-only)

- **Noema's LLM path is contextual-orchestrator only.**
`services.noema_agent:run_noema_agent` must not call
`resolve_runtime_llm_provider`, must not pick a tenant `OpenAIChatModel` /
`gpt-4o`, and must not copy draft email-writing clients that require a
tenant `model_profile_id`. Send the single model alias
`contextual-orchestrator` to a dedicated gateway inference token + HTTPS
`/v1` base URL from the Fernet tenant KV (`noema_orchestrator_token`,
`noema_orchestrator_base_url`). Catalog files
(`registered_agents.json` `provider_source=contextual-orchestrator`,
`task_agent_mapping.json`) stay catalog-only; do not wire Decision Points
or `mail.triage` dispatchers in the same slice.
- **Do not sequentially fail over** to the next agent or model inside naruon
or Noema. Do not copy OpenCode sidecar model lists. Never use
`COPILOT_GITHUB_TOKEN` or GitHub Models for Noema.
- **Upstream provider keys stay in the orchestrator KV**, not in naruon at
request time: `NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`,
`BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`. List prices for
free-but-priced models are stored in the orchestrator, not here. Do not
reimplement the orchestrator catalog in this repo. Keep the existing
owner-scoped tools and opt-in writeback surface.

- Noema gateway setup uses the signed `GET`/`PUT /api/noema-gateway` route.
It is scoped to the authenticated `(user_id, organization_id)` pair, stores
the token through `EncryptedString`, returns only `has_token` readiness, and
records generic audit events. Do not add target-user or mailbox credential
fields to this route without a separate membership/delegation ADR.

### This repo's role in the ecosystem

- **This repo (naruon) is the ECOSYSTEM HUB:** email/PIM that DOM-decomposes
Expand Down
13 changes: 13 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ syntax, no userinfo/query or fragment, exact host membership in
Missing allowlist configuration fails closed; the default provider path should
leave `base_url` unset.

## Noema LLM routing

Noema remains the in-process general agent (`registered_agents.json` →
`services.noema_agent:run_noema_agent`) with the existing owner-scoped
tools and opt-in writeback surface. Its LLM calls go only to
**contextual-orchestrator**: dedicated Fernet-KV inference token, HTTPS
base URL ending in `/v1`, and the single model alias
`contextual-orchestrator`. Catalog mappings (`mail.triage`, and the rest)
are catalog-only; they are not a live Decision Points dispatcher. naruon
does not hold upstream provider keys at request time and does not
sequentially fail over models. Design:
[`docs/architecture/noema-decision-agent.md`](docs/architecture/noema-decision-agent.md).

## Batch embedding routing boundary

Bulk, latency-tolerant embedding work (email import, backfills) does not call a
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
## [Unreleased]
- **Noema gateway setup:** added signed-session `GET`/`PUT /api/noema-gateway`
settings with HTTPS `/v1` allowlist validation, Fernet-backed token storage,
masked readiness responses, and generic audit records. The route keeps the
existing per-user organization scope and does not expose gateway tokens.
Doctoring records the OWASP ASVS 5.0.0 and NIST SP 800-63B-4 evidence mapping.
- **Noema LLM routing through contextual-orchestrator.**
`run_noema_agent` no longer calls `resolve_runtime_llm_provider` or a
tenant `gpt-4o` chat model. Completions go to the orchestrator gateway
(dedicated Fernet-KV inference token `noema_orchestrator_token`, HTTPS
`/v1` URL `noema_orchestrator_base_url`, model alias
`contextual-orchestrator`). Catalog `provider_source` is
`contextual-orchestrator`. Existing tools, owner-scope, and opt-in
writeback stay. This slice does not add a Decision Points / `mail.triage`
dispatcher. naruon does not sequentially fail over models and does not
read upstream provider keys (`NVIDIA_NIM_API_KEY`, `BYTEZ_API_KEY`,
`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `COPILOT_GITHUB_TOKEN`) at
request time. See `docs/architecture/noema-decision-agent.md`.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ indexes, and auditable writeback intent. `ARCHITECTURE.md` and
```
Next.js frontend ──> FastAPI backend (control plane) ──> Postgres + pgvector
├──> Noema agent LLM ──> contextual-orchestrator (/v1)
├──> OpenAI-compatible LLM providers (Ollama locally)
└──> outbound-only self-hosted connector (connector/)
└──> customer IMAP/POP3/SMTP + CalDAV/CardDAV/WebDAV
Expand All @@ -121,6 +122,12 @@ Next.js frontend ──> FastAPI backend (control plane) ──> Postgres + pgve
(`services/threading_service.py` is the only thread-id assignment owner),
vector search, AI summaries, ticket tasks, and server-authoritative
calendar/WebDAV writeback intents. Authorization is deny-first RBAC + ABAC.
In-process **Noema** (`services/noema_agent.py`) keeps the existing
owner-scoped tool surface; its LLM calls go only to
contextual-orchestrator (model alias `contextual-orchestrator`, dedicated
gateway token + HTTPS `/v1` URL from the Fernet KV). naruon does not hold
upstream provider keys, pick tenant `gpt-4o`, or sequentially fail over
models. Catalog mappings are not a live dispatcher.
- `frontend/` — Next.js workspace shell (Today dashboard, Mail, Calendar,
Tasks, Projects, Context Search, AI Hub, Data, Security, Settings). Browser
writes go through the same-origin `/api/*` proxy, which converts the HttpOnly
Expand Down
54 changes: 54 additions & 0 deletions backend/alembic/versions/0018_noema_orch_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""add noema contextual-orchestrator gateway columns

Revision ID: 0018_noema_orch_gateway
Revises: 0017_merge_newsdom_carddav_heads
Create Date: 2026-08-16 00:00:00.000000

Noema judgments call only the contextual-orchestrator OpenAI-compatible
gateway. The dedicated inference token and HTTPS ``/v1`` base URL live on
``tenant_configs`` in the Fernet KV (token is EncryptedString). naruon does
not store upstream provider keys for this path.
"""

from alembic import op
import sqlalchemy as sa

revision = "0018_noema_orch_gateway"
down_revision = "0017_merge_newsdom_carddav_heads"
branch_labels = None
depends_on = None

_TENANT_TABLE = "tenant_configs"


def upgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if not inspector.has_table(_TENANT_TABLE):
return
for column in _noema_gateway_columns():
if not _has_column(inspector, _TENANT_TABLE, column.name):
op.add_column(_TENANT_TABLE, column)


def downgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if not inspector.has_table(_TENANT_TABLE):
return
for column in reversed(_noema_gateway_columns()):
if _has_column(inspector, _TENANT_TABLE, column.name):
op.drop_column(_TENANT_TABLE, column.name)


def _noema_gateway_columns() -> list["sa.Column"]:
return [
sa.Column("noema_orchestrator_base_url", sa.String(), nullable=True),
sa.Column("noema_orchestrator_token", sa.String(), nullable=True),
]


def _has_column(inspector, table_name: str, column_name: str) -> bool:
return any(
column["name"] == column_name for column in inspector.get_columns(table_name)
)
192 changes: 192 additions & 0 deletions backend/api/noema_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Signed-session settings for the per-user Noema gateway credential."""

from __future__ import annotations

import hashlib

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy.exc import StatementError
from sqlalchemy.ext.asyncio import AsyncSession

from api.auth import AuthContext, get_auth_context
from core.runtime_secrets import EncryptionConfigurationError
from db.models import AuditLog, SecurityAuditEvent, TenantConfig
from db.session import get_db
from services.llm_provider_urls import validate_llm_provider_base_url_async
from services.orchestrator_gateway import validate_orchestrator_gateway_url
from services.tenant_config_scope import (
get_scoped_tenant_config,
new_scoped_tenant_config,
)

router = APIRouter(prefix="/api/noema-gateway", tags=["noema-gateway"])


class NoemaGatewayUpdate(BaseModel):
"""Optional values for the signed-session user's Noema gateway."""

model_config = ConfigDict(extra="forbid")

base_url: str | None = None
token: str | None = None


class NoemaGatewayResponse(BaseModel):
"""Safe gateway state that never returns the Fernet-protected token."""

base_url: str | None = None
configured: bool = False
has_token: bool = False


def _resource_uid(auth_context: AuthContext) -> str:
"""Return a stable, non-secret audit identifier for the scoped setting."""
scope = f"{auth_context.organization_id or ''}:{auth_context.user_id}"
digest = hashlib.sha256(scope.encode("utf-8")).hexdigest()[:16]
return f"noema_gateway:{digest}"


async def _validated_base_url(value: str) -> str:
"""Validate the HTTPS /v1 shape and the configured global-host policy."""
try:
shaped_url = validate_orchestrator_gateway_url(value)
normalized_url = await validate_llm_provider_base_url_async(shaped_url)
if not normalized_url:
raise ValueError("gateway host is not allowlisted")
return validate_orchestrator_gateway_url(normalized_url)
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="Noema gateway base URL is not allowed",
) from exc


def _clean_token(value: str | None) -> str | None:
"""Normalize a submitted token without recording or returning its value."""
if value is None:
return None
token = value.strip()
if not token or token == "*" * 8:
return None
if any(ord(character) < 32 or ord(character) == 127 for character in token):
raise HTTPException(status_code=422, detail="Noema gateway token is invalid")
return token


def _response(config: TenantConfig | None) -> NoemaGatewayResponse:
"""Build a response containing only non-secret gateway state."""
if config is None:
return NoemaGatewayResponse()
has_token = bool(config.noema_orchestrator_token)
return NoemaGatewayResponse(
base_url=config.noema_orchestrator_base_url,
configured=bool(config.noema_orchestrator_base_url and has_token),
has_token=has_token,
)


def _is_encryption_configuration_error(error: BaseException) -> bool:
"""Recognize direct or SQLAlchemy-wrapped encryption configuration errors."""
if isinstance(error, EncryptionConfigurationError):
return True
return isinstance(error, StatementError) and isinstance(
error.orig, EncryptionConfigurationError
)


@router.get("", response_model=NoemaGatewayResponse)
async def get_noema_gateway(
db: AsyncSession = Depends(get_db),
auth_context: AuthContext = Depends(get_auth_context),
) -> NoemaGatewayResponse:
"""Return the signed-session user's scoped gateway readiness state."""
try:
config = await get_scoped_tenant_config(
db, auth_context.user_id, auth_context.organization_id
)
return _response(config)
except Exception as exc:
if not _is_encryption_configuration_error(exc):
raise
raise HTTPException(
status_code=503,
detail="Server encryption key is not configured. Contact your workspace administrator.",
) from exc


@router.put("", response_model=NoemaGatewayResponse)
async def update_noema_gateway(
update: NoemaGatewayUpdate,
db: AsyncSession = Depends(get_db),
auth_context: AuthContext = Depends(get_auth_context),
) -> NoemaGatewayResponse:
"""Persist the current user's gateway settings with an auditable change."""
updates = update.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=422, detail="No gateway settings supplied")

try:
config = await get_scoped_tenant_config(
db, auth_context.user_id, auth_context.organization_id
)
if config is None:
config = new_scoped_tenant_config(
user_id=auth_context.user_id,
organization_id=auth_context.organization_id,
)
db.add(config)

if "token" in updates:
token = _clean_token(updates["token"])
if token is not None:
config.noema_orchestrator_token = token
elif not config.noema_orchestrator_token:
raise HTTPException(
status_code=422, detail="Noema gateway token is required"
)

if "base_url" in updates:
config.noema_orchestrator_base_url = await _validated_base_url(
updates["base_url"] or ""
)

if not config.noema_orchestrator_base_url or not config.noema_orchestrator_token:
raise HTTPException(
status_code=422,
detail="Noema gateway base URL and token are required",
)

resource_uid = _resource_uid(auth_context)
db.add(
AuditLog(
user_id=auth_context.user_id,
action="update",
resource_type="noema_gateway",
resource_id=resource_uid,
details="Updated Noema gateway settings",
)
)
db.add(
SecurityAuditEvent(
actor_user_id=auth_context.user_id,
actor_role=auth_context.role,
organization_id=auth_context.organization_id,
workspace_id=auth_context.workspace_id,
event_action="update",
resource_type="noema_gateway",
resource_uid=resource_uid,
evidence_source="api.noema_config",
detail_text="Updated Noema gateway settings",
)
)
await db.commit()
return _response(config)
except Exception as exc:
if not _is_encryption_configuration_error(exc):
raise
raise HTTPException(
status_code=503,
detail="Server encryption key is not configured. Contact your workspace administrator.",
) from exc
return _response(config)
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
6 changes: 5 additions & 1 deletion backend/core/runtime_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
ENCRYPTION_KEY_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")


class EncryptionConfigurationError(RuntimeError):
"""Raised when the runtime cannot encrypt or decrypt protected settings."""


@dataclass(frozen=True)
class RuntimeEncryptionKey:
key_id: str
Expand Down Expand Up @@ -147,7 +151,7 @@ def build_encryption_keyring(
previous_keys_value: str | None = None,
) -> EncryptionKeyRing:
if active_key_value is None or not active_key_value.strip():
raise RuntimeError(
raise EncryptionConfigurationError(
"ENCRYPTION_KEY is required. Refusing to encrypt without a configured key."
)

Expand Down
10 changes: 10 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,16 @@ class TenantConfig(Base):
batch_attribution_group: Mapped[str | None] = mapped_column(String, nullable=True)
batch_attribution_company: Mapped[str | None] = mapped_column(String, nullable=True)

# Dedicated contextual-orchestrator inference gateway for in-process Noema
# judgments. Token is EncryptedString (Fernet KV); URL is SSRF-guarded at
# call time. Upstream provider keys stay in the orchestrator KV.
noema_orchestrator_base_url: Mapped[str | None] = mapped_column(
String, nullable=True
)
noema_orchestrator_token: Mapped[str | None] = mapped_column(
EncryptedString, nullable=True
)

def __repr__(self) -> str:
return (
f"<TenantConfig(id={self.id}, user_id='{self.user_id}', "
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from api.ai_hub import router as ai_hub_router
from api.projects import router as projects_router
from api.session import router as auth_session_router
from api.noema_config import router as noema_config_router
from core.config import canonical_origin, settings
from core.telemetry import setup_telemetry
from core.version import get_release_version
Expand Down Expand Up @@ -239,6 +240,7 @@ async def add_security_headers(request: Request, call_next):
app.include_router(ai_hub_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(projects_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(auth_session_router, dependencies=PRIVATE_API_DEPENDENCIES)
app.include_router(noema_config_router, dependencies=PRIVATE_API_DEPENDENCIES)


@app.get("/")
Expand Down
Loading
Loading