diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 26957bf42..a7c6f6926 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/DOMAIN_MAP.md b/docs/DOMAIN_MAP.md index cb00f8866..fb611decb 100644 --- a/docs/DOMAIN_MAP.md +++ b/docs/DOMAIN_MAP.md @@ -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` diff --git a/docs/architecture/01-high-level-architecture.md b/docs/architecture/01-high-level-architecture.md index 2808c3bd5..685e83847 100644 --- a/docs/architecture/01-high-level-architecture.md +++ b/docs/architecture/01-high-level-architecture.md @@ -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/` 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. diff --git a/docs/architecture/03-web-ui-pages-and-buttons.md b/docs/architecture/03-web-ui-pages-and-buttons.md index 3b4165120..65325acc5 100644 --- a/docs/architecture/03-web-ui-pages-and-buttons.md +++ b/docs/architecture/03-web-ui-pages-and-buttons.md @@ -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. diff --git a/docs/architecture/04-server-api-endpoints.md b/docs/architecture/04-server-api-endpoints.md index 1e71eacb2..12cf6ac9d 100644 --- a/docs/architecture/04-server-api-endpoints.md +++ b/docs/architecture/04-server-api-endpoints.md @@ -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` | diff --git a/docs/inventories/DATA_LAYOUT_INVENTORY.md b/docs/inventories/DATA_LAYOUT_INVENTORY.md index 9005d1392..362821f63 100644 --- a/docs/inventories/DATA_LAYOUT_INVENTORY.md +++ b/docs/inventories/DATA_LAYOUT_INVENTORY.md @@ -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) diff --git a/ouroboros/context.py b/ouroboros/context.py index 052f07683..7ed5d0ad2 100644 --- a/ouroboros/context.py +++ b/ouroboros/context.py @@ -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)) diff --git a/ouroboros/desktop_autostart.py b/ouroboros/desktop_autostart.py new file mode 100644 index 000000000..a1c10c428 --- /dev/null +++ b/ouroboros/desktop_autostart.py @@ -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 {})} diff --git a/ouroboros/domains.toml b/ouroboros/domains.toml index 7b8409bc8..88edb178a 100644 --- a/ouroboros/domains.toml +++ b/ouroboros/domains.toml @@ -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" diff --git a/ouroboros/gateway/desktop_autostart.py b/ouroboros/gateway/desktop_autostart.py index 447804973..a4a38195e 100644 --- a/ouroboros/gateway/desktop_autostart.py +++ b/ouroboros/gateway/desktop_autostart.py @@ -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) diff --git a/ouroboros/gateway/owner_settings.py b/ouroboros/gateway/owner_settings.py index 8fc8f22c6..ce278f617 100644 --- a/ouroboros/gateway/owner_settings.py +++ b/ouroboros/gateway/owner_settings.py @@ -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", diff --git a/ouroboros/windows_autostart.py b/ouroboros/windows_autostart.py index e3bebf8cb..4a0e3814d 100644 --- a/ouroboros/windows_autostart.py +++ b/ouroboros/windows_autostart.py @@ -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" diff --git a/tests/reference_book_grants/architecture/01-high-level-architecture/2026-10-03-host-autostart.grant b/tests/reference_book_grants/architecture/01-high-level-architecture/2026-10-03-host-autostart.grant new file mode 100644 index 000000000..736a455f4 --- /dev/null +++ b/tests/reference_book_grants/architecture/01-high-level-architecture/2026-10-03-host-autostart.grant @@ -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. diff --git a/tests/reference_book_grants/architecture/03-web-ui-pages-and-buttons/2026-10-03-host-autostart.grant b/tests/reference_book_grants/architecture/03-web-ui-pages-and-buttons/2026-10-03-host-autostart.grant new file mode 100644 index 000000000..8b442f5f2 --- /dev/null +++ b/tests/reference_book_grants/architecture/03-web-ui-pages-and-buttons/2026-10-03-host-autostart.grant @@ -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. diff --git a/tests/reference_book_grants/architecture/04-server-api-endpoints/2026-10-03-host-autostart.grant b/tests/reference_book_grants/architecture/04-server-api-endpoints/2026-10-03-host-autostart.grant new file mode 100644 index 000000000..f95ac8260 --- /dev/null +++ b/tests/reference_book_grants/architecture/04-server-api-endpoints/2026-10-03-host-autostart.grant @@ -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. diff --git a/tests/reference_book_grants/development/10-platform-abstraction-rule/2026-10-03-pr1405-windows-autostart.grant b/tests/reference_book_grants/development/10-platform-abstraction-rule/2026-10-03-pr1405-windows-autostart.grant new file mode 100644 index 000000000..94f56b6db --- /dev/null +++ b/tests/reference_book_grants/development/10-platform-abstraction-rule/2026-10-03-pr1405-windows-autostart.grant @@ -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. diff --git a/tests/test_desktop_autostart.py b/tests/test_desktop_autostart.py new file mode 100644 index 000000000..818aaffc1 --- /dev/null +++ b/tests/test_desktop_autostart.py @@ -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") diff --git a/tests/test_windows_autostart.py b/tests/test_windows_autostart.py index 2591a5797..8a5e2cd0c 100644 --- a/tests/test_windows_autostart.py +++ b/tests/test_windows_autostart.py @@ -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("")] - 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("

Reasoning Effort

")] + assert "host computer" in block + assert "Startup & 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 == {} diff --git a/web/modules/api_types.js b/web/modules/api_types.js index 873e06d82..32e06017f 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -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 diff --git a/web/modules/settings_autostart.js b/web/modules/settings_autostart.js index eafff5588..806cf83c0 100644 --- a/web/modules/settings_autostart.js +++ b/web/modules/settings_autostart.js @@ -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'); } } }; diff --git a/web/modules/settings_ui.js b/web/modules/settings_ui.js index 3e2ac2b62..d4743744c 100644 --- a/web/modules/settings_ui.js +++ b/web/modules/settings_ui.js @@ -409,6 +409,23 @@ export function renderSettingsPage() {
+ +

Reasoning Effort

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.
@@ -802,21 +819,6 @@ export function renderSettingsPage() {
-
diff --git a/web/settings.css b/web/settings.css index ed3665cdf..55635edb1 100644 --- a/web/settings.css +++ b/web/settings.css @@ -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); } diff --git a/web/tests/settings_autostart.test.js b/web/tests/settings_autostart.test.js index b82266f82..683e6a978 100644 --- a/web/tests/settings_autostart.test.js +++ b/web/tests/settings_autostart.test.js @@ -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); + }); +}