Skip to content
Draft
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
86 changes: 0 additions & 86 deletions cowork/api/v1/endpoints/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from sqlmodel import Session

from cowork.api.v1.endpoints.guards import require_local
from cowork.common.paths import cowork_home
from cowork.db.session import get_session
from cowork.schemas.base import CamelRequest
from cowork.schemas.settings import (
Expand Down Expand Up @@ -346,88 +345,3 @@ async def recommended_models(session: SessionDep, refresh: bool = False):
"modelEnabled": model_enabled,
"modelLabels": model_labels,
}


# ── Raw .env access (legacy, used by Onboarding) ─────────────────────

_ENV_PATH = cowork_home() / ".env"


def _parse_dotenv_content(content: str) -> dict[str, str]:
result: dict[str, str] = {}
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
result[key.strip()] = val.strip().strip('"').strip("'")
return result


def _read_env_dict() -> dict[str, str]:
if not _ENV_PATH.exists():
return {}
try:
return _parse_dotenv_content(_ENV_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return {}


@router.get("/raw")
def read_raw_settings(request: Request):
# /raw dumps the dotenv verbatim (all provider secrets) — same loopback
# restriction as reveal-key.
require_local(request)
return _read_env_dict()


class _RawSettingsBody(BaseModel):
content: str


@router.post("/raw")
def write_raw_settings(body: _RawSettingsBody, session: SessionDep, request: Request):
"""Merge dotenv content into ~/.cowork/.env and sync recognised keys to the DB.

Uses key-level merge (not full overwrite) because callers like the
OAuth token refresh only send a subset of keys — a full overwrite
would wipe model config and other settings from .env.

The .env persists for the standalone ``anton`` CLI and is read by
``GET /raw``; the DB is authoritative for cowork-server. By syncing
to both we keep them consistent regardless of which frontend code
path writes settings (onboarding, OAuth token refresh, etc.)."""
# Writing the dotenv lands provider secrets on disk — same loopback
# restriction as the /raw read.
require_local(request)

from cowork.migrations import sync_env_vars_to_db

incoming = _parse_dotenv_content(body.content)

try:
existing = _read_env_dict()
existing.update(incoming)

# Sync recognised ANTON_* vars to the DB first. If validation fails,
# leave the legacy .env untouched so the DB remains authoritative.
sync_env_vars_to_db(session, existing)

_ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{k}={v}" for k, v in existing.items()]
_ENV_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
try:
_ENV_PATH.chmod(0o600)
except OSError:
pass
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=500, detail="Settings could not be saved.") from e

return {"ok": True}


# NOTE: .env → DB migration now runs at server startup via
# cowork.migrations.migrate_env_to_db(), called from dev_setup.
76 changes: 76 additions & 0 deletions cowork/common/settings/env_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""The ``.env`` -> DB settings boundary (inbound only).

The DB (``UserSettings``) is the source of truth. This module owns the inbound
conversion from a legacy ``.env`` into DB updates: the ANTON_* alias map and
provider-value normalization live here so ``user_settings`` stays purely about
the DB model.

The outbound DB -> ``.env`` export was removed in ENG-1295. The standalone
``anton`` CLI now owns its own ``~/.anton/.env`` (it no longer reads the Cowork
mirror), so the server no longer mirrors settings to ``~/.cowork/.env``. Only
the inbound direction remains, used by the one-time boot migration that seeds
the DB from a legacy ``.env``.

Model keys (planning_model / coding_model) are deliberately absent from the alias
map (ENG-739): a model is CLI-only and must never ride a bulk ``.env`` sync.
"""
from __future__ import annotations

from cowork.common.settings.user_settings import Provider

# DB setting key -> its ANTON_* .env variable, for every field that overlaps
# between AntonSettings (.env) and UserSettings (DB). Single canonical map (was
# hand-maintained in two places that drifted — ENG-1125).
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_* -> DB key) for the inbound (.env-first) callers.
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:
"""A .env / UI provider string -> the DB ``Provider`` enum value.

