mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
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:
parent
e9517a87c5
commit
ba261e4eeb
23 changed files with 547 additions and 110 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
183
ouroboros/desktop_autostart.py
Normal file
183
ouroboros/desktop_autostart.py
Normal 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 {})}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
226
tests/test_desktop_autostart.py
Normal file
226
tests/test_desktop_autostart.py
Normal 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")
|
||||
|
|
@ -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 & 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 == {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 & 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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue