Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 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
407 changes: 407 additions & 0 deletions cowork/common/settings/env_boundary.py

Large diffs are not rendered by default.

71 changes: 0 additions & 71 deletions cowork/common/settings/user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,77 +181,6 @@ def _harness_options() -> list[str]:
return available_harness_ids()


# ── .env ↔ DB setting aliases ────────────────────────────────────────
#
# One canonical map of DB setting key → its ANTON_* .env variable name, for
# every field that overlaps between AntonSettings (.env, read by the standalone
# ``anton`` CLI) and UserSettings (DB). This is the single source the .env→DB
# seed (``migrations``), the ``POST /settings/raw`` merge sync, and the client
# all derive from. The same map was previously hand-maintained in both
# ``migrations._ENV_TO_SETTING`` and the client's ``syncSettings.ts`` — the two
# had already drifted (the client copy silently omitted gemini, the OpenAI-
# compatible key, router_provider, memory_enabled, act_first, proactive_
# dashboards and publish_url), which is exactly the class of bug ENG-941/ENG-1125
# retires.
#
# planning_model / coding_model are DELIBERATELY absent (ENG-739): a model in
# .env is CLI-only and must never ride a bulk .env→DB sync, or a login /
# token-refresh would re-pin a picker choice from a stale ``latest:`` line.
#
# max_tool_rounds / max_continuations are DELIBERATELY absent too, for the
# ENG-739 reason plus a harder failure mode: anton's own CoreSettings accepts
# any int, so a stale anton-CLI line like ANTON_MAX_TOOL_ROUNDS=1000 in the
# shared ~/.cowork/.env is valid for the CLI but fails UserSettings' bounds —
# and because sync_env_vars_to_db validates every mapped key and raises on the
# first failure, one such line would 400 every credential push / token
# refresh. Budgets enter the DB only via explicit writes (Settings UI / API).
#
# General inclusion rule: a key belongs here only if (a) every value anton's
# own settings accept for it is also valid for UserSettings, and (b)
# re-syncing a stale .env line can never override a choice the user made in
# the product. When in doubt, leave it out — .env lines still work for the
# standalone anton CLI.
SETTING_ENV_ALIASES: dict[str, str] = {
"anthropic_api_key": "ANTON_ANTHROPIC_API_KEY",
"openai_api_key": "ANTON_OPENAI_API_KEY",
"openai_compatible_api_key": "ANTON_OPENAI_API_KEY_CUSTOM",
"gemini_api_key": "ANTON_GEMINI_API_KEY",
"minds_api_key": "ANTON_MINDS_API_KEY",
"planning_provider": "ANTON_PLANNING_PROVIDER",
"coding_provider": "ANTON_CODING_PROVIDER",
"router_provider": "ANTON_ROUTER_PROVIDER",
"minds_url": "ANTON_MINDS_URL",
"openai_base_url": "ANTON_OPENAI_BASE_URL",
"memory_enabled": "ANTON_MEMORY_ENABLED",
"memory_mode": "ANTON_MEMORY_MODE",
"episodic_memory": "ANTON_EPISODIC_MEMORY",
"proactive_dashboards": "ANTON_PROACTIVE_DASHBOARDS",
"act_first": "ANTON_ACT_FIRST",
"publish_url": "ANTON_PUBLISH_URL",
}

# Inverse view (ANTON_* .env var → DB setting key) for .env-first callers, i.e.
# the first-boot migration seed and the ``POST /settings/raw`` merge sync.
ENV_ALIAS_TO_SETTING: dict[str, str] = {v: k for k, v in SETTING_ENV_ALIASES.items()}


def normalize_provider_value(val: str, *, minds_key_present: bool) -> str:
"""Translate a .env / UI provider string to the DB ``Provider`` enum value.

The single home for the hyphen→underscore canonicalization plus the
"a Minds key is present, so ``openai-compatible`` really means
``minds_cloud``" heuristic. This was reimplemented in
``migrations._normalize_provider_value``, the client's ``syncSettings.ts``,
and ``settingsTransform.js`` — three copies that could disagree, silently
routing a provider to the wrong client (the inverse direction, DB→UI, is
``Provider.ui_value``).
"""
canonical = val.replace("-", "_")
if canonical == Provider.OPENAI_COMPATIBLE.value and minds_key_present:
return Provider.MINDS_CLOUD.value
return canonical


