diff --git a/.gitignore b/.gitignore index a139ce1..0d9ff1c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ !uiflow.md !cap.md !skills.md +!settings.md # Backup files *.bak diff --git a/AGENTS.md b/AGENTS.md index b069b70..0caa167 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ - Interactive UI flows (`plexer_show`, `PlexerFlow`, `PlexerController.ask`, the `show`/`result`/`cancel` control commands) are specified in [uiflow.md](uiflow.md): read it before touching any flow code, and keep its contract and cap tables in step with the code (`UiflowDocTests` fails when they drift). - CAP protocol support (`Cap*` classes, `--cap-manifest`, `--cap-speak`, orchestrator drivers) is specified in [cap.md](cap.md): read it before touching any `Cap*` code, and keep its event, error and manifest tables in step with the code (`CapDocTests` fails when they drift). - Skills (`discover_skills`, `SKILL_BLUEPRINTS`, `BUNDLED_SKILLS`, the `skills` install target) are specified in [skills.md](skills.md): read it before touching skill code or embedded skill text, and keep its bundled-skill and adaptation tables in step with the code (`BundledSkillsTests` fails when they drift). +- Settings (`SETTINGS_CATALOG`, the `settings` table, `get_or_create_settings`/`upsert_settings`/`get_all_settings`, `boot_settings`) are specified in [settings.md](settings.md): read it before touching settings code, and keep its catalog table in step with the code (`SettingsTests` fails when they drift). ## Testing diff --git a/README.md b/README.md index d17fc98..67ffec3 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,8 @@ The interactive UI toolkit lives in `tai.py` itself (stdlib-only, no dependencie | `CAP_SESSION_CONFIG` | Bulk session-config JSON for `--cap-speak` | Empty | | `TELEGRAM_BOT_TOKEN` | Bot token, or set during install | Empty | +Every `TAI_*` knob above that behaves like a setting (model, backends, keys, voices, theme, CLI paths, capability flags) is also overridable from the vault through `get_or_create_settings` / `upsert_settings` / `get_all_settings`, with env always winning. Full contract: [settings.md](settings.md). + ## Telegram ./tai.py --install-telegram @@ -420,5 +422,6 @@ Unit tests live at the top level (`test_tai.py`, `test_seal.py`); integration te uiflow.md interactive UI flows (`plexer_show`): contract and caps cap.md the CAP protocol binding (`--cap-*`, `--cap`): wire contract skills.md skills system and bundled set: provenance, adaptations, install + settings.md vault-backed settings: catalog, precedence, API research/ archived design research, registry at research/INDEX.md AGENTS.md contributor conventions (tests, research rule) diff --git a/settings.md b/settings.md new file mode 100644 index 0000000..5b73173 --- /dev/null +++ b/settings.md @@ -0,0 +1,154 @@ +# settings.md + +Vault-backed settings: one catalog, three functions, env always wins. +Every `TAI_*` knob that behaves like a setting lives in the `settings` +table and is readable and overridable through +`get_or_create_settings` / `upsert_settings` / `get_all_settings`. +`SettingsTests.test_settings_doc_names_every_key` fails when this file +drifts from the code. + +## Precedence + +For every catalog key, the effective value resolves in this order: + +1. **env**: a non-empty `${ENV_VAR}` (empty counts as unset, matching + every existing reader) +2. **db, own scope**: the profile row when one is stored +3. **db, global scope**: the shared fallback row +4. **catalog default** + +Two run-scoped inputs outrank even env: CLI `--theme` beats the theme +setting, and CLI `--no-opencode` beats the stored opencode flag. A +stored model pin counts as explicit, so per-directory model defaults +do not override it. + +Boot applies vault settings in `boot()` (and in `default_runner` for +workers) through `boot_settings`: `Config` fields first, then the +peripheral keys are exported into `os.environ` so the existing +readers, which all run later, pick them up unchanged. Applied keys are +printed (`settings from vault: ...`) and also exported for nested +processes. A second boot pass is idempotent: exported env wins, so +nothing is applied twice. + +## Row shape + +Every function returns the same row dicts: + +| Field | Meaning | +|---|---| +| `key`, `env`, `desc` | catalog identity and one-line docs | +| `type` | `string`, `bool`, or `float` | +| `default` | python value used when nothing is set (may be null) | +| `required` | null and empty are rejected; a required key must always resolve | +| `nullable` | whether null (unset) is a legal stored value | +| `options` | strict list the value must be one of, or null | +| `preferred` | suggestions that still allow free input, or null (reserved: no v1 entry uses it) | +| `encrypted` | sealed at rest with the vault key when true | +| `stored` | decrypted canonical string in the vault, or null | +| `value` | effective typed value after precedence | +| `source` | where `value` came from: `env`, `db`, or `default` | +| `scope`, `created`, `updated` | owning scope and ISO UTC timestamps | + +Canonical stored forms: bool as `1`/`0`, float as `str(float)`, +strings raw. Env bools accept `1/true/yes/on` and `0/false/no/off` +(case-insensitive, surrounding whitespace ignored); anything else +falls back to the default, exactly like the readers. Env floats use +plain decimals (`12`, `2.5`); anything else falls back to the default. +Three bool keys extend the env word sets (see catalog): those legacy +spellings stay readable from env but are never written by upsert. + +## API + +- `get_or_create_settings(keys=None, scope="global")`: ensure rows + exist (all 28 when `keys` is None, a subset or single key otherwise) + and return their merged rows. Unknown keys and bad scopes raise + `ValueError`, as does a required key that resolves to nothing. +- `get_all_settings(scope="global")`: the same merge, read-only: + creates no rows. +- `upsert_settings(items, scope="global")`: validate a + `{key: value}` mapping and store it atomically; anything invalid + raises `ValueError` and writes nothing. `None` clears a nullable + key back to null. Returns fresh rows for the written keys. + +Scopes are `global` or a profile name (`PROFILE_RE`). A profile +literally named `global` shares the global scope. + +## Encryption + +Keys marked encrypted are sealed with `seal.lock` on write and opened +on read; plaintext vaults store them as-is, same as vault secrets. At +rest they are indistinguishable from other sealed blobs (`tai1$...`). +Passphrase rotation re-seals every sealed settings value and leaves +plaintext values alone. Booting a vault that holds sealed settings +without the passphrase refuses to start, like the other sealed +tables. Encrypted settings are config, not vault secrets: they are +not covered by secret redaction, exactly like their env equivalents. + +## Catalog + +28 keys. Types, defaults, and flags below are asserted by +`SettingsTests.test_catalog_self_check`, so this table cannot drift +silently. + +| Key | Env | Type | Default | Req | Null | Options | Enc | What it does | +|---|---|---|---|---|---|---|---|---| +| `model` | `TAI_MODEL` | string | `muse` | yes | no | - | no | Default model id. A stored pin counts as explicit. | +| `backends` | `TAI_BACKENDS` | string | null | no | yes | - | yes | Extra OpenAI-compatible backends: `;`-separated `label\|base\|key[\|model][\|profile]`; a `$VAR` key reads another env var. | +| `openrouter_key` | `OPENROUTER_API_KEY` | string | null | no | yes | - | yes | OpenRouter key. Most free models work without one. | +| `openrouter_models` | `TAI_OPENROUTER_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | +| `openrouter_enabled` | `TAI_OPENROUTER` | bool | true | no | no | - | no | Offer the OpenRouter backend. | +| `opencode_enabled` | `TAI_OPENCODE` | bool | true | no | no | - | no | Offer the opencode backend. CLI `--no-opencode` wins. | +| `opencode_models` | `TAI_OPENCODE_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | +| `devplace_key` | `DEVPLACE_API_KEY` | string | null | no | yes | - | yes | DevPlace key. Null disables the fallback backend. | +| `devplace_base` | `TAI_DEVPLACE_BASE` | string | `https://devplace.net/openai/v1` | no | no | - | no | DevPlace API base URL. Trailing slashes stripped. | +| `devplace_models` | `TAI_DEVPLACE_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | +| `pollinations_enabled` | `TAI_POLLINATIONS` | bool | true | no | no | - | no | Offer the Pollinations backend. | +| `pollinations_models` | `TAI_POLLINATIONS_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | +| `zen_enabled` | `TAI_ZEN` | bool | false | no | no | - | no | Opt in to the opencode.ai zen backend. | +| `zen_models` | `TAI_ZEN_MODELS` | string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. | +| `retry_seconds` | `TAI_RETRY_SECONDS` | float | 0.0 | no | no | - | no | Seconds to retry all backends on total failure. 0 means forever. | +| `voice` | `TAI_VOICE` | string | `en-US-EmmaMultilingualNeural` | no | no | - | no | Edge TTS voice name for speak. | +| `theme` | `TAI_THEME` | string | `dark` | no | no | dark, light, nord, gruvbox, ansi | no | TUI theme, strict. CLI `--theme` wins. | +| `gemini_plan` | `TAI_GEMINI_PLAN` | bool | false | no | no | - | no | Try the Gemini plan approval variant first. Env also accepts `plan`. | +| `codex_sandbox` | `TAI_CODEX_SANDBOX` | bool | false | no | no | - | no | Re-enable Codex sandbox flags. Env also accepts `read-only`. | +| `box_caps` | `TAI_BOX_CAPS` | bool | true | no | no | - | no | Apply caps to old sandbox boxes. False skips. Env honors only `0` as false. | +| `muse_bin` | `MUSE_BIN` | string | null | no | yes | - | no | muse CLI path override. Null means PATH lookup. | +| `opencode_bin` | `OPENCODE_BIN` | string | null | no | yes | - | no | opencode CLI path override. Null means PATH lookup. | +| `grok_bin` | `GROK_BIN` | string | null | no | yes | - | no | grok CLI path override (then grokcli). Null means PATH lookup. | +| `claude_bin` | `CLAUDE_BIN` | string | null | no | yes | - | no | claude CLI path override. Null means PATH lookup. | +| `codex_bin` | `CODEX_BIN` | string | null | no | yes | - | no | codex CLI path override. Null means PATH lookup. | +| `gemini_bin` | `GEMINI_BIN` | string | null | no | yes | - | no | gemini CLI path override. Null means PATH lookup. | +| `opencode_client_name` | `OPENCODE_CLIENT_NAME` | string | null | no | yes | - | no | `x-opencode-client` header override. Null means built-in. | +| `opencode_client_ua` | `OPENCODE_CLIENT_USER_AGENT` | string | null | no | yes | - | no | User-Agent override for zen. Null means built-in. | + +## Deliberately not cataloged + +| Var | Why it stays out | +|---|---| +| `TAI_HOME` | locates the vault; cannot come from inside it | +| `TAI_PASSPHRASE` | unlocks the vault; cannot come from inside it | +| `TELEGRAM_BOT_TOKEN` | owned by `telegram.env` plus the service unit; move it only with a migration | +| `TAI_SESSION` | per-process derived context, resolved around store boot | +| `TAI_TUI_SOCK` | per-process socket rendezvous path | +| `TAI_TUI_VERIFY` | dev-only pipeline verification switch | +| `GRR_URL` | owned by `grr.json` (env wins there too) | +| `CAP_SESSION_CONFIG` | wire passthrough for one speak turn, not a setting | +| `TAI_PLEXER_*` | runtime plexer state, not settings | +| `TERM`, `NO_COLOR`, `SHELL`, `TMUX*`, `EDITOR`, `VISUAL`, `HISTFILE`, `USER`, `TZ`, `VIRTUAL_ENV`, `XDG_*`, `COLORFGBG`, `TERMINFO` | foreign standards tai reads but does not own | + +Internal tuning constants (`WORKER_STEPS`, `CAP_*`, `BOX_*`, scheduler +intervals) are code, not settings: promoting one means a catalog +entry, a wiring decision, and a test, not a silent centrally-stored +override. `mplex.toml`, CAP fleet files, and per-directory backend +pins (`dir_defaults`) keep their own homes for the same reason. + +## Maintenance + +To add a setting: append a `setting_spec` to `SETTINGS_CATALOG`, route +it through `CONFIG_SETTING_FIELDS` (read at `Config` build) or +`SETTING_EXPORT_KEYS` (read later at call sites), extend the catalog +table above, and extend `SettingsTests`. The catalog self-check +asserts every default validates, every options list holds its +default, env names are unique, and the two routing tuples partition +the catalog exactly. The theme row is pinned against `TUI_THEMES` so +a new theme breaks the test until the options list follows. diff --git a/tai.py b/tai.py index b15a9ca..db44a2c 100755 --- a/tai.py +++ b/tai.py @@ -102,7 +102,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.40.0" +VERSION = "1.41.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -2357,6 +2357,7 @@ def rotate_seal(config, old_seal, new_passphrase): secret_rows = db.execute("SELECT profile, name, value FROM secrets").fetchall() if has_profile else [(None, name, value) for name, value in db.execute("SELECT name, value FROM secrets").fetchall()] if "secrets" in tables else [] meta_rows = db.execute("SELECT profile, name, meta FROM secrets WHERE meta IS NOT NULL").fetchall() if has_profile and "meta" in secret_cols else [(None, name, meta) for name, meta in db.execute("SELECT name, meta FROM secrets WHERE meta IS NOT NULL").fetchall()] if "meta" in secret_cols else [] planned_rows = db.execute("SELECT id, prompt FROM schedules").fetchall() if "schedules" in tables else [] + setting_rows = db.execute("SELECT key, scope, value FROM settings").fetchall() if "settings" in tables else [] db.close() texts = {} for row_id, text in rows: @@ -2370,6 +2371,12 @@ def rotate_seal(config, old_seal, new_passphrase): planned_texts = {} for row_id, prompt in planned_rows: planned_texts[row_id] = old_seal.unlock(prompt) if prompt.startswith(Seal.PREFIX) else prompt + setting_texts = {} + for key, scope, value in setting_rows: + if value and value.startswith(Seal.PREFIX): + setting_texts[(key, scope)] = (old_seal.unlock(value), True) + else: + setting_texts[(key, scope)] = (value, False) files = {} for entry in os.listdir(config.profiles_dir): if not entry.endswith((".sys.md", ".session.json", ".bots.json")): @@ -2398,6 +2405,9 @@ def rotate_seal(config, old_seal, new_passphrase): db.execute("UPDATE secrets SET meta = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) for row_id, plain in planned_texts.items(): db.execute("UPDATE schedules SET prompt = ? WHERE id = ?", (fresh.lock(plain), row_id)) + for (key, scope), (plain, was_sealed) in setting_texts.items(): + db.execute("UPDATE settings SET value = ? WHERE key = ? AND scope = ?", + (fresh.lock(plain) if was_sealed else plain, key, scope)) db.commit() db.close() for path, plain in files.items(): @@ -5003,7 +5013,8 @@ class Config: self.openrouter_models = split_models(os.environ.get("TAI_OPENROUTER_MODELS")) self.retry_seconds = float(os.environ.get("TAI_RETRY_SECONDS") or 0) if re.match(r"^\d+(\.\d+)?$", (os.environ.get("TAI_RETRY_SECONDS") or "0").strip()) else 0.0 self.opencode_enabled = (os.environ.get("TAI_OPENCODE") or "1").strip().lower() not in ("0", "false", "no", "off") - if getattr(args, "no_opencode", False): + self.no_opencode_cli = bool(getattr(args, "no_opencode", False)) + if self.no_opencode_cli: self.opencode_enabled = False self.opencode_models = split_models(os.environ.get("TAI_OPENCODE_MODELS")) self.devplace_key = os.environ.get("DEVPLACE_API_KEY") or "" @@ -5020,6 +5031,261 @@ class Config: os.makedirs(self.profiles_dir, exist_ok=True) os.makedirs(self.audio_dir, exist_ok=True) + def apply_store_settings(self, store): + """Second boot pass: fill fields the environment left unset from the + vault settings table. Env always wins; a CLI --no-opencode wins over + the stored opencode flag too. Returns the applied key names.""" + rows = dict((row["key"], row) for row in store.get_or_create_settings(scope="global")) + applied = [] + for attr, key, env_var, kind in CONFIG_SETTING_FIELDS: + if os.environ.get(env_var): + continue + if key == "opencode_enabled" and self.no_opencode_cli: + continue + stored = rows[key]["stored"] + if stored is None: + continue + if kind == "bool": + setattr(self, attr, stored == "1") + elif kind == "float": + try: + setattr(self, attr, float(stored)) + except ValueError: + continue + elif kind == "models": + setattr(self, attr, split_models(stored)) + elif kind == "backends": + setattr(self, attr, parse_backends(stored)) + elif kind == "rstrip": + setattr(self, attr, stored.rstrip("/")) + else: + setattr(self, attr, stored) + if key == "model": + self.explicit_model = stored + applied.append(key) + return applied + + +SETTING_TRUE_WORDS = ("1", "true", "yes", "on") +SETTING_FALSE_WORDS = ("0", "false", "no", "off") +SETTING_FLOAT_RE = re.compile(r"^\d+(\.\d+)?$") +SETTING_SCOPE_GLOBAL = "global" + + +def setting_spec(key, env, kind, default, desc, required=False, nullable=False, options=None, + preferred=None, encrypted=False, true_words=None, false_words=None): + return {"key": key, "env": env, "type": kind, "default": default, "desc": desc, "required": required, + "nullable": nullable, "options": options, "preferred": preferred, "encrypted": encrypted, + "true_words": true_words, "false_words": false_words} + + +SETTINGS_CATALOG = dict((spec["key"], spec) for spec in ( + setting_spec("model", "TAI_MODEL", "string", DEFAULT_MODEL, + "Default model id. A stored pin counts as explicit: per-directory model defaults do not override it.", + required=True), + setting_spec("backends", "TAI_BACKENDS", "string", None, + "Extra OpenAI-compatible backends. Semicolon-separated label|base|key[|model][|profile] entries; a key starting with $ reads another env var. Holds secrets: encrypted at rest.", + nullable=True, encrypted=True), + setting_spec("openrouter_key", "OPENROUTER_API_KEY", "string", None, + "OpenRouter API key. Most free models work without one; some require it. Encrypted at rest.", + nullable=True, encrypted=True), + setting_spec("openrouter_models", "TAI_OPENROUTER_MODELS", "string", None, + "Comma-separated OpenRouter model ids to offer. Null means live discovery.", + nullable=True), + setting_spec("openrouter_enabled", "TAI_OPENROUTER", "bool", True, + "Offer the OpenRouter backend."), + setting_spec("opencode_enabled", "TAI_OPENCODE", "bool", True, + "Offer the opencode backend. CLI --no-opencode always wins over this stored flag."), + setting_spec("opencode_models", "TAI_OPENCODE_MODELS", "string", None, + "Comma-separated opencode model ids to offer. Null means live discovery.", + nullable=True), + setting_spec("devplace_key", "DEVPLACE_API_KEY", "string", None, + "DevPlace API key. Null disables the DevPlace fallback backend. Encrypted at rest.", + nullable=True, encrypted=True), + setting_spec("devplace_base", "TAI_DEVPLACE_BASE", "string", DEVPLACE_BASE, + "DevPlace API base URL. Trailing slashes are stripped."), + setting_spec("devplace_models", "TAI_DEVPLACE_MODELS", "string", None, + "Comma-separated DevPlace model ids to offer. Null means live discovery.", + nullable=True), + setting_spec("pollinations_enabled", "TAI_POLLINATIONS", "bool", True, + "Offer the Pollinations backend."), + setting_spec("pollinations_models", "TAI_POLLINATIONS_MODELS", "string", None, + "Comma-separated Pollinations model ids to offer. Null means live discovery.", + nullable=True), + setting_spec("zen_enabled", "TAI_ZEN", "bool", False, + "Opt in to the opencode.ai zen backend."), + setting_spec("zen_models", "TAI_ZEN_MODELS", "string", None, + "Comma-separated zen model ids to offer. Null means live discovery.", + nullable=True), + setting_spec("retry_seconds", "TAI_RETRY_SECONDS", "float", 0.0, + "Seconds to keep retrying all backends on total failure. 0 means forever. Plain decimal, no units."), + setting_spec("voice", "TAI_VOICE", "string", DEFAULT_VOICE, + "Edge TTS voice name for the speak tool."), + setting_spec("theme", "TAI_THEME", "string", "dark", + "TUI theme. Strict: must be one of the listed options. CLI --theme wins over env and vault.", + options=["dark", "light", "nord", "gruvbox", "ansi"]), + setting_spec("gemini_plan", "TAI_GEMINI_PLAN", "bool", False, + "Use the Gemini CLI plan approval variant first.", + true_words=SETTING_TRUE_WORDS + ("plan",)), + setting_spec("codex_sandbox", "TAI_CODEX_SANDBOX", "bool", False, + "Re-enable Codex CLI sandbox flags.", + true_words=SETTING_TRUE_WORDS + ("read-only", "readonly")), + setting_spec("box_caps", "TAI_BOX_CAPS", "bool", True, + "Apply resource caps to pre-existing sandbox boxes. Escape hatch: false skips the live update. Env honors only 0 as false.", + false_words=("0",)), + setting_spec("muse_bin", "MUSE_BIN", "string", None, + "Absolute muse CLI path override. Null means PATH lookup.", + nullable=True), + setting_spec("opencode_bin", "OPENCODE_BIN", "string", None, + "Absolute opencode CLI path override. Null means PATH lookup.", + nullable=True), + setting_spec("grok_bin", "GROK_BIN", "string", None, + "Absolute grok CLI path override (falls back to grokcli). Null means PATH lookup.", + nullable=True), + setting_spec("claude_bin", "CLAUDE_BIN", "string", None, + "Absolute claude CLI path override. Null means PATH lookup.", + nullable=True), + setting_spec("codex_bin", "CODEX_BIN", "string", None, + "Absolute codex CLI path override. Null means PATH lookup.", + nullable=True), + setting_spec("gemini_bin", "GEMINI_BIN", "string", None, + "Absolute gemini CLI path override. Null means PATH lookup.", + nullable=True), + setting_spec("opencode_client_name", "OPENCODE_CLIENT_NAME", "string", None, + "x-opencode-client header override for the zen backend. Null means the built-in.", + nullable=True), + setting_spec("opencode_client_ua", "OPENCODE_CLIENT_USER_AGENT", "string", None, + "User-Agent header override for the zen backend. Null means the built-in.", + nullable=True), +)) + +CONFIG_SETTING_FIELDS = ( + ("model", "model", "TAI_MODEL", "str"), + ("backends", "backends", "TAI_BACKENDS", "backends"), + ("openrouter_key", "openrouter_key", "OPENROUTER_API_KEY", "str"), + ("openrouter_models", "openrouter_models", "TAI_OPENROUTER_MODELS", "models"), + ("retry_seconds", "retry_seconds", "TAI_RETRY_SECONDS", "float"), + ("opencode_enabled", "opencode_enabled", "TAI_OPENCODE", "bool"), + ("opencode_models", "opencode_models", "TAI_OPENCODE_MODELS", "models"), + ("devplace_key", "devplace_key", "DEVPLACE_API_KEY", "str"), + ("devplace_base", "devplace_base", "TAI_DEVPLACE_BASE", "rstrip"), + ("devplace_models", "devplace_models", "TAI_DEVPLACE_MODELS", "models"), + ("openrouter_enabled", "openrouter_enabled", "TAI_OPENROUTER", "bool"), + ("pollinations_enabled", "pollinations_enabled", "TAI_POLLINATIONS", "bool"), + ("pollinations_models", "pollinations_models", "TAI_POLLINATIONS_MODELS", "models"), + ("zen_enabled", "zen_enabled", "TAI_ZEN", "bool"), + ("zen_models", "zen_models", "TAI_ZEN_MODELS", "models"), + ("voice", "voice", "TAI_VOICE", "str"), +) + +SETTING_EXPORT_KEYS = ("theme", "muse_bin", "opencode_bin", "grok_bin", "claude_bin", "codex_bin", + "gemini_bin", "gemini_plan", "codex_sandbox", "box_caps", "opencode_client_name", + "opencode_client_ua") + + +def validate_setting_scope(scope): + if scope != SETTING_SCOPE_GLOBAL and not PROFILE_RE.match(scope or ""): + raise ValueError("invalid settings scope %r" % (scope,)) + return scope + + +def normalize_setting_value(key, spec, value): + """Validate an upsert value, return its canonical stored string (or None). + + Canonical forms: bool as 1/0, float as str(float), strings raw. Legacy env + spellings (read-only, plan) stay readable from env but are never written.""" + if value is None: + if spec["nullable"]: + return None + raise ValueError("setting '%s' is not nullable" % key) + kind = spec["type"] + if kind == "bool": + if value is True or value == 1 and not isinstance(value, str): + return "1" + if value is False or value == 0 and not isinstance(value, str): + return "0" + text = str(value).strip().lower() if isinstance(value, str) else "" + if text in SETTING_TRUE_WORDS: + return "1" + if text in SETTING_FALSE_WORDS: + return "0" + raise ValueError("setting '%s' wants a bool (1/0, true/false, yes/no, on/off)" % key) + if kind == "float": + if isinstance(value, bool): + raise ValueError("setting '%s' wants a float, not a bool" % key) + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError("setting '%s' wants a float" % key) + if not math.isfinite(number) or number < 0: + raise ValueError("setting '%s' wants a finite float >= 0" % key) + return str(number) + if not isinstance(value, str): + raise ValueError("setting '%s' wants a string" % key) + if spec["required"] and not value: + raise ValueError("setting '%s' is required and cannot be empty" % key) + if spec["options"] is not None and value not in spec["options"]: + raise ValueError("setting '%s' must be one of: %s" % (key, ", ".join(spec["options"]))) + return value + + +def parse_setting_env(spec, raw): + """Parse a non-empty env value with the same semantics as the readers.""" + default = spec["default"] + if spec["type"] == "bool": + text = raw.strip().lower() + if text in (spec["true_words"] or SETTING_TRUE_WORDS): + return True + if text in (spec["false_words"] or SETTING_FALSE_WORDS): + return False + return default + if spec["type"] == "float": + if SETTING_FLOAT_RE.match(raw.strip()): + return float(raw.strip()) + return default + if spec["options"] is not None and raw not in spec["options"]: + raise ValueError("setting '%s' must be one of: %s (from %s)" % (spec["key"], ", ".join(spec["options"]), spec["env"])) + return raw + + +def parse_setting_stored(spec, stored): + """Parse a canonical stored string back to its python value.""" + if spec["type"] == "bool": + return stored == "1" + if spec["type"] == "float": + return float(stored) + return stored + + +def export_settings_to_env(store, keys=None): + """Fill unset env vars from vault settings. Returns the exported key names. + + Only fills: a set (non-empty) env var always wins. Stored values are + canonical, so every existing reader parses them exactly like env input.""" + rows = dict((row["key"], row) for row in store.get_or_create_settings(scope=SETTING_SCOPE_GLOBAL)) + exported = [] + for key in keys or SETTING_EXPORT_KEYS: + spec = SETTINGS_CATALOG[key] + if os.environ.get(spec["env"]): + continue + if rows[key]["stored"] is None: + continue + os.environ[spec["env"]] = rows[key]["stored"] + exported.append(key) + return exported + + +def boot_settings(config, store): + """Apply vault settings at boot: Config fields plus env export for the + peripheral keys and every applied Config key (so nested processes agree). + Returns the sorted names that took effect from the vault.""" + applied = config.apply_store_settings(store) + keys = list(SETTING_EXPORT_KEYS) + [key for key in applied if key not in SETTING_EXPORT_KEYS] + if getattr(config, "no_opencode_cli", False) and "opencode_enabled" in keys: + keys.remove("opencode_enabled") + exported = export_settings_to_env(store, keys=keys) + return sorted(set(applied + exported)) + def ensure_column(db, table, column, ddl): names = [row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()] @@ -5157,6 +5423,7 @@ SCHEMA = ( ("dir_defaults", ("dir TEXT PRIMARY KEY", "provider TEXT", "model TEXT", "bot TEXT", "updated TEXT"), ()), ("input_history", ("id INTEGER PRIMARY KEY", "profile TEXT", "bot TEXT", "ts TEXT", "text TEXT"), ()), ("shell_sessions", ("sid TEXT PRIMARY KEY", "parent TEXT", "shell TEXT", "pid INTEGER", "started TEXT", "last_seen TEXT", "cwd TEXT", "chain TEXT"), ()), + ("settings", ("key TEXT", "scope TEXT", "value TEXT", "created TEXT", "updated TEXT"), ("key", "scope")), ) SCHEMA_INDEXES = ( @@ -5222,6 +5489,11 @@ class Store: self.db.execute("UPDATE secrets SET meta = tai_enc(COALESCE(meta, '{}')) WHERE meta IS NULL OR meta NOT LIKE 'tai1$%'") self.db.execute("UPDATE schedules SET prompt = tai_enc(prompt) WHERE prompt NOT LIKE 'tai1$%'") self.db.execute("UPDATE input_history SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") + sealed_keys = [key for key, spec in SETTINGS_CATALOG.items() if spec["encrypted"]] + if sealed_keys: + self.db.execute( + "UPDATE settings SET value = tai_enc(value) WHERE key IN (%s) AND value IS NOT NULL AND value NOT LIKE 'tai1$%%'" + % ", ".join("?" for _key in sealed_keys), sealed_keys) self.db.commit() self.seal_files() else: @@ -5237,6 +5509,9 @@ class Store: typed = self.db.execute("SELECT COUNT(*) FROM input_history WHERE text LIKE 'tai1$%'").fetchone()[0] if typed: raise SealError("input history is sealed, set TAI_PASSPHRASE") + configured = self.db.execute("SELECT COUNT(*) FROM settings WHERE value LIKE 'tai1$%'").fetchone()[0] + if configured: + raise SealError("settings are sealed, set TAI_PASSPHRASE") self.memdb = None self.mem_events_max = 0 if seal.enabled and self.fts_ok: @@ -6001,6 +6276,113 @@ class Store: except OSError: pass + def settings_row(self, key, scope, own, fallback): + """Merge one catalog entry with its stored rows into a result dict. + + own/fallback are (value, created, updated) tuples or None. Precedence: + non-empty env, own scope row, global fallback row, catalog default.""" + spec = SETTINGS_CATALOG[key] + stored = None + created = updated = None + if own is not None and own[0] is not None: + stored, created, updated = own + elif fallback is not None and fallback[0] is not None: + stored, created, updated = fallback + elif own is not None: + created, updated = own[1], own[2] + if spec["encrypted"] and stored is not None and stored.startswith(Seal.PREFIX): + stored = self.seal.safe_unlock(stored) + env_raw = os.environ.get(spec["env"]) or "" + if env_raw.strip(): + value = parse_setting_env(spec, env_raw.strip()) + source = "env" + elif stored is not None: + value = parse_setting_stored(spec, stored) + source = "db" + else: + value = spec["default"] + source = "default" + if spec["required"] and (value is None or value == ""): + raise ValueError("setting '%s' is required but unset" % key) + return {"key": key, "scope": scope, "type": spec["type"], "default": spec["default"], + "required": spec["required"], "nullable": spec["nullable"], "options": spec["options"], + "preferred": spec["preferred"], "encrypted": spec["encrypted"], "env": spec["env"], + "desc": spec["desc"], "stored": stored, "value": value, "source": source, + "created": created, "updated": updated} + + def read_setting_rows(self, scope): + try: + found = self.db.execute("SELECT key, value, created, updated FROM settings WHERE scope = ?", (scope,)).fetchall() + except sqlite3.Error: + return {} + return dict((key, (value, created, updated)) for key, value, created, updated in found) + + def get_or_create_settings(self, keys=None, scope="global"): + """Ensure vault rows exist for keys (all catalog keys when None) and + return their merged rows. Unknown keys and bad scopes raise.""" + validate_setting_scope(scope) + if isinstance(keys, str): + keys = [keys] + names = sorted(SETTINGS_CATALOG) if keys is None else sorted(keys) + for name in names: + if name not in SETTINGS_CATALOG: + raise ValueError("unknown setting '%s'" % (name,)) + own = self.read_setting_rows(scope) + global_rows = self.read_setting_rows(SETTING_SCOPE_GLOBAL) if scope != SETTING_SCOPE_GLOBAL else own + now = now_iso() + created_any = False + for name in names: + if name not in own: + self.db.execute("INSERT INTO settings (key, scope, value, created, updated) VALUES (?, ?, NULL, ?, ?)", + (name, scope, now, now)) + created_any = True + if created_any: + self.db.commit() + own = self.read_setting_rows(scope) + return [self.settings_row(name, scope, own.get(name), + None if scope == SETTING_SCOPE_GLOBAL else global_rows.get(name)) for name in names] + + def get_all_settings(self, scope="global"): + """Read-only merge of the whole catalog for one scope. Creates nothing.""" + validate_setting_scope(scope) + own = self.read_setting_rows(scope) + global_rows = self.read_setting_rows(SETTING_SCOPE_GLOBAL) if scope != SETTING_SCOPE_GLOBAL else own + return [self.settings_row(name, scope, own.get(name), + None if scope == SETTING_SCOPE_GLOBAL else global_rows.get(name)) + for name in sorted(SETTINGS_CATALOG)] + + def upsert_settings(self, items, scope="global"): + """Validate and store setting values atomically. items maps key to a + python value; None clears a nullable key back to null. Anything + invalid raises ValueError and writes nothing. Returns fresh rows.""" + validate_setting_scope(scope) + if not isinstance(items, dict) or not items: + raise ValueError("upsert needs a non-empty {key: value} mapping") + canonical = {} + for key in sorted(items): + if key not in SETTINGS_CATALOG: + raise ValueError("unknown setting '%s'" % (key,)) + canonical[key] = normalize_setting_value(key, SETTINGS_CATALOG[key], items[key]) + now = now_iso() + try: + for key in sorted(canonical): + spec = SETTINGS_CATALOG[key] + value = canonical[key] + if value is not None and spec["encrypted"]: + value = self.seal.lock(value) + exists = self.db.execute("SELECT 1 FROM settings WHERE key = ? AND scope = ?", (key, scope)).fetchone() + if exists: + self.db.execute("UPDATE settings SET value = ?, updated = ? WHERE key = ? AND scope = ?", + (value, now, key, scope)) + else: + self.db.execute("INSERT INTO settings (key, scope, value, created, updated) VALUES (?, ?, ?, ?, ?)", + (key, scope, value, now, now)) + self.db.commit() + except sqlite3.Error as exc: + self.db.rollback() + raise ValueError("settings upsert failed: %s" % exc) + return self.get_or_create_settings(sorted(canonical), scope=scope) + def load_dir_defaults(self, directory): try: row = self.db.execute("SELECT provider, model, bot FROM dir_defaults WHERE dir = ?", (directory,)).fetchone() @@ -18919,6 +19301,7 @@ class Agent: def default_runner(config, seal, profile, depth, task, timeout, max_steps=WORKER_STEPS, stats=None): store = Store(config, seal) + boot_settings(config, store) try: worker = Agent(config, store, persist=False, quiet=True, depth=depth) if profile != worker.profile: @@ -20487,6 +20870,9 @@ def boot(args): store = Store(config, seal) if store.schema_notes: print("vault schema upgraded: %s" % ", ".join(store.schema_notes)) + applied_settings = boot_settings(config, store) + if applied_settings: + print("settings from vault: %s" % ", ".join(applied_settings)) ctx = ShellSession.current() ctx.register(store) ctx.export() diff --git a/test_tai.py b/test_tai.py index 125eb9d..aecc1b9 100644 --- a/test_tai.py +++ b/test_tai.py @@ -2340,6 +2340,340 @@ class BundledSkillsTests(unittest.TestCase): self.assertEqual(self.app.skills, {}) +class SettingsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.home = os.path.join(self.tmp.name, "home") + os.makedirs(self.home) + self.env = mock.patch.dict(os.environ, {"HOME": self.home, "TAI_HOME": os.path.join(self.home, ".tai")}) + self.env.start() + self.saved_env = {} + for spec in tai.SETTINGS_CATALOG.values(): + if spec["env"] in os.environ: + self.saved_env[spec["env"]] = os.environ.pop(spec["env"]) + + class FakeArgs: + profile = "t" + yes = True + + self.config = tai.Config(FakeArgs()) + self.store = tai.Store(self.config, tai.Seal(self.config.home, "settings-test-1")) + + def tearDown(self): + self.store.close() + os.environ.update(self.saved_env) + for spec in tai.SETTINGS_CATALOG.values(): + if spec["env"] not in self.saved_env and spec["env"] in os.environ: + del os.environ[spec["env"]] + self.env.stop() + self.tmp.cleanup() + os.environ.pop("TAI_HOME", None) + + def rows(self, scope="global"): + return dict((row["key"], row) for row in self.store.get_all_settings(scope=scope)) + + def test_catalog_self_check(self): + catalog = tai.SETTINGS_CATALOG + self.assertEqual(len(catalog), 28) + envs = [spec["env"] for spec in catalog.values()] + self.assertEqual(len(set(envs)), len(envs)) + for key in sorted(catalog): + spec = catalog[key] + self.assertIn(spec["type"], ("string", "bool", "float"), key) + self.assertTrue(spec["desc"], key) + self.assertFalse(spec["required"] and spec["nullable"], key) + if spec["options"] is not None: + self.assertTrue(spec["options"], key) + self.assertIn(spec["default"], spec["options"], key) + if spec["required"]: + self.assertTrue(spec["default"], key) + if spec["preferred"] is not None: + self.assertTrue(spec["preferred"], key) + tai.normalize_setting_value(key, spec, spec["default"]) + spec = catalog["theme"] + self.assertEqual(spec["default"], tai.TUI_THEME_DEFAULT) + self.assertEqual(sorted(spec["options"]), sorted(tai.TUI_THEMES)) + config_keys = [key for _attr, key, _env, _kind in tai.CONFIG_SETTING_FIELDS] + for _attr, key, env_var, kind in tai.CONFIG_SETTING_FIELDS: + self.assertIn(key, catalog, key) + self.assertEqual(catalog[key]["env"], env_var, key) + self.assertIn(kind, ("str", "models", "backends", "bool", "float", "rstrip"), key) + for key in tai.SETTING_EXPORT_KEYS: + self.assertIn(key, catalog, key) + self.assertFalse(set(config_keys) & set(tai.SETTING_EXPORT_KEYS)) + self.assertEqual(sorted(config_keys + list(tai.SETTING_EXPORT_KEYS)), sorted(catalog)) + + def test_settings_doc_names_every_key(self): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings.md"), encoding="utf-8") as handle: + doc = handle.read() + for key in sorted(tai.SETTINGS_CATALOG): + self.assertIn("`%s`" % key, doc, key) + self.assertIn("`%s`" % tai.SETTINGS_CATALOG[key]["env"], doc, key) + for symbol in ("SETTINGS_CATALOG", "get_or_create_settings", "upsert_settings", + "get_all_settings", "boot_settings", "CONFIG_SETTING_FIELDS", "SETTING_EXPORT_KEYS"): + self.assertIn(symbol, doc, symbol) + self.assertNotIn(chr(0x2014), doc) + + def test_get_or_create_seeds_rows(self): + rows = self.store.get_or_create_settings() + self.assertEqual(len(rows), 28) + self.assertTrue(all(row["source"] == "default" for row in rows)) + count = self.store.db.execute("SELECT COUNT(*) FROM settings").fetchone()[0] + self.assertEqual(count, 28) + row = [item for item in rows if item["key"] == "model"][0] + self.assertEqual(row["value"], "muse") + self.assertTrue(row["created"] and row["updated"]) + self.store.get_or_create_settings() + count = self.store.db.execute("SELECT COUNT(*) FROM settings").fetchone()[0] + self.assertEqual(count, 28) + single = self.store.get_or_create_settings("model") + self.assertEqual([item["key"] for item in single], ["model"]) + with self.assertRaises(ValueError): + self.store.get_or_create_settings(["nope"]) + with self.assertRaises(ValueError): + self.store.get_or_create_settings(scope="bad scope!") + + def test_get_all_is_read_only(self): + rows = self.store.get_all_settings() + self.assertEqual(len(rows), 28) + count = self.store.db.execute("SELECT COUNT(*) FROM settings").fetchone()[0] + self.assertEqual(count, 0) + + def test_upsert_types_and_canonical(self): + self.store.upsert_settings({"zen_enabled": True, "opencode_enabled": "yes", "pollinations_enabled": 1}) + self.store.upsert_settings({"openrouter_enabled": False, "gemini_plan": "off", "codex_sandbox": 0}) + self.store.upsert_settings({"retry_seconds": 30}) + self.store.upsert_settings({"retry_seconds": "2.5"}) + rows = self.rows() + for key in ("zen_enabled", "opencode_enabled", "pollinations_enabled"): + self.assertEqual(rows[key]["stored"], "1", key) + self.assertIs(rows[key]["value"], True, key) + for key in ("openrouter_enabled", "gemini_plan", "codex_sandbox"): + self.assertEqual(rows[key]["stored"], "0", key) + self.assertIs(rows[key]["value"], False, key) + self.assertEqual(rows["retry_seconds"]["stored"], "2.5") + self.assertEqual(rows["retry_seconds"]["value"], 2.5) + for bad in ("maybe", "read-only", "plan", "", 2): + with self.assertRaises(ValueError, msg=repr(bad)): + self.store.upsert_settings({"zen_enabled": bad}) + for bad in (True, "abc", "nan", "inf", "-1", "-0.5"): + with self.assertRaises(ValueError, msg=repr(bad)): + self.store.upsert_settings({"retry_seconds": bad}) + with self.assertRaises(ValueError): + self.store.upsert_settings({"model": 42}) + + def test_upsert_validation_atomic(self): + with self.assertRaises(ValueError): + self.store.upsert_settings({"theme": "frobnicate"}) + with self.assertRaises(ValueError): + self.store.upsert_settings({"model": ""}) + with self.assertRaises(ValueError): + self.store.upsert_settings({"model": None}) + with self.assertRaises(ValueError): + self.store.upsert_settings({"muse_bin": 42}) + with self.assertRaises(ValueError): + self.store.upsert_settings({}) + with self.assertRaises(ValueError): + self.store.upsert_settings([("model", "x")]) + with self.assertRaises(ValueError): + self.store.upsert_settings({"model": "x"}, scope="bad scope!") + with self.assertRaises(ValueError): + self.store.upsert_settings({"model": "kept?", "theme": "frobnicate"}) + count = self.store.db.execute("SELECT COUNT(*) FROM settings").fetchone()[0] + self.assertEqual(count, 0) + self.store.upsert_settings({"theme": "nord"}) + self.assertEqual(self.rows()["theme"]["value"], "nord") + + def test_upsert_clear_and_timestamps(self): + first = self.store.upsert_settings({"muse_bin": "/x/muse"}) + created = first[0]["created"] + self.assertEqual(first[0]["updated"], created) + second = self.store.upsert_settings({"muse_bin": "/y/muse"}) + self.assertEqual(second[0]["created"], created) + self.assertTrue(second[0]["updated"] >= created) + cleared = self.store.upsert_settings({"muse_bin": None}) + self.assertIsNone(cleared[0]["stored"]) + self.assertIsNone(cleared[0]["value"]) + self.assertEqual(cleared[0]["source"], "default") + self.assertEqual(cleared[0]["created"], created) + + def test_encryption_at_rest(self): + self.store.upsert_settings({"openrouter_key": "sk-secret", "model": "plain-model"}) + raw_key = self.store.db.execute("SELECT value FROM settings WHERE key = 'openrouter_key'").fetchone()[0] + self.assertTrue(raw_key.startswith(tai.Seal.PREFIX)) + self.assertNotIn("sk-secret", raw_key) + raw_model = self.store.db.execute("SELECT value FROM settings WHERE key = 'model'").fetchone()[0] + self.assertEqual(raw_model, "plain-model") + rows = self.rows() + self.assertEqual(rows["openrouter_key"]["stored"], "sk-secret") + self.assertEqual(rows["openrouter_key"]["value"], "sk-secret") + with self.assertRaises(tai.SealError): + tai.Store(self.config, tai.Seal(self.config.home, "")) + plain_home = os.path.join(self.tmp.name, "plain") + os.makedirs(plain_home) + + class PlainArgs: + profile = "t" + yes = True + + with mock.patch.dict(os.environ, {"TAI_HOME": plain_home}): + plain_config = tai.Config(PlainArgs()) + plain_seal = tai.Seal(plain_config.home, "") + self.assertFalse(plain_seal.enabled) + plain = tai.Store(plain_config, plain_seal) + try: + plain.upsert_settings({"devplace_key": "dp-plain"}) + raw = plain.db.execute("SELECT value FROM settings WHERE key = 'devplace_key'").fetchone()[0] + self.assertEqual(raw, "dp-plain") + found = dict((row["key"], row) for row in plain.get_all_settings()) + self.assertEqual(found["devplace_key"]["value"], "dp-plain") + finally: + plain.close() + + def test_scopes_fall_back_to_global(self): + self.store.upsert_settings({"model": "global-model", "zen_enabled": True}) + self.store.upsert_settings({"model": "work-model"}, scope="work") + everything = self.store.get_all_settings(scope="work") + rows = dict((row["key"], row) for row in everything) + self.assertEqual(rows["model"]["value"], "work-model") + self.assertEqual(rows["zen_enabled"]["value"], True) + self.assertEqual(rows["zen_enabled"]["source"], "db") + self.assertEqual(self.rows()["model"]["value"], "global-model") + self.store.get_or_create_settings(["model"], scope="work") + count = self.store.db.execute("SELECT COUNT(*) FROM settings WHERE scope = 'work'").fetchone()[0] + self.assertEqual(count, 1) + + def test_env_precedence_and_source(self): + self.store.upsert_settings({"model": "db-model", "zen_enabled": True}) + with mock.patch.dict(os.environ, {"TAI_MODEL": "env-model"}): + row = self.rows()["model"] + self.assertEqual(row["value"], "env-model") + self.assertEqual(row["source"], "env") + self.assertEqual(row["stored"], "db-model") + self.assertEqual(self.rows()["zen_enabled"]["source"], "db") + with mock.patch.dict(os.environ, {"TAI_MODEL": ""}): + self.assertEqual(self.rows()["model"]["value"], "db-model") + with mock.patch.dict(os.environ, {"TAI_CODEX_SANDBOX": "read-only", "TAI_GEMINI_PLAN": "plan"}): + self.assertIs(self.rows()["codex_sandbox"]["value"], True) + self.assertIs(self.rows()["gemini_plan"]["value"], True) + with mock.patch.dict(os.environ, {"TAI_BOX_CAPS": "false"}): + self.assertIs(self.rows()["box_caps"]["value"], True) + with mock.patch.dict(os.environ, {"TAI_BOX_CAPS": "0"}): + self.assertIs(self.rows()["box_caps"]["value"], False) + with mock.patch.dict(os.environ, {"TAI_RETRY_SECONDS": "abc"}): + self.assertEqual(self.rows()["retry_seconds"]["value"], 0.0) + with mock.patch.dict(os.environ, {"TAI_THEME": "frobnicate"}): + with self.assertRaises(ValueError): + self.store.get_all_settings() + + def test_config_second_pass(self): + self.store.upsert_settings({"model": "db-model", "zen_enabled": True, "retry_seconds": 30, + "opencode_models": "a, b", "devplace_base": "https://x.test/v1/"}) + applied = self.config.apply_store_settings(self.store) + self.assertIn("model", applied) + self.assertEqual(self.config.model, "db-model") + self.assertEqual(self.config.explicit_model, "db-model") + self.assertIs(self.config.zen_enabled, True) + self.assertEqual(self.config.retry_seconds, 30.0) + self.assertEqual(self.config.opencode_models, ["a", "b"]) + self.assertEqual(self.config.devplace_base, "https://x.test/v1") + with mock.patch.dict(os.environ, {"TAI_MODEL": "env-model"}): + + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + applied = config.apply_store_settings(self.store) + self.assertNotIn("model", applied) + self.assertEqual(config.model, "env-model") + + def test_no_opencode_cli_wins_over_vault(self): + self.store.upsert_settings({"opencode_enabled": True}) + + class FakeArgs: + profile = "t" + yes = True + no_opencode = True + + config = tai.Config(FakeArgs()) + self.assertIs(config.opencode_enabled, False) + applied = config.apply_store_settings(self.store) + self.assertNotIn("opencode_enabled", applied) + self.assertIs(config.opencode_enabled, False) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAI_OPENCODE", None) + exported = tai.boot_settings(config, self.store) + self.assertNotIn("opencode_enabled", exported) + self.assertNotIn("TAI_OPENCODE", os.environ) + + def test_export_settings_to_env(self): + self.store.upsert_settings({"theme": "nord", "muse_bin": "/x/muse"}) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAI_THEME", None) + os.environ.pop("MUSE_BIN", None) + exported = tai.export_settings_to_env(self.store) + self.assertIn("theme", exported) + self.assertIn("muse_bin", exported) + self.assertEqual(os.environ["TAI_THEME"], "nord") + self.assertEqual(os.environ["MUSE_BIN"], "/x/muse") + with mock.patch.dict(os.environ, {"TAI_THEME": "light"}): + exported = tai.export_settings_to_env(self.store) + self.assertNotIn("theme", exported) + self.assertEqual(os.environ["TAI_THEME"], "light") + + def test_boot_settings(self): + self.store.upsert_settings({"model": "db-model", "theme": "nord"}) + + class FakeArgs: + profile = "t" + yes = True + + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TAI_MODEL", None) + os.environ.pop("TAI_THEME", None) + config = tai.Config(FakeArgs()) + applied = tai.boot_settings(config, self.store) + self.assertEqual(sorted(applied), ["model", "theme"]) + self.assertEqual(config.model, "db-model") + self.assertEqual(os.environ["TAI_MODEL"], "db-model") + self.assertEqual(os.environ["TAI_THEME"], "nord") + + def test_rotate_seal_reseals_settings(self): + self.store.upsert_settings({"openrouter_key": "sk-secret", "model": "plain-model"}) + before = self.store.db.execute("SELECT value FROM settings WHERE key = 'openrouter_key'").fetchone()[0] + self.store.close() + fresh = tai.rotate_seal(self.config, tai.Seal(self.config.home, "settings-test-1"), "settings-test-2") + self.store = tai.Store(self.config, fresh) + after = self.store.db.execute("SELECT value FROM settings WHERE key = 'openrouter_key'").fetchone()[0] + self.assertTrue(after.startswith(tai.Seal.PREFIX)) + self.assertNotEqual(before, after) + self.assertEqual(self.rows()["openrouter_key"]["value"], "sk-secret") + self.assertEqual(self.rows()["model"]["value"], "plain-model") + + def test_migration_creates_settings_table(self): + self.store.close() + legacy = os.path.join(self.tmp.name, "legacy.db") + db = sqlite3.connect(legacy) + db.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, profile TEXT)") + db.commit() + db.close() + + class FakeArgs: + profile = "t" + yes = True + + config = tai.Config(FakeArgs()) + config.db_path = legacy + store = tai.Store(config, tai.Seal(config.home, "settings-test-1")) + try: + tables = [row[0] for row in store.db.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()] + self.assertIn("settings", tables) + self.assertEqual(len(store.get_all_settings()), 28) + finally: + store.close() + + class BoxCapsTests(unittest.TestCase): def setUp(self): tai._BOX_CAPS_STAMP["done"] = False