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:
@@ -13,6 +13,7 @@
|
||||
!uiflow.md
|
||||
!cap.md
|
||||
!skills.md
|
||||
!settings.md
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user