class UserSettings(Settings):
# The recommended-model catalog and per-provider model defaults are
# global, application-level config and live in app_settings
Expand Down
50 changes: 14 additions & 36 deletions cowork/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,8 @@

from anton.core.tools.skill_format import normalize_name, DESC_MAX, SKILL_FILE
from cowork.common.paths import cowork_home
from cowork.common.settings import invalidate_user_settings_cache
from cowork.common.settings.user_settings import (
ENV_ALIAS_TO_SETTING,
UserSettings,
normalize_provider_value,
)
from cowork.common.settings.env_boundary import ENV_ALIAS_TO_SETTING, env_to_db_updates
from cowork.common.settings.user_settings import UserSettings
from cowork.models.setting import Setting
from cowork.models.skill import META_CREATED_AT, META_DISPLAY_NAME, Skill, SkillLegacy
from cowork.services.settings import SettingService
Expand All @@ -55,7 +51,7 @@

# Map of .env keys -> DB setting keys for all fields that overlap between
# AntonSettings (.env) and UserSettings (DB). Derived from the single canonical
# alias map in user_settings (SETTING_ENV_ALIASES) so this table can no longer
# alias map in env_boundary (SETTING_ENV_ALIASES) so this table can no longer
# drift from the client's copy — see the note there, incl. why the model keys
# (ANTON_PLANNING_MODEL / ANTON_CODING_MODEL) are deliberately excluded
# (ENG-739). Drives both the first-boot .env→DB seed (migrate_env_to_db) and the
Expand All @@ -80,33 +76,14 @@ def _parse_env_file() -> dict[str, str]:
return result


def _normalize_provider_value(val: str, dotenv: dict[str, str]) -> str:
"""Translate .env provider strings to DB enum values.

Thin adapter over the canonical ``normalize_provider_value`` in
user_settings — kept so the .env-shaped call sites here don't each repeat
the "does this dotenv carry a Minds key" check.
"""
return normalize_provider_value(
val, minds_key_present=bool(dotenv.get("ANTON_MINDS_API_KEY"))
)


def sync_env_vars_to_db(session: Session, dotenv: dict[str, str]) -> list[str]:
"""Upsert a dict of ANTON_* env vars into the settings DB.

Returns the list of DB setting keys that were written. Skips env
keys that have no mapping in ``_ENV_TO_SETTING``.
"""
svc = SettingService(session)
updates: dict[str, str] = {}
for env_key, setting_key in _ENV_TO_SETTING.items():
val = dotenv.get(env_key)
if not val:
continue
if setting_key.endswith("_provider"):
val = _normalize_provider_value(val, dotenv)
updates[setting_key] = val
updates = env_to_db_updates(dotenv)

for key, value in updates.items():
svc._validate_key(key)
Expand Down Expand Up @@ -135,17 +112,15 @@ def migrate_env_to_db(session: Session) -> bool:

if dotenv:
migrated_keys: list[str] = []
for env_key, setting_key in _ENV_TO_SETTING.items():
val = dotenv.get(env_key)
if not val:
continue
if setting_key.endswith("_provider"):
val = _normalize_provider_value(val, dotenv)
for setting_key, val in env_to_db_updates(dotenv).items():
try:
svc.upsert_setting(setting_key, val)
# export_env=False: this loop seeds the DB *from* .env; a per-key
# re-export mid-loop would strip the keys not migrated yet. The
# next real write exports the reconciled file (ENG-1127).
svc.upsert_setting(setting_key, val, export_env=False)
migrated_keys.append(setting_key)
except Exception as e:
logger.debug("Skipping env migration for %s: %s", env_key, e)
logger.debug("Skipping env migration for %s: %s", setting_key, e)

