Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b538ec9
ci: preserve Strix failure evidence in OpenCode reviews
seonghobae Jun 12, 2026
f91487f
fix: address Strix review gate feedback
seonghobae Jun 12, 2026
3c315a1
fix: report Strix provider blockers concretely
seonghobae Jun 12, 2026
9179a46
fix: explain pending checks when OpenCode models fail
seonghobae Jun 12, 2026
51c4854
fix: explain cancelled Strix runs without logs
seonghobae Jun 12, 2026
45686f8
fix: anchor Strix provider blockers to config line
seonghobae Jun 12, 2026
31c5582
fix: scope failed-check evidence to Strix runs
seonghobae Jun 12, 2026
fb1580f
fix: cap OpenCode prompt evidence
seonghobae Jun 12, 2026
4b29cc8
fix: align OpenCode evidence self-test
seonghobae Jun 12, 2026
add611c
fix: add contextual workspace aria labels
seonghobae Jun 12, 2026
75bd5d7
fix: debounce network graph resize fitting
seonghobae Jun 12, 2026
ee48163
fix: reduce email grouping lookup overhead
seonghobae Jun 12, 2026
474b4e6
fix: include manual Strix evidence reruns
seonghobae Jun 13, 2026
9d48b7f
fix: validate configured CORS origins
seonghobae Jun 13, 2026
6f621d1
fix: expand checkbox label hit targets
seonghobae Jun 13, 2026
5a6c6ca
fix: harden session auth checks
seonghobae Jun 13, 2026
01a576b
fix: address review gate follow-ups
seonghobae Jun 13, 2026
341cb4b
fix: forward-port http-only browser sessions
seonghobae Jun 13, 2026
217b00c
fix: address strix session and scan findings
seonghobae Jun 13, 2026
6f15aaf
fix: fail closed on session claim fetch
seonghobae Jun 13, 2026
d0efcba
fix: require organization scope for browser session claims
seonghobae Jun 13, 2026
21c58d8
fix: reject non-source-backed opencode findings
seonghobae Jun 13, 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
155 changes: 137 additions & 18 deletions .github/workflows/opencode-review.yml

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on:
strix_llm:
description: Optional Strix model override for manual evidence runs
required: false
default: openai/openai/gpt-5
default: openai/gpt-5
type: string

