Add host sign-in startup adapters and Behavior controls

Keep Windows registry behavior and add macOS LaunchAgent and Linux systemd/XDG adapters behind the immutable launcher version gate. Read OS state through the existing endpoint and expose lifecycle facts in model context.

Move the immediate host control into Behavior, document its contract, and cover automatic Panic/pause retention and adapter artifacts. Native Linux activation remains dependent on the separately owned stage-0 unit flag; its contract test intentionally exposes that missing integration.
This commit is contained in:
Ouroboros 2026-10-03 18:56:56 +03:00
parent e9517a87c5
commit ba261e4eeb
23 changed files with 547 additions and 110 deletions

View file

@ -1368,6 +1368,8 @@ engineering rules; no second policy list may exist.
never *look, I am still working*. It exists so the owner can leave the window
and still be reached by a question or a finished task.
**Startup & background.** Settings → Behavior owns host lifecycle choices, separate from the client-local Appearance controls. Sign-in startup is off until the owner opts in and always names the computer running Ouroboros, even in a remote client. OS registration is the truth: another copy, an OS-disabled entry and an unavailable app build remain visible rather than becoming a saved-settings guess. Automatic startup preserves Panic stops and saved pauses; an explicit owner start releases Panic. Closing the desktop window still exits; keeping it running after close has no control yet. Android deliberately keeps its existing automatic boot entry by default, with the same Panic-preservation contract.
**When the client runs.** Notifications are a property of a running client. This
version adds no tray agent, no background process and no push channel, so
closing Ouroboros ends them. The existing Telegram bridge remains the separate

View file

@ -25,10 +25,10 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
| D15 | Memory, knowledge, consciousness & self-evolution | 23 | 0 |
| D16 | Observability, usage accounting & cost | 15 | 0 |
| D17 | Projects, workspaces & task results | 27 | 0 |
| D18 | Launcher, packaging, platform & shared substrate | 16 | 0 |
| D18 | Launcher, packaging, platform & shared substrate | 17 | 0 |
| D19 | Frozen contracts (ABI) | 10 | 0 |
| D20 | Presence | 10 | 0 |
| **total** | | **618** | **0** |
| **total** | | **619** | **0** |
## Dependency direction matrix (strict, pinned)
@ -826,6 +826,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
- `launcher.py`
- `ouroboros/__init__.py`
- `ouroboros/cli.py`
- `ouroboros/desktop_autostart.py`
- `ouroboros/jsonl_tail.py`
- `ouroboros/launcher_bootstrap.py`
- `ouroboros/launcher_server_reaper.py`

View file