if migrated_keys:
logger.info(
Expand Down Expand Up @@ -197,7 +172,10 @@ def backfill_minds_url(session: Session) -> bool:
changed.append(key)
if changed:
session.commit()
invalidate_user_settings_cache()
# Route through the exporting hook (not a bare cache-invalidate) so the
# rewritten minds_url also reaches the CLI's .env — otherwise the DB moves
# to the canonical host while .env keeps the dead mdb.ai one (ENG-1127 review).
svc._after_write()
logger.info(
"Backfilled legacy MindsHub host (mdb.ai -> %s) in: %s",
canonical, ", ".join(changed),
Expand Down
57 changes: 52 additions & 5 deletions cowork/services/settings.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import json
import logging
import threading
from enum import Enum

from cryptography.fernet import InvalidToken
from pydantic import SecretStr, ValidationError
from sqlmodel import Session, select

from cowork.common.encryption import decrypt, encrypt
from cowork.common.paths import cowork_home
from cowork.common.settings.app_settings import get_app_settings
from cowork.common.settings.env_boundary import (
atomic_write_env,
db_to_env,
merge_env_lines,
)
from cowork.common.settings.user_settings import (
UserSettings,
invalidate_user_settings_cache,
Expand All @@ -16,6 +24,12 @@

logger = logging.getLogger(__name__)

# Serializes the .env export's read/merge/write so two concurrent settings writes
# can't lost-update the file — the last exporter re-reads the DB under the lock and
# installs the latest committed state (ENG-1127 review). In-process; the client's
# own .env writes go away in Phase B, making the server the sole writer.
_env_export_lock = threading.Lock()


def _mask_provider_keys(providers_json: str) -> str:
"""Return providers_json with each card's apiKey replaced by '***'.
Expand Down Expand Up @@ -154,11 +168,44 @@ def _write_row(self, key: str, store_val: str) -> None:
row.value = store_val
self.session.add(row)

def upsert_setting(self, key: str, value: str) -> SettingResponse:
def _after_write(self, *, export_env: bool = True) -> None:
Comment thread
pnewsam marked this conversation as resolved.
"""Post-commit hook shared by the settings mutators.

Invalidates the cache and (desktop install) mirrors the DB to the CLI's
``.env`` (ENG-1127); ``export_env=False`` skips the mirror for the seeding migration.
"""
invalidate_user_settings_cache()
if export_env:
self._export_env_for_cli()

def _export_env_for_cli(self) -> None:
Comment thread
pnewsam marked this conversation as resolved.
"""Mirror the DB's aliased settings to the CLI's ``.env`` (best-effort).

Local (desktop) tenancy only — a cloud pod must not spill decrypted secrets
to disk (ENG-1127). Never raises — a stale ``.env`` must not fail a save.
"""
try:
if get_app_settings().tenancy_mode != "local":
return
# Serialize the whole read/merge/write. The DB read is INSIDE the lock,
# so the last exporter to acquire it installs the latest committed
# state — no lost update between concurrent settings writes.
with _env_export_lock:
rows = self._fetch_all_rows()
managed = db_to_env(self._load(rows), {row.key for row in rows})
path = cowork_home() / ".env"
existing = path.read_text(encoding="utf-8") if path.exists() else ""
content = merge_env_lines(existing, managed)
if content != existing:
atomic_write_env(path, content)
except Exception as exc: # noqa: BLE001 - export is best-effort
logger.warning("settings: .env export for the CLI failed: %s", exc)

def upsert_setting(self, key: str, value: str, *, export_env: bool = True) -> SettingResponse:
store_val, validated = self._encode_for_store(key, value)
self._write_row(key, store_val)
self.session.commit()
invalidate_user_settings_cache()
self._after_write(export_env=export_env)
return self._to_response(key, validated, True)

def save_all(self, updates: dict[str, str]) -> list[str]:
Expand All @@ -180,7 +227,7 @@ def save_all(self, updates: dict[str, str]) -> list[str]:
self._write_row(key, store_val)
if encoded:
self.session.commit()
invalidate_user_settings_cache()
self._after_write()
return list(encoded.keys())

def bulk_upsert(self, updates: dict[str, str]) -> list[str]:
Expand Down Expand Up @@ -228,7 +275,7 @@ def delete_setting(self, key: str) -> bool:
return False
self.session.delete(row)
self.session.commit()
invalidate_user_settings_cache()
self._after_write()
return True

def clear_credentials(self) -> list[str]:
Expand Down Expand Up @@ -261,6 +308,6 @@ def clear_credentials(self) -> list[str]:
deleted.append(key)
if deleted:
self.session.commit()
invalidate_user_settings_cache()
self._after_write()
return deleted

3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
# File bytes too — without this, any test using FileService writes into the
# developer's real ~/.cowork/files/ and orphans dirs there.
os.environ["COWORK_FILES_DIR"] = str(TMP / "files")
# Home dir too: a settings write now mirrors the DB to $COWORK_HOME/.env for the
# CLI (ENG-1127), so without this a test would rewrite the real ~/.cowork/.env.
os.environ["COWORK_HOME"] = str(TMP)
os.environ["ENV"] = "test"

import pytest
Expand Down
Loading
Loading