本页暂时只有英文版
Backend¶
Python FastAPI backend serving the REST API, managing browser sessions, and executing tasks.
Entry Point¶
main.py— FastAPI app setup, CORS middleware, router registration, lifespan (DB init on startup)config.py— Paths (BASE_DIR,DATA_DIR,SCREENSHOTS_DIR,DB_PATH),DATABASE_URL, default user constants, env varsdatabase.py— SQLAlchemy async engine, session factory,init_db()(creates tables + seeds default user),get_db()dependency
Modules¶
| Directory | Purpose |
|---|---|
models/ |
SQLAlchemy ORM models (DB schema) |
models/base.py |
SQLAlchemy declarative Base (decoupled from database.py to break circular imports) |
task_runner.py |
Task prompt assembly + execution pipelines (extracted from routers/tasks.py) |
schemas/ |
Pydantic request/response schemas |
routers/ |
FastAPI route handlers (API endpoints) |
browser/ |
Pluggable browser backends + session management |
ai/ |
AI agent abstraction (Claude Code CLI backend) |
executor/ |
Legacy browser task runner (deprecated — browser automation now via browser-use CLI) |
events/ |
SSE event bus for real-time streaming |
events_system/ |
Business event extraction + sync backends |
channels/ |
Message channel integrations (WeCom self-built app) |
notifiers/ |
Chat bot webhook senders (WeCom / DingTalk / Feishu / Slack) — base.py holds the PLATFORMS registry + detect_platform (platform is derived from the URL host, never client-supplied); one module per platform; never echoes the webhook URL in errors |
scheduler/ |
Task scheduling (cron, fan-out, concurrent per-store queue) |
workspace/ |
Workspace management (~/.vibe-seller/) |
knowledge/ |
Project knowledge files (synced to workspace) |
skills/ |
Built-in skills (synced to workspace) |
prompts/ |
LLM system prompts (loaded at import time) |
plugins.py |
Plugin framework (IoC registry) — core reads gates/guards/backends/skills/services from here instead of hardcoding them. See Plugin Framework. |
builtin_plugin.py |
The OSS "builtin plugin" — registers every core contribution through the plugin API. |
utils/ |
Shared utilities (crypto, etc.) |
uploads.py |
One definition of what may be uploaded and how big: MIME allow-list, extensions, MAX_UPLOAD_SIZE (100MB), the streaming save_upload() writer, and UploadBodyLimitMiddleware. Every multipart route imports from here — the limit used to live in four places and had already drifted. See Upload limits. |
text_utils.py |
Surrogate-safe text: SafeStr (request fields), SafeText (DB columns), sanitize_text (file writes). Agent text can carry a lone surrogate that no UTF-8 encode accepts — see Lone surrogates. |
platform.py |
Cross-platform abstractions (Windows/macOS/Linux) — psutil-based process management (kill_process, find_processes_by_pattern, reap_task_agents, collect_agent_descendants), venv path helpers (Scripts/ vs bin/, venv_python, venv_executable), prepend_to_path (uses os.pathsep), safe_chmod (no-op on Windows). Centralises every platform difference so the rest of the code stays platform-agnostic. See Cross-platform support. |
Shutdown cleanup — reaping task agents¶
A running task is a claude -p subprocess tree (claude → MCP server →
skill_cli.daemon → browser-use). Stopping the server must take that
whole tree down; a survivor keeps calling POST /stores/<id>/browser/start
on the next server and, because the anti-detect browser client is a
single shared process, competing survivors thrash it. Two layers ensure
no orphans:
ClaudeCodeBackend.stop_all()(ai/claude_backend_manager.py) — stops every runningAgentSession(eachstop()killpg's its subtree; Windows usestaskkill /F /Tviaprocess_utils.taskkill_tree). Wired intomain.py'slifespanshutdown so a graceful SIGTERM never orphans an agent.platform.reap_task_agents(pids=None)— a process-level backstop for the paths that bypass graceful shutdown (a SIGKILL of the server; WindowsTerminateProcessfromvibe-seller stop/ the tray quit-restart). Matches the headless-agent,skill_cli.daemon, and running-wrapper signatures and kills each with its child tree.vibe-seller stopscopes it withcollect_agent_descendants(server_pid)(snapshotted before the kill) so a second server instance's agents are left alone.
Request Flow¶
Client Request
→ FastAPI Router (app/routers/)
→ Pydantic Schema validation (app/schemas/)
→ SQLAlchemy Model CRUD (app/models/)
→ BrowserManager (app/browser/) if browser needed
→ AI Agent (app/ai/) for task execution
→ EventBus (app/events/) for real-time updates
→ SSE stream back to client
Database Models¶
SQLAlchemy 2.0 ORM models. All extend Base from app/database.py.
| Model | Table | Description |
|---|---|---|
AppSettings |
app_settings |
Key-value app settings (auth_required, admin_credentials_set, max_agent_concurrency, default_schedule_phase_mode, default_schedule_timezone, task_retention_days, google_workspace_enabled) |
User |
users |
Team members with JWT auth. username (unique, required), email (unique, nullable, validated). Login by username or email. Admin seeds from env (ADMIN_USERNAME, ADMIN_EMAIL). Per-user prefs: plan_mode_default, debug_mode, default_profile_id, sync_profile_to_schedules (re-pin owned schedules on default-profile change — see subsystems.md § Schedule AI profile resolution). |
Store |
stores |
E-commerce stores with browser config |
BrowserSession |
browser_sessions |
Active browser sessions per store |
Task |
tasks |
Units of work created by users |
TaskStep |
task_steps |
Individual steps within a task execution |
Screenshot |
screenshots |
Browser screenshot references (file paths on disk) |
TaskLog |
task_logs |
Execution log entries |
TaskAttachment |
task_attachments |
File attachments for tasks |
TaskMessage |
task_messages |
Agent chat history per task |
ZiniaoAccount |
ziniao_accounts |
Ziniao anti-detect browser accounts |
Event |
events |
Business events (deadlines, campaigns, cases) |
EventActivity |
event_activities |
Activity timeline entries per event |
Schedule |
schedules |
Recurring task schedules (daily/weekly/monthly, store-specific or all-stores) |
ScheduleState |
schedule_state |
Agent-managed cross-run cursor per schedule (schedule_id, key) → value. Lets one scheduled run persist a watermark (e.g. last processed email date) for the next run to resume from. |
EmailAccount |
email_accounts |
Email accounts with Fernet-encrypted IMAP passwords |
StoreEmailLink |
store_email_links |
Many-to-many store↔email with watermark tracking |
AIProfile |
ai_profiles |
AI agent configuration profiles |
Key Fields¶
Task: store_id, status, plan, result, todos (JSON), plan_mode, wait_condition (JSON), ai_profile_id (FK), batch_id, platform, country
Store: browser_backend ("chrome" | "ziniao"), browser_config (JSON), platforms (JSON), countries (JSON), platform_countries (JSON — platform→country mapping)
BrowserSession: store_id (UNIQUE), cdp_port, proxy_port, chrome_pid, status
Pydantic Schemas¶
Request/response schemas in app/schemas/. Key conventions:
- Schemas mirror model fields but handle JSON parsing (models store JSON as TEXT, schemas use native Python types)
- Response schemas use Config.from_attributes = True for ORM → schema conversion
- Request schemas only include user-provided fields (IDs and timestamps are auto-generated)
- Any field carrying agent- or user-authored prose is SafeStr, not str — see below
Lone surrogates in agent text¶
Claude's stream-json occasionally emits an unpaired surrogate
(U+D800–U+DFFF). JSON escapes them losslessly, so the request body is
valid on the wire and json.loads hands back a str that Python
cannot encode to UTF-8. Everything downstream then dies with
surrogates not allowed, and the agent sees an opaque tool error it
cannot fix (the character is invisible in its own output), so it retries
the same text.
Two layers, because there are two ways in:
| Layer | Covers |
|---|---|
sanitize_json in mcp_server.call_api |
The agent's actual ingress. httpx serializes json= with ensure_ascii=False and then encodes UTF-8, so a surrogate raises inside the MCP process and no request is ever made — the server-side guards below would never see it |
SafeStr on request fields |
Everything a request touches — including consumers that are not writes: the result gates hand the text to a language detector (a Rust extension) that encodes to UTF-8, so a submission crashed before any persistence. Also covers clients that are not our MCP server (the UI, curl) |
SafeText on DB columns |
Text that never passed through a request body — the stream reader persisting task_messages, plan text, internally assembled prose |
File writes are sanitized inside the managers that own them
(workspace/manager.py, workspace/skills_manager.py).
Composing SafeStr with StringConstraints has one trap:
Annotated[SafeStr, StringConstraints(...)] silently drops the
constraint. Wrap the constrained type instead —
Annotated[Annotated[str, StringConstraints(...)], SANITIZE_SURROGATES]
(see routers/tasks_schedule_state.py).
External Config Detection (ai/external_config.py)¶
Detects when an external tool (e.g. cc-switch — https://github.com/farion1231/cc-switch) has written ANTHROPIC_* env entries into ~/.claude/settings.json. Claude Code applies those entries with higher precedence than the subprocess env our agent passes at spawn time, so without this guard, a user's AI-profile selection silently becomes a no-op and the agent runs against whatever endpoint the external tool configured.
| Symbol | Purpose |
|---|---|
detect_claude_settings_overrides() |
Returns the sorted list of ANTHROPIC_* keys present in settings.json's env block. Prefix-match (no hardcoded enumeration) so a future Anthropic env var is caught automatically. |
assert_profile_compatible(profile_id) |
Raises ExternalConfigOverrideError if profile_id is non-default and the env block has any ANTHROPIC_* keys. No-op for the default profile (the documented escape hatch — lets the external tool fully own routing). |
ExternalConfigOverrideError.to_api_detail() |
Structured HTTPException.detail payload (code, profile_id, overriding_keys, settings_path, clear_command, English-fallback message). The frontend renders this in the user's locale via errors.externalConfigOverride.* i18n keys (see frontend/src/components/ExternalConfigOverrideModal.tsx and ExternalConfigOverrideErrorCard.tsx). |
Wired into¶
routers/profiles.py— POST/api/profiles, PUT/api/profiles/{id}, PATCH/api/profiles/{id}/set-defaultall return HTTP 409 with the structured detail when a non-default profile is selected while overrides exist.task_runner_auto.auto_run_task— fails the task fast witherror_category='external_config_override'and the JSON-encoded detail intask.errorbefore the agent is spawned. The frontend'sExternalConfigOverrideErrorCardparses the JSON and renders the localized template on the failed-task panel.
Test surface¶
tests/unit/test_external_config.py— detection across no-settings / no-env / malformed JSON / non-ANTHROPICkeys / single + multiple overrides; default-profile escape hatch; load-bearing pieces of the user message; future-key prefix-match.tests/workflow/test_wf_external_config_override.py— end-to-end across the profile router (409) and the task runner (fail-fast).
Profile Endpoint Validation (ai/profile_validation.py)¶
Every non-default profile routes the agent through an Anthropic-compatible endpoint declared entirely in the profile's env (ANTHROPIC_BASE_URL + a token + ANTHROPIC_MODEL). Nothing used to check that those three actually work together, so a typo'd base URL, a wrong/expired key, or a model id the provider has since retired all saved cleanly and only surfaced later as an opaque agent error. This module lets the config page probe the config on save.
validate_profile_env(env, *, client=None) (async) makes one minimal /v1/messages request against the profile's own endpoint and returns a ProfileValidationResult:
| Field | Meaning |
|---|---|
ok |
The gate the router/UI enforces. |
code |
Stable machine-readable outcome: ok, no_endpoint (native Claude — no base URL, no-op pass), missing_key, missing_model, unreachable, auth, not_found, http_error, protocol. |
error |
Human-facing message, often the provider's own text (e.g. a retired model id) — the stale-model signal. |
reported_model |
The model string the endpoint echoed back on success. Reveals silent fallbacks: lenient providers (e.g. MiniMax) 200 on an unknown model and report the real served model instead of rejecting. |
Design notes:
- Auth mirrors Claude Code so a pass here means a pass at runtime:
ANTHROPIC_AUTH_TOKEN→Authorization: Bearer,ANTHROPIC_API_KEY→x-api-key(both if both set). The token is never logged. - No
ANTHROPIC_BASE_URL→ no-op pass (no_endpoint): the native-Claude/defaultshape has no third-party endpoint to probe. clientis injectable so unit tests drive it with anhttpx.MockTransport.
Wired into¶
routers/profiles.py—POST /api/profiles/validatecalls it and returnsresult.to_dict()(always HTTP 200; verdict in the body). Persistence-free — the endpoint never writes a profile. See api.md.frontend/src/components/ProfileModal.tsx— the save handler awaits/api/profiles/validatefirst and blocks the save (showingerror) whenokis false.
Provider presets & model options (ai/profiles.py)¶
PROVIDER_PRESETS holds the per-provider env template (base URL, timeouts, per-tier model aliases). PROVIDER_MODELS holds the config UI's model dropdown per provider — each option is {id, label, context?, vision?}; the first entry is the default and must equal the preset's ANTHROPIC_MODEL (pinned by a test). get_provider_presets() merges the two for the UI. Both are convenience shortlists (the UI also offers free-text "Custom"), and every choice is endpoint-validated on save, so a stale entry is caught rather than silently breaking a run.
A preset may also carry group + variant: presets sharing a group (e.g. Alibaba Cloud → Pay-as-you-go China/International, Coding Plan, Token Plan; GLM → China/International; MiMo → Pay-as-you-go / Token Plan) collapse into one top-level button in the picker that reveals a variant sub-row, instead of cluttering the flat list.
vision is verified, not casually web-sourced. Where an account key exists it is live-probed: each id was sent a 64×64 solid-red and solid-blue image and had to name both correctly, three times. Latest results (2026-10-29): glm-5.3-flash (Z.AI), deepseek-flash, MiniMax-M3.1-Flash, and MiniMax-M3 (api.minimax.cn) read images → vision; glm-5.3 and glm-5.2 misname the colors and qwen3.7-max 400s on images → text-only, matching DeepSeek's pricing table (deepseek-flash 图像理解 supported, deepseek-v4-pro not). The earlier live-verified Qwen results (qwen3.7-plus / qwen3.6-flash / the Qwen-VL series → vision) still stand; qwen3.8-max / qwen3.8-flash have no key to probe so their label is omitted, not guessed. Where no key exists, the label is set only from first-party evidence: Kimi k3.1-pro / k3 / kimi-for-coding → vision (Moonshot's official vision guide lists kimi-k3.1-pro / kimi-k3 / kimi-k2.7-code — doc-verified, not live: the account's Kimi Code subscription lapsed), and MiMo mimo-v2.6-pro / mimo-v2.6-flash / mimo-v2.6-pro-ultraspeed → vision (MiMo's image-understanding guide lists all three v2.6 chat models as supporting image input, and its Anthropic endpoint takes base64/url image blocks — doc-verified, not live: no MiMo key on this machine). context is doc-sourced (a 1M window isn't practical to probe). Add a key for a doc-verified provider and re-run the probe to upgrade it to live-verified.
A provider only gets a preset if its own docs ship an Anthropic-Messages-compatible endpoint for Claude Code — no third-party gateways. That bar is why xAI/Grok is absent: POST https://api.x.ai/v1/messages does answer Anthropic-shaped requests, but xAI's own REST reference marks it "Deprecated: The Anthropic SDK compatibility is fully deprecated. Please migrate to the Responses API or the gRPC API." and xAI publishes no Claude Code guide (its official Claude Code integration is the Grok Build CLI + grok-build-plugin-cc, which shells out to a local binary rather than re-formatting the API). Google Gemini and OpenAI are absent for a plainer reason: they ship no Anthropic-Messages endpoint for their own models at all — POST https://api.openai.com/v1/messages and POST https://generativelanguage.googleapis.com/v1/messages both 404. (Google's Anthropic-format endpoints on Google Cloud serve Claude, not Gemini.)
Test surface¶
tests/unit/test_profile_validation.py— the full verdict matrix viahttpx.MockTransport(success/auth/not-found/stale-model/unreachable/wrong-protocol/non-JSON/missing-key/missing-model, trailing-slash join, header mapping).tests/workflow/test_wf_profiles.py— the/validateendpoint contract (200, no persistence) and the model-options contract.tests/ai/test_profile_validation_live.py—ai-marked, against real vendor endpoints using CI's{PROVIDER}_API_KEYsecrets: good key/model → ok, bad model → rejected or reports-a-different-model, bad key → rejected (each provider self-skips without a key).
Plugin Framework¶
app/plugins.py is the inversion-of-control seam: core knows no
customer. Instead of importing customer gates/guards/backends, core
reads them from an ExtensionContext that plugins populate at startup.
Removing a customer = not installing its wheel — zero core edits,
nothing to re-leak. This is PR-1 of the public-OSS / private-plugin split
(design-of-record: plugin_design_v2.md).
Contract¶
| Symbol | Purpose |
|---|---|
Plugin (ABC) |
A plugin subclasses this and implements install(ctx). Optional load_contexts, name, version. |
ExtensionContext |
What install writes to. register_gate, register_pretool_gate, register_browser_backend, register_skill_source, register_prompt_fragment, register_service, register_router, register_frontend_bundle. |
load_plugins(ctx) |
Loads the builtin (direct import, fail-closed — a missing builtin aborts startup) then external plugins (logged-and-skipped on failure — fail-open so one bad wheel can't take the server down). |
get_extension_context() |
Process-wide singleton; loads plugins once, lazily (so app-less unit tests that hit the gate path still see a populated registry). |
registered_gates() / registered_pretool_gates() / registered_browser_backends() / registered_skill_sources() |
Convenience accessors core call sites read. |
Registration is declarative — install only records into ctx; it
never touches the live FastAPI app. App-level effects are applied
separately: main._wire_plugins mounts plugin routers / frontend-bundle
routes at module load; the lifespan starts/stops background services.
Discovery & isolation¶
Per-customer isolation is at pack/install time: each customer's deployment installs only that customer's plugin wheels, so other customers' code is absent on the box. The on-box loader then just loads whatever is installed:
- the OSS builtin (
app/builtin_plugin.py:BuiltinPlugin) by direct import — always present, no entry point / reinstall needed; - external plugins via the
vibe_seller.pluginsentry-point group their wheels declare (sorted by name; deterministic order).
What it replaced¶
stop_gates.get_registered_gates, bash_safety.first_bash_deny, and
BrowserManager._get_backend previously hardcoded their lists (and
imported customer gates / a money-transfer guard / browser-backend
classes directly). They now read from the registry. The BuiltinPlugin
registers only core's own, customer-agnostic contributions; customer
gates/guards/skills arrive via externally-installed plugin wheels.
Test surface: tests/unit/test_plugins.py.
Turn Lifecycle (process-per-turn model)¶
A turn is one CLI process, and the turn ends when the process
exits. The claude CLI never exits on its own while stdin is open
(spike-verified), so closing stdin is the turn terminator — owned
by the quiescence watchdog in ai/claude_backend_turns.py
(_TurnLifecycleMixin, ticked from the stream's readline-timeout
branch).
When stdin may close (soft linger) — ALL of:
- an accepted result exists for the current turn (_turn_result_seen),
- the review/exec gates pass (same composite + fail-open budget as the
result branch),
- no tracked async work is pending — async subagents (Agent tool) OR
background shell commands (Bash/PowerShell auto-backgrounded past the
blocking budget or run_in_background). Both register in _async_agents
(ai/claude_backend_subagents.py::_track_async_agents): subagents on
their "Async agent launched" ack, shells on their "…background… with
ID: <task-notification> completion. So
a turn does not end while a find/export it launched is still running,
and that command finishes + is incorporated instead of being orphaned,
- no AskUserQuestion is parked and not in the planning phase,
- the stream has been idle for the tier window.
Never while a question is pending or plan approval is parked.
A process hosts many turns (gate redrives, <task-notification>
continuations, injected follow-ups): every accepted execute-phase
result event is a turn boundary that emits its own role='result'
card, and the LAST non-empty one is the deliverable (task.result;
an explicit set_task_result still wins). _last_result_is_error is
per-result and folded into _is_error_result at process exit, so a
turn that recovered from an intermediate error ships COMPLETED while
forced errors (circuit breaker, rc≠0) stay FAILED.
Backstops (both strictly tighter than the pre-migration design):
- hard idle — total stream silence for VIBE_TURN_HARD_IDLE_S closes
regardless of gate/async holds (a live subagent streams events
through the parent, so silence that long means the work is dead);
- post-close kill escalation — a process still alive ~120s after any
close is _force_kill()ed and its stall-reaper heartbeat stops
(bounds the GLM stall-after-result wedge).
Runaway background command backstop. The hard-idle "silence = dead"
premise holds for subagents (they stream) but NOT for background shell
commands (a sleep/find emits nothing to the stream, so its liveness
is invisible). A background command that never completes is therefore
force-closed by hard idle while still running and killed with no
completion record. Resuming that session on the next follow-up would
make the CLI inject a reconciliation <task-notification> that collides
with the follow-up and aborts the turn's tool calls (CANCEL_MESSAGE) —
a confirm-gated generate_image then dies before its gate. So a
follow-up starts a fresh session (compacted history, not --resume)
when the prior session has an orphaned background task
(workspace/manager.py::session_has_orphaned_bg_task, wired in
routers/tasks_conversation.py::send_task_message). Two layers: the
turn-level wait prevents orphans for commands that finish; this contains
the orphan a genuine runaway still leaves.
Follow-up delivery: send_user_message returns a delivery bool
and opens a new turn; on a missed write (the message raced the turn
terminator) the conversation router falls through to the
resume/fresh-session spawn, so the message is re-routed, never
silently dropped.
| Variable | Default | Description |
|---|---|---|
VIBE_TURN_LINGER_S |
60 |
Soft-linger window (s) when async work (subagents or background shell commands) was launched this process. 0 = close at the result event (legacy behavior) |
VIBE_TURN_LINGER_QUIET_S |
5 |
Soft-linger window (s) for processes with no async work |
VIBE_TURN_HARD_IDLE_S |
600 |
Close after this much total stream silence regardless of holds. 0 = disabled |
Browser lifecycle (see docs/browser.md § Browser Lifecycle):
| Variable | Default | Description |
|---|---|---|
VIBE_BROWSER_IDLE_S |
300 |
Idle window before a browser with no live task is terminated. 0 = never |
VIBE_TAB_CAP |
12 |
Max tabs one task may keep open; oldest closed beyond it. 0 = unbounded |
VIBE_BROWSER_RELAUNCH_MAX |
3 |
Dead-mux full-env relaunches allowed per store per window before start_session refuses. 0 = unbounded |
VIBE_BROWSER_RELAUNCH_WINDOW_S |
600 |
Sliding window for the relaunch budget above. 0 = unbounded |
VIBE_BROWSER_START_TIMEOUT_S |
180 |
Ceiling on one store's backend.start(). It runs under a global lock, so an unbounded per-store retry loop starves every other store. 0 = unbounded |
Logging (app/logging_setup.py) — the backend log rolls on size or
age, whichever comes first, keeping LOG_BACKUP_COUNT numbered backups
(backend_<port>.log.1, …). On-disk ceiling is
LOG_MAX_BYTES × (LOG_BACKUP_COUNT + 1); defaults give 10 GiB / 2 weeks.
--dev (LOG_LEVEL=DEBUG) is what makes this matter — DEBUG chatter from
aiosqlite/httpcore/websockets once grew an unrotated log to 5 GB.
start.sh applies the same policy to backend.log (uvicorn's
stdout/stderr, captured by a shell redirect the handler can't reach).
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
INFO |
--dev sets DEBUG |
LOG_MAX_BYTES |
5368709120 (5 GiB) |
Roll when the log would exceed this. 0 = no size trigger |
LOG_ROTATE_DAYS |
7 |
Roll when the log is this old. 0 = no age trigger |
LOG_BACKUP_COUNT |
1 |
Old logs kept; the oldest is deleted as backups shift down. 0 = keep none |
Setting both LOG_MAX_BYTES=0 and LOG_ROTATE_DAYS=0 disables rotation
(plain unbounded FileHandler).
Configuration¶
All config in config.py. Key settings:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
sqlite+aiosqlite:///$HOME/.vibe-seller/data/vibe_seller.db |
Database connection (computed at runtime via Path.home(), not settable via env) |
SECRET_KEY |
Auto-generated | JWT signing key (env: SECRET_KEY) |
LOG_LEVEL |
INFO |
Python logging level (DEBUG, INFO, WARNING, ERROR). Use ./start.sh --dev to set DEBUG + AGENT_DEBUG=1 |
Database¶
SQLite with async access via aiosqlite. Tables auto-created on startup via Base.metadata.create_all. No Alembic migrations — schema managed directly by SQLAlchemy model definitions.
Auth: JWT httpOnly cookie (24h rolling expiry — rolled forward on activity; the 7-day TTL is the service tokens, not browser sessions). Login is optional (default off). When auth_required is false (DB setting), all endpoints return the default admin user without authentication. When enabled, JWT validation applies as usual. The auth_required setting is seeded from VIBE_AUTH_REQUIRED env var on first boot only — once toggled in the UI, the env var is ignored. Admin credentials (ADMIN_EMAIL/ADMIN_PASSWORD) are also first-boot-only; once changed in the UI, env vars are ignored unless FORCE_ADMIN_RESET=true is set.
Locked out: vibe-seller reset-auth¶
The seeded admin is admin / admin@vibe-seller.local / admin (app/env_options.py), valid only until someone changes the password in the UI — after that the env defaults are ignored and bcrypt keeps no copy of the old password. FORCE_ADMIN_RESET=true resets it, but the flag is applied by the boot seed, so it needs a server restart, and the agent tasks run as children of that server — the restart kills whatever was running.
vibe-seller reset-auth (implemented in app/auth_reset.py) is the third way in. It edits the app database directly, so the server stays up and running tasks are not interrupted:
| Command | Effect |
|---|---|
vibe-seller reset-auth status |
Whether a password is required, whether the seeded default still works, which accounts can log in |
vibe-seller reset-auth password |
Reset the default admin account's password (prompts for it) |
vibe-seller reset-auth password --user admin --rename |
Rename the default admin account back to admin and reset its password |
vibe-seller reset-auth disable |
Turn the login screen off — every request acts as the default admin |
vibe-seller reset-auth enable |
Turn the login screen back on |
--db PATH points at a database other than the app's runtime one. The action reads app_settings per request, so disable / enable take effect immediately (the frontend caches the flag — the browser needs a reload).
--user follows the login screen's own rule: an identifier containing @ is an email, anything else is a username. A username is validated with the app's own rules (app/schemas/user.py:validate_username) before it is written, because the login screen routes an @ to the email lookup — a username that broke either rule could never log in, and the recovery tool must not write one. An email identifies an account but cannot be renamed to.
A reset of the seeded admin also pins the admin_credentials_set setting: while that flag is unset, the boot seed re-applies ADMIN_USERNAME / ADMIN_PASSWORD on every start, which would undo the reset at the next restart. That guarantee has one exception the tool cannot fix from the outside: with FORCE_ADMIN_RESET=true in the server's environment, the boot seed overwrites the credentials and deletes the pin on every start, so the reset does not survive a restart. The command detects that and says so instead of promising otherwise; status reports it too. It reads the environment of the shell it runs in, which is not necessarily the server's — a launchd/systemd service can have its own, and there is no portable way to read it from outside.
A reset does not end existing sessions: the session is a stateless JWT in an httpOnly cookie with a 24h rolling expiry (the frontend keepalive rolls it forward on activity; 7 days is the service-token TTL, not the browser session), and nothing in it is tied to the password. To force every session to log in again, stop the server, delete ~/.vibe-seller/data/jwt_secret (the next start regenerates it) and start it again — that also invalidates the tokens the agent's MCP client holds, which the tasks re-issue as they run. Deleting the file only helps when the secret comes from the file: an install that pins JWT_SECRET in the environment never reads it (app/config.py:_resolve_jwt_secret), so change or drop that variable instead.
Database file: ~/.vibe-seller/data/vibe_seller.db. Delete to reset.
Conventions¶
- All PKs are UUIDs generated via
uuid.uuid4(), stored as TEXT - Timestamps are ISO 8601 strings stored as TEXT
- JSON data stored as TEXT columns — serialized with
json.dumps(), deserialized withjson.loads() - All models registered via
app/models/__init__.pyto ensure table creation on startup