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,305 @@
|
|
|
1
|
+
"""ACP policy pipeline — contract C.4, rules P1-P9.
|
|
2
|
+
|
|
3
|
+
A pure state machine, independent of any transport: feed it one JSON-RPC message at a time
|
|
4
|
+
(`from_client` / `from_agent`) and it returns what to send and to whom. The WebSocket relay
|
|
5
|
+
(relay.py) is just a transport around it.
|
|
6
|
+
|
|
7
|
+
P1 initialize: force clientCapabilities fs/terminal off
|
|
8
|
+
P2 session/new|load|resume: cwd must be inside the agent's scope
|
|
9
|
+
P3 session/new|load|resume: HTTP MCP servers must be reachable under the network policy
|
|
10
|
+
(checked after P8, so the platform's own servers are checked too)
|
|
11
|
+
P4 agent→client fs/* and terminal/*: never forwarded, the agent gets an error
|
|
12
|
+
P5 client→agent `$/…` notifications (heartbeats): dropped
|
|
13
|
+
P6 `_…` extension methods: forwarded, logged
|
|
14
|
+
P7 after session/new or session/load: apply session defaults (mode, config options) before
|
|
15
|
+
the client sees the session; failure → the client gets an error, never a session in
|
|
16
|
+
the wrong mode (a loaded session runs in a new sandbox: the agent's mode applies again)
|
|
17
|
+
P8 session/new|load|resume: add the platform's MCP servers (its addons') to the
|
|
18
|
+
client's; a client server with the same name is replaced
|
|
19
|
+
P9 the first session/prompt of a session this connection started (session/new): the
|
|
20
|
+
agent's instructions before its text (a daemon session does it itself, F.3)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import posixpath
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from handler_contracts import (
|
|
32
|
+
BLOCKED_AGENT_METHOD_PREFIXES,
|
|
33
|
+
CWD_CHECKED_METHODS,
|
|
34
|
+
FilteredNetwork,
|
|
35
|
+
NetworkPolicy,
|
|
36
|
+
NoNetwork,
|
|
37
|
+
PolicyErrorCode,
|
|
38
|
+
SessionDefaults,
|
|
39
|
+
egress_target,
|
|
40
|
+
is_within,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("handler.policy")
|
|
44
|
+
|
|
45
|
+
Message = dict[str, Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ToAgent:
|
|
50
|
+
msg: Message
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ToClient:
|
|
55
|
+
msg: Message
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Action = ToAgent | ToClient
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse(line: str) -> Message | None:
|
|
62
|
+
"""One line of an NDJSON stream as a JSON-RPC message; None when it is not a single object
|
|
63
|
+
(C.1: batches and unparseable lines are dropped)."""
|
|
64
|
+
try:
|
|
65
|
+
msg = json.loads(line)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
return msg if isinstance(msg, dict) else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_request(m: Message) -> bool:
|
|
72
|
+
return "method" in m and "id" in m
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_notification(m: Message) -> bool:
|
|
76
|
+
return "method" in m and "id" not in m
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_response(m: Message) -> bool:
|
|
80
|
+
return "method" not in m and "id" in m
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def error_response(req_id: Any, code: PolicyErrorCode, rule: str, message: str) -> Message:
|
|
84
|
+
error = {"code": int(code), "message": message, "data": {"policy": rule}}
|
|
85
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": error}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def target_allowed(target: str, network: NetworkPolicy) -> bool:
|
|
89
|
+
"""Whether 'host:port' is reachable under the network policy (A.3: the port must match)."""
|
|
90
|
+
if isinstance(network, NoNetwork):
|
|
91
|
+
return False
|
|
92
|
+
if not isinstance(network, FilteredNetwork):
|
|
93
|
+
return True # open
|
|
94
|
+
host, _, port = target.rpartition(":")
|
|
95
|
+
for entry in network.allow:
|
|
96
|
+
entry_host, _, entry_port = entry.rpartition(":")
|
|
97
|
+
if entry_port != port:
|
|
98
|
+
continue
|
|
99
|
+
if entry_host.startswith("*.") and host.endswith(entry_host[1:]):
|
|
100
|
+
return True
|
|
101
|
+
if host == entry_host:
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class _HeldSession:
|
|
108
|
+
"""A session/new response held back while P7 applies the session defaults."""
|
|
109
|
+
|
|
110
|
+
response: Message
|
|
111
|
+
session_id: str
|
|
112
|
+
queue: list[Message] # injected requests still to send, in order
|
|
113
|
+
failures: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AcpPolicy:
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
scope: str,
|
|
121
|
+
network: NetworkPolicy,
|
|
122
|
+
session_defaults: SessionDefaults | None = None,
|
|
123
|
+
instructions: str | None = None,
|
|
124
|
+
on_event: Callable[[str], None] | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.scope = scope
|
|
127
|
+
self.network = network
|
|
128
|
+
self.defaults = session_defaults
|
|
129
|
+
self.instructions = instructions
|
|
130
|
+
self.on_event = on_event or log.info
|
|
131
|
+
self._session_ids: dict[Any, str | None] = {} # session/new|load request id → its sessionId
|
|
132
|
+
self._uninstructed: set[str] = set() # sessions started here, their first prompt to come
|
|
133
|
+
self._held: dict[str, _HeldSession] = {} # injected request id → held session
|
|
134
|
+
self._ids = itertools.count(1)
|
|
135
|
+
|
|
136
|
+
# ── client → agent ──────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def from_client(self, m: Message) -> list[Action]:
|
|
139
|
+
method = m.get("method")
|
|
140
|
+
if is_notification(m) and isinstance(method, str) and method.startswith("$/"):
|
|
141
|
+
return [] # P5
|
|
142
|
+
if method == "initialize" and is_request(m):
|
|
143
|
+
return [ToAgent(self._force_capabilities(m))] # P1
|
|
144
|
+
if method in CWD_CHECKED_METHODS and is_request(m):
|
|
145
|
+
m = self._add_mcp_servers(m) # P8
|
|
146
|
+
if refusal := self.check_session_params(m):
|
|
147
|
+
return [ToClient(refusal)] # P2 / P3
|
|
148
|
+
if method in ("session/new", "session/load"):
|
|
149
|
+
self._session_ids[m["id"]] = (m.get("params") or {}).get("sessionId")
|
|
150
|
+
if method == "session/prompt" and is_request(m):
|
|
151
|
+
m = self._p9_instruct(m)
|
|
152
|
+
if isinstance(method, str) and method.startswith("_"):
|
|
153
|
+
self.on_event(f"extension {method} (client→agent)") # P6
|
|
154
|
+
return [ToAgent(m)]
|
|
155
|
+
|
|
156
|
+
def _p9_instruct(self, m: Message) -> Message:
|
|
157
|
+
params = m.get("params") or {}
|
|
158
|
+
session_id = params.get("sessionId")
|
|
159
|
+
if not self.instructions or session_id not in self._uninstructed:
|
|
160
|
+
return m
|
|
161
|
+
self._uninstructed.discard(session_id)
|
|
162
|
+
return m | {"params": params | {"prompt": instructed(self.instructions, params)}}
|
|
163
|
+
|
|
164
|
+
def _force_capabilities(self, m: Message) -> Message:
|
|
165
|
+
params = dict(m.get("params") or {})
|
|
166
|
+
caps = dict(params.get("clientCapabilities") or {})
|
|
167
|
+
caps["fs"] = {"readTextFile": False, "writeTextFile": False}
|
|
168
|
+
caps["terminal"] = False
|
|
169
|
+
params["clientCapabilities"] = caps
|
|
170
|
+
return m | {"params": params}
|
|
171
|
+
|
|
172
|
+
def _add_mcp_servers(self, m: Message) -> Message:
|
|
173
|
+
platform = (self.defaults.mcp_servers if self.defaults else None) or []
|
|
174
|
+
if not platform:
|
|
175
|
+
return m
|
|
176
|
+
params = dict(m.get("params") or {})
|
|
177
|
+
theirs = params.get("mcpServers") or []
|
|
178
|
+
if not isinstance(theirs, list):
|
|
179
|
+
return m # malformed: forwarded as is, the agent refuses it
|
|
180
|
+
names = {s.name for s in platform}
|
|
181
|
+
client = []
|
|
182
|
+
for server in theirs:
|
|
183
|
+
if isinstance(server, dict) and server.get("name") in names:
|
|
184
|
+
self.on_event(f"client MCP server '{server['name']}' replaced by the platform's")
|
|
185
|
+
else:
|
|
186
|
+
client.append(server)
|
|
187
|
+
own = [s.model_dump(mode="json", by_alias=True, exclude_none=True) for s in platform]
|
|
188
|
+
params["mcpServers"] = client + own
|
|
189
|
+
return m | {"params": params}
|
|
190
|
+
|
|
191
|
+
def check_session_params(self, m: Message) -> Message | None:
|
|
192
|
+
params = m.get("params") or {}
|
|
193
|
+
cwd = params.get("cwd")
|
|
194
|
+
inside = (
|
|
195
|
+
isinstance(cwd, str)
|
|
196
|
+
and cwd.startswith("/")
|
|
197
|
+
and is_within(posixpath.normpath(cwd), self.scope)
|
|
198
|
+
)
|
|
199
|
+
if not inside:
|
|
200
|
+
return error_response(
|
|
201
|
+
m["id"], PolicyErrorCode.CWD_OUTSIDE_SCOPE, "P2", f"cwd must be inside {self.scope}"
|
|
202
|
+
)
|
|
203
|
+
for server in params.get("mcpServers") or []:
|
|
204
|
+
url = server.get("url") if isinstance(server, dict) else None
|
|
205
|
+
if not isinstance(url, str):
|
|
206
|
+
continue
|
|
207
|
+
try:
|
|
208
|
+
target = egress_target(url)
|
|
209
|
+
except (KeyError, ValueError): # not http(s), or a bad port
|
|
210
|
+
target = url
|
|
211
|
+
if not target_allowed(target, self.network):
|
|
212
|
+
return error_response(
|
|
213
|
+
m["id"], PolicyErrorCode.MCP_NOT_ALLOWED, "P3",
|
|
214
|
+
f"MCP server {target} is not reachable under the network policy",
|
|
215
|
+
) # fmt: skip
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# ── agent → client ──────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def from_agent(self, m: Message) -> list[Action]:
|
|
221
|
+
method = m.get("method")
|
|
222
|
+
if isinstance(method, str) and method.startswith(BLOCKED_AGENT_METHOD_PREFIXES):
|
|
223
|
+
self.on_event(f"blocked agent→client {method}")
|
|
224
|
+
if is_request(m): # P4
|
|
225
|
+
reply = error_response(
|
|
226
|
+
m["id"], PolicyErrorCode.NOT_PERMITTED, "P4", "not permitted by sandbox policy"
|
|
227
|
+
)
|
|
228
|
+
return [ToAgent(reply)]
|
|
229
|
+
return []
|
|
230
|
+
if isinstance(method, str) and method.startswith("_"):
|
|
231
|
+
self.on_event(f"extension {method} (agent→client)") # P6
|
|
232
|
+
if is_response(m):
|
|
233
|
+
if isinstance(m["id"], str) and m["id"] in self._held:
|
|
234
|
+
return self._p7_progress(m)
|
|
235
|
+
if m["id"] in self._session_ids:
|
|
236
|
+
return self._p7_start(m, self._session_ids.pop(m["id"]))
|
|
237
|
+
return [ToClient(m)]
|
|
238
|
+
|
|
239
|
+
# ── P7: session defaults ────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def _p7_start(self, response: Message, loaded: str | None) -> list[Action]:
|
|
242
|
+
result = response.get("result")
|
|
243
|
+
session_id = result.get("sessionId", loaded) if isinstance(result, dict) else None
|
|
244
|
+
if loaded is None and isinstance(session_id, str):
|
|
245
|
+
self._uninstructed.add(session_id) # P9: a new session, its first prompt instructed
|
|
246
|
+
if not self.defaults or not isinstance(session_id, str):
|
|
247
|
+
return [ToClient(response)]
|
|
248
|
+
queue: list[Message] = []
|
|
249
|
+
if self.defaults.mode_id:
|
|
250
|
+
params = {"sessionId": session_id, "modeId": self.defaults.mode_id}
|
|
251
|
+
queue.append({"method": "session/set_mode", "params": params})
|
|
252
|
+
for config_id, value in (self.defaults.config_options or {}).items():
|
|
253
|
+
params = {"sessionId": session_id, "configId": config_id, "value": value}
|
|
254
|
+
queue.append({"method": "session/set_config_option", "params": params})
|
|
255
|
+
if not queue:
|
|
256
|
+
return [ToClient(response)]
|
|
257
|
+
held = _HeldSession(response=response, session_id=session_id, queue=queue)
|
|
258
|
+
return self._p7_send_next(held)
|
|
259
|
+
|
|
260
|
+
def _p7_send_next(self, held: _HeldSession) -> list[Action]:
|
|
261
|
+
request = held.queue.pop(0)
|
|
262
|
+
req_id = f"handler-p7-{next(self._ids)}"
|
|
263
|
+
self._held[req_id] = held
|
|
264
|
+
return [ToAgent({"jsonrpc": "2.0", "id": req_id, **request})]
|
|
265
|
+
|
|
266
|
+
def _p7_progress(self, response: Message) -> list[Action]:
|
|
267
|
+
held = self._held.pop(response["id"])
|
|
268
|
+
if "error" in response:
|
|
269
|
+
held.failures.append(str(response["error"].get("message", response["error"])))
|
|
270
|
+
if held.queue and not held.failures:
|
|
271
|
+
return self._p7_send_next(held)
|
|
272
|
+
if held.failures:
|
|
273
|
+
detail = "; ".join(held.failures)
|
|
274
|
+
self.on_event(f"session defaults failed for {held.session_id}: {detail}")
|
|
275
|
+
reply = error_response(
|
|
276
|
+
held.response["id"], PolicyErrorCode.SESSION_DEFAULTS_FAILED, "P7",
|
|
277
|
+
f"session defaults could not be applied: {detail}",
|
|
278
|
+
) # fmt: skip
|
|
279
|
+
return [ToClient(reply)]
|
|
280
|
+
return [ToClient(self._patched(held.response))]
|
|
281
|
+
|
|
282
|
+
def _patched(self, response: Message) -> Message:
|
|
283
|
+
assert self.defaults is not None
|
|
284
|
+
result = dict(response["result"])
|
|
285
|
+
if self.defaults.mode_id and isinstance(result.get("modes"), dict):
|
|
286
|
+
result["modes"] = result["modes"] | {"currentModeId": self.defaults.mode_id}
|
|
287
|
+
wanted = self.defaults.config_options or {}
|
|
288
|
+
if wanted and isinstance(result.get("configOptions"), list):
|
|
289
|
+
result["configOptions"] = [
|
|
290
|
+
o | {"currentValue": wanted[o["id"]]}
|
|
291
|
+
if isinstance(o, dict) and o.get("id") in wanted
|
|
292
|
+
else o
|
|
293
|
+
for o in result["configOptions"]
|
|
294
|
+
]
|
|
295
|
+
return response | {"result": result}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def instructed(instructions: str, params: Message) -> list[Any]:
|
|
299
|
+
"""A prompt's blocks with the agent's instructions before its text, a blank line between
|
|
300
|
+
them, in one text block: every adapter reads it (some would drop a second one)."""
|
|
301
|
+
blocks = list(params.get("prompt") or [])
|
|
302
|
+
if blocks and isinstance(blocks[0], dict) and blocks[0].get("type") == "text":
|
|
303
|
+
first = blocks[0]
|
|
304
|
+
return [first | {"text": f"{instructions}\n\n{first.get('text', '')}"}, *blocks[1:]]
|
|
305
|
+
return [{"type": "text", "text": instructions}, *blocks]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Projects — what you work on: a code folder, its caches setting, the skills it gives to all
|
|
2
|
+
its agents, and its agents.
|
|
3
|
+
|
|
4
|
+
An agent of a project is an agent (agents.py) with a `project_id`, so its sessions and sandboxes
|
|
5
|
+
are the same as for an ad-hoc one. Its settings are its own; this module keeps in step
|
|
6
|
+
what it gets from its project: the folder, the caches setting and the project's skills.
|
|
7
|
+
|
|
8
|
+
ProjectRecord one project, persisted in <state>/projects.json
|
|
9
|
+
ProjectRegistry load, save, create, update, delete; its agents, resolved
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from handler_contracts import CacheSharing, RemoteAgent, RemoteProject
|
|
20
|
+
|
|
21
|
+
from .agents import AgentRecord, AgentRegistry, CheckedSettings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProjectRecord:
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
folder: str
|
|
29
|
+
description: str | None = None
|
|
30
|
+
skills: list[str] = field(default_factory=list) # given to all its agents (set by D.17)
|
|
31
|
+
default_agent: str | None = None
|
|
32
|
+
caches: CacheSharing = "project" # the toolbox's caches for its agents (caches.py)
|
|
33
|
+
context: str | None = None # its context: the accounts and providers it runs (contexts.py)
|
|
34
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
self.skills = sorted(set(self.skills))
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"id": self.id,
|
|
42
|
+
"name": self.name,
|
|
43
|
+
"folder": self.folder,
|
|
44
|
+
"description": self.description,
|
|
45
|
+
"skills": self.skills,
|
|
46
|
+
"defaultAgent": self.default_agent,
|
|
47
|
+
"caches": self.caches,
|
|
48
|
+
"context": self.context,
|
|
49
|
+
"createdAt": self.created_at.isoformat(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_json(cls, r: dict[str, Any]) -> "ProjectRecord":
|
|
54
|
+
return cls(
|
|
55
|
+
id=r["id"],
|
|
56
|
+
name=r["name"],
|
|
57
|
+
folder=r["folder"],
|
|
58
|
+
description=r.get("description"),
|
|
59
|
+
skills=r.get("skills", []),
|
|
60
|
+
default_agent=r.get("defaultAgent"),
|
|
61
|
+
caches=r.get("caches", "project"),
|
|
62
|
+
context=r.get("context"),
|
|
63
|
+
created_at=datetime.fromisoformat(r["createdAt"]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def folder_name(folder: str) -> str:
|
|
68
|
+
return folder.rstrip("/").rsplit("/", 1)[-1] or folder
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProjectRegistry:
|
|
72
|
+
def __init__(self, state_dir: Path, registry: AgentRegistry) -> None:
|
|
73
|
+
self.file = state_dir / "projects.json"
|
|
74
|
+
self.registry = registry
|
|
75
|
+
self.projects: dict[str, ProjectRecord] = {}
|
|
76
|
+
if self.file.exists():
|
|
77
|
+
for r in json.loads(self.file.read_text()):
|
|
78
|
+
self.projects[r["id"]] = ProjectRecord.from_json(r)
|
|
79
|
+
|
|
80
|
+
def save(self) -> None:
|
|
81
|
+
self.file.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
tmp = self.file.with_suffix(".tmp")
|
|
83
|
+
tmp.write_text(json.dumps([p.to_json() for p in self.projects.values()], indent=2))
|
|
84
|
+
tmp.replace(self.file)
|
|
85
|
+
|
|
86
|
+
def new_id(self) -> str:
|
|
87
|
+
pid = "p_" + secrets.token_hex(5)
|
|
88
|
+
while pid in self.projects:
|
|
89
|
+
pid = "p_" + secrets.token_hex(5)
|
|
90
|
+
return pid
|
|
91
|
+
|
|
92
|
+
# ── reading ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def get(self, pid: str) -> ProjectRecord | None:
|
|
95
|
+
return self.projects.get(pid)
|
|
96
|
+
|
|
97
|
+
def by_folder(self, folder: str) -> ProjectRecord | None:
|
|
98
|
+
return next((p for p in self.projects.values() if p.folder == folder), None)
|
|
99
|
+
|
|
100
|
+
def agents(self, project: ProjectRecord) -> list[AgentRecord]:
|
|
101
|
+
"""Its agents: the default one first, then by name."""
|
|
102
|
+
agents = [w for w in list(self.registry.agents.values()) if w.project_id == project.id]
|
|
103
|
+
return sorted(agents, key=lambda w: (w.id != project.default_agent, (w.name or "").lower()))
|
|
104
|
+
|
|
105
|
+
def remote_agents(self) -> list[RemoteProject]:
|
|
106
|
+
"""The projects and their agents paired devices may start sessions with (D.7)."""
|
|
107
|
+
listed: list[RemoteProject] = []
|
|
108
|
+
for project in sorted(self.projects.values(), key=lambda p: p.name.lower()):
|
|
109
|
+
agents = [
|
|
110
|
+
RemoteAgent(
|
|
111
|
+
id=a.id,
|
|
112
|
+
name=a.name or a.models[0].harness,
|
|
113
|
+
description=a.description,
|
|
114
|
+
default=a.id == project.default_agent,
|
|
115
|
+
)
|
|
116
|
+
for a in self.agents(project)
|
|
117
|
+
if a.remote_start
|
|
118
|
+
]
|
|
119
|
+
if agents:
|
|
120
|
+
listed.append(
|
|
121
|
+
RemoteProject(project_id=project.id, project=project.name, agents=agents)
|
|
122
|
+
)
|
|
123
|
+
return listed
|
|
124
|
+
|
|
125
|
+
def label(self, agent: AgentRecord) -> str | None:
|
|
126
|
+
"""How a session's agent is named in short: "project · agent" for an agent of a
|
|
127
|
+
project."""
|
|
128
|
+
project = self.get(agent.project_id or "")
|
|
129
|
+
if project is None:
|
|
130
|
+
return agent.name
|
|
131
|
+
return f"{project.name} · {agent.name or agent.models[0].harness}"
|
|
132
|
+
|
|
133
|
+
def last_used(self, project: ProjectRecord) -> datetime | None:
|
|
134
|
+
used = [w.last_used_at for w in self.agents(project) if w.last_used_at]
|
|
135
|
+
return max(used) if used else None
|
|
136
|
+
|
|
137
|
+
# ── writing ─────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def resolve(self, project: ProjectRecord, agent: AgentRecord) -> None:
|
|
140
|
+
"""What the agent gets from its project: its folder, the caches setting, the project's
|
|
141
|
+
skills and its context."""
|
|
142
|
+
agent.scope = project.folder
|
|
143
|
+
agent.caches = project.caches
|
|
144
|
+
agent.context = project.context
|
|
145
|
+
agent.project_skills = list(project.skills)
|
|
146
|
+
|
|
147
|
+
def add(self, project: ProjectRecord) -> ProjectRecord:
|
|
148
|
+
self.projects[project.id] = project
|
|
149
|
+
self.save()
|
|
150
|
+
return project
|
|
151
|
+
|
|
152
|
+
def update(self, project: ProjectRecord, **fields: Any) -> None:
|
|
153
|
+
"""Change its own settings; its agents follow at once (the caller applies what can change
|
|
154
|
+
in running sandboxes)."""
|
|
155
|
+
for key, value in fields.items():
|
|
156
|
+
setattr(project, key, value)
|
|
157
|
+
project.__post_init__()
|
|
158
|
+
for agent in self.agents(project):
|
|
159
|
+
self.resolve(project, agent)
|
|
160
|
+
self.registry.save()
|
|
161
|
+
self.save()
|
|
162
|
+
|
|
163
|
+
def delete(self, project: ProjectRecord) -> list[AgentRecord]:
|
|
164
|
+
"""Remove it and its agents; returns the agents (the caller stops their sessions)."""
|
|
165
|
+
agents = self.agents(project)
|
|
166
|
+
for agent in agents:
|
|
167
|
+
self.registry.agents.pop(agent.id, None)
|
|
168
|
+
self.projects.pop(project.id, None)
|
|
169
|
+
self.registry.save()
|
|
170
|
+
self.save()
|
|
171
|
+
return agents
|
|
172
|
+
|
|
173
|
+
def add_agent(
|
|
174
|
+
self,
|
|
175
|
+
project: ProjectRecord,
|
|
176
|
+
settings: CheckedSettings,
|
|
177
|
+
*,
|
|
178
|
+
name: str,
|
|
179
|
+
description: str | None,
|
|
180
|
+
instructions: str | None,
|
|
181
|
+
default: bool = False,
|
|
182
|
+
remote_start: bool = False,
|
|
183
|
+
skills: list[str] | None = None,
|
|
184
|
+
) -> AgentRecord:
|
|
185
|
+
agent = AgentRecord(
|
|
186
|
+
**settings.model_dump(),
|
|
187
|
+
name=name,
|
|
188
|
+
project_id=project.id,
|
|
189
|
+
description=description,
|
|
190
|
+
instructions=instructions,
|
|
191
|
+
remote_start=remote_start,
|
|
192
|
+
skills=sorted(set(skills or [])),
|
|
193
|
+
)
|
|
194
|
+
self.resolve(project, agent)
|
|
195
|
+
self.registry.add(agent)
|
|
196
|
+
if default or project.default_agent is None:
|
|
197
|
+
project.default_agent = agent.id
|
|
198
|
+
self.save()
|
|
199
|
+
return agent
|
|
200
|
+
|
|
201
|
+
def update_agent(
|
|
202
|
+
self,
|
|
203
|
+
project: ProjectRecord,
|
|
204
|
+
agent: AgentRecord,
|
|
205
|
+
settings: CheckedSettings,
|
|
206
|
+
*,
|
|
207
|
+
name: str,
|
|
208
|
+
description: str | None,
|
|
209
|
+
instructions: str | None,
|
|
210
|
+
default: bool = False,
|
|
211
|
+
remote_start: bool = False,
|
|
212
|
+
skills: list[str] | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
agent.set_settings(settings)
|
|
215
|
+
agent.name, agent.description, agent.instructions = name, description, instructions
|
|
216
|
+
agent.remote_start = remote_start
|
|
217
|
+
agent.skills = sorted(set(skills or []))
|
|
218
|
+
self.resolve(project, agent)
|
|
219
|
+
self.registry.save()
|
|
220
|
+
if default and project.default_agent != agent.id:
|
|
221
|
+
project.default_agent = agent.id
|
|
222
|
+
self.save()
|
|
223
|
+
|
|
224
|
+
def remove_agent(self, project: ProjectRecord, agent: AgentRecord) -> None:
|
|
225
|
+
self.registry.agents.pop(agent.id, None)
|
|
226
|
+
self.registry.save()
|
|
227
|
+
if project.default_agent == agent.id:
|
|
228
|
+
rest = self.agents(project)
|
|
229
|
+
project.default_agent = rest[0].id if rest else None
|
|
230
|
+
self.save()
|
|
231
|
+
|
|
232
|
+
# ── skills (D.17): the projects that give them to all their agents, the agents picking them ──
|
|
233
|
+
|
|
234
|
+
def skill_projects(self, name: str) -> list[ProjectRecord]:
|
|
235
|
+
"""The projects that give a skill to all their agents."""
|
|
236
|
+
return [p for p in self.projects.values() if name in p.skills]
|
|
237
|
+
|
|
238
|
+
def skill_users(self, name: str) -> list[AgentRecord]:
|
|
239
|
+
return [w for w in self.registry.agents.values() if name in w.skills]
|
|
240
|
+
|
|
241
|
+
def give_skill(self, old: str | None, new: str, givers: list[ProjectRecord]) -> None:
|
|
242
|
+
"""Skill `old` (None: a new one) is now `new`, given by `givers` to all their agents: the
|
|
243
|
+
agents picking it follow a rename, and drop it when their project gives it."""
|
|
244
|
+
ids = {p.id for p in givers}
|
|
245
|
+
for p in self.projects.values():
|
|
246
|
+
given = {new} if p.id in ids else set()
|
|
247
|
+
p.skills = sorted(({*p.skills} - {old or new, new}) | given)
|
|
248
|
+
for agent in self.skill_users(old or new):
|
|
249
|
+
kept: set[str] = set() if agent.project_id in ids else {new}
|
|
250
|
+
agent.skills = sorted(({*agent.skills} - {old or new}) | kept)
|
|
251
|
+
self._resolve_all()
|
|
252
|
+
|
|
253
|
+
def drop_skill(self, name: str) -> None:
|
|
254
|
+
"""A deleted skill: no project gives it, no agent uses it."""
|
|
255
|
+
for p in self.projects.values():
|
|
256
|
+
p.skills = [s for s in p.skills if s != name]
|
|
257
|
+
for agent in self.skill_users(name):
|
|
258
|
+
agent.skills = [s for s in agent.skills if s != name]
|
|
259
|
+
self._resolve_all()
|
|
260
|
+
|
|
261
|
+
def _resolve_all(self) -> None:
|
|
262
|
+
for project in self.projects.values():
|
|
263
|
+
for agent in self.agents(project):
|
|
264
|
+
self.resolve(project, agent)
|
|
265
|
+
self.registry.save()
|
|
266
|
+
self.save()
|