concurrency:
Expand Down Expand Up @@ -117,7 +117,7 @@ jobs:
- name: Gate Strix secrets
id: gate
env:
STRIX_MODEL: ${{ github.event.inputs.strix_llm || 'openai/openai/gpt-5' }}
STRIX_MODEL: ${{ github.event.inputs.strix_llm || 'openai/gpt-5' }}
STRIX_OPENAI_API_KEY: ${{ secrets.STRIX_OPENAI_API_KEY }}
STRIX_VERTEX_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}
STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN }}
Expand All @@ -132,7 +132,8 @@ jobs:
;;
openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \
openai/openai/gpt-5* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]* | \
github_models/*)
github_models/openai/gpt-5* | github_models/openai/gpt-[6-9]* | github_models/openai/gpt-[1-9][0-9]* | \
github_models/deepseek/deepseek-r1-0528 | github_models/deepseek/deepseek-v3-0324)
echo 'enabled=true' >> "$GITHUB_OUTPUT"
echo 'provider_mode=github_models' >> "$GITHUB_OUTPUT"
sanitized_github_models_token="$(printf '%s' "$STRIX_GITHUB_MODELS_TOKEN" | tr -d '\r\n')"
Expand Down Expand Up @@ -252,7 +253,7 @@ jobs:
- name: Prepare Strix model input file
if: steps.gate.outputs.enabled == 'true'
env:
STRIX_MODEL: ${{ github.event.inputs.strix_llm || 'openai/openai/gpt-5' }}
STRIX_MODEL: ${{ github.event.inputs.strix_llm || 'openai/gpt-5' }}
run: |
umask 077
strix_llm_file="$RUNNER_TEMP/strix_llm.txt"
Expand All @@ -266,7 +267,8 @@ jobs:
;;
openai/gpt-5* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \
openai/openai/gpt-5* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]* | \
github_models/*)
github_models/openai/gpt-5* | github_models/openai/gpt-[6-9]* | github_models/openai/gpt-[1-9][0-9]* | \
github_models/deepseek/deepseek-r1-0528 | github_models/deepseek/deepseek-v3-0324)
printf '%s' "${strix_model#github_models/}" > "$strix_llm_file"
;;
openai/*)
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.ollama
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ RUN set -eux; \
sleep 1; \
done; \
if [ "${server_ready}" != "true" ]; then cat /tmp/ollama-build.log; exit 1; fi; \
ollama pull gemma4; \
ollama list | grep -E '^gemma4(:|[[:space:]])'; \
ollama pull gemma4:e2b-it-qat; \
ollama list | grep -E '^gemma4:e2b-it-qat[[:space:]]'; \
kill "$server_pid"; \
wait "$server_pid" 2>/dev/null || true; \
chown -R ollama:ollama /usr/share/ollama/.ollama
Expand Down
19 changes: 15 additions & 4 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ def _build_oidc_jwks_client() -> PyJWKClient | None:
SESSION_AUDIENCE = "naruon-api"
SESSION_SIGNING_ALGORITHM = "HS256"
OIDC_SIGNING_ALGORITHM = "RS256"
SESSION_ALLOWED_ALGORITHMS = (SESSION_SIGNING_ALGORITHM,)
OIDC_ALLOWED_ALGORITHMS = (OIDC_SIGNING_ALGORITHM,)
JWT_DECODE_REQUIRED_CLAIMS = ("exp", "iss", "aud")
MIN_SESSION_SECRET_BYTES = 32


Expand Down Expand Up @@ -224,7 +227,7 @@ def _oidc_unverified_header(token: str) -> dict[str, Any]:
except Exception:
raise _authentication_error() from None
_reject_unsupported_critical_headers(header)
if header.get("alg") != OIDC_SIGNING_ALGORITHM:
if header.get("alg") not in OIDC_ALLOWED_ALGORITHMS:
raise _authentication_error()
key_id = header.get("kid")
if not isinstance(key_id, str) or not key_id.strip():
Expand All @@ -243,9 +246,13 @@ def _decode_cached_oidc_session_payload(token: str) -> dict[str, Any]:
payload = jwt.decode(
token,
signing_key.key,
algorithms=[OIDC_SIGNING_ALGORITHM],
algorithms=OIDC_ALLOWED_ALGORITHMS,
audience=settings.OIDC_CLIENT_ID,
issuer=settings.OIDC_ISSUER_URL,
options={
"require": JWT_DECODE_REQUIRED_CLAIMS,
"verify_signature": True,
},
)
except jwt.PyJWTError:
continue
Expand Down Expand Up @@ -308,17 +315,21 @@ def _verify_signed_session_payload(
header = jwt.get_unverified_header(token)
except jwt.PyJWTError:
raise _authentication_error() from None
if header.get("alg") != SESSION_SIGNING_ALGORITHM:
if header.get("alg") not in SESSION_ALLOWED_ALGORITHMS:
raise _authentication_error()
_reject_unsupported_critical_headers(header)

try:
payload = jwt.decode(
token,
_session_secret_bytes(),
algorithms=[SESSION_SIGNING_ALGORITHM],
algorithms=SESSION_ALLOWED_ALGORITHMS,
audience=SESSION_AUDIENCE,
issuer=SESSION_ISSUER,
options={
"require": JWT_DECODE_REQUIRED_CLAIMS,
"verify_signature": True,
},
)
except jwt.PyJWTError:
raise _authentication_error()
Expand Down
22 changes: 14 additions & 8 deletions backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,14 @@ async def get_emails(
if message_is_from_user(email, user_addresses):
has_sent_message[group_key] = True

visible_groups = [
email
for group_key, email in grouped.items()
if folder != "sent" or has_sent_message.get(group_key, False)
]
if is_sent_folder:
visible_groups = [
email
for group_key, email in grouped.items()
if has_sent_message.get(group_key, False)
]
else:
visible_groups = list(grouped.values())
sorted_groups = sorted(visible_groups, key=lambda x: x.date, reverse=True)[:limit]

items = []
Expand Down Expand Up @@ -391,12 +394,15 @@ def _build_email_lookup_dicts(
by_fingerprint: dict[str, Email] = {}
for email_row in existing_emails:
for lookup_value in _email_message_lookup_values(email_row):
by_message_id.setdefault(lookup_value, email_row)
if lookup_value not in by_message_id:
by_message_id[lookup_value] = email_row
row_fingerprint = email_strong_fingerprint(email_row)
if row_fingerprint:
by_fingerprint.setdefault(row_fingerprint, email_row)
if row_fingerprint not in by_fingerprint:
by_fingerprint[row_fingerprint] = email_row
if email_row.fingerprint:
by_fingerprint.setdefault(email_row.fingerprint, email_row)
if email_row.fingerprint not in by_fingerprint:
by_fingerprint[email_row.fingerprint] = email_row
return by_message_id, by_fingerprint


Expand Down
15 changes: 7 additions & 8 deletions backend/api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
from sqlalchemy import select, func, union_all
from db.session import get_db
from db.models import Email, Attachment
from services.embedding import generate_embeddings
from services.embedding import STORAGE_EMBEDDING_DIMENSION, fit_embedding_vector, generate_embeddings
from api.auth import AuthContext, get_auth_context
from services.llm_provider_selection import resolve_runtime_llm_provider

router = APIRouter(prefix="/api")
logger = logging.getLogger(__name__)
SEARCH_VECTOR_DIMENSIONS = 1536
SEARCH_VECTOR_DIMENSIONS = STORAGE_EMBEDDING_DIMENSION


class SearchRequest(BaseModel):
Expand Down Expand Up @@ -199,12 +199,11 @@ async def hybrid_search(
base_url=runtime_provider.base_url,
model=runtime_provider.embedding_model,
)
query_embedding = embeddings[0] if embeddings else None
if (
query_embedding is not None
and len(query_embedding) != SEARCH_VECTOR_DIMENSIONS
):
query_embedding = None
query_embedding = (
fit_embedding_vector(embeddings[0], SEARCH_VECTOR_DIMENSIONS)
if embeddings
else None
)

owner_filters = email_owner_filters(
target_user_id, auth_context.organization_id
Expand Down
24 changes: 24 additions & 0 deletions backend/api/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel

from api.auth import AuthContext, get_auth_context


router = APIRouter(prefix="/api/auth", tags=["auth"])


class SessionResponse(BaseModel):
user_id: str
organization_id: str | None
workspace_id: str


@router.get("/session", response_model=SessionResponse)
async def current_session(
auth_context: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
return SessionResponse(
user_id=auth_context.user_id,
organization_id=auth_context.organization_id,
workspace_id=auth_context.workspace_id,
)
57 changes: 56 additions & 1 deletion backend/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, cast
from urllib.parse import urlsplit

from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Expand All @@ -8,6 +9,52 @@
)
from core.url_validation import parse_allowed_hosts, validate_https_url_host

DEFAULT_ORIGIN_PORTS = {
"http": 80,
"https": 443,
}


def canonical_origin(scheme: str, hostname: str, port: int | None) -> str:
normalized_scheme = scheme.lower()
normalized_host = hostname.lower()
if ":" in normalized_host and not normalized_host.startswith("["):
normalized_host = f"[{normalized_host}]"
default_port = DEFAULT_ORIGIN_PORTS.get(normalized_scheme)
port_suffix = f":{port}" if port is not None and port != default_port else ""
return f"{normalized_scheme}://{normalized_host}{port_suffix}"


def parse_allowed_cors_origins(raw_origins: str) -> list[str]:
origins: list[str] = []
for raw_origin in raw_origins.split(","):
origin = raw_origin.strip()
if not origin:
continue
if "*" in origin:
raise ValueError("ALLOWED_CORS_ORIGINS must not include wildcards")

parsed = urlsplit(origin)
if parsed.scheme.lower() not in {"http", "https"}:
raise ValueError("ALLOWED_CORS_ORIGINS entries must use http or https")
if parsed.username or parsed.password:
raise ValueError("ALLOWED_CORS_ORIGINS entries must not include userinfo")
if not parsed.netloc or not parsed.hostname:
raise ValueError("ALLOWED_CORS_ORIGINS entries must include a host")
if parsed.path or parsed.query or parsed.fragment:
raise ValueError(
"ALLOWED_CORS_ORIGINS entries must be origins without path, query, or fragment"
)
try:
port = parsed.port
except ValueError as exc:
raise ValueError(
"ALLOWED_CORS_ORIGINS entries must include a valid port"
) from exc

origins.append(canonical_origin(parsed.scheme, parsed.hostname, port))
return origins


class Settings(BaseSettings):
DATABASE_URL: str
Expand All @@ -24,7 +71,9 @@ class Settings(BaseSettings):
ALLOWED_POP3_PORTS: str = "995"
ALLOWED_LLM_BASE_URL_HOSTS: str = ""
ALLOW_LOCAL_LLM_PROVIDERS: bool = False
ALLOWED_CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:8000,http://127.0.0.1:8000"
ALLOWED_CORS_ORIGINS: str = (
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:8000,http://127.0.0.1:8000"
)
ENABLE_PROMETHEUS_METRICS: bool = False
DATA_REGION: str = "kr"
SECONDARY_DATA_REGION: str = "eu"
Expand Down Expand Up @@ -55,6 +104,8 @@ class Settings(BaseSettings):

@model_validator(mode="after")
def validate_session_secret(self) -> "Settings":
parse_allowed_cors_origins(self.ALLOWED_CORS_ORIGINS)

configured = self.AUTH_SESSION_HMAC_SECRET
if configured is None:
raise ValueError(
Expand Down Expand Up @@ -96,5 +147,9 @@ def validate_session_secret(self) -> "Settings":
)
return self

@property
def ALLOWED_CORS_ORIGINS_LIST(self) -> list[str]:
return parse_allowed_cors_origins(self.ALLOWED_CORS_ORIGINS)


settings = Settings(**cast(dict[str, Any], {})) # type: ignore
13 changes: 10 additions & 3 deletions backend/import_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
from db.session import AsyncSessionLocal
from db.models import Email, Attachment
from services.email_parser import parse_eml
from services.embedding import generate_embeddings
from services.embedding import (
STORAGE_EMBEDDING_DIMENSION,
fit_embedding_vector,
generate_embeddings,
)
from services.threading_service import assign_thread_id
import os
from sqlalchemy import select

EMBEDDING_DIMENSION = 1536
EMBEDDING_DIMENSION = STORAGE_EMBEDDING_DIMENSION
IMPORT_USER_ID = os.environ.get("NARUON_IMPORT_USER_ID", "default")
IMPORT_ORGANIZATION_ID = os.environ.get("NARUON_IMPORT_ORGANIZATION_ID", "default")

Expand All @@ -24,7 +28,10 @@ async def generate_fixture_embedding(text: str) -> list[float]:
if not openai_api_key:
return [0.0] * EMBEDDING_DIMENSION

return (await generate_embeddings([text], openai_api_key=openai_api_key))[0]
embeddings = await generate_embeddings([text], openai_api_key=openai_api_key)
if not embeddings:
return [0.0] * EMBEDDING_DIMENSION
return fit_embedding_vector(embeddings[0], EMBEDDING_DIMENSION)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


async def import_eml_file(session, eml_file: Path) -> bool:
Expand Down
Loading
Loading