handler-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
"""Remote connector (docs/dev/design/remote.md §5): paired devices reach shared sessions through a
|
|
2
|
+
gateway that handlerd polls — handlerd never listens to anything but loopback.
|
|
3
|
+
|
|
4
|
+
config <config>/remote.json gateway URL + host token (0600)
|
|
5
|
+
devices <config>/remote-devices.json paired devices and their keys (0600)
|
|
6
|
+
replay <state>/remote-replay.json ids of the envelopes accepted in the last 10 min
|
|
7
|
+
queued <state>/remote-queue-replay.json ids of the queued envelopes taken (8 days)
|
|
8
|
+
push key <config>/vapid.json Web Push signing key (0600), made when first asked
|
|
9
|
+
|
|
10
|
+
Connector one loop for every device: long-polls the gateway while a gateway is set and
|
|
11
|
+
at least one session is shared; opens each envelope (remote_crypto), refuses
|
|
12
|
+
replays and envelopes older than 5 min, and hands the frames to the sessions.
|
|
13
|
+
Link one device: its attachments (one per session it has open), its outbox (frames
|
|
14
|
+
batched ~250 ms per envelope), its lease (no envelope for 90 s: detached).
|
|
15
|
+
|
|
16
|
+
A device reaches only running sessions whose `remote` switch is on, and only with the methods of
|
|
17
|
+
ALLOWED (plus answers to permission requests). Everything goes through the session like any
|
|
18
|
+
client's traffic: the policy (C.4) applies.
|
|
19
|
+
|
|
20
|
+
A device belongs to a person (`owner`, or a family member) and has a scope: `all`, or `apps` for
|
|
21
|
+
mini apps only (no session, ever). Frames `s: "app:<name>"` reach a mini app shared with the
|
|
22
|
+
device's person (app_remote.py, H.8). handlerd polls while a session or an app is shared, or while
|
|
23
|
+
an agent may be started from a device (`remoteStart`: the owner's devices start sessions of it).
|
|
24
|
+
|
|
25
|
+
A5 (docs/dev/design/miniapps.md §5.3, §7): what a device queued at the gateway while the host was
|
|
26
|
+
off is taken when handlerd is back (only app calls to routes the app declares "queued", up to 7 days
|
|
27
|
+
old); what handler tells a person (its inbox: apps' notifications, automations' results) is pushed,
|
|
28
|
+
content-free, to the devices of that person (Web Push).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import contextlib
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import secrets
|
|
37
|
+
import time
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
from pydantic import BaseModel, ValidationError
|
|
46
|
+
|
|
47
|
+
from handler_contracts import (
|
|
48
|
+
AcpCloseCode,
|
|
49
|
+
AgentList,
|
|
50
|
+
AppHttpParams,
|
|
51
|
+
AppList,
|
|
52
|
+
ClosedNotice,
|
|
53
|
+
DetachParams,
|
|
54
|
+
DeviceScope,
|
|
55
|
+
InboxList,
|
|
56
|
+
InboxMessage,
|
|
57
|
+
PushKey,
|
|
58
|
+
PushSubscribeParams,
|
|
59
|
+
PushSubscription,
|
|
60
|
+
RemoteDevice,
|
|
61
|
+
RemoteLight,
|
|
62
|
+
RemotePairing,
|
|
63
|
+
RemoteStatus,
|
|
64
|
+
SessionList,
|
|
65
|
+
SharedApps,
|
|
66
|
+
SharedSession,
|
|
67
|
+
StartedSession,
|
|
68
|
+
StartSessionParams,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
from . import files, webpush
|
|
72
|
+
from . import remote_crypto as rc
|
|
73
|
+
from .app_remote import AppChannel, RemoteAppError
|
|
74
|
+
from .apps import AppError, AppStore
|
|
75
|
+
from .policy import is_request, is_response
|
|
76
|
+
from .projects import ProjectRegistry
|
|
77
|
+
from .sessions import (
|
|
78
|
+
Attachment,
|
|
79
|
+
LiveSession,
|
|
80
|
+
SessionError,
|
|
81
|
+
SessionStore,
|
|
82
|
+
error,
|
|
83
|
+
notification,
|
|
84
|
+
now,
|
|
85
|
+
)
|
|
86
|
+
from .settings import Settings
|
|
87
|
+
|
|
88
|
+
log = logging.getLogger("handler.remote")
|
|
89
|
+
|
|
90
|
+
Message = dict[str, Any]
|
|
91
|
+
|
|
92
|
+
MAX_AGE = 300.0 # seconds: an older command is refused (the gateway held it back)
|
|
93
|
+
REPLAY_KEEP = 2 * MAX_AGE
|
|
94
|
+
QUEUE_MAX_AGE = 7 * 24 * 3600.0 # a queued call older than this is refused
|
|
95
|
+
QUEUE_KEEP = QUEUE_MAX_AGE + 24 * 3600.0
|
|
96
|
+
QUEUE_EVERY = 30.0 # seconds between two looks at the gateway's queue
|
|
97
|
+
PUSH_CONTACT = os.environ.get("HANDLER_PUSH_CONTACT", "mailto:handler@example.invalid")
|
|
98
|
+
LEASE = 90.0 # seconds without an envelope from a device: its attachments are closed
|
|
99
|
+
POLL_WAIT = 25
|
|
100
|
+
FLUSH_DELAY = 0.25
|
|
101
|
+
ENVELOPE_BYTES = 512 * 1024 # plaintext per up envelope (a single bigger frame goes alone)
|
|
102
|
+
OUTBOX_BYTES = 16 * 1024 * 1024 # a device's unsent output; beyond, it is detached (re-syncs)
|
|
103
|
+
|
|
104
|
+
ALLOWED = frozenset(
|
|
105
|
+
{
|
|
106
|
+
"initialize",
|
|
107
|
+
"session/load",
|
|
108
|
+
"session/prompt",
|
|
109
|
+
"session/cancel",
|
|
110
|
+
"session/set_mode",
|
|
111
|
+
"session/set_config_option",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
APP_CHANNEL = "app:"
|
|
115
|
+
IDLE_CLOSE = 1001 # "going away": the lease ran out
|
|
116
|
+
NOT_SHARED = -32020
|
|
117
|
+
NOT_ATTACHED = -32021
|
|
118
|
+
NOT_ALLOWED = -32022
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RemoteError(RuntimeError):
|
|
122
|
+
"""A request the connector cannot do now (e.g. pairing without a gateway)."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def write_private(path: Path, data: Any) -> None:
|
|
126
|
+
files.write_private(path, json.dumps(data, indent=2))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_json(path: Path, default: Any) -> Any:
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(path.read_text())
|
|
132
|
+
except (FileNotFoundError, ValueError):
|
|
133
|
+
return default
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Device:
|
|
138
|
+
id: str
|
|
139
|
+
name: str
|
|
140
|
+
key: bytes
|
|
141
|
+
created_at: datetime
|
|
142
|
+
last_seen_at: datetime | None = None
|
|
143
|
+
person: str = "owner"
|
|
144
|
+
scope: DeviceScope = "all"
|
|
145
|
+
push_endpoint: str | None = None # its Web Push subscription (A5)
|
|
146
|
+
|
|
147
|
+
def to_json(self) -> Message:
|
|
148
|
+
return {
|
|
149
|
+
"id": self.id,
|
|
150
|
+
"name": self.name,
|
|
151
|
+
"person": self.person,
|
|
152
|
+
"scope": self.scope,
|
|
153
|
+
"pushEndpoint": self.push_endpoint,
|
|
154
|
+
"key": rc.b64(self.key),
|
|
155
|
+
"createdAt": self.created_at.isoformat(),
|
|
156
|
+
"lastSeenAt": self.last_seen_at.isoformat() if self.last_seen_at else None,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_json(cls, d: Message) -> "Device":
|
|
161
|
+
seen = d.get("lastSeenAt")
|
|
162
|
+
return cls(
|
|
163
|
+
d["id"],
|
|
164
|
+
d["name"],
|
|
165
|
+
rc.unb64(d["key"]),
|
|
166
|
+
datetime.fromisoformat(d["createdAt"]),
|
|
167
|
+
datetime.fromisoformat(seen) if seen else None,
|
|
168
|
+
d.get("person", "owner"),
|
|
169
|
+
"apps" if d.get("scope") == "apps" else "all",
|
|
170
|
+
d.get("pushEndpoint"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class Link:
|
|
176
|
+
"""One device's traffic."""
|
|
177
|
+
|
|
178
|
+
device: Device
|
|
179
|
+
attachments: dict[str, tuple[LiveSession, Attachment]] = field(default_factory=dict)
|
|
180
|
+
frames: list[Message] = field(default_factory=list)
|
|
181
|
+
size: int = 0
|
|
182
|
+
acks: list[str] = field(default_factory=list)
|
|
183
|
+
rejected: list[Message] = field(default_factory=list)
|
|
184
|
+
last_seen: float = 0.0 # monotonic
|
|
185
|
+
flushing: asyncio.Task[None] | None = None
|
|
186
|
+
app_calls: set[asyncio.Task[None]] = field(default_factory=set)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Connector:
|
|
190
|
+
def __init__(
|
|
191
|
+
self, settings: Settings, sessions: SessionStore, apps: AppStore | None = None
|
|
192
|
+
) -> None:
|
|
193
|
+
self.settings = settings
|
|
194
|
+
self.sessions = sessions
|
|
195
|
+
self.apps = apps
|
|
196
|
+
self.projects: ProjectRegistry | None = None # set by the app: names, remote agents
|
|
197
|
+
self.app_channel = AppChannel(apps) if apps else None
|
|
198
|
+
self.config_file = settings.config_dir / "remote.json"
|
|
199
|
+
self.devices_file = settings.config_dir / "remote-devices.json"
|
|
200
|
+
self.replay_file = settings.state_dir / "remote-replay.json"
|
|
201
|
+
self.config: Message | None = read_json(self.config_file, None)
|
|
202
|
+
self.devices = {
|
|
203
|
+
d["id"]: Device.from_json(d)
|
|
204
|
+
for d in read_json(self.devices_file, {}).get("devices", [])
|
|
205
|
+
}
|
|
206
|
+
cutoff = time.time() * 1000 - REPLAY_KEEP * 1000
|
|
207
|
+
self.replay: dict[str, int] = {
|
|
208
|
+
k: v for k, v in read_json(self.replay_file, {}).items() if v > cutoff
|
|
209
|
+
}
|
|
210
|
+
self.links: dict[str, Link] = {}
|
|
211
|
+
self.light: RemoteLight = "off" if self.config is None else "idle"
|
|
212
|
+
self.detail: str | None = None
|
|
213
|
+
self.since = now()
|
|
214
|
+
self.cursor = ""
|
|
215
|
+
self.synced = False
|
|
216
|
+
self.wake = asyncio.Event()
|
|
217
|
+
self.tasks: list[asyncio.Task[None]] = []
|
|
218
|
+
self.http = httpx.AsyncClient(timeout=httpx.Timeout(15.0, read=POLL_WAIT + 15.0))
|
|
219
|
+
sessions.listeners.append(self.sessions_changed)
|
|
220
|
+
if apps is not None:
|
|
221
|
+
apps.listeners.append(self.apps_changed)
|
|
222
|
+
apps.inbox.listeners.append(self.on_message)
|
|
223
|
+
self.queue_file = settings.state_dir / "remote-queue-replay.json"
|
|
224
|
+
cutoff = time.time() * 1000 - QUEUE_KEEP * 1000
|
|
225
|
+
self.queue_replay: dict[str, int] = {
|
|
226
|
+
k: v for k, v in read_json(self.queue_file, {}).items() if v > cutoff
|
|
227
|
+
}
|
|
228
|
+
self.queue_checked = 0.0 # monotonic
|
|
229
|
+
self._vapid: webpush.Vapid | None = None
|
|
230
|
+
self.pushes: set[asyncio.Task[None]] = set()
|
|
231
|
+
|
|
232
|
+
# ── lifecycle ───────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
def start(self) -> None:
|
|
235
|
+
self.tasks = [asyncio.create_task(self.run()), asyncio.create_task(self.leases())]
|
|
236
|
+
|
|
237
|
+
async def close(self) -> None:
|
|
238
|
+
for task in self.tasks:
|
|
239
|
+
task.cancel()
|
|
240
|
+
await asyncio.gather(*self.tasks, return_exceptions=True)
|
|
241
|
+
await self.http.aclose()
|
|
242
|
+
|
|
243
|
+
def restart(self) -> None:
|
|
244
|
+
"""The gateway changed: start polling over."""
|
|
245
|
+
self.cursor, self.synced = "", False
|
|
246
|
+
if self.tasks:
|
|
247
|
+
self.tasks[0].cancel()
|
|
248
|
+
self.tasks[0] = asyncio.create_task(self.run())
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def host_id(self) -> str | None:
|
|
252
|
+
return self.config["hostToken"].split(".")[0] if self.config else None
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def base(self) -> str:
|
|
256
|
+
assert self.config is not None
|
|
257
|
+
return self.config["gatewayUrl"].rstrip("/")
|
|
258
|
+
|
|
259
|
+
def headers(self) -> dict[str, str]:
|
|
260
|
+
assert self.config is not None
|
|
261
|
+
return {"Authorization": f"Bearer {self.config['hostToken']}"}
|
|
262
|
+
|
|
263
|
+
def shared(self) -> list[LiveSession]:
|
|
264
|
+
return [
|
|
265
|
+
live
|
|
266
|
+
for live in self.sessions.live.values()
|
|
267
|
+
if live.remote and live.record.state == "running"
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
def active(self) -> bool:
|
|
271
|
+
"""Something is shared, or an agent may be started from a device: handlerd polls."""
|
|
272
|
+
agents = bool(self.projects and self.projects.remote_agents())
|
|
273
|
+
return bool(self.shared()) or bool(self.apps and self.apps.remote_apps()) or agents
|
|
274
|
+
|
|
275
|
+
def agents_changed(self) -> None:
|
|
276
|
+
"""An agent's `remoteStart` may have changed: poll (or stop polling)."""
|
|
277
|
+
self.wake.set()
|
|
278
|
+
|
|
279
|
+
def set_light(self, light: RemoteLight, detail: str | None = None) -> None:
|
|
280
|
+
if light != self.light:
|
|
281
|
+
self.since = now()
|
|
282
|
+
log.info("remote: %s%s", light, f" ({detail})" if detail else "")
|
|
283
|
+
self.light, self.detail = light, detail
|
|
284
|
+
|
|
285
|
+
async def sleep(self, seconds: float) -> None:
|
|
286
|
+
with contextlib.suppress(TimeoutError):
|
|
287
|
+
await asyncio.wait_for(self.wake.wait(), seconds)
|
|
288
|
+
|
|
289
|
+
async def run(self) -> None:
|
|
290
|
+
backoff = 1.0
|
|
291
|
+
while True:
|
|
292
|
+
self.wake.clear()
|
|
293
|
+
if self.config is None:
|
|
294
|
+
self.set_light("off")
|
|
295
|
+
await self.wake.wait()
|
|
296
|
+
continue
|
|
297
|
+
if not self.active():
|
|
298
|
+
self.set_light("idle")
|
|
299
|
+
await self.wake.wait()
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
if not self.synced:
|
|
303
|
+
await self.sync_devices()
|
|
304
|
+
r = await self.poll()
|
|
305
|
+
if r is None:
|
|
306
|
+
continue # nothing shared any more
|
|
307
|
+
if r.status_code == 401:
|
|
308
|
+
self.set_light("unauthorized", "the gateway refused the host token")
|
|
309
|
+
await self.sleep(60)
|
|
310
|
+
continue
|
|
311
|
+
r.raise_for_status()
|
|
312
|
+
data = r.json()
|
|
313
|
+
self.set_light("ok")
|
|
314
|
+
for envelope in data["envelopes"]:
|
|
315
|
+
await self.receive(envelope)
|
|
316
|
+
self.cursor = data["cursor"]
|
|
317
|
+
if data.get("queued") or time.monotonic() - self.queue_checked > QUEUE_EVERY:
|
|
318
|
+
await self.drain_queue()
|
|
319
|
+
backoff = 1.0
|
|
320
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
|
|
321
|
+
self.set_light("unreachable", describe(e))
|
|
322
|
+
await self.sleep(backoff)
|
|
323
|
+
backoff = min(backoff * 2, 30.0)
|
|
324
|
+
|
|
325
|
+
async def poll(self) -> httpx.Response | None:
|
|
326
|
+
"""One long poll, given up when the last session stops being shared (the gateway keeps
|
|
327
|
+
the envelopes: the cursor has not moved, the next poll gets them again)."""
|
|
328
|
+
# not known to be reachable: a quick poll first, so the light turns green at once
|
|
329
|
+
wait = POLL_WAIT if self.light == "ok" else 0
|
|
330
|
+
poll = asyncio.create_task(
|
|
331
|
+
self.http.get(
|
|
332
|
+
f"{self.base}/v1/host/poll",
|
|
333
|
+
params={"after": self.cursor, "wait": wait},
|
|
334
|
+
headers=self.headers(),
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
while True:
|
|
338
|
+
woken = asyncio.create_task(self.wake.wait())
|
|
339
|
+
done, _ = await asyncio.wait({poll, woken}, return_when=asyncio.FIRST_COMPLETED)
|
|
340
|
+
woken.cancel()
|
|
341
|
+
if poll in done:
|
|
342
|
+
return poll.result()
|
|
343
|
+
self.wake.clear()
|
|
344
|
+
if not self.active():
|
|
345
|
+
poll.cancel()
|
|
346
|
+
with contextlib.suppress(asyncio.CancelledError, httpx.HTTPError):
|
|
347
|
+
await poll
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
async def leases(self) -> None:
|
|
351
|
+
while True:
|
|
352
|
+
await asyncio.sleep(LEASE / 6)
|
|
353
|
+
for link in self.links.values():
|
|
354
|
+
if link.attachments and time.monotonic() - link.last_seen > LEASE:
|
|
355
|
+
log.info("remote: device %s went quiet: detached", link.device.id)
|
|
356
|
+
for s in list(link.attachments):
|
|
357
|
+
self.detach(link, s)
|
|
358
|
+
# it re-attaches when it wakes up (the notice waits at the gateway)
|
|
359
|
+
self.send(link, None, closed_notice(s, IDLE_CLOSE, "device idle"))
|
|
360
|
+
|
|
361
|
+
def sessions_changed(self) -> None:
|
|
362
|
+
"""A session was shared, unshared or stopped: poll (or stop polling), and tell the
|
|
363
|
+
devices that are around, with the new list (with nothing shared, handlerd stops polling: a
|
|
364
|
+
device could not ask)."""
|
|
365
|
+
self.wake.set()
|
|
366
|
+
shared = wire(SessionList(sessions=[self.describe(live) for live in self.shared()]))
|
|
367
|
+
for link in self.links.values():
|
|
368
|
+
if time.monotonic() - link.last_seen < LEASE and link.device.scope == "all":
|
|
369
|
+
self.send(link, None, notification("_handler/remote/sessions_changed", **shared))
|
|
370
|
+
|
|
371
|
+
def apps_changed(self) -> None:
|
|
372
|
+
"""An app was shared, unshared, deployed or removed: each device around gets its list."""
|
|
373
|
+
self.wake.set()
|
|
374
|
+
for link in self.links.values():
|
|
375
|
+
if time.monotonic() - link.last_seen < LEASE and self.apps is not None:
|
|
376
|
+
apps = wire(SharedApps(apps=self.apps.remote_apps(link.device.person)))
|
|
377
|
+
self.send(link, None, notification("_handler/remote/apps_changed", **apps))
|
|
378
|
+
|
|
379
|
+
# ── gateway configuration and devices (D.7) ────────────────────────
|
|
380
|
+
|
|
381
|
+
def set_gateway(self, url: str, host_token: str) -> None:
|
|
382
|
+
self.config = {"gatewayUrl": url.rstrip("/"), "hostToken": host_token}
|
|
383
|
+
write_private(self.config_file, self.config)
|
|
384
|
+
self.set_light("idle")
|
|
385
|
+
self.restart()
|
|
386
|
+
|
|
387
|
+
def clear_gateway(self) -> None:
|
|
388
|
+
self.config = None
|
|
389
|
+
self.config_file.unlink(missing_ok=True)
|
|
390
|
+
self.set_light("off")
|
|
391
|
+
self.restart()
|
|
392
|
+
|
|
393
|
+
def save_devices(self) -> None:
|
|
394
|
+
write_private(self.devices_file, {"devices": [d.to_json() for d in self.devices.values()]})
|
|
395
|
+
|
|
396
|
+
async def sync_devices(self) -> None:
|
|
397
|
+
"""The gateway knows exactly our devices (registered while it was unreachable, or
|
|
398
|
+
revoked)."""
|
|
399
|
+
r = await self.http.get(f"{self.base}/v1/host/devices", headers=self.headers())
|
|
400
|
+
if r.status_code == 401:
|
|
401
|
+
return # the poll reports it
|
|
402
|
+
r.raise_for_status()
|
|
403
|
+
known = set(r.json()["devices"])
|
|
404
|
+
for device in self.devices.values():
|
|
405
|
+
if device.id not in known:
|
|
406
|
+
await self.register(device)
|
|
407
|
+
for device_id in known - set(self.devices):
|
|
408
|
+
await self.http.delete(
|
|
409
|
+
f"{self.base}/v1/host/devices/{device_id}", headers=self.headers()
|
|
410
|
+
)
|
|
411
|
+
self.synced = True
|
|
412
|
+
|
|
413
|
+
async def register(self, device: Device) -> None:
|
|
414
|
+
r = await self.http.put(
|
|
415
|
+
f"{self.base}/v1/host/devices/{device.id}",
|
|
416
|
+
json={"tokenHash": rc.gateway_token_hash(device.key)},
|
|
417
|
+
headers=self.headers(),
|
|
418
|
+
)
|
|
419
|
+
r.raise_for_status()
|
|
420
|
+
|
|
421
|
+
async def pair(
|
|
422
|
+
self, name: str, person: str = "owner", scope: DeviceScope = "all"
|
|
423
|
+
) -> RemotePairing:
|
|
424
|
+
if self.config is None:
|
|
425
|
+
raise RemoteError("set the gateway first")
|
|
426
|
+
if person != "owner" and scope == "all":
|
|
427
|
+
raise RemoteError("only the owner's devices reach sessions: pair it for apps only")
|
|
428
|
+
device = Device(
|
|
429
|
+
"d_" + secrets.token_hex(8), name, rc.new_device_key(), now(), None, person, scope
|
|
430
|
+
)
|
|
431
|
+
self.devices[device.id] = device
|
|
432
|
+
self.save_devices()
|
|
433
|
+
try:
|
|
434
|
+
await self.register(device)
|
|
435
|
+
except httpx.HTTPError as e: # registered on the next successful poll
|
|
436
|
+
log.warning("remote: registering %s: %s", device.id, describe(e))
|
|
437
|
+
self.synced = False
|
|
438
|
+
assert self.host_id is not None
|
|
439
|
+
fragment = rc.pairing_fragment(self.base, self.host_id, device.id, device.key, name)
|
|
440
|
+
return RemotePairing(device=self.to_device(device), pairing_url=f"{self.base}/#{fragment}")
|
|
441
|
+
|
|
442
|
+
async def revoke(self, device_id: str) -> bool:
|
|
443
|
+
device = self.devices.pop(device_id, None)
|
|
444
|
+
if device is None:
|
|
445
|
+
return False
|
|
446
|
+
self.save_devices()
|
|
447
|
+
if (link := self.links.pop(device_id, None)) is not None:
|
|
448
|
+
for s in list(link.attachments):
|
|
449
|
+
self.detach(link, s)
|
|
450
|
+
if link.flushing:
|
|
451
|
+
link.flushing.cancel()
|
|
452
|
+
if self.config is not None:
|
|
453
|
+
try:
|
|
454
|
+
r = await self.http.delete(
|
|
455
|
+
f"{self.base}/v1/host/devices/{device_id}", headers=self.headers()
|
|
456
|
+
)
|
|
457
|
+
r.raise_for_status()
|
|
458
|
+
except httpx.HTTPError:
|
|
459
|
+
self.synced = False # removed on the next sync; we refuse it anyway
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
def to_device(self, device: Device) -> RemoteDevice:
|
|
463
|
+
link = self.links.get(device.id)
|
|
464
|
+
return RemoteDevice(
|
|
465
|
+
id=device.id,
|
|
466
|
+
name=device.name,
|
|
467
|
+
created_at=device.created_at,
|
|
468
|
+
last_seen_at=device.last_seen_at,
|
|
469
|
+
person=device.person,
|
|
470
|
+
scope=device.scope,
|
|
471
|
+
attached=sorted(link.attachments) if link else [],
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def status(self) -> RemoteStatus:
|
|
475
|
+
return RemoteStatus(
|
|
476
|
+
gateway_url=self.config["gatewayUrl"] if self.config else None,
|
|
477
|
+
host_id=self.host_id,
|
|
478
|
+
light=self.light,
|
|
479
|
+
detail=self.detail,
|
|
480
|
+
since=self.since,
|
|
481
|
+
shared_sessions=sorted(live.record.id for live in self.shared()),
|
|
482
|
+
devices=[self.to_device(d) for d in self.devices.values()],
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# ── down: device → sessions ────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
def link(self, device: Device) -> Link:
|
|
488
|
+
if (link := self.links.get(device.id)) is None:
|
|
489
|
+
link = self.links[device.id] = Link(device)
|
|
490
|
+
return link
|
|
491
|
+
|
|
492
|
+
async def receive(self, envelope: Message) -> None:
|
|
493
|
+
device = self.devices.get(str(envelope.get("dev")))
|
|
494
|
+
if device is None or self.host_id is None:
|
|
495
|
+
return # revoked (or never ours)
|
|
496
|
+
try:
|
|
497
|
+
plain = rc.open_envelope(device.key, self.host_id, device.id, "down", envelope)
|
|
498
|
+
except rc.EnvelopeError as e:
|
|
499
|
+
log.warning("remote: dropped an envelope from %s: %s", device.id, e)
|
|
500
|
+
return
|
|
501
|
+
envelope_id = envelope["id"]
|
|
502
|
+
if envelope_id in self.replay or envelope_id in self.queue_replay:
|
|
503
|
+
log.warning("remote: dropped a replayed envelope from %s", device.id)
|
|
504
|
+
return
|
|
505
|
+
link = self.link(device)
|
|
506
|
+
self.remember(envelope_id)
|
|
507
|
+
if abs(time.time() * 1000 - plain["ts"]) > MAX_AGE * 1000:
|
|
508
|
+
link.rejected.append({"id": envelope_id, "reason": "expired"})
|
|
509
|
+
self.schedule_flush(link)
|
|
510
|
+
return
|
|
511
|
+
link.last_seen = time.monotonic()
|
|
512
|
+
if device.last_seen_at is None or (now() - device.last_seen_at).total_seconds() > 60:
|
|
513
|
+
device.last_seen_at = now()
|
|
514
|
+
self.save_devices()
|
|
515
|
+
link.acks.append(envelope_id)
|
|
516
|
+
for frame in plain.get("frames") or []:
|
|
517
|
+
if isinstance(frame, dict):
|
|
518
|
+
await self.frame(link, frame)
|
|
519
|
+
self.schedule_flush(link)
|
|
520
|
+
|
|
521
|
+
def remember(self, envelope_id: str) -> None:
|
|
522
|
+
cutoff = time.time() * 1000 - REPLAY_KEEP * 1000
|
|
523
|
+
self.replay = {k: v for k, v in self.replay.items() if v > cutoff}
|
|
524
|
+
self.replay[envelope_id] = int(time.time() * 1000)
|
|
525
|
+
write_private(self.replay_file, self.replay)
|
|
526
|
+
|
|
527
|
+
async def frame(self, link: Link, frame: Message) -> None:
|
|
528
|
+
s, m = frame.get("s"), frame.get("m")
|
|
529
|
+
if not isinstance(m, dict):
|
|
530
|
+
return
|
|
531
|
+
if s is None:
|
|
532
|
+
self.control(link, m)
|
|
533
|
+
return
|
|
534
|
+
if not isinstance(s, str):
|
|
535
|
+
return
|
|
536
|
+
if s.startswith(APP_CHANNEL):
|
|
537
|
+
if is_request(m): # answered when the backend is done: the poll loop goes on
|
|
538
|
+
task = asyncio.create_task(self.app_call(link, s, m))
|
|
539
|
+
link.app_calls.add(task)
|
|
540
|
+
task.add_done_callback(link.app_calls.discard)
|
|
541
|
+
elif link.device.scope != "all":
|
|
542
|
+
if is_request(m):
|
|
543
|
+
self.send(link, s, error(m["id"], NOT_ALLOWED, "this device reaches apps only"))
|
|
544
|
+
else:
|
|
545
|
+
await self.session_frame(link, s, m)
|
|
546
|
+
|
|
547
|
+
async def session_frame(self, link: Link, s: str, m: Message) -> None:
|
|
548
|
+
live = self.sessions.live.get(s)
|
|
549
|
+
held = link.attachments.get(s)
|
|
550
|
+
if live is None or not live.remote or live.record.state != "running":
|
|
551
|
+
if held is not None:
|
|
552
|
+
self.detach(link, s)
|
|
553
|
+
if is_request(m):
|
|
554
|
+
self.send(link, s, error(m["id"], NOT_SHARED, "this session is not shared"))
|
|
555
|
+
return
|
|
556
|
+
method = m.get("method")
|
|
557
|
+
if method == "initialize" and is_request(m):
|
|
558
|
+
if held is not None:
|
|
559
|
+
self.detach(link, s)
|
|
560
|
+
held = self.attach(link, s, live)
|
|
561
|
+
elif held is None or held[1].closed:
|
|
562
|
+
if is_request(m):
|
|
563
|
+
self.send(link, s, error(m["id"], NOT_ATTACHED, "not attached: initialize"))
|
|
564
|
+
return
|
|
565
|
+
elif not (is_response(m) or method in ALLOWED):
|
|
566
|
+
if is_request(m):
|
|
567
|
+
self.send(link, s, error(m["id"], NOT_ALLOWED, "not allowed remotely"))
|
|
568
|
+
return
|
|
569
|
+
await live.from_client(held[1], m)
|
|
570
|
+
|
|
571
|
+
async def app_call(self, link: Link, s: str, m: Message) -> None:
|
|
572
|
+
params = m.get("params") if isinstance(m.get("params"), dict) else {}
|
|
573
|
+
try:
|
|
574
|
+
if self.app_channel is None:
|
|
575
|
+
raise RemoteAppError(NOT_SHARED, "no apps here")
|
|
576
|
+
name = s[len(APP_CHANNEL) :]
|
|
577
|
+
result = await self.app_channel.handle(
|
|
578
|
+
name, link.device.person, str(m.get("method")), params or {}
|
|
579
|
+
)
|
|
580
|
+
self.send(link, s, {"jsonrpc": "2.0", "id": m["id"], "result": wire(result)})
|
|
581
|
+
except RemoteAppError as e:
|
|
582
|
+
self.send(link, s, error(m["id"], e.code, str(e)))
|
|
583
|
+
except Exception as e: # the device must get an answer
|
|
584
|
+
log.exception("remote: app call on %s", s)
|
|
585
|
+
self.send(link, s, error(m["id"], -32603, f"{type(e).__name__}: {e}"))
|
|
586
|
+
|
|
587
|
+
def control(self, link: Link, m: Message) -> None:
|
|
588
|
+
"""The control channel (D.7): a request is answered with its handler's result, refused
|
|
589
|
+
when the method is not one of them or its params do not fit its model."""
|
|
590
|
+
method = str(m.get("method"))
|
|
591
|
+
params = m.get("params") if isinstance(m.get("params"), dict) else {}
|
|
592
|
+
if method == "_handler/remote/detach":
|
|
593
|
+
with contextlib.suppress(ValidationError):
|
|
594
|
+
s = DetachParams.model_validate(params).s
|
|
595
|
+
if s in link.attachments:
|
|
596
|
+
self.detach(link, s)
|
|
597
|
+
return
|
|
598
|
+
if not is_request(m): # _handler/remote/ping is a notification: the lease is renewed
|
|
599
|
+
return
|
|
600
|
+
handlers: dict[str, tuple[type[BaseModel] | None, Callable[..., BaseModel]]] = {
|
|
601
|
+
"_handler/remote/list_sessions": (None, self.list_sessions),
|
|
602
|
+
"_handler/remote/list_agents": (None, self.list_agents),
|
|
603
|
+
"_handler/remote/start_session": (StartSessionParams, self.start_session),
|
|
604
|
+
"_handler/remote/list_apps": (None, self.list_apps),
|
|
605
|
+
"_handler/remote/inbox": (None, self.device_inbox),
|
|
606
|
+
"_handler/remote/push_key": (None, self.push_key),
|
|
607
|
+
"_handler/remote/push_subscribe": (PushSubscribeParams, self.push_subscribe),
|
|
608
|
+
}
|
|
609
|
+
if method not in handlers:
|
|
610
|
+
self.send(link, None, error(m["id"], NOT_ALLOWED, "not allowed remotely"))
|
|
611
|
+
return
|
|
612
|
+
model, handler = handlers[method]
|
|
613
|
+
try:
|
|
614
|
+
args = [model.model_validate(params or {})] if model else []
|
|
615
|
+
result = handler(link.device, *args)
|
|
616
|
+
except ValidationError as e:
|
|
617
|
+
self.send(link, None, error(m["id"], -32602, files.describe(e)))
|
|
618
|
+
return
|
|
619
|
+
except RemoteError as e:
|
|
620
|
+
self.send(link, None, error(m["id"], -32602, str(e)))
|
|
621
|
+
return
|
|
622
|
+
self.send(link, None, {"jsonrpc": "2.0", "id": m["id"], "result": wire(result)})
|
|
623
|
+
|
|
624
|
+
def list_sessions(self, device: Device) -> SessionList:
|
|
625
|
+
shared = self.shared() if device.scope == "all" else []
|
|
626
|
+
return SessionList(sessions=[self.describe(live) for live in shared])
|
|
627
|
+
|
|
628
|
+
def list_agents(self, device: Device) -> AgentList:
|
|
629
|
+
"""The agents a device may start sessions with: those marked `remoteStart` (D.7)."""
|
|
630
|
+
if device.scope != "all" or self.projects is None:
|
|
631
|
+
return AgentList(projects=[])
|
|
632
|
+
return AgentList(projects=self.projects.remote_agents())
|
|
633
|
+
|
|
634
|
+
def start_session(self, device: Device, params: StartSessionParams) -> StartedSession:
|
|
635
|
+
"""A new session of a `remoteStart` agent, its request sent first, shared with the
|
|
636
|
+
devices once it runs (so this one can attach)."""
|
|
637
|
+
allowed = {a.id for p in self.list_agents(device).projects for a in p.agents}
|
|
638
|
+
if params.agent_id not in allowed:
|
|
639
|
+
raise RemoteError("agentId: not an agent you may start from a device")
|
|
640
|
+
agent = self.sessions.registry.get(params.agent_id)
|
|
641
|
+
assert agent is not None # listed just above
|
|
642
|
+
rec = self.sessions.create(
|
|
643
|
+
agent, agent.runs_as(), prompt=params.prompt or None, origin="phone"
|
|
644
|
+
)
|
|
645
|
+
task = asyncio.get_running_loop().create_task(self.share_when_running(rec.id))
|
|
646
|
+
self.pushes.add(task)
|
|
647
|
+
task.add_done_callback(self.pushes.discard)
|
|
648
|
+
log.info("remote: %s started session %s", device.id, rec.id)
|
|
649
|
+
return StartedSession(session=rec.id)
|
|
650
|
+
|
|
651
|
+
async def share_when_running(self, session_id: str) -> None:
|
|
652
|
+
with contextlib.suppress(SessionError):
|
|
653
|
+
await self.sessions.ensure_running(session_id)
|
|
654
|
+
self.sessions.set_remote(session_id, True)
|
|
655
|
+
|
|
656
|
+
def list_apps(self, device: Device) -> AppList:
|
|
657
|
+
apps = self.apps.remote_apps(device.person) if self.apps else []
|
|
658
|
+
return AppList(person=device.person, scope=device.scope, apps=apps)
|
|
659
|
+
|
|
660
|
+
def device_inbox(self, device: Device) -> InboxList:
|
|
661
|
+
found = self.apps.inbox.list(device.person) if self.apps else []
|
|
662
|
+
return InboxList(messages=found)
|
|
663
|
+
|
|
664
|
+
# ── Web Push (A5) ───────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
@property
|
|
667
|
+
def vapid(self) -> webpush.Vapid:
|
|
668
|
+
if self._vapid is None:
|
|
669
|
+
self._vapid = webpush.Vapid(self.settings.config_dir / "vapid.json")
|
|
670
|
+
return self._vapid
|
|
671
|
+
|
|
672
|
+
def push_key(self, _device: Device) -> PushKey:
|
|
673
|
+
return PushKey(public_key=self.vapid.public_key)
|
|
674
|
+
|
|
675
|
+
def push_subscribe(self, device: Device, params: PushSubscribeParams) -> PushSubscription:
|
|
676
|
+
endpoint = params.endpoint
|
|
677
|
+
if endpoint is not None and not webpush.valid_endpoint(endpoint):
|
|
678
|
+
raise RemoteError("endpoint: an https URL")
|
|
679
|
+
device.push_endpoint = endpoint # None: unsubscribe
|
|
680
|
+
self.save_devices()
|
|
681
|
+
return PushSubscription(subscribed=endpoint is not None)
|
|
682
|
+
|
|
683
|
+
def on_message(self, message: InboxMessage) -> None:
|
|
684
|
+
for device in self.devices.values():
|
|
685
|
+
if device.person == message.person and device.push_endpoint:
|
|
686
|
+
self.push_to(device)
|
|
687
|
+
|
|
688
|
+
def push_to(self, device: Device) -> None:
|
|
689
|
+
task = asyncio.get_running_loop().create_task(self.push(device))
|
|
690
|
+
self.pushes.add(task)
|
|
691
|
+
task.add_done_callback(self.pushes.discard)
|
|
692
|
+
|
|
693
|
+
async def push(self, device: Device) -> None:
|
|
694
|
+
endpoint = device.push_endpoint
|
|
695
|
+
if endpoint is None:
|
|
696
|
+
return
|
|
697
|
+
try:
|
|
698
|
+
status = await webpush.push(self.http, self.vapid, endpoint, PUSH_CONTACT)
|
|
699
|
+
except httpx.HTTPError as e:
|
|
700
|
+
log.info("remote: push to %s: %s", device.id, describe(e))
|
|
701
|
+
return
|
|
702
|
+
if status in (404, 410): # the subscription is gone (the phone unsubscribed)
|
|
703
|
+
device.push_endpoint = None
|
|
704
|
+
self.save_devices()
|
|
705
|
+
elif status >= 400:
|
|
706
|
+
log.info("remote: push to %s: HTTP %s", device.id, status)
|
|
707
|
+
|
|
708
|
+
# ── queued calls (A5) ───────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
async def drain_queue(self) -> None:
|
|
711
|
+
"""Take what devices queued at the gateway while handlerd was off; each is deleted there
|
|
712
|
+
once processed (or refused): taken at most once, even if handlerd stops halfway (ids are
|
|
713
|
+
kept)."""
|
|
714
|
+
self.queue_checked = time.monotonic()
|
|
715
|
+
r = await self.http.get(f"{self.base}/v1/host/queue", headers=self.headers())
|
|
716
|
+
if r.status_code == 404:
|
|
717
|
+
return # a gateway without a queue
|
|
718
|
+
r.raise_for_status()
|
|
719
|
+
for envelope in r.json().get("envelopes", []):
|
|
720
|
+
await self.take_queued(envelope)
|
|
721
|
+
await self.http.delete(
|
|
722
|
+
f"{self.base}/v1/host/queue/{envelope.get('id')}", headers=self.headers()
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
async def take_queued(self, envelope: Message) -> None:
|
|
726
|
+
device = self.devices.get(str(envelope.get("dev")))
|
|
727
|
+
if device is None or self.host_id is None:
|
|
728
|
+
return
|
|
729
|
+
try:
|
|
730
|
+
plain = rc.open_envelope(device.key, self.host_id, device.id, "down", envelope)
|
|
731
|
+
except rc.EnvelopeError as e:
|
|
732
|
+
log.warning("remote: dropped a queued envelope from %s: %s", device.id, e)
|
|
733
|
+
return
|
|
734
|
+
envelope_id = envelope["id"]
|
|
735
|
+
if envelope_id in self.queue_replay or envelope_id in self.replay:
|
|
736
|
+
return
|
|
737
|
+
self.queue_replay[envelope_id] = int(time.time() * 1000)
|
|
738
|
+
cutoff = time.time() * 1000 - QUEUE_KEEP * 1000
|
|
739
|
+
self.queue_replay = {k: v for k, v in self.queue_replay.items() if v > cutoff}
|
|
740
|
+
write_private(self.queue_file, self.queue_replay)
|
|
741
|
+
link = self.link(device)
|
|
742
|
+
if abs(time.time() * 1000 - plain["ts"]) > QUEUE_MAX_AGE * 1000:
|
|
743
|
+
link.rejected.append({"id": envelope_id, "reason": "expired"})
|
|
744
|
+
self.schedule_flush(link)
|
|
745
|
+
return
|
|
746
|
+
link.acks.append(envelope_id)
|
|
747
|
+
for frame in plain.get("frames") or []:
|
|
748
|
+
s, m = (frame.get("s"), frame.get("m")) if isinstance(frame, dict) else (None, None)
|
|
749
|
+
if not (isinstance(s, str) and s.startswith(APP_CHANNEL) and isinstance(m, dict)):
|
|
750
|
+
continue
|
|
751
|
+
if not is_request(m):
|
|
752
|
+
continue
|
|
753
|
+
if not self.queueable(s[len(APP_CHANNEL) :], m):
|
|
754
|
+
self.send(link, s, error(m["id"], NOT_ALLOWED, "not a queued route of this app"))
|
|
755
|
+
continue
|
|
756
|
+
await self.app_call(link, s, m)
|
|
757
|
+
self.schedule_flush(link)
|
|
758
|
+
if device.push_endpoint:
|
|
759
|
+
self.push_to(device)
|
|
760
|
+
|
|
761
|
+
def queueable(self, name: str, m: Message) -> bool:
|
|
762
|
+
"""Only `_handler/app/http` calls to a route the installed version declares queued."""
|
|
763
|
+
if m.get("method") != "_handler/app/http" or self.apps is None:
|
|
764
|
+
return False
|
|
765
|
+
try:
|
|
766
|
+
params = AppHttpParams.model_validate(m.get("params"))
|
|
767
|
+
except ValidationError:
|
|
768
|
+
return False
|
|
769
|
+
record = self.apps.records.get(name)
|
|
770
|
+
if record is None or record.current is None:
|
|
771
|
+
return False
|
|
772
|
+
try:
|
|
773
|
+
manifest = self.apps.manifest(name, record.current.seq)
|
|
774
|
+
except AppError:
|
|
775
|
+
return False
|
|
776
|
+
path = httpx.URL(params.path).path
|
|
777
|
+
return f"{params.method.upper()} {path}" in manifest.queued_routes
|
|
778
|
+
|
|
779
|
+
def describe(self, live: LiveSession) -> SharedSession:
|
|
780
|
+
rec, agent = live.record, live.agent
|
|
781
|
+
project = self.projects.get(agent.project_id or "") if self.projects else None
|
|
782
|
+
return SharedSession(
|
|
783
|
+
id=rec.id,
|
|
784
|
+
title=rec.title,
|
|
785
|
+
label=self.projects.label(agent) if self.projects else agent.name,
|
|
786
|
+
project_id=project.id if project else None,
|
|
787
|
+
project=project.name if project else None,
|
|
788
|
+
agent=(agent.name or live.runs.harness) if project else None,
|
|
789
|
+
origin=rec.origin,
|
|
790
|
+
harness=rec.harness,
|
|
791
|
+
scope=rec.scope,
|
|
792
|
+
acp_session_id=rec.acp_session_id,
|
|
793
|
+
busy=live.busy,
|
|
794
|
+
pending_permissions=len(live.pending),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def attach(self, link: Link, s: str, live: LiveSession) -> tuple[LiveSession, Attachment]:
|
|
798
|
+
async def send(text: str) -> None:
|
|
799
|
+
self.send(link, s, json.loads(text))
|
|
800
|
+
|
|
801
|
+
async def close(code: int, reason: str) -> None:
|
|
802
|
+
self.send(link, None, closed_notice(s, code, reason))
|
|
803
|
+
if link.attachments.get(s, (None, None))[1] is attachment:
|
|
804
|
+
del link.attachments[s]
|
|
805
|
+
live.remove(attachment)
|
|
806
|
+
|
|
807
|
+
attachment = Attachment(send, close, kind="remote")
|
|
808
|
+
live.add(attachment)
|
|
809
|
+
link.attachments[s] = (live, attachment)
|
|
810
|
+
return live, attachment
|
|
811
|
+
|
|
812
|
+
def detach(self, link: Link, s: str) -> None:
|
|
813
|
+
held = link.attachments.pop(s, None)
|
|
814
|
+
if held is not None:
|
|
815
|
+
held[0].remove(held[1])
|
|
816
|
+
|
|
817
|
+
# ── up: sessions → device ──────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
def send(self, link: Link, s: str | None, m: Message) -> None:
|
|
820
|
+
frame = {"s": s, "m": m}
|
|
821
|
+
link.frames.append(frame)
|
|
822
|
+
link.size += len(json.dumps(frame))
|
|
823
|
+
if link.size > OUTBOX_BYTES: # the gateway is not taking it: drop, the device re-syncs
|
|
824
|
+
log.warning("remote: device %s is too far behind: detached", link.device.id)
|
|
825
|
+
closed = [
|
|
826
|
+
{"s": None, "m": closed_notice(s_, AcpCloseCode.TOO_SLOW, "too slow")}
|
|
827
|
+
for s_ in link.attachments
|
|
828
|
+
]
|
|
829
|
+
for s_ in list(link.attachments):
|
|
830
|
+
held = link.attachments.pop(s_)
|
|
831
|
+
held[0].remove(held[1])
|
|
832
|
+
link.frames, link.size = closed, 0
|
|
833
|
+
self.schedule_flush(link)
|
|
834
|
+
|
|
835
|
+
def schedule_flush(self, link: Link) -> None:
|
|
836
|
+
if link.flushing is None or link.flushing.done():
|
|
837
|
+
link.flushing = asyncio.create_task(self.flush(link))
|
|
838
|
+
|
|
839
|
+
async def flush(self, link: Link) -> None:
|
|
840
|
+
await asyncio.sleep(FLUSH_DELAY)
|
|
841
|
+
while (link.frames or link.acks or link.rejected) and self.config is not None:
|
|
842
|
+
frames, size = [], 0
|
|
843
|
+
while link.frames and (not frames or size < ENVELOPE_BYTES):
|
|
844
|
+
frame = link.frames.pop(0)
|
|
845
|
+
frames.append(frame)
|
|
846
|
+
size += len(json.dumps(frame))
|
|
847
|
+
link.size = max(0, link.size - size)
|
|
848
|
+
plain: Message = {"ts": int(time.time() * 1000), "frames": frames}
|
|
849
|
+
if link.acks:
|
|
850
|
+
plain["acks"], link.acks = link.acks, []
|
|
851
|
+
if link.rejected:
|
|
852
|
+
plain["rejected"], link.rejected = link.rejected, []
|
|
853
|
+
assert self.host_id is not None
|
|
854
|
+
envelope = rc.seal(link.device.key, self.host_id, link.device.id, "up", plain)
|
|
855
|
+
try:
|
|
856
|
+
r = await self.http.post(
|
|
857
|
+
f"{self.base}/v1/host/up",
|
|
858
|
+
json={"envelopes": [envelope | {"dev": link.device.id}]},
|
|
859
|
+
headers=self.headers(),
|
|
860
|
+
)
|
|
861
|
+
if r.status_code == 404: # the gateway does not know the device (yet)
|
|
862
|
+
self.synced = False
|
|
863
|
+
r.raise_for_status()
|
|
864
|
+
except httpx.HTTPError as e:
|
|
865
|
+
log.info("remote: sending to %s: %s", link.device.id, describe(e))
|
|
866
|
+
link.frames[:0] = frames # retried, re-sealed (new id and time)
|
|
867
|
+
link.size += size
|
|
868
|
+
link.acks[:0] = plain.get("acks", [])
|
|
869
|
+
link.rejected[:0] = plain.get("rejected", [])
|
|
870
|
+
await asyncio.sleep(2.0)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def closed_notice(s: str, code: int, reason: str) -> Message:
|
|
874
|
+
"""One of the device's attachments was closed (with a WebSocket-like close code, F.4)."""
|
|
875
|
+
return notification(
|
|
876
|
+
"_handler/remote/closed", **wire(ClosedNotice(s=s, code=code, reason=reason))
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def wire(model: BaseModel) -> Message:
|
|
881
|
+
"""A D.7 document as it travels in an envelope (as the Control API's: absent, not null)."""
|
|
882
|
+
return model.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def describe(e: Exception) -> str:
|
|
886
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
887
|
+
return f"HTTP {e.response.status_code}"
|
|
888
|
+
return str(e) or type(e).__name__
|