Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cf78f28
feat: harden live macOS runtime and governance
Aug 11, 2026
30323d4
fix: address PR review findings
Aug 11, 2026
cf8c21e
fix: execute trusted archive validation safely
Aug 11, 2026
9092b22
fix: harden live mail import embeddings and locks
seonghobae Aug 12, 2026
9f4d44a
Merge branch 'develop' into codex/naruon-live-audit
opencode-agent[bot] Aug 12, 2026
a57f8a7
fix: embed semantic mail segments before pooling
seonghobae Aug 12, 2026
44563f8
fix: preserve migration history and enforce token-safe embedding chunks
seonghobae Aug 12, 2026
0b5f447
fix: keep migration identifiers within Alembic limits
seonghobae Aug 12, 2026
a6f36eb
fix: align token-weighted regression expectations
seonghobae Aug 12, 2026
844a3cb
test: cover canonical read state and UTF-8 token boundaries
seonghobae Aug 12, 2026
d225700
fix: preserve canonical migration and Unicode embedding text
seonghobae Aug 12, 2026
8cc7e16
test: require model-native tokenizer for local embeddings
seonghobae Aug 12, 2026
7306930
fix: use provider-native tokenization for local embeddings
seonghobae Aug 12, 2026
1d4483f
test: keep embedding helper after imports
seonghobae Aug 12, 2026
d0920c2
fix: send provider auth to native tokenizer endpoints
seonghobae Aug 12, 2026
8c0a654
fix: normalize weighted embedding pooling
seonghobae Aug 12, 2026
e78179a
test: cover published migration and remote embedding fallback
seonghobae Aug 12, 2026
071a9d6
fix: preserve migration contract and fallback tokenizer
seonghobae Aug 12, 2026
ab0ab18
test: keep published and canonical migration contracts distinct
seonghobae Aug 12, 2026
805e715
Merge branch 'develop' into codex/naruon-live-audit
opencode-agent[bot] Aug 12, 2026
e22b1f9
Merge branch 'develop' into codex/naruon-live-audit
opencode-agent[bot] Aug 13, 2026
19408ba
Merge branch 'develop' into codex/naruon-live-audit
opencode-agent[bot] Aug 15, 2026
10ed194
Merge branch 'develop' into codex/naruon-live-audit
opencode-agent[bot] Aug 17, 2026
714a2e5
Merge branch 'develop' into codex/naruon-live-audit
seonghobae Aug 17, 2026
5dcd313
fix(governance): extract trusted gate at workspace root
seonghobae Aug 20, 2026
b6040d1
chore: sync live audit branch with develop
seonghobae Aug 20, 2026
8772ca3
fix(embedding): validate provider results and cleanup
seonghobae Aug 20, 2026
248652d
test(embedding): cover tokenizer failure contracts
seonghobae Aug 20, 2026
25d8019
Merge branch 'develop' into codex/naruon-live-audit
opencode-agent[bot] Aug 21, 2026
1b422f1
Merge branch 'develop' into codex/naruon-live-audit
seonghobae Aug 26, 2026
af362d5
fix(import): retain physical leases across item transactions
seonghobae Sep 6, 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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ ENABLE_PROMETHEUS_METRICS=false

# AI features. Leave blank to disable LLM/embedding-backed flows locally.
OPENAI_API_KEY=
OPENAI_EMBEDDING_BASE_URL=
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
OPENAI_MODEL=gpt-4o

Expand All @@ -20,11 +21,13 @@ ALLOW_DOCKER_BACKEND_INTERNAL_URL=1
# 기본 .env는 공유값을 유지하고, 로컬 모델 경로는 오버레이에서만 바꿔 적용합니다.
# Linux 기본 경로에서는 docker-compose.yml의 ollama 서비스를 계속 사용합니다.
#NARUON_MLX_OPENAI_API_KEY=mlx
#NARUON_MLX_BASE_URL=http://host.docker.internal:11434/v1
#NARUON_MLX_BASE_URL=http://host.docker.internal:8080/v1
#NARUON_MLX_EMBEDDING_BASE_URL=http://host.docker.internal:8082/v1
#NARUON_MLX_ALLOWED_LLM_BASE_URL_HOSTS=localhost,127.0.0.1,host.docker.internal
#NARUON_MLX_EMBEDDING_MODEL=embeddinggemma
#NARUON_MLX_LLM_MODEL=gemma4:e2b-it-qat
#NARUON_MLX_LLM_MODEL=mlx-community/gemma-4-e4b-it-4bit
#NARUON_MLX_EXTRA_HOSTS=host-gateway
#NARUON_LLAMA_CPP_EMBEDDING_BASE_URL=http://host.docker.internal:8082/v1