@ -28,7 +28,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── modules/task_checkpoints.js, cancel_presentation.js ← Typed task checkpoints and recorded cancellation-cause text shared by Chat and Logs (§3)
│ ├── modules/dashboard.js, logs.js, costs.js, files.js ← Dashboard tab host; Logs (backfill plus live-stream duplicate guard); Costs (an open zero is never shown as free); Files browser over `/api/files/*` (§3 Dashboard, Files)
│ ├── modules/skills.js, marketplace.js, skill_review_card.js, skill_publish_flow.js ← Installed-skills UI; the ClawHub marketplace inside Skills; Skill Review chat cards; publish-dialog detail rows (§3 Skills and Widgets)
│ ├── modules/settings_ui.js, settings_catalog.js, settings_controls.js, settings_secrets.js, settings_local_model.js, settings_autostart.js, mcp_settings.js ← Settings page (Accounts → Secrets → Models → Agents); model-catalog refresh with a 25-second bound and a sequence guard; control binders; shared full-secret Show/Hide; the local-model form; the Start with Windows toggle; MCP cards that keep masked tokens until edited (§3 Settings and onboarding)
│ ├── modules/settings_ui.js, settings_catalog.js, settings_controls.js, settings_secrets.js, settings_local_model.js, settings_autostart.js, mcp_settings.js ← Settings page (Accounts → Secrets → Models → Agents); model-catalog refresh with a 25-second bound and a sequence guard; control binders; shared full-secret Show/Hide; the local-model form; the host sign-in toggle; MCP cards that keep masked tokens until edited (§3 Settings and onboarding)
│ ├── modules/model_roles.js, model_chooser.js ← The Models editor shared by Settings and onboarding; the editable chooser shared with the route editors — catalog arrival never assigns a value (§3 Navigation and shared UI contracts)
│ ├── modules/subagents_settings.js, subagent_status_primitives.js, reviewer_slots.js, route_editor_primitives.js, harness_accounts.js, harness_login_cards.js, claudexor_status_store.js ← Agents surfaces: the Available-subagents editor; one card's pure status/meta projection; Review lanes rows; neutral route-editor primitives; Agent accounts; host-neutral login cards; the ONE client store over `GET /api/claudexor/status` (`facetReadState`) (§3 Agent accounts, Review lanes and Available subagents)
│ ├── modules/onboarding_agents_step.js, onboarding_overlay.js, project_create.js, utils.js ← The first-run "Connect your accounts" step; the framed wizard's sandbox policy, kept in one place because it is a security boundary; the New Project dialog; shared escaping/formatting utilities (§2; §3 Project rooms)
@ -310,7 +310,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── launcher_onboarding.py ← First-run onboarding as the desktop launcher presents it (serves the gateway /onboarding page; §2)
├── launcher_server_reaper.py ← POSIX same-install server discovery, pre-signal descendant capture, root-first termination, live identity revalidation; PID-lock-owning launcher only (Runtime topology below)
├── launcher_windows_runtime.py ← Windows-only pythonnet/pywebview runtime preparation
├── windows_autostart.py ← Start with Windows: HKCU `Run\Ouroboros` → `Ouroboros.exe --launch-intent automatic` (Panic holds) beside the exported `_internal`; reads the Startup apps switch; unavailable elsewhere
├── desktop_autostart.py, windows_autostart.py ← Host sign-in adapter table; packaged-launcher gate, Windows registry, macOS LaunchAgent and Linux systemd/XDG state (Runtime topology below)
├── provider_models.py ← Model-ID helpers; the `ACTIVE_MODEL_SETTING_KEYS` vs `LEGACY_MODEL_SETTING_KEYS` split keeps Heavy out of startup/Provider Test/new consumers while migration/history still read it
├── runtime_mode_policy.py ← Protected-path policy (safety-critical files, frozen contracts, release/managed invariants) shared by the registry, git tools, and gateway guards (§6 Safety and runtime mode)
├── schedule_contract.py ← Schedule id, 5-field cron, IANA timezone validation SSOT
@ -419,7 +419,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── settings_secrets.py ← Explicit single-secret Settings reads, sharing the effective loader and saved MCP identity rules; passive Settings responses stay masked (§3 Settings and onboarding)
│ ├── settings.py ← /api/settings + /api/owner/*; `GET /api/reviewer-slots` with row limits (triad 10 / scope 4 / advisory 1 / deep_review 1) and typed `config_error`, never a 500; the deep self-review singleton rides the response (saved, or labeled `synthesized_from`), beside a `config_error` only as a repair placeholder, never an effective row
│ ├── presence_settings.py ← Owner-facing runtime overrides and working-folder selection for reviewed Presence behavior skills
│ ├── desktop_autostart.py ← GET/POST /api/desktop/autostart over `windows_autostart.py`; outside the settings write seam, audited
│ ├── desktop_autostart.py ← GET/POST /api/desktop/autostart over `desktop_autostart.py`; OS calls off the event loop, outside the settings write seam, public `owner_audit`
│ ├── control.py ← /api/reset, /api/command, /api/git/*, /api/update/*, /api/evolution-data HTTP handlers
│ ├── update_progress.py ← process-local stages owned by the synchronous update executor; status projection and WS invalidation, never recovery authority
│ ├── schedules.py ← Cron schedule HTTP surface
@ -581,6 +581,8 @@ System self-modification, external workspace, and genesis remain distinct task c
### Runtime topology
Host sign-in startup is owner opt-in in Behavior (§3), with OS registration as the sole authority. `desktop_autostart.py` selects the platform adapter only for a packaged host whose launcher exports `OUROBOROS_APP_VERSION` ≥ 7.2.0, its bundle and presentation; source runs and older builds are unavailable. Windows retains HKCU `Run\Ouroboros`, the `StartupApproved` switch and other-copy detection. macOS writes `~/Library/LaunchAgents/com.ouroboros.agent.plist` with the app executable plus `--launch-intent automatic`, `RunAtLoad` and no `KeepAlive`; `launchctl print-disabled gui/<uid>` observes the OS override, and enabling clears that override without starting a second instance. DMG/AppTranslocation paths require moving to Applications. Linux native packages enable/disable the shipped `ouroboros.service` (which must carry automatic intent); AppImage/tar builds use `$XDG_CONFIG_HOME/autostart/ouroboros.desktop` (default `~/.config`), preserving the stable `$APPIMAGE` path and observing `Hidden=true`. Switching copies removes the alternative registration; no `--now`, linger or restart policy is added. States are `unavailable`, `off`, `on`, `other_copy`, `disabled_by_os`; failed OS reads remain errors. The existing launcher Panic gate and saved-pause recovery retain authority. `context.py` captures `runtime_env.autostart` and `keep_running_after_close=false` at task start, with an unavailable reason when needed; these are host facts independent of the sending client. Window close still exits the desktop runtime.
Two continuity roles: `launcher.py` owns the PID lock, bundle bootstrap, the server process, presentation, the restart signal, and cleanup (desktop launchers run outside the managed repo; Android source-host mode retains a separate immutable seed); `server.py` is the self-editable inner runtime. Native packages ship an opt-in systemd user unit as an alternate ingress, not a third role — deliberately without a restart policy, because the launcher owns managed restart, the crash fuse, and panic-to-complete-stop (`KillMode=control-group`).
Spawn custody: POSIX children start in a new session/process group; Windows creates the server suspended, assigns a kill-on-close Job, then resumes — failure to establish Job custody refuses to run. The launcher Job permits explicit breakaway; only the shared daemon requests it, while ordinary generation children remain covered by Job close. Lazy worker and direct-server starts use the same platform helper. An old packaged or external Job that cannot confirm breakaway retains the earlier working spawn with a disclosed survive-close limitation; desktop managed source updates cannot replace their immutable launcher. The launcher records `data/state/server_process.json` (PID, pgid, server/repo paths, requested and actual ports, argv, creation time) and re-proves identity before cleanup. Forced tree/group cleanup excludes the shared daemon subtree; a graceful stop signals only the server PID (§9); sweeps keep scope.

View file

@ -295,7 +295,9 @@ The synchronous lock-owning apply executor publishes process-local stage observa
### Settings and onboarding
Settings has Accounts, Secrets, Models, Agents, Behavior, Appearance, Advanced and About tabs — a sequence from connections to runtime detail. Accounts: managed subscriptions and their shared service banner, API providers, custom compatible endpoints, local runtime entry points, and the optional non-loopback network gate. Secrets: known provider/integration secrets, skill-requested keys and owner-defined custom keys, masked until explicit Show (DESIGN "Controls and editable choices"). Models: compact source/model/account role rows, ordered fallbacks, context assertions and effort lanes. Agents: task actors and review lanes, with delegation permissions, per-root and depth limits and subagent path roots; their accounts are managed in Accounts. Behavior: context, safety-supervisor coverage, task acceptance, self-evolution, prompt-cache posture. Appearance: the client-local theme choice described above and, in the packaged Windows app, Start with Windows (§1 `windows_autostart.py`); neither is a runtime setting. Advanced: process, timeout, local-model, integration, source-control and cleanup controls (worker count is process capacity, so it lives here). About reports application/runtime identity. Keys, defaults and per-key semantics are the §7 Default settings table, not this chapter. Models, Available subagents and Review lanes share ONE grouped source select owned by `web/modules/route_editor_primitives.js` (`routeChoiceGroups`, `configuredApiProviders`): the owner picks a source and the editor composes the stored id, so the provider prefixes (`provider::model`, `claudexor::source=model`, `harness=model`) are serialization only, never owner input.
Settings has Accounts, Secrets, Models, Agents, Behavior, Appearance, Advanced and About tabs — a sequence from connections to runtime detail. Accounts: managed subscriptions and their shared service banner, API providers, custom compatible endpoints, local runtime entry points, and the optional non-loopback network gate. Secrets: known provider/integration secrets, skill-requested keys and owner-defined custom keys, masked until explicit Show (DESIGN "Controls and editable choices"). Models: compact source/model/account role rows, ordered fallbacks, context assertions and effort lanes. Agents: task actors and review lanes, with delegation permissions, per-root and depth limits and subagent path roots; their accounts are managed in Accounts. Behavior: context, safety-supervisor coverage, task acceptance, self-evolution, prompt-cache posture, and Startup & background. Appearance: client-local theme and notifications, never host lifecycle controls. Advanced: process, timeout, local-model, integration, source-control and cleanup controls (worker count is process capacity, so it lives here). About reports application/runtime identity. Keys, defaults and per-key semantics are the §7 Default settings table, not this chapter. Models, Available subagents and Review lanes share ONE grouped source select owned by `web/modules/route_editor_primitives.js` (`routeChoiceGroups`, `configuredApiProviders`): the owner picks a source and the editor composes the stored id, so the provider prefixes (`provider::model`, `claudexor::source=model`, `harness=model`) are serialization only, never owner input.
Startup & background offers one immediate host sign-in checkbox (`settings_autostart.js` → `GET/POST /api/desktop/autostart`), outside the Settings draft and Save button. Its label explicitly names the host computer for remote browsers and phone clients. The block refreshes from OS truth whenever Settings opens; unavailable hosts retain a disabled control with the reason (including an older app build or unstable macOS bundle), other-copy and OS-disabled registrations have explanatory notes, and failed writes re-read the actual state. The section reserves the independent keep-running-after-close control without exposing it yet; closing the desktop window still exits (§1 Runtime topology; DESIGN §9).
`settings_catalog.js` owns model-catalog reads and the Accounts subscription: a changed confirmed account or recovery from a failed read refreshes discovery; identical settled status stays quiet, and `settings.js` arms/disposes the subscription with the page. Catalog updates preserve drafts. Per-button ownership releases busy state independently of data freshness: background reads cannot strand a manual button.

View file

@ -78,8 +78,8 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera
| POST | `/api/owner/capability-ack` | `gateway.settings.api_acknowledge_capability` |
| GET | `/api/ui/preferences` | `gateway.ui_preferences.api_ui_preferences_get` |
| POST | `/api/ui/preferences` | `gateway.ui_preferences.api_ui_preferences_post` |
| GET | `/api/desktop/autostart` | `gateway.desktop_autostart.api_desktop_autostart_get` |
| POST | `/api/desktop/autostart` | `gateway.desktop_autostart.api_desktop_autostart_post` |
| GET | `/api/desktop/autostart` | `gateway.desktop_autostart.api_desktop_autostart_get`: host OS `{state}`, plus `reason` when unavailable |
| POST | `/api/desktop/autostart` | `gateway.desktop_autostart.api_desktop_autostart_post`: exactly `{enabled: boolean}`, returns observed `{state}`; 400 invalid body, 409 unavailable, 500 OS failure; public `owner_audit`, no settings mirror |
| GET | `/api/model-catalog` | `gateway.models.api_model_catalog` |
| POST | `/api/openai-compatible/models` | `gateway.models.api_openai_compatible_models` |
| POST | `/api/providers/test` | `gateway.models.api_provider_test` |

View file

@ -2,7 +2,7 @@
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 634-727; UTF-8 SHA-256 `0994fa0b3aae0698581ffd216364257aa11b02239f80e11f7cd670508878fd39`.
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 636-729; UTF-8 SHA-256 `819ab027711eb19d3d915abf42f2fee91a153fe268bdbc0a24703ff3f6eea674`.
- entries: **83** (code-ref: 76, repo-dir: 6, repo-path: 1)

View file

@ -12,6 +12,7 @@ from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple
from ouroboros.config import get_context_mode
from ouroboros.desktop_autostart import runtime_facts as desktop_runtime_facts
from ouroboros.context_budget import (
LARGE_CONTEXT_SECTION_CHARS,
MAX_RECENT_CHAT_TAIL,
@ -358,14 +359,12 @@ def build_runtime_section(env: Any, task: Dict[str, Any], *, ctx: Any = None, sc
"allowed_resources": task.get("allowed_resources"),
"context": task.get("context"),
},
# Server-process presentation posture (launcher-exported; absent = a
# web/headless serving process). This is the PROCESS's shell, NOT the
# surface the owner's current message came from — that per-message fact
# is `owner_client` below. (The former `is_desktop` flag read
# OUROBOROS_DESKTOP_MODE, which no producer ever set — retired.)
# Host shell/lifecycle, not the sender's per-message `owner_client`.
# Launcher-exported presentation is absent on web/headless processes.
"runtime_env": {
"presentation": os.environ.get("OUROBOROS_PRESENTATION", "").strip() or "web",
"platform": sys.platform,
**desktop_runtime_facts(),
},
}
runtime_data.update(_task_authority_projection(env, task))

View file

@ -0,0 +1,183 @@
"""Host sign-in registration, read from the OS rather than settings.json.
Only packaged launchers with automatic-intent support may register. Adapters
change the NEXT sign-in, never start/stop the current runtime or release a saved
pause. launchd has no KeepAlive; systemd uses the shipped, non-restarting unit.
Linux selects one registration: native packages use systemd, portable builds XDG.
"""
from __future__ import annotations
import configparser
import logging
import os
from pathlib import Path
import plistlib
import re
import subprocess
import sys
from xml.parsers.expat import ExpatError
from ouroboros import windows_autostart
from ouroboros.platform_layer import BUNDLE_DIR_ENV, is_unstable_macos_app_path
from ouroboros.utils import write_bytes_atomic, write_text_atomic
LABEL = "com.ouroboros.agent"
UNIT = "ouroboros.service"
NATIVE_LAUNCHER = Path("/opt/ouroboros/Ouroboros")
NATIVE_UNIT = Path("/usr/lib/systemd/user/ouroboros.service")
AUTOMATIC_ARGS = ["--launch-intent", "automatic"]
def launcher_target() -> tuple[Path | None, str]:
"""The launcher's immutable build facts, not the managed repo's version."""
presentation = os.environ.get("OUROBOROS_PRESENTATION")
allowed = ("desktop_window", "browser_fallback") if sys.platform == "linux" else ("desktop_window",)
bundle_text = os.environ.get(BUNDLE_DIR_ENV, "")
if (sys.platform not in ADAPTERS or presentation not in allowed or not bundle_text
or os.environ.get("OUROBOROS_MANAGED_BY_LAUNCHER") != "1"):
return None, "Available only when the host runs the packaged desktop app."
bundle = Path(bundle_text)
if sys.platform == "win32":
exe = windows_autostart.launcher_path()
elif sys.platform == "darwin":
app = next((p for p in bundle.parents if p.suffix == ".app"), None)
if app and is_unstable_macos_app_path(app):
return None, "Move Ouroboros to Applications before enabling sign-in startup."
exe = app / "Contents/MacOS/Ouroboros" if app else None
else:
exe = bundle.parent / "Ouroboros" if bundle.name == "_internal" else None
appimage = os.environ.get("APPIMAGE", "")
if appimage:
exe = Path(appimage)
elif os.environ.get("APPDIR"):
return None, "Open Ouroboros from its stable AppImage file first."
if exe is None or not exe.is_absolute() or not exe.is_file():
return None, "Available only when the host runs the packaged desktop app."
try:
version = tuple(int(part) for part in os.environ.get("OUROBOROS_APP_VERSION", "").lstrip("v").split("."))
except ValueError:
version = ()
if len(version) != 3 or version < (7, 2, 0):
return None, "Sign-in startup needs a newer app build (7.2.0 or later)."
return exe, ""
def _run(argv: list[str], *, codes: tuple[int, ...] = (0,)) -> str:
try:
result = subprocess.run(argv, capture_output=True, text=True, timeout=5)
except subprocess.TimeoutExpired as exc:
raise OSError(f"{argv[0]} did not answer within 5 seconds") from exc
if result.returncode not in codes:
raise OSError(result.stderr.strip() or f"{argv[0]} exited {result.returncode}")
return result.stdout.strip()
def _windows(_exe: Path, enabled: bool | None) -> str:
return windows_autostart.autostart_state() if enabled is None else windows_autostart.set_autostart(enabled)
def _macos(exe: Path, enabled: bool | None) -> str:
path = Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist"
if enabled is False:
path.unlink(missing_ok=True)
elif enabled is True:
write_bytes_atomic(path, plistlib.dumps({
"Label": LABEL, "ProgramArguments": [str(exe), *AUTOMATIC_ARGS], "RunAtLoad": True,
}))
# Clear a launchctl override only on an explicit owner enable. Do not
# bootstrap: RunAtLoad would launch another instance in this session.
_run(["launchctl", "enable", f"gui/{os.getuid()}/{LABEL}"])
try:
entry = plistlib.loads(path.read_bytes())
except FileNotFoundError:
return "off"
except (plistlib.InvalidFileException, ValueError, ExpatError):
return "other_copy"
if (not isinstance(entry, dict) or entry.get("Label") != LABEL
or entry.get("ProgramArguments") != [str(exe), *AUTOMATIC_ARGS]
or entry.get("RunAtLoad") is not True or entry.get("KeepAlive", False) is not False):
return "other_copy"
disabled = _run(["launchctl", "print-disabled", f"gui/{os.getuid()}"])
return "disabled_by_os" if re.search(r'"' + re.escape(LABEL) + r'"\s*=>\s*true\b', disabled) else "on"
def _desktop_path() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
return config_home / "autostart/ouroboros.desktop"
def _desktop_command(exe: Path) -> str:
# Desktop Entry's two escaping layers: argv quoting, then string escaping.
quoted = "".join("\\" + c if c in '\\"`$' else c for c in str(exe)).replace("%", "%%")
quoted = quoted.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
return f'"{quoted}" --launch-intent automatic'
def _desktop_state(path: Path, exe: Path) -> str:
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read_string(path.read_text(encoding="utf-8"))
entry = parser["Desktop Entry"]
except FileNotFoundError:
return "off"
except (configparser.Error, KeyError, UnicodeError):
return "other_copy"
if entry.get("Exec") != _desktop_command(exe) or entry.get("Type") != "Application":
return "other_copy"
return "disabled_by_os" if entry.get("Hidden", "false").lower() == "true" else "on"
def _systemd_state() -> str:
value = _run(["systemctl", "--user", "is-enabled", UNIT], codes=(0, 1, 4))
states = {"enabled": "on", "enabled-runtime": "on", "disabled": "off",
"masked": "disabled_by_os", "masked-runtime": "disabled_by_os", "not-found": "unavailable"}
if value not in states:
raise OSError(f"Could not determine systemd sign-in startup: {value or 'no answer'}")
return states[value]
def _linux(exe: Path, enabled: bool | None) -> str:
path = _desktop_path()
native = exe == NATIVE_LAUNCHER
unit_state = _systemd_state() if native or NATIVE_UNIT.is_file() else "off"
if enabled is not None:
if native:
path.unlink(missing_ok=True)
_run(["systemctl", "--user", "enable" if enabled else "disable", UNIT])
return _systemd_state()
if unit_state == "on":
_run(["systemctl", "--user", "disable", UNIT])
if _systemd_state() == "on":
raise OSError("Disable the host's Ouroboros user service before switching to this copy.")
if enabled:
write_text_atomic(path, "[Desktop Entry]\nType=Application\nName=Ouroboros\n"
f"Exec={_desktop_command(exe)}\nTerminal=false\n")
else:
path.unlink(missing_ok=True)
unit_state = "off"
if native:
return "other_copy" if path.exists() else unit_state
return "other_copy" if unit_state == "on" else _desktop_state(path, exe)
ADAPTERS = {"win32": _windows, "darwin": _macos, "linux": _linux}
def autostart_status(enabled: bool | None = None) -> dict[str, str]:
"""Read OS state; a boolean additionally requests an owner registration edit."""
exe, reason = launcher_target()
if exe is None:
return {"state": "unavailable", "reason": reason}
state = ADAPTERS[sys.platform](exe, enabled)
return {"state": state, **({"reason": "The packaged sign-in service is unavailable."} if state == "unavailable" else {})}
def runtime_facts() -> dict:
"""One task-start observation; OS read failures do not break model context."""
try:
status = autostart_status()
except OSError as exc:
logging.getLogger(__name__).warning("Autostart state unavailable: %s", exc)
status = {"state": "unavailable", "reason": str(exc)}
return {"autostart": status["state"], "keep_running_after_close": False,
**({"autostart_reason": status["reason"]} if status.get("reason") else {})}

View file

@ -69,6 +69,7 @@ D20 = "Presence"
"ouroboros/owner_continue.py" = "D01"
"ouroboros/model_sleep.py" = "D01"
"ouroboros/launcher_windows_runtime.py" = "D18"
"ouroboros/desktop_autostart.py" = "D18"
"ouroboros/windows_autostart.py" = "D18"
"ouroboros/__init__.py" = "D18"
"ouroboros/_outcome_receipts.py" = "D01"

View file

@ -1,7 +1,7 @@
"""Desktop autostart endpoints: read and set the Windows sign-in entry.
"""Desktop autostart endpoints: read and set the host's sign-in registration.
Transport only; the entry, its states and its target live in
``ouroboros.windows_autostart``. The registry is not settings.json, so these
``ouroboros.desktop_autostart``. OS registration is not settings.json, so these
routes stay outside the owner settings write seam; a change is still audited.
"""
@ -12,31 +12,31 @@ import logging
from starlette.requests import Request
from starlette.responses import JSONResponse
from ouroboros import windows_autostart
from ouroboros.gateway._helpers import json_error, request_json_or
from ouroboros.gateway.owner_settings import _owner_audit
from ouroboros import desktop_autostart
from ouroboros.gateway._helpers import json_error, request_json_or, run_sync_to_completion
from ouroboros.gateway.owner_settings import owner_audit
log = logging.getLogger(__name__)
async def api_desktop_autostart_get(request: Request) -> JSONResponse:
try:
return JSONResponse({"state": windows_autostart.autostart_state()})
return JSONResponse(await run_sync_to_completion(desktop_autostart.autostart_status))
except OSError as exc:
log.warning("Windows sign-in entry could not be read: %s", exc)
return json_error("Windows startup entry could not be read", 500)
log.warning("Host sign-in entry could not be read: %s", exc)
return json_error("Host startup entry could not be read", 500)
async def api_desktop_autostart_post(request: Request) -> JSONResponse:
body = await request_json_or(request, None)
if not isinstance(body, dict) or set(body) != {"enabled"} or not isinstance(body["enabled"], bool):
return json_error('request body must be {"enabled": true} or {"enabled": false}', 400)
if windows_autostart.launcher_path() is None:
return json_error("autostart is available only in the packaged Windows desktop app", 409)
try:
state = windows_autostart.set_autostart(body["enabled"])
status = await run_sync_to_completion(desktop_autostart.autostart_status, body["enabled"])
except OSError as exc:
log.warning("Windows sign-in entry could not be changed: %s", exc)
return json_error("Windows startup entry could not be changed", 500)
_owner_audit(request, "desktop_autostart", {"enabled": body["enabled"], "state": state})
return JSONResponse({"state": state})
log.warning("Host sign-in entry could not be changed: %s", exc)
return json_error("Host startup entry could not be changed", 500)
if status["state"] == "unavailable":
return json_error(status["reason"], 409)
owner_audit(request, "desktop_autostart", {"enabled": body["enabled"], "state": status["state"]})
return JSONResponse(status)

View file

@ -183,7 +183,8 @@ def owner_write_guard(endpoint: Callable) -> Callable:
return _guarded
def _owner_audit(request: Request, action: str, payload: Dict[str, Any]) -> None:
def owner_audit(request: Request, action: str, payload: Dict[str, Any]) -> None:
"""Record an owner action, including OS controls outside settings.json."""
try:
drive_root = request_drive_root(request)
except Exception:
@ -208,6 +209,9 @@ def _owner_audit(request: Request, action: str, payload: Dict[str, Any]) -> None
log.debug("Failed to write owner API audit event", exc_info=True)
_owner_audit = owner_audit # Existing settings writers retain their import surface.
def settings_document_digest() -> str:
"""What the settings document looked like at a given instant.
@ -380,6 +384,7 @@ __all__ = [
"settings_document_mutation",
"_CONTEXT_MODE_KEYS",
"_owner_audit",
"owner_audit",
"_owner_read_settings_raw",
"_owner_update_settings",
"_owner_write_settings",

View file

@ -24,7 +24,7 @@ from typing import Any, Literal, Optional
from ouroboros.platform_layer import BUNDLE_DIR_ENV, IS_WINDOWS
AutostartState = Literal["unavailable", "off", "on", "other_copy", "disabled_in_windows"]
AutostartState = Literal["unavailable", "off", "on", "other_copy", "disabled_by_os"]
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
APPROVED_KEY = r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"
@ -96,7 +96,7 @@ def autostart_state() -> AutostartState:
return "other_copy"
switch = _read_value(APPROVED_KEY)
if isinstance(switch, bytes) and switch[:1] and switch[0] & 1:
return "disabled_in_windows"
return "disabled_by_os"
return "on"

View file

@ -0,0 +1,2 @@
1550
Document host autostart adapters, their OS authority and availability, the Behavior control and lifecycle facts, and the existing endpoint contract.

View file

@ -0,0 +1,2 @@
649
Document host autostart adapters, their OS authority and availability, the Behavior control and lifecycle facts, and the existing endpoint contract.

View file

@ -0,0 +1,2 @@
202
Document host autostart adapters, their OS authority and availability, the Behavior control and lifecycle facts, and the existing endpoint contract.

View file

@ -0,0 +1,2 @@
67
Cover PR #1405's existing documentation of the guarded function-local winreg accessor; retain that platform exception while adding the host adapter table.

View file

@ -0,0 +1,226 @@
"""Sign-in contract across packaged hosts; OS commands never reach the real host."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import plistlib
from types import SimpleNamespace
import pytest
from ouroboros import desktop_autostart as startup
from ouroboros.launcher_bootstrap import automatic_launch_allowed, parse_launch_options
@pytest.fixture
def host(tmp_path, monkeypatch):
monkeypatch.setenv("OUROBOROS_MANAGED_BY_LAUNCHER", "1")
monkeypatch.setenv("OUROBOROS_PRESENTATION", "desktop_window")
monkeypatch.setenv("OUROBOROS_APP_VERSION", "7.2.0")
monkeypatch.delenv("APPIMAGE", raising=False)
monkeypatch.delenv("APPDIR", raising=False)
monkeypatch.setattr(startup.Path, "home", lambda: tmp_path)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / ".config"))
monkeypatch.setattr(startup, "NATIVE_UNIT", tmp_path / "ouroboros.service")
monkeypatch.setattr(startup.os, "getuid", lambda: 501, raising=False)
state = SimpleNamespace(calls=[], disabled=False, unit="disabled")
def run(argv, **kwargs):
state.calls.append(argv)
if argv[0] == "launchctl":
if argv[1] == "enable":
state.disabled = False
return ""
assert argv == ["launchctl", "print-disabled", "gui/501"]
return f'{{ "{startup.LABEL}" => {str(state.disabled).lower()} }}'
assert argv[0:2] == ["systemctl", "--user"] and argv[-1] == "ouroboros.service"
if argv[2] == "is-enabled":
return state.unit
assert argv[2] in {"enable", "disable"} # never --now, start, stop or linger
state.unit = "enabled" if argv[2] == "enable" else "disabled"
return ""
monkeypatch.setattr(startup, "_run", run)
def package(platform, *, native=False, appimage=False):
monkeypatch.setattr(startup, "sys", SimpleNamespace(platform=platform))
bundle = (tmp_path / "Ouroboros.app/Contents/Resources" if platform == "darwin"
else tmp_path / "Ouroboros/_internal")
exe = (bundle.parent / "MacOS/Ouroboros" if platform == "darwin"
else bundle.parent / "Ouroboros")
bundle.mkdir(parents=True, exist_ok=True)
exe.parent.mkdir(parents=True, exist_ok=True)
exe.touch()
monkeypatch.setenv("OUROBOROS_BUNDLE_DIR", str(bundle))
if native:
monkeypatch.setattr(startup, "NATIVE_LAUNCHER", exe)
startup.NATIVE_UNIT.touch()
if appimage:
exe = tmp_path / "My Ouroboros.AppImage"
exe.touch()
monkeypatch.setenv("APPIMAGE", str(exe))
monkeypatch.setenv("APPDIR", str(bundle.parent))
return exe
return package, state
def test_macos_writes_one_launchagent_and_respects_os_override(host, tmp_path):
package, os_state = host
exe = package("darwin")
path = tmp_path / "Library/LaunchAgents/com.ouroboros.agent.plist"
assert startup.autostart_status() == {"state": "off"}
assert not path.exists() and os_state.calls == [] # GET writes nothing
assert startup.autostart_status(True) == {"state": "on"}
entry = plistlib.loads(path.read_bytes())
assert entry == {"Label": "com.ouroboros.agent", "ProgramArguments": [str(exe), "--launch-intent", "automatic"], "RunAtLoad": True}
assert not (tmp_path / ".config").exists()
assert os_state.calls[0] == ["launchctl", "enable", "gui/501/com.ouroboros.agent"]
os_state.disabled = True
assert startup.autostart_status()["state"] == "disabled_by_os"
assert startup.autostart_status(True)["state"] == "on"
entry["ProgramArguments"] = ["/Applications/Other.app/Contents/MacOS/Ouroboros"]
path.write_bytes(plistlib.dumps(entry))
assert startup.autostart_status()["state"] == "other_copy"
assert startup.autostart_status(False) == {"state": "off"}
assert not path.exists()
@pytest.mark.parametrize("appimage", [False, True])
def test_linux_portable_registers_only_its_stable_target(host, tmp_path, appimage):
package, os_state = host
exe = package("linux", appimage=appimage)
path = tmp_path / ".config/autostart/ouroboros.desktop"
assert startup.autostart_status()["state"] == "off"
assert startup.autostart_status(True)["state"] == "on"
text = path.read_text(encoding="utf-8")
assert text == f'[Desktop Entry]\nType=Application\nName=Ouroboros\nExec="{exe}" --launch-intent automatic\nTerminal=false\n'
assert os_state.calls == [] and not (tmp_path / "Library").exists()
path.write_text(text + "Hidden=true\n", encoding="utf-8")
assert startup.autostart_status()["state"] == "disabled_by_os"
assert startup.autostart_status(True)["state"] == "on"
path.write_text(text.replace(str(exe), "/other/Ouroboros"), encoding="utf-8")
assert startup.autostart_status()["state"] == "other_copy"
assert startup.autostart_status(False)["state"] == "off"
assert not path.exists()
def test_linux_native_uses_shipped_unit_and_replaces_xdg_registration(host, tmp_path):
package, os_state = host
package("linux", native=True)
path = tmp_path / ".config/autostart/ouroboros.desktop"
path.parent.mkdir(parents=True)
path.write_text("[Desktop Entry]\nExec=/old/Ouroboros\n", encoding="utf-8")
assert startup.autostart_status()["state"] == "other_copy"
assert startup.autostart_status(True)["state"] == "on"
assert not path.exists()
assert ["systemctl", "--user", "enable", "ouroboros.service"] in os_state.calls
os_state.unit = "masked"
assert startup.autostart_status()["state"] == "disabled_by_os"
os_state.unit = "enabled"
assert startup.autostart_status(False)["state"] == "off"
assert ["systemctl", "--user", "disable", "ouroboros.service"] in os_state.calls
def test_linux_portable_disables_existing_native_registration_before_writing(host, tmp_path):
package, os_state = host
package("linux", appimage=True)
startup.NATIVE_UNIT.touch()
os_state.unit = "enabled"
assert startup.autostart_status()["state"] == "other_copy"
assert startup.autostart_status(True)["state"] == "on"
assert os_state.unit == "disabled"
assert (tmp_path / ".config/autostart/ouroboros.desktop").exists()
@pytest.mark.parametrize("native", [False, True])
def test_linux_refuses_a_second_registration_when_the_first_cannot_be_removed(host, tmp_path, monkeypatch, native):
package, os_state = host
package("linux", native=native, appimage=not native)
startup.NATIVE_UNIT.touch()
path = tmp_path / ".config/autostart/ouroboros.desktop"
if native:
path.parent.mkdir(parents=True)
path.mkdir() # unlink must fail before systemctl enable can run
else:
os_state.unit = "enabled"
monkeypatch.setattr(startup, "_run", lambda argv, **kwargs: "enabled")
with pytest.raises(OSError):
startup.autostart_status(True)
assert ["systemctl", "--user", "enable", "ouroboros.service"] not in os_state.calls
assert not path.is_file()
def test_linux_browser_fallback_still_configures_the_packaged_host(host, monkeypatch):
package, _ = host
package("linux")
monkeypatch.setenv("OUROBOROS_PRESENTATION", "browser_fallback")
assert startup.autostart_status(True)["state"] == "on"
@pytest.mark.parametrize("version", ["7.1.9", "", "unknown", "7.2.0-rc.1"])
def test_old_or_unproven_launcher_cannot_register(host, monkeypatch, version):
package, os_state = host
package("darwin")
monkeypatch.setenv("OUROBOROS_APP_VERSION", version)
for enable in (None, True, False):
status = startup.autostart_status(enable)
assert status["state"] == "unavailable" and "newer app build" in status["reason"]
assert os_state.calls == []
@pytest.mark.parametrize("kind", ["source_launcher", "source_server", "unstable_macos", "missing_appimage"])
def test_unavailable_hosts_never_register(host, tmp_path, monkeypatch, kind):
package, os_state = host
exe = package("linux" if kind == "missing_appimage" else "darwin", appimage=kind == "missing_appimage")
if kind == "source_launcher":
monkeypatch.setenv("OUROBOROS_BUNDLE_DIR", str(tmp_path / "source"))
elif kind == "source_server":
monkeypatch.delenv("OUROBOROS_MANAGED_BY_LAUNCHER")
elif kind == "unstable_macos":
monkeypatch.setattr(startup, "is_unstable_macos_app_path", lambda path: True)
else:
exe.unlink()
result = startup.autostart_status(True)
assert result["state"] == "unavailable" and os_state.calls == []
if kind == "unstable_macos":
assert "Move Ouroboros to Applications" in result["reason"]
def test_runtime_context_reports_host_lifecycle_even_for_a_remote_sender(host, tmp_path, monkeypatch):
from ouroboros import context
package, _ = host
package("linux")
startup.autostart_status(True)
env = SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path, drive_path=lambda name: tmp_path / name)
monkeypatch.setattr(context, "_runtime_budget_info", lambda *args: {})
rendered = context.build_runtime_section(env, {"metadata": {"client_surface": {"channel": "web"}}})
data = json.JSONDecoder().raw_decode(rendered.split("\n\n", 1)[1])[0]
assert data["runtime_env"]["autostart"] == "on"
assert data["runtime_env"]["keep_running_after_close"] is False
def test_a_sign_in_start_keeps_a_saved_pause(tmp_path, monkeypatch):
from ouroboros import budget_pause
from supervisor import worker_chat_lane
from tests._budget_pause_exact_helpers import _install_queue, _parked
queue, _state, workers = _install_queue(tmp_path, monkeypatch)
_parked(tmp_path, monkeypatch)
assert queue.persist_queue_snapshot(reason="sign_out")
workers.PENDING.clear()
intent = parse_launch_options(["--launch-intent", "automatic"]).launch_intent
assert automatic_launch_allowed(intent, tmp_path, logging.getLogger(__name__))
assert queue.restore_pending_from_snapshot() == 1
monkeypatch.setattr(worker_chat_lane, "_pool", lambda: workers)
worker_chat_lane.auto_resume_after_restart()
assert workers.PENDING[0]["_budget_pause"]["exact_continuation"]
assert budget_pause.budget_pause_row(tmp_path, "pause-task")["state"] == budget_pause.STATE_PAUSED
def test_native_unit_uses_automatic_intent():
"""Stage-0 dependency: its executor owns the shipped unit, not this change."""
unit = Path(__file__).resolve().parents[1] / "packaging/systemd/ouroboros.service"
assert "ExecStart=/opt/ouroboros/Ouroboros --launch-intent automatic" in unit.read_text(encoding="utf-8")

View file

@ -9,25 +9,16 @@ from __future__ import annotations
import json
import pathlib
import sys
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from starlette.applications import Starlette
from ouroboros import windows_autostart as autostart
from ouroboros import desktop_autostart, windows_autostart as autostart
from ouroboros.gateway.router import collect_routes
class _Key:
def __init__(self, path: str):
self.path = path
def __enter__(self):
return self
def __exit__(self, *exc):
return False
class _FakeWinreg:
HKEY_CURRENT_USER = "HKCU"
KEY_SET_VALUE = 0x0002
@ -41,24 +32,24 @@ class _FakeWinreg:
assert root == self.HKEY_CURRENT_USER
if path not in self.keys:
raise FileNotFoundError(path)
return _Key(path)
return nullcontext(path)
def CreateKeyEx(self, root, path, reserved=0, access=0):
assert root == self.HKEY_CURRENT_USER
self.keys.setdefault(path, {})
return _Key(path)
return nullcontext(path)
def QueryValueEx(self, key, name):
values = self.keys[key.path]
values = self.keys[key]
if name not in values:
raise FileNotFoundError(name)
return values[name]
def SetValueEx(self, key, name, reserved, kind, value):
self.keys[key.path][name] = (value, kind)
self.keys[key][name] = (value, kind)
def DeleteValue(self, key, name):
values = self.keys[key.path]
values = self.keys[key]
if name not in values:
raise FileNotFoundError(name)
del values[name]
@ -72,6 +63,9 @@ def registry(monkeypatch):
fake = _FakeWinreg()
monkeypatch.setitem(sys.modules, "winreg", fake)
monkeypatch.setattr(autostart, "IS_WINDOWS", True)
monkeypatch.setattr(desktop_autostart, "sys", SimpleNamespace(platform="win32"))
monkeypatch.setenv("OUROBOROS_MANAGED_BY_LAUNCHER", "1")
monkeypatch.setenv("OUROBOROS_APP_VERSION", "7.2.0")
monkeypatch.delenv("OUROBOROS_PRESENTATION", raising=False)
monkeypatch.delenv("OUROBOROS_BUNDLE_DIR", raising=False)
return fake
@ -125,7 +119,7 @@ def test_turning_on_writes_the_quoted_launcher_and_off_removes_it(packaged, regi
def test_windows_startup_apps_switch_is_reported_and_cleared(packaged, registry):
autostart.set_autostart(True)
registry.keys.setdefault(autostart.APPROVED_KEY, {})[autostart.VALUE_NAME] = _approved(0x03)
assert autostart.autostart_state() == "disabled_in_windows"
assert autostart.autostart_state() == "disabled_by_os"
registry.keys[autostart.APPROVED_KEY][autostart.VALUE_NAME] = _approved(0x02)
assert autostart.autostart_state() == "on"
registry.keys[autostart.APPROVED_KEY][autostart.VALUE_NAME] = _approved(0x07)
@ -174,10 +168,13 @@ def test_the_toggle_never_enters_the_settings_draft():
"""Like the notification block: no `s-` field, and edits never mark the server draft dirty."""
root = pathlib.Path(__file__).resolve().parents[1]
markup = (root / "web" / "modules" / "settings_ui.js").read_text(encoding="utf-8")
panel = markup[markup.index('data-settings-panel="appearance"'):]
panel = markup[markup.index('data-settings-panel="behavior"'):]
panel = panel[:panel.index("</section>")]
assert "data-autostart-settings" in panel, "the block lives on the Appearance tab"
block = panel[panel.index("data-autostart-settings"):]
assert "data-autostart-settings" in panel, "the block lives on the Behavior tab"
block = panel[panel.index("data-autostart-settings"):panel.index("<h3>Reasoning Effort</h3>")]
assert "host computer" in block
assert "Startup &amp; background" in block
assert "data-autostart-settings" not in markup.split('data-settings-panel="appearance"')[1]
for attribute in ('id="s-', 'name="s-'):
assert attribute not in block
settings = (root / "web" / "modules" / "settings.js").read_text(encoding="utf-8")
@ -195,7 +192,7 @@ def client(tmp_path):
def test_endpoints_report_unavailable_outside_the_packaged_desktop(registry, client):
assert client.get("/api/desktop/autostart").json() == {"state": "unavailable"}
assert client.get("/api/desktop/autostart").json()["state"] == "unavailable"
response = client.post("/api/desktop/autostart", json={"enabled": True})
assert response.status_code == 409
assert registry.keys == {}

View file

@ -1450,10 +1450,7 @@
* @property {boolean=} ok
*/
/**
* @typedef {Object} DesktopAutostartResponse
* @property {'unavailable'|'off'|'on'|'other_copy'|'disabled_in_windows'} state // Windows sign-in entry of THIS packaged copy
*/
/** @typedef {{state: 'unavailable'|'off'|'on'|'other_copy'|'disabled_by_os', reason?: string}} DesktopAutostartResponse Host registration and optional unavailability reason. */
/**
* @typedef {Object} UpdateMergePlan
@ -1477,9 +1474,7 @@
* @property {'auto_merge'|'assisted'=} recommended_strategy
*/
/**
* @typedef {Object} UpdatePreflightRequest
*/
/** @typedef {Object} UpdatePreflightRequest */
/**
* @typedef {Object} UpdatePreflightResponse

View file

@ -1,18 +1,15 @@
import { apiClient } from './api_client.js';
import { setInlineStatus } from './ui_primitives.js';
/* Start with Windows (Appearance tab). Not a server setting: the Windows sign-in
entry of the host running Ouroboros, applied on click — so, like the
notification block, no `s-` field and no part of the /api/settings draft.
Windows can change the entry too, so the block re-reads it whenever Settings
is shown. Only the states below reveal it; `unavailable` (every run except the
packaged Windows desktop copy) and anything unknown keep it hidden. */
/* Host startup is an immediate OS edit, separate from the /api/settings draft.
Re-read when Settings opens: the OS can change the same registration. */
const NOTES = {
unavailable: 'Sign-in startup is unavailable on this host.',
on: '',
off: '',
other_copy: 'Windows starts Ouroboros at sign-in from a different entry (another copy, or one set up by hand). Turn this on to start this copy instead.',
disabled_in_windows: 'Turned off in Windows Startup apps. Turn this on to start Ouroboros at sign-in again.',
other_copy: 'This host starts Ouroboros at sign-in from a different entry (another copy, or one set up by hand). Turn this on to start this copy instead.',
disabled_by_os: 'Turned off in the host operating system. Turn this on to enable it, or check the host’s startup settings if it stays disabled.',
};
export function bindAutostartControl(page) {
@ -24,12 +21,12 @@ export function bindAutostartControl(page) {
let busy = false;
let generation = 0;
const paint = (state) => {
const paint = ({ state, reason }) => {
const known = Object.hasOwn(NOTES, state);
section.hidden = !known;
box.checked = state === 'on';
box.disabled = false;
const note = known ? NOTES[state] : '';
box.disabled = state === 'unavailable';
const note = known ? (reason || NOTES[state]) : '';
setInlineStatus(status, note, note ? 'warn' : 'muted');
};
@ -37,13 +34,13 @@ export function bindAutostartControl(page) {
if (busy || destroyed) return;
const current = ++generation;
try {
const { state } = await apiClient.desktopAutostart();
if (!destroyed && !busy && current === generation) paint(state);
const snapshot = await apiClient.desktopAutostart();
if (!destroyed && !busy && current === generation) paint(snapshot);
} catch (error) {
// Availability unknown stays hidden; a visible block reports the failed read.
if (destroyed || busy || current !== generation || section.hidden) return;
box.disabled = true;
setInlineStatus(status, `Could not read the Windows startup entry: ${error.message}`, 'danger');
setInlineStatus(status, `Could not read the host startup entry: ${error.message}`, 'danger');
}
};
@ -55,22 +52,22 @@ export function bindAutostartControl(page) {
box.disabled = true;
setInlineStatus(status, '', 'muted');
try {
const { state } = await apiClient.setDesktopAutostart(wanted);
const snapshot = await apiClient.setDesktopAutostart(wanted);
busy = false;
if (!destroyed) paint(state);
if (!destroyed) paint(snapshot);
} catch (error) {
// A refusal may land between the two registry writes: show what Windows now holds.
let state;
try { ({ state } = await apiClient.desktopAutostart()); } catch { /* current state is unknown */ }
// A refusal may land between the two OS registration writes: show what the OS now holds.
let snapshot;
try { snapshot = await apiClient.desktopAutostart(); } catch { /* current state is unknown */ }
busy = false;
if (destroyed) return;
if (state === undefined) {
box.checked = !wanted; // last observed value, not a claim about the current registry
if (snapshot === undefined) {
box.checked = !wanted; // last observed value, not a claim about the current OS registration
box.disabled = true;
setInlineStatus(status, `Could not change the Windows startup entry: ${error.message}. Current Windows state could not be read; reopen Settings to retry.`, 'danger');
setInlineStatus(status, `Could not change the host startup entry: ${error.message}. Current host state could not be read; reopen Settings to retry.`, 'danger');
} else {
paint(state);
setInlineStatus(status, `Could not change the Windows startup entry: ${error.message}`, 'danger');
paint(snapshot);
setInlineStatus(status, `Could not change the host startup entry: ${error.message}`, 'danger');
}
}
};

View file

@ -409,6 +409,23 @@ export function renderSettingsPage() {
</section>
<section class="settings-panel" data-settings-panel="behavior">
<div class="form-section" data-autostart-settings hidden>
<!-- Future keep-running-after-close control shares this section. -->
<h3>Startup &amp; background</h3>
<div class="settings-section-copy">
Applies immediately to the host computer running Ouroboros, including when you
connect from another device. Signing in preserves Panic stops and saved pauses.
Closing the desktop window still exits Ouroboros.
</div>
<div class="settings-effort-card">
<label class="local-toggle ui-field ui-field-inline">
<input type="checkbox" class="ui-checkbox" data-autostart-toggle>
Start Ouroboros on the host computer when you sign in
</label>
<div class="settings-inline-status" data-autostart-status role="status" aria-live="polite"></div>
</div>
</div>
<div class="form-section">
<h3>Reasoning Effort</h3>
<div class="settings-section-copy">Preferred reasoning effort per task type. Unsupported levels adapt to the route; native mappings, required minimums or provider defaults may apply. Requested, sent and reported effort are recorded in Logs.</div>
@ -802,21 +819,6 @@ export function renderSettingsPage() {
</div>
</div>
<div class="form-section" data-autostart-settings hidden>
<h3>Start with Windows</h3>
<div class="settings-section-copy">
Applies right away to the Windows account Ouroboros runs under. Windows lists the
entry under Settings → Apps → Startup, where it can be turned off as well. After
Panic, a sign-in does not start Ouroboros; opening it yourself does.
</div>
<div class="settings-effort-card">
<label class="local-toggle ui-field ui-field-inline">
<input type="checkbox" class="ui-checkbox" data-autostart-toggle>
Start Ouroboros when I sign in to Windows
</label>
<div class="settings-inline-status" data-autostart-status role="status" aria-live="polite"></div>
</div>
</div>
</section>
<section class="settings-panel" data-settings-panel="advanced">

View file

@ -790,6 +790,11 @@
cursor: pointer;
}
[data-autostart-settings] .local-toggle {
white-space: normal;
flex-wrap: nowrap;
}
.local-toggle[data-warning="1"] {
color: var(--status-warn-fg);
}

View file

@ -56,8 +56,8 @@ function withApi(t, { read, write }) {
return win;
}
test('the block stays hidden unless this is a packaged Windows desktop copy', async (t) => {
for (const state of ['unavailable', 'something_newer', undefined]) {
test('unknown states stay hidden; unavailable hosts show a disabled explanation', async (t) => {
for (const state of ['something_newer', undefined]) {
withApi(t, { read: async () => ({ state }) });
const block = fakeBlock();
bindAutostartControl(block.page);
@ -67,7 +67,7 @@ test('the block stays hidden unless this is a packaged Windows desktop copy', as
}
});
test('the registry state paints the toggle and explains entries it does not own', async (t) => {
test('the OS state paints the toggle and explains entries it does not own', async (t) => {
let state = 'on';
const win = withApi(t, { read: async () => ({ state }) });
const block = fakeBlock();
@ -77,23 +77,23 @@ test('the registry state paints the toggle and explains entries it does not own'
assert.equal(block.box.checked, true);
assert.equal(block.status.textContent, '');
state = 'disabled_in_windows'; // switched off in Windows Startup apps meanwhile
state = 'disabled_by_os'; // switched off in Windows Startup apps meanwhile
win.fire('ouro:page-shown', { detail: { page: 'settings' } });
await settle();
assert.equal(block.box.checked, false);
assert.match(block.status.textContent, /Turned off in Windows Startup apps/);
assert.match(block.status.textContent, /Turned off in the host operating system/);
assert.equal(block.status.dataset.tone, 'warn');
state = 'other_copy';
win.fire('ouro:page-shown', { detail: { page: 'chat' } });
await settle();
assert.match(block.status.textContent, /Startup apps/, 'another page does not trigger a read');
assert.match(block.status.textContent, /host operating system/, 'another page does not trigger a read');
win.fire('ouro:page-shown', { detail: { page: 'settings' } });
await settle();
assert.match(block.status.textContent, /from a different entry \(another copy/);
});
test('a click applies at once and a refused change shows what Windows now holds', async (t) => {
test('a click applies at once and a refused change shows what the OS now holds', async (t) => {
const writes = [];
let server = 'off';
let refuse = false;
@ -120,7 +120,7 @@ test('a click applies at once and a refused change shows what Windows now holds'
await block.click(false);
assert.deepEqual(writes, [true, false]);
assert.equal(block.box.checked, true, 'the entry is still there, so the toggle says so');
assert.match(block.status.textContent, /Could not change the Windows startup entry: access denied/);
assert.match(block.status.textContent, /Could not change the host startup entry: access denied/);
assert.equal(block.status.dataset.tone, 'danger');
refuse = false;
@ -146,7 +146,7 @@ test('a read that started before a click cannot repaint over the owner choice',
assert.equal(block.box.checked, true);
});
test('a failed write and failed read never assert a guessed Windows state', async (t) => {
test('a failed write and failed read never assert a guessed host state', async (t) => {
let unreadable = false;
const win = withApi(t, {
read: async () => {
@ -160,7 +160,7 @@ test('a failed write and failed read never assert a guessed Windows state', asyn
await settle();
await block.click(true);
assert.equal(block.box.disabled, true);
assert.match(block.status.textContent, /Current Windows state could not be read/);
assert.match(block.status.textContent, /Current host state could not be read/);
assert.equal(block.status.dataset.tone, 'danger');
unreadable = false;
win.fire('ouro:page-shown', { detail: { page: 'settings' } });
@ -181,3 +181,15 @@ test('pagehide releases every listener the block took', async (t) => {
assert.equal(win.has('ouro:page-shown'), false);
assert.equal(win.has('pagehide'), false);
});
for (const reason of ['Sign-in startup needs a newer app build (7.2.0 or later).', 'Move Ouroboros to Applications before enabling sign-in startup.']) {
test(reason, async (t) => {
withApi(t, { read: async () => ({ state: 'unavailable', reason }) });
const block = fakeBlock();
bindAutostartControl(block.page);
await settle();
assert.equal(block.section.hidden, false);
assert.equal(block.box.disabled, true);
assert.equal(block.status.textContent, reason);
});
}