subactor-shell 0.2.2__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.
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import urlsplit
6
+
7
+ import httpx
8
+
9
+ from .redaction import ExactRedactor
10
+ from .secret_refs import SecretResolver
11
+
12
+
13
+ class ControlError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class SubactorControlClient:
18
+ def __init__(
19
+ self,
20
+ config: dict[str, Any],
21
+ resolver: SecretResolver,
22
+ *,
23
+ transport: httpx.BaseTransport | None = None,
24
+ ):
25
+ self.config = config
26
+ self.resolver = resolver
27
+ self.transport = transport
28
+ self.base_url = str(config.get("base_url", "http://127.0.0.1:8088")).rstrip("/")
29
+ parsed = urlsplit(self.base_url)
30
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
31
+ raise ValueError("control.base_url musi być adresem http:// lub https://")
32
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
33
+ raise ValueError("control.base_url nie może zawierać credentiali ani query")
34
+ self.account_id = self._identifier(str(config.get("account_id", "softreck")))
35
+ self.provider = self._identifier(str(config.get("provider", "chatgpt")))
36
+ self.tool_id = self._identifier(str(config.get("tool_id", "codex")))
37
+ self.bearer_ref = str(config.get("bearer_ref", ""))
38
+ self.allowed_tools = sorted(
39
+ str(item) for item in config.get("allowed_tools", ["cli.status", "cli.plan", "cli.execute"])
40
+ )
41
+ self.timeout = float(config.get("timeout_seconds", 10.0))
42
+
43
+ @staticmethod
44
+ def _identifier(value: str) -> str:
45
+ import re
46
+
47
+ normalized = value.strip().lower()
48
+ if re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", normalized) is None:
49
+ raise ValueError(f"Nieprawidłowy identyfikator Subactor: {value}")
50
+ return normalized
51
+
52
+ @property
53
+ def endpoint(self) -> str:
54
+ return (
55
+ f"/mcp/accounts/{self.account_id}/providers/{self.provider}/tools/{self.tool_id}"
56
+ )
57
+
58
+ def _token(self) -> str:
59
+ if not self.bearer_ref:
60
+ raise ControlError("Brak control.bearer_ref")
61
+ token = self.resolver.resolve(self.bearer_ref)
62
+ if len(token) < 16:
63
+ raise ControlError("Bearer Subactor jest pusty albo zbyt krótki")
64
+ return token
65
+
66
+ def health(self) -> tuple[bool, str]:
67
+ try:
68
+ with httpx.Client(
69
+ base_url=self.base_url, timeout=self.timeout, transport=self.transport
70
+ ) as client:
71
+ response = client.get("/health")
72
+ except httpx.HTTPError as exc:
73
+ return False, f"błąd połączenia ({type(exc).__name__})"
74
+ if response.status_code != 200:
75
+ return False, f"HTTP {response.status_code}"
76
+ try:
77
+ payload = response.json()
78
+ except ValueError:
79
+ return False, "odpowiedź nie jest JSON"
80
+ if not isinstance(payload, dict):
81
+ return False, "odpowiedź JSON nie jest obiektem"
82
+ if payload.get("ok") is True:
83
+ return True, "ok=true"
84
+ status = payload.get("status")
85
+ return status == "ok", f"status={status!r}"
86
+
87
+ def _rpc(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
88
+ token = self._token()
89
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
90
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": method}
91
+ if params is not None:
92
+ payload["params"] = params
93
+ try:
94
+ with httpx.Client(
95
+ base_url=self.base_url, timeout=self.timeout, transport=self.transport
96
+ ) as client:
97
+ response = client.post(self.endpoint, headers=headers, json=payload)
98
+ except httpx.HTTPError as exc:
99
+ raise ControlError(f"Błąd połączenia z Subactor Control ({type(exc).__name__})") from exc
100
+ if response.status_code >= 400:
101
+ raise ControlError(f"Subactor Control zwrócił HTTP {response.status_code}")
102
+ try:
103
+ result = response.json()
104
+ except ValueError as exc:
105
+ raise ControlError("Subactor Control zwrócił nieprawidłowy JSON") from exc
106
+ if not isinstance(result, dict):
107
+ raise ControlError("Subactor Control zwrócił nieprawidłową odpowiedź RPC")
108
+ if "error" in result:
109
+ # Redagujemy bearer na wypadek odbicia nagłówków przez serwer/proxy.
110
+ safe = ExactRedactor([token]).redact(json.dumps(result["error"], ensure_ascii=False))
111
+ raise ControlError(f"Błąd JSON-RPC Subactor: {safe}")
112
+ return result
113
+
114
+ def list_tools(self, *, strict: bool = True) -> list[dict[str, Any]]:
115
+ response = self._rpc("tools/list")
116
+ tools = response.get("result", {}).get("tools", [])
117
+ if not isinstance(tools, list):
118
+ raise ControlError("tools/list nie zwróciło listy")
119
+ names = sorted(
120
+ item.get("name") for item in tools if isinstance(item, dict) and isinstance(item.get("name"), str)
121
+ )
122
+ if strict and names != self.allowed_tools:
123
+ raise ControlError(
124
+ f"Naruszona granica MCP: otrzymano {names}, oczekiwano {self.allowed_tools}"
125
+ )
126
+ return [item for item in tools if isinstance(item, dict)]
127
+
128
+ def call_tool(
129
+ self,
130
+ name: str,
131
+ arguments: dict[str, Any],
132
+ *,
133
+ allow_execute: bool = False,
134
+ ) -> Any:
135
+ self.list_tools(strict=True)
136
+ if name not in self.allowed_tools:
137
+ raise ControlError(f"Narzędzie '{name}' nie jest dozwolone")
138
+ if name == "cli.execute" and not allow_execute:
139
+ raise ControlError("cli.execute wymaga jawnego potwierdzenia")
140
+ response = self._rpc("tools/call", {"name": name, "arguments": arguments})
141
+ return response.get("result")
@@ -0,0 +1,93 @@
1
+ """Read the bounded local Control environment without sourcing a shell file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import sys
8
+ from collections.abc import MutableMapping
9
+ from pathlib import Path
10
+
11
+
12
+ _ALLOWED_KEYS = frozenset(
13
+ {
14
+ "SUBACTOR_ADMIN_TOKEN",
15
+ "SUBACTOR_CONTROL_URL",
16
+ "SUBACTOR_FOUNDER_URL",
17
+ "SUBACTOR_PLANFILE_URL",
18
+ }
19
+ )
20
+ _MAX_ENV_BYTES = 1_048_576
21
+
22
+
23
+ class ControlEnvironmentError(ValueError):
24
+ """The optional Control environment file cannot be safely used."""
25
+
26
+
27
+ def default_environment_file() -> Path:
28
+ """Locate ``platform/.env`` from a workspace, not from site-packages."""
29
+
30
+ roots: list[Path] = []
31
+ configured_root = os.environ.get("SUBACTOR_WORKSPACE_ROOT", "").strip()
32
+ if configured_root:
33
+ roots.append(Path(configured_root).expanduser())
34
+ for anchor in (Path.cwd(), Path(sys.argv[0]).resolve(), Path(__file__).resolve()):
35
+ roots.extend((anchor, *anchor.parents[:6]))
36
+ seen: set[Path] = set()
37
+ for root in roots:
38
+ candidate = root / "platform" / ".env"
39
+ if candidate in seen:
40
+ continue
41
+ seen.add(candidate)
42
+ if candidate.is_file():
43
+ return candidate
44
+ return Path.cwd() / "platform" / ".env"
45
+
46
+
47
+ def _environment_file(values: MutableMapping[str, str]) -> tuple[Path, bool]:
48
+ configured = values.get("SUBACTOR_ENV_FILE", "").strip()
49
+ return (Path(configured).expanduser(), True) if configured else (default_environment_file(), False)
50
+
51
+
52
+ def _read_selected_values(path: Path, *, explicit: bool) -> dict[str, str]:
53
+ if not path.exists():
54
+ if explicit:
55
+ raise ControlEnvironmentError("SUBACTOR_ENV_FILE nie istnieje")
56
+ return {}
57
+ if path.is_symlink() or not path.is_file():
58
+ raise ControlEnvironmentError("Plik Control environment musi być zwykłym plikiem")
59
+ try:
60
+ metadata = path.stat()
61
+ except OSError as exc:
62
+ raise ControlEnvironmentError("Nie można sprawdzić pliku Control environment") from exc
63
+ if metadata.st_size > _MAX_ENV_BYTES:
64
+ raise ControlEnvironmentError("Plik Control environment jest zbyt duży")
65
+ if stat.S_IMODE(metadata.st_mode) & 0o022:
66
+ raise ControlEnvironmentError("Plik Control environment nie może być zapisywalny dla grupy ani innych")
67
+ try:
68
+ content = path.read_text(encoding="utf-8")
69
+ except OSError as exc:
70
+ raise ControlEnvironmentError("Nie można odczytać pliku Control environment") from exc
71
+ selected: dict[str, str] = {}
72
+ for line in content.splitlines():
73
+ key, separator, value = line.partition("=")
74
+ if separator and key in _ALLOWED_KEYS and key not in selected:
75
+ selected[key] = value.strip().strip("\"'")
76
+ return selected
77
+
78
+
79
+ def apply_control_environment(values: MutableMapping[str, str] | None = None) -> tuple[str, ...]:
80
+ """Populate missing Control keys from one validated file; values are never logged."""
81
+
82
+ target = os.environ if values is None else values
83
+ path, explicit = _environment_file(target)
84
+ loaded = _read_selected_values(path, explicit=explicit)
85
+ applied: list[str] = []
86
+ for key, value in loaded.items():
87
+ if value and not target.get(key):
88
+ target[key] = value
89
+ applied.append(key)
90
+ if not target.get("SUBACTOR_CONTROL_URL") and target.get("SUBACTOR_FOUNDER_URL"):
91
+ target["SUBACTOR_CONTROL_URL"] = target["SUBACTOR_FOUNDER_URL"]
92
+ applied.append("SUBACTOR_CONTROL_URL")
93
+ return tuple(applied)
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ class IntentValidationError(ValueError):
9
+ pass
10
+
11
+
12
+ _ALLOWED_MODES = {"ask", "plan", "execute"}
13
+ _FORBIDDEN_ARGUMENT_NAMES = {
14
+ "cmd",
15
+ "command",
16
+ "shell",
17
+ "script",
18
+ "executable",
19
+ "endpoint",
20
+ "connector",
21
+ "connector_id",
22
+ "api_key",
23
+ "password",
24
+ "secret",
25
+ "token",
26
+ }
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class IntentIR:
31
+ v: int
32
+ intent_id: str
33
+ mode: str = "execute"
34
+ args: dict[str, Any] = field(default_factory=dict)
35
+ requirements: list[str] = field(default_factory=list)
36
+ constraints: list[str] = field(default_factory=list)
37
+ unresolved: list[str] = field(default_factory=list)
38
+
39
+ @classmethod
40
+ def from_dict(cls, payload: dict[str, Any]) -> "IntentIR":
41
+ if not isinstance(payload, dict):
42
+ raise IntentValidationError("IntentIR musi być obiektem JSON")
43
+ allowed_top = {
44
+ "v",
45
+ "intent_id",
46
+ "mode",
47
+ "args",
48
+ "requirements",
49
+ "constraints",
50
+ "unresolved",
51
+ }
52
+ unknown_top = set(payload) - allowed_top
53
+ if unknown_top:
54
+ raise IntentValidationError(
55
+ "IntentIR zawiera nieznane pola: " + ", ".join(sorted(unknown_top))
56
+ )
57
+ if payload.get("v", 1) != 1:
58
+ raise IntentValidationError("Obsługiwana jest wyłącznie wersja IntentIR v1")
59
+ intent_id = payload.get("intent_id")
60
+ if not isinstance(intent_id, str) or not intent_id.strip():
61
+ raise IntentValidationError("intent_id jest wymagany")
62
+ mode = payload.get("mode", "execute")
63
+ if mode not in _ALLOWED_MODES:
64
+ raise IntentValidationError("mode musi być ask, plan albo execute")
65
+ args = payload.get("args", {})
66
+ if not isinstance(args, dict):
67
+ raise IntentValidationError("args musi być obiektem")
68
+ clean_args: dict[str, Any] = {}
69
+ for raw_name, value in args.items():
70
+ name = str(raw_name)
71
+ if name.casefold() in _FORBIDDEN_ARGUMENT_NAMES:
72
+ raise IntentValidationError(
73
+ f"Argument '{name}' jest zarezerwowany; model nie może wybierać wykonawcy ani sekretu"
74
+ )
75
+ _validate_json_value(name, value)
76
+ clean_args[name] = value
77
+
78
+ def string_list(name: str) -> list[str]:
79
+ value = payload.get(name, [])
80
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
81
+ raise IntentValidationError(f"{name} musi być tablicą stringów")
82
+ return list(dict.fromkeys(item.strip() for item in value if item.strip()))
83
+
84
+ return cls(
85
+ v=1,
86
+ intent_id=intent_id.strip(),
87
+ mode=mode,
88
+ args=clean_args,
89
+ requirements=string_list("requirements"),
90
+ constraints=string_list("constraints"),
91
+ unresolved=string_list("unresolved"),
92
+ )
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ return {
96
+ "v": 1,
97
+ "intent_id": self.intent_id,
98
+ "mode": self.mode,
99
+ "args": self.args,
100
+ "requirements": self.requirements,
101
+ "constraints": self.constraints,
102
+ "unresolved": self.unresolved,
103
+ }
104
+
105
+
106
+ def _validate_json_value(name: str, value: Any) -> None:
107
+ if isinstance(value, str):
108
+ lowered = value.casefold()
109
+ if "{{secret:" in lowered or lowered.startswith(("vault://", "env://", "file://")):
110
+ raise IntentValidationError(
111
+ f"Argument '{name}' nie może zawierać sekretu ani referencji do sekretu"
112
+ )
113
+ if len(value) > 4096:
114
+ raise IntentValidationError(f"Argument '{name}' jest zbyt długi")
115
+ return
116
+ if value is None or isinstance(value, (bool, int, float)):
117
+ return
118
+ if isinstance(value, list):
119
+ if len(value) > 64:
120
+ raise IntentValidationError(f"Argument '{name}' ma zbyt wiele elementów")
121
+ for item in value:
122
+ if isinstance(item, (dict, list)):
123
+ raise IntentValidationError("Zagnieżdżone struktury nie są dozwolone w IntentIR")
124
+ _validate_json_value(name, item)
125
+ return
126
+ raise IntentValidationError(f"Nieobsługiwany typ argumentu '{name}'")
127
+
128
+
129
+ def parse_json_object(text: str) -> dict[str, Any]:
130
+ candidate = text.strip()
131
+ if candidate.startswith("```"):
132
+ lines = candidate.splitlines()
133
+ if lines and lines[0].startswith("```"):
134
+ lines = lines[1:]
135
+ if lines and lines[-1].strip() == "```":
136
+ lines = lines[:-1]
137
+ candidate = "\n".join(lines).strip()
138
+ try:
139
+ parsed = json.loads(candidate)
140
+ except json.JSONDecodeError:
141
+ start = candidate.find("{")
142
+ end = candidate.rfind("}")
143
+ if start < 0 or end <= start:
144
+ raise IntentValidationError("Model nie zwrócił obiektu JSON")
145
+ try:
146
+ parsed = json.loads(candidate[start : end + 1])
147
+ except json.JSONDecodeError as exc:
148
+ raise IntentValidationError("Niepoprawny JSON zwrócony przez model") from exc
149
+ if not isinstance(parsed, dict):
150
+ raise IntentValidationError("Model musi zwrócić jeden obiekt JSON")
151
+ return parsed
152
+
153
+
154
+ def intent_ir_schema(candidate_ids: list[str]) -> dict[str, Any]:
155
+ intent_property: dict[str, Any] = {"type": "string", "minLength": 1}
156
+ if candidate_ids:
157
+ intent_property["enum"] = candidate_ids
158
+ scalar: dict[str, Any] = {"type": ["string", "number", "integer", "boolean", "null"]}
159
+ return {
160
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
161
+ "title": "SubactorIntentIRV1",
162
+ "type": "object",
163
+ "additionalProperties": False,
164
+ "required": [
165
+ "v",
166
+ "intent_id",
167
+ "mode",
168
+ "args",
169
+ "requirements",
170
+ "constraints",
171
+ "unresolved",
172
+ ],
173
+ "properties": {
174
+ "v": {"const": 1},
175
+ "intent_id": intent_property,
176
+ "mode": {"type": "string", "enum": ["ask", "plan", "execute"]},
177
+ "args": {
178
+ "type": "object",
179
+ "additionalProperties": {
180
+ "anyOf": [scalar, {"type": "array", "maxItems": 64, "items": scalar}]
181
+ },
182
+ },
183
+ "requirements": {"type": "array", "items": {"type": "string"}},
184
+ "constraints": {"type": "array", "items": {"type": "string"}},
185
+ "unresolved": {"type": "array", "items": {"type": "string"}},
186
+ },
187
+ }
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ Role = Literal["system", "user", "assistant"]
9
+
10
+
11
+ def utc_now() -> str:
12
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class Session:
17
+ id: str
18
+ name: str
19
+ provider: str
20
+ model: str
21
+ created_at: str
22
+ updated_at: str
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class Message:
27
+ id: int
28
+ session_id: str
29
+ role: Role
30
+ display_content: str
31
+ context_content: str
32
+ metadata: dict[str, Any]
33
+ created_at: str
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class Artifact:
38
+ id: str
39
+ original_path: str
40
+ stored_path: Path
41
+ mime_type: str
42
+ size: int
43
+ created_at: str
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class ProviderProfile:
48
+ name: str
49
+ kind: str
50
+ model: str
51
+ base_url: str = ""
52
+ endpoint: str = ""
53
+ api_key_ref: str = ""
54
+ auth_required: bool = False
55
+ max_tokens: int = 4096
56
+ max_output_tokens: int = 256
57
+ structured_mode: str = "auto"
58
+ reasoning_effort: str = ""
59
+ anthropic_version: str = "2023-06-01"
60
+ timeout_seconds: float = 120.0
61
+ extra_headers: dict[str, str] = field(default_factory=dict)
62
+ input_cost_per_million: float = 0.0
63
+ cached_input_cost_per_million: float = 0.0
64
+ output_cost_per_million: float = 0.0
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class ChatChunk:
69
+ text: str
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class PreparedPrompt:
74
+ display_content: str
75
+ safe_context_content: str
76
+ provider_content: str
77
+ resolved_secret_values: list[str]
78
+ metadata: dict[str, Any]