Vault-backed settings catalog with three-function API (1.41.0)

- SETTINGS_CATALOG: 28 env-mapped settings with type, default,
  required/nullable, strict options, preferred (reserved), encrypted
  flags, per-key bool word sets; routing split into
  CONFIG_SETTING_FIELDS (Config build) and SETTING_EXPORT_KEYS
- Store.get_or_create_settings / get_all_settings /
  upsert_settings: global+profile scopes, atomic strict validation,
  canonical forms, created/updated timestamps, env/db/default source
  reporting; encrypted values sealed at rest, rotation-aware,
  plaintext-boot refusal covers the table
- boot_settings in boot() and default_runner: Config second pass
  (env wins, CLI --no-opencode wins) plus env export for peripheral
  and applied keys; applied keys printed at boot
- settings.md contract with full catalog and exclusion rationale;
  16 SettingsTests incl. catalog self-check and doc drift guard
This commit is contained in:
2026-10-18 14:15:35 +02:00
parent 789f5206c4
commit fa4e638a5c
6 changed files with 881 additions and 2 deletions
+1
View File
@@ -13,6 +13,7 @@
!uiflow.md
!cap.md
!skills.md
!settings.md
# Backup files
*.bak
+1
View File
@@ -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
+3
View File
@@ -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)
+154
View File
@@ -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.
+388 -2
View File
@@ -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()
+334
View File
@@ -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