Hyphen->underscore canonicalization plus the "a Minds key is present, so
``openai-compatible`` really means ``minds_cloud``" heuristic. The inverse
(DB -> UI/.env) is ``Provider.ui_value``.
"""
canonical = val.replace("-", "_")
if canonical == Provider.OPENAI_COMPATIBLE.value and minds_key_present:
return Provider.MINDS_CLOUD.value
return canonical


def env_to_db_updates(dotenv: dict[str, str]) -> dict[str, str]:
"""A parsed ``.env`` dict -> ``{db_key: value}`` ready for the DB.

Maps ANTON_* names, skips absent/empty vars, normalizes provider fields.
Pure conversion — validation, encryption and the DB write stay in the caller.
"""
updates: dict[str, str] = {}
for env_var, setting_key in ENV_ALIAS_TO_SETTING.items():
val = dotenv.get(env_var)
if not val:
continue
if setting_key.endswith("_provider"):
val = normalize_provider_value(
val, minds_key_present=bool(dotenv.get("ANTON_MINDS_API_KEY"))
)
updates[setting_key] = val
return updates
76 changes: 4 additions & 72 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 Expand Up @@ -586,8 +515,11 @@ def apply_model_defaults(self) -> 'UserSettings':
self.coding_provider.value, CODING_MODEL_DEFAULTS, enabled_map
)
if self.router_model is None:
# Default from the router's OWN provider — deriving from coding_provider
# paired an independently-set router (e.g. router=OpenAI, coding=Anthropic)
# with the wrong vendor's model (ENG-1127 review).
self.router_model = _enabled_aware_default(
self.coding_provider.value, ROUTER_MODEL_DEFAULTS, enabled_map
self.router_provider.value, ROUTER_MODEL_DEFAULTS, enabled_map
)
return self

Expand Down
8 changes: 7 additions & 1 deletion cowork/harnesses/anton_harness/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,13 @@ async def _build_chat_session(
from cowork.common.settings.user_settings import get_user_settings
from pydantic import SecretStr

anton_settings = AntonSettings()
# Isolate the embedded base from the user's standalone CLI config
# (ENG-1295): the standalone `anton` now owns ~/.anton/.env, so loading
# the .env chain here would bleed a user's personal CLI settings (e.g.
# ANTON_MAX_TOKENS, ANTON_MEMORY_MODE) into Cowork sessions. Build with
# no .env base so embedded settings come only from anton defaults + the
# DB overlay applied below.
anton_settings = AntonSettings(_env_file=None)
anton_settings.resolve_workspace(str(base))

# Per-project skills
Expand Down
59 changes: 16 additions & 43 deletions cowork/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@
had the old sentinel fire against the wrong path get a fresh migration run
from the correct ``~/.cowork/.env`` location.

After migration the DB is **authoritative** for all overlapping fields.
The ``.env`` file continues to exist for:
- The standalone ``anton`` CLI (reads ``AntonSettings`` from ``.env``)
- Onboarding (writes ``.env`` first, then syncs to DB)
- Fields that only exist in ``AntonSettings`` (workspace paths, etc.)

Cowork-server runtime code should read from ``get_user_settings()`` (DB),
never from the ``.env`` directly.
After migration the DB is **authoritative**. This is a one-way seed: the
server no longer writes ``~/.cowork/.env`` back (the DB->.env export was
removed in ENG-1295, and the standalone ``anton`` CLI now owns its own
``~/.anton/.env``). Reading a legacy ``~/.cowork/.env`` here only bootstraps
the DB on first upgrade; cowork-server runtime code reads from
``get_user_settings()`` (DB), never from ``.env`` directly.
"""
from __future__ import annotations

Expand All @@ -33,12 +31,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 +49,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 +74,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 +110,14 @@ 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:
# Seed the DB from a legacy .env (ENG-1295). One-time, sentinel-
# guarded; the DB is authoritative thereafter.
svc.upsert_setting(setting_key, val)
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 +169,8 @@ def backfill_minds_url(session: Session) -> bool:
changed.append(key)
if changed:
session.commit()
invalidate_user_settings_cache()
# Invalidate the settings cache so the next read sees the rewritten host.
svc._after_write()
logger.info(
"Backfilled legacy MindsHub host (mdb.ai -> %s) in: %s",
canonical, ", ".join(changed),
Expand Down
Loading
Loading