# Optional host bind overrides for local conflict cases when running with a custom override.
# Format is host:container, e.g. 127.0.0.1:3000
Expand Down
79 changes: 72 additions & 7 deletions .github/workflows/pr-governance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,34 @@ jobs:
}

trusted_ref=""
if [[ "${TRUSTED_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]]; then
trusted_ref="$TRUSTED_BASE_SHA"
echo "Using event-provided trusted governance base SHA ${trusted_ref}."
elif [ -n "$TRUSTED_PR_NUMBER" ]; then
trusted_ref="$(gh_api_with_retry "repos/${GITHUB_REPOSITORY}/pulls/${TRUSTED_PR_NUMBER}" --jq '.base.sha')"
if [ -n "$TRUSTED_PR_NUMBER" ]; then
if ! [[ "$TRUSTED_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::Trusted pull request number must be a positive integer." >&2
exit 1
fi
api_base_sha="$(gh_api_with_retry "repos/${GITHUB_REPOSITORY}/pulls/${TRUSTED_PR_NUMBER}" --jq '.base.sha')"
if ! [[ "$api_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::GitHub API returned an invalid pull request base SHA." >&2
exit 1
fi
if [ -n "${TRUSTED_BASE_SHA:-}" ] && [ "$TRUSTED_BASE_SHA" != "$api_base_sha" ]; then
echo "::error::Supplied governance base SHA does not match the live pull request base SHA." >&2
exit 1
fi
trusted_ref="$api_base_sha"
echo "Using the live pull request base SHA ${trusted_ref}."
else
default_branch="$(gh_api_with_retry "repos/${GITHUB_REPOSITORY}" --jq '.default_branch')"
trusted_ref="$(gh_api_with_retry "repos/${GITHUB_REPOSITORY}/branches/${default_branch}" --jq '.commit.sha')"
default_sha="$(gh_api_with_retry "repos/${GITHUB_REPOSITORY}/branches/${default_branch}" --jq '.commit.sha')"
if ! [[ "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::GitHub API returned an invalid default branch SHA." >&2
exit 1
fi
if [ -n "${TRUSTED_BASE_SHA:-}" ] && [ "$TRUSTED_BASE_SHA" != "$default_sha" ]; then
echo "::error::Manual governance base SHA must equal the current default branch SHA when no PR number is supplied." >&2
exit 1
fi
trusted_ref="$default_sha"
fi
if [[ ! "$trusted_ref" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Trusted governance ref must be a full commit SHA." >&2
Expand All @@ -207,7 +227,52 @@ jobs:
echo "Trusted governance archive materialization attempt ${attempt} did not produce a valid archive; retrying." >&2
sleep $((attempt * 3))
done
tar -xzf "$trusted_archive" -C "$trusted_workspace" --strip-components=1
python3 - "$trusted_archive" "$trusted_workspace" <<'PY'
import copy
import os
import sys
import tarfile
from pathlib import Path, PurePosixPath

archive_path, workspace_path = sys.argv[1:3]
workspace = Path(workspace_path).resolve()
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
safe_members = []
unsafe = []
for member in members:
member_path = PurePosixPath(member.name)
if len(member_path.parts) == 1:
if not member.isdir():
unsafe.append(member.name)
continue
target = (workspace / Path(*member_path.parts[1:])).resolve()
if (
not member.name
or member.name.startswith("/")
or ".." in member_path.parts
or member.issym()
or member.islnk()
or member.isdev()
or os.path.commonpath((workspace, target)) != str(workspace)
):
unsafe.append(member.name)
continue
safe_member = copy.copy(member)
safe_member.name = PurePosixPath(
*member_path.parts[1:]
).as_posix()
safe_members.append(safe_member)
if unsafe:
raise SystemExit(f"unsafe trusted governance archive member: {unsafe[0]!r}")
archive.extractall(workspace, members=safe_members)
PY
test -d "$trusted_workspace/scripts"
governance_script="$trusted_workspace/scripts/ci/pr_governance_gate.sh"
if [ ! -f "$governance_script" ] || [ -L "$governance_script" ]; then
echo "::error::Trusted governance gate is missing or is a symbolic link." >&2
exit 1
fi
test -f "$trusted_workspace/scripts/ci/pr_governance_gate.sh"
echo "GOVERNANCE_GATE=$trusted_workspace/scripts/ci/pr_governance_gate.sh" >> "$GITHUB_ENV"

Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ in this repo.
that unlocks the registry and keep env strictly as bootstrap transport; do not
add further `os.getenv` secret reads, and migrate toward the KV pattern as it
is adopted.
- **Approved test-only exception:** `LIVE_E2E_SESSION_SECRET` may be read from
the environment by `backend/scripts/private_mail_http_smoke.py` only for a
local/private live-smoke run. It must equal the controlled test
`AUTH_SESSION_HMAC_SECRET`, must never be logged or persisted, and is not an
application or production credential source. New runtime paths must use the
credential registry instead.

### This repo's role in the ecosystem

Expand Down
12 changes: 11 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ documented in `docs/threading-contract.md`.

## Data and tenancy boundary

The `emails` table has non-null `user_id` and `organization_id` owner keys, and
The `email_records` table has non-null `user_id` and `organization_id` owner keys, and
the current email list, detail, thread, search, and network graph endpoints scope
their queries to the authenticated user plus organization. Fresh local databases
get these columns from SQLAlchemy metadata; existing local databases get them
Expand All @@ -77,6 +77,16 @@ not globally. Fixture import upserts and reply-thread lookup use the same owner
scope so a reused RFC Message-ID from another organization cannot overwrite an
email row or attach a reply to another tenant's thread.

PR #1317's proposed import repair retains the provider-lookup connection for
the entire account-level advisory lease, including per-item commits. Uncertain
acquisition/release discards the physical connection; SQL cannot continue on a
replacement connection without the lease. Persisted body/attachment graph
identities include the same account scope so a reused Message-ID cannot collide
across accounts. The [repair record](docs/doctoring/import_lease_lifecycle.md)
documents caller transaction ownership, migration prerequisites, real-PostgreSQL
counterexamples, and the separate hosted/provider/browser gates. This is not a
protected integration or deployed-runtime claim.

`llm_providers` is also owner-scoped. Provider rows carry non-null `user_id` and
`organization_id`, provider names are unique only within an organization, and the
registry/list/update/delete plus prompt-preview provider selection paths filter by
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## [Unreleased]

- 이메일 가져오기를 취소하거나 연결이 끊긴 뒤에도 다음 가져오기를 계속할 수 있도록 보강했습니다. 같은 계정의 중복 가져오기와 다른 계정의 동일 메일 가져오기를 실제 PostgreSQL에서 검증했습니다. 아직 배포되지 않은 #1317 수정안이며, 실제 제공자·브라우저 검증은 남아 있습니다.
- Starlette `TestClient`의 기존 `httpx2==2.5.0` pin을 core 개발·테스트 의존성으로 승격하고, deprecated `httpx` fallback 경고 억제를 제거했습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.
Expand Down
119 changes: 46 additions & 73 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ mail/calendar/file systems.
on the allowlisted hostname.
- Session authority is assigned by the verified HMAC or OIDC code path, not by a
`_session_verifier` JWT payload claim supplied inside the token.
- Production membership authentication is expected to use the ecosystem's
[Keyverse OIDC/JWKS identity provider](https://github.com/ContextualWisdomLab/keyverse).
Configure `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_JWKS_URL`, and
`ALLOWED_OIDC_HOSTS` together; the local HMAC session is for controlled smoke
tests and is not authoritative cross-workspace membership evidence.

## Agentic Ontology & Auto-Organization

Expand Down Expand Up @@ -173,69 +178,38 @@ python3 -m webbrowser http://localhost:3000

### Apple Silicon / MLX local path (OS별 로컬 API 모델 서버 사용)

기본 `docker-compose.yml`는 Linux Ollama 컨테이너를 그대로 유지합니다. Apple Silicon
로컬 실 테스트(또는 외부 MLX/OpenAI-compatible 서비스)만 분리하려면 임시 오버라이드 파일을 붙여 실행합니다.
macOS에서는 `./scripts/naruon_compose.sh`가 OpenAI-compatible 엔드포인트를
`mlx-lm → llama.cpp → Ollama` 순서로 확인합니다. 먼저 응답하는 호스트 런타임을
사용하고, 모두 없으면 기존 Ollama 컨테이너를 그대로 빌드·기동합니다.

```bash
# 다음 블록은 로컬 실사용 검증용 샘플입니다. 민감한 쿼리로 대체할 수 있지만,
# 현재 실검증에서는 아래 두 키워드로 테스트합니다.
cat > .env.mlx <<'EOF'

# 기존 보안값은 그대로 두고, 로컬 모델 경로만 오버라이드
OPENAI_API_KEY=mlx
ALLOWED_LLM_BASE_URL_HOSTS=localhost,127.0.0.1,host.docker.internal
ALLOW_LOCAL_LLM_PROVIDERS=true
OPENAI_BASE_URL=http://host.docker.internal:11434/v1
OPENAI_EMBEDDING_MODEL=embeddinggemma
OPENAI_MODEL=gemma4:e2b-it-qat
# 포트 충돌이 있으면 아래 두 값으로 변경
NARUON_FRONTEND_HOST_PORT=127.0.0.1:3000
NARUON_BACKEND_HOST_PORT=127.0.0.1:8000
# Linux에서만 host-gateway가 필요합니다.
NARUON_MLX_EXTRA_HOSTS=host-gateway
NARUON_MLX_ALLOWED_LLM_BASE_URL_HOSTS=localhost,127.0.0.1,host.docker.internal
NARUON_MLX_OPENAI_API_KEY=mlx
NARUON_MLX_BASE_URL=http://host.docker.internal:11434/v1
NARUON_MLX_EMBEDDING_MODEL=embeddinggemma
NARUON_MLX_LLM_MODEL=gemma4:e2b-it-qat
EOF

# 로컬에서만 쓰는 compose 오버라이드는 임시 파일로 만들고 커밋하지 않습니다.
# OS 분기 없이 환경변수 하나로 host.docker.internal 매핑을 제어합니다.
# Linux에서 host-gateway가 필요한 환경이면 .env.mlx에서 NARUON_MLX_EXTRA_HOSTS를 덮어씁니다.
# Apple Silicon 검증 기준: 백엔드는 host.docker.internal:11434의 MLX(OpenAI-compatible)
# 엔드포인트로 바로 연결해 Ollama 컨테이너 의존을 피합니다.
mlx_compose_override="$(mktemp "${TMPDIR:-/tmp}/docker-compose.mlx.XXXXXX.yml")"
cat > "$mlx_compose_override" <<'EOF'
services:
backend:
depends_on:
db:
condition: service_healthy
environment:
ALLOW_LOCAL_LLM_PROVIDERS: "true"
ALLOWED_LLM_BASE_URL_HOSTS: ${NARUON_MLX_ALLOWED_LLM_BASE_URL_HOSTS:-localhost,127.0.0.1,host.docker.internal}
OPENAI_API_KEY: ${NARUON_MLX_OPENAI_API_KEY:-mlx}
OPENAI_BASE_URL: ${NARUON_MLX_BASE_URL:-http://host.docker.internal:11434/v1}
OPENAI_EMBEDDING_MODEL: ${NARUON_MLX_EMBEDDING_MODEL:-embeddinggemma}
OPENAI_MODEL: ${NARUON_MLX_LLM_MODEL:-gemma4:e2b-it-qat}
extra_hosts:
- "host.docker.internal:${NARUON_MLX_EXTRA_HOSTS:-host.docker.internal}"
ports:
- "${NARUON_BACKEND_HOST_PORT:-127.0.0.1:8000}:8000"
frontend:
ports:
- "${NARUON_FRONTEND_HOST_PORT:-127.0.0.1:3000}:3000"
EOF

NARUON_ENV_FILE=.env.mlx \
docker compose --env-file .env.mlx -f docker-compose.yml -f "$mlx_compose_override" up -d --build

# 혹시 모델 엔드포인트 미노출이 있을 경우는 위 명령 직전에 로컬 MLX 서버/게이트웨이를
# 먼저 확인합니다. (호스트는 본인 환경별로 달라질 수 있음)
curl -sf http://127.0.0.1:11434/v1/models >/dev/null && \
echo "MLX/OpenAI-compatible server is reachable" || \
echo "MLX endpoint is not reachable on 127.0.0.1:11434"
# mlx-lm은 서비스로 유지한다. 기본 포트는 8080이다.
brew services start mlx-lm
curl -sf http://127.0.0.1:8080/v1/models | head

./scripts/naruon_compose.sh up -d --build
```

기본 모델/주소를 바꿀 때는 `NARUON_MLX_BASE_URL`, `NARUON_MLX_LLM_MODEL`,
`NARUON_MLX_EMBEDDING_MODEL`을 설정합니다. llama.cpp는
`NARUON_LLAMA_CPP_BASE_URL`을 사용하며 기본 포트는 8081입니다. 별도
EmbeddingGemma 서버가 `NARUON_MLX_EMBEDDING_BASE_URL` 또는
`NARUON_LLAMA_CPP_EMBEDDING_BASE_URL`로 지정되면 MLX/llama.cpp chat 경로와
분리해 검색·임포트에 연결하고, 지정하지 않으면 기본 8082 embedding endpoint를
자동 탐색합니다. 자동 선택을 무시하려면
`NARUON_COMPOSE_LLM_RUNTIME=mlx|llama.cpp|ollama`를 지정합니다.

EmbeddingGemma 후보는 `llmfit info taide/embeddinggemma-GTAIDE-300m-2605 --json`
으로 현재 장비 적합성을 확인합니다. 실제 embedding-capable llama.cpp 캐시를
준비하고 확인하려면 다음을 실행합니다. 이 서버는 chat fallback과 포트를 분리한
embedding 전용 예시입니다.

```bash
llama-server -hf ggml-org/embeddinggemma-300M-GGUF:Q8_0 \
--embeddings --alias embeddinggemma --host 127.0.0.1 --port 8082
curl -sf http://127.0.0.1:8082/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{"model":"ggml-org/embeddinggemma-300M-GGUF:Q8_0","input":["embedding smoke"]}'
```

실 메일 임포트 + 요약/초안 검증:
Expand All @@ -249,26 +223,22 @@ if [ ! -r "$MAIL_DIR" ]; then
exit 1
fi

AUTH_SESSION_HMAC_SECRET="$(grep -E '^AUTH_SESSION_HMAC_SECRET=' .env | cut -d= -f2-)"
LIVE_E2E_SESSION_SECRET="$(sed -n 's/^AUTH_SESSION_HMAC_SECRET=//p' .env)" \
python3 backend/scripts/private_mail_http_smoke.py \
--mail-dir "$MAIL_DIR" \
--base-url http://127.0.0.1:3000 \
--frontend-base-url http://127.0.0.1:3000 \
--api-base-url http://127.0.0.1:8000 \
--session-secret "$AUTH_SESSION_HMAC_SECRET" \
--query "중공업 전력PU 회의록" \
--query "중공업 기전PU 회의록" \
--match-mode all-terms \
--limit 20 \
--batch-size 6 \
--require-browser-visible \
--llm-smoke \
--print-session-token
--llm-smoke
```

`--print-session-token`이 켜진 경우 스크립트가 같은 토큰을 브라우저로 전파하는
`/auth/session` 호출 예시를 출력합니다. 위 출력의 JS 한 줄을 앱 콘솔에서 실행하면
`naruon_session` 쿠키가 갱신되어 API로 임포트한 메일이 브라우저와 동일 세션에서 보입니다.
`LIVE_E2E_SESSION_SECRET`는 프로세스 환경변수로만 전달되며 세션 토큰은 출력하지 않습니다.
`session_check=ok` 로그는 세션 클레임이 브라우저에서 확인되었음을 뜻하고,
`session_check=failed(...)`는 토큰 검증/클레임 파싱 문제가 있음을 뜻합니다.
`--require-browser-visible`은 동일 토큰을 `Cookie: naruon_session=...`로 주입해
Expand All @@ -292,20 +262,23 @@ python3 backend/scripts/private_mail_http_smoke.py \
브라우저 세션 값(`session_check=ok`)이 스크립트 출력에 남아있는지 확인
- 브라우저에서 동일 이메일을 선택한 뒤 LLM 요약/초안 버튼 동작 확인
5) 세션 불일치 의심 시 `session_check=failed(...)` 또는 `session_check=skipped(...)`가
출력되면 `--print-session-token`의 콘솔 스니펫을 다시 실행하고 새로고침 후 2~4단계를 반복
출력되면 같은 `LIVE_E2E_SESSION_SECRET` 환경변수로 smoke를 재실행하고 2~4단계를 반복

실행 전 체크(빠른 사전 진단):

```bash
# Podman/Docker 런타임 연결 확인
podman system connection ls
# Colima가 제공하는 Docker 런타임 연결 확인
docker context show
docker info --format '{{.ServerVersion}} {{.Architecture}}'

# MLX(OpenAI-compatible) 엔드포인트 노출 확인
curl -sf http://127.0.0.1:11434/v1/models | head
curl -sf http://127.0.0.1:8080/v1/models | head

# 기존 웹 서비스(Nginx/프록시)가 3000/8000/11434를 가로채고 있지 않은지 확인
# 기존 웹 서비스(Nginx/프록시)가 3000/8000/8080/8081/11434를 가로채고 있지 않은지 확인
lsof -iTCP:3000 -sTCP:LISTEN
lsof -iTCP:8000 -sTCP:LISTEN
lsof -iTCP:8080 -sTCP:LISTEN
lsof -iTCP:8081 -sTCP:LISTEN
lsof -iTCP:11434 -sTCP:LISTEN
```

Expand Down
Loading
Loading