berthos-sdk 0.2.1__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.
- berth_sdk/__init__.py +13 -0
- berth_sdk/app.py +65 -0
- berth_sdk/context_bus.py +114 -0
- berth_sdk/context_bus_pb2.py +48 -0
- berth_sdk/generate_capability_policy.py +117 -0
- berth_sdk/local_context_bus.py +36 -0
- berth_sdk/manifest.py +93 -0
- berth_sdk/rpc.py +108 -0
- berth_sdk/run_lifecycle.py +37 -0
- berth_sdk/runtime.py +94 -0
- berthos_sdk-0.2.1.dist-info/METADATA +57 -0
- berthos_sdk-0.2.1.dist-info/RECORD +15 -0
- berthos_sdk-0.2.1.dist-info/WHEEL +5 -0
- berthos_sdk-0.2.1.dist-info/licenses/LICENSE +121 -0
- berthos_sdk-0.2.1.dist-info/top_level.txt +1 -0
berth_sdk/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .app import AppContext, BerthApp, define_app
|
|
2
|
+
from .manifest import BerthManifest, ExportSpec, load_manifest, matches_capability, parse_capability
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AppContext",
|
|
6
|
+
"BerthApp",
|
|
7
|
+
"define_app",
|
|
8
|
+
"BerthManifest",
|
|
9
|
+
"ExportSpec",
|
|
10
|
+
"load_manifest",
|
|
11
|
+
"matches_capability",
|
|
12
|
+
"parse_capability",
|
|
13
|
+
]
|
berth_sdk/app.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Mirrors @berthos/sdk's app.ts (defineApp/BerthApp/ExportDefinition). Python
|
|
2
|
+
has no default-export convention, so the equivalent authoring pattern is a
|
|
3
|
+
module-level `app = define_app(...)` — runtime.py looks for that attribute
|
|
4
|
+
name on the imported app module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from .manifest import BerthManifest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ExportDefinition:
|
|
17
|
+
name: str
|
|
18
|
+
handler: Callable[[Any], Any]
|
|
19
|
+
# Optional pydantic BaseModel subclasses — validated the same way zod
|
|
20
|
+
# schemas validate input/output in the TS SDK's ExportDefinition.
|
|
21
|
+
input_model: Optional[type] = None
|
|
22
|
+
output_model: Optional[type] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AppContext:
|
|
26
|
+
"""Semantic-fs's tag/query control API is deliberately out of scope for
|
|
27
|
+
this SDK — see docs/sdk-python-reference.md. Direct file I/O against the
|
|
28
|
+
FUSE-mounted /context already works from any process with zero SDK code,
|
|
29
|
+
which is this SDK's honest v1 scope for context access. `context_bus` is
|
|
30
|
+
real (see context_bus.py / local_context_bus.py, wired in by
|
|
31
|
+
runtime.py)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, manifest: BerthManifest, context_bus: Any) -> None:
|
|
34
|
+
self.manifest = manifest
|
|
35
|
+
self.context_bus = context_bus
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BerthApp:
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.exports: dict[str, ExportDefinition] = {}
|
|
41
|
+
self.on_install_hooks: list[Callable[[], None]] = []
|
|
42
|
+
self.on_agent_ready_hooks: list[Callable[[AppContext], None]] = []
|
|
43
|
+
|
|
44
|
+
def export(
|
|
45
|
+
self,
|
|
46
|
+
name: str,
|
|
47
|
+
handler: Callable[[Any], Any],
|
|
48
|
+
input_model: Optional[type] = None,
|
|
49
|
+
output_model: Optional[type] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if name in self.exports:
|
|
52
|
+
raise ValueError(f'export "{name}" is already registered')
|
|
53
|
+
self.exports[name] = ExportDefinition(name, handler, input_model, output_model)
|
|
54
|
+
|
|
55
|
+
def on_install(self, fn: Callable[[], None]) -> None:
|
|
56
|
+
self.on_install_hooks.append(fn)
|
|
57
|
+
|
|
58
|
+
def on_agent_ready(self, fn: Callable[[AppContext], None]) -> None:
|
|
59
|
+
self.on_agent_ready_hooks.append(fn)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def define_app(setup: Callable[[BerthApp], None]) -> BerthApp:
|
|
63
|
+
app = BerthApp()
|
|
64
|
+
setup(app)
|
|
65
|
+
return app
|
berth_sdk/context_bus.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Real context-bus client — talks to the same Rust daemon @berthos/sdk's
|
|
2
|
+
unix-socket.ts does, over the same wire contract: a Unix socket carrying
|
|
3
|
+
length-prefixed (4-byte big-endian length + protobuf bytes) Envelope frames,
|
|
4
|
+
per proto/context_bus.proto. Unlike rpc.py's protocol (plain newline-JSON,
|
|
5
|
+
reused as-is), this one needs real protobuf codegen — see
|
|
6
|
+
scripts/gen_proto.sh for how context_bus_pb2.py is generated, and why
|
|
7
|
+
`python3 -m grpc_tools.protoc` is used instead of a bare system `protoc`
|
|
8
|
+
(version-compatibility reasons documented there).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from typing import Any, Callable, Optional
|
|
19
|
+
|
|
20
|
+
from . import context_bus_pb2 as pb
|
|
21
|
+
|
|
22
|
+
CONNECT_TIMEOUT_S = 2.0
|
|
23
|
+
CONNECT_RETRY_INTERVAL_S = 0.1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ContextBusClient:
|
|
27
|
+
"""Same interface shape as @berthos/sdk's ContextBusClient
|
|
28
|
+
(register/publish/subscribe) — resident app code doesn't change based on
|
|
29
|
+
which implementation runtime.py wires in, matching the TS SDK's own
|
|
30
|
+
local-vs-real symmetry."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
33
|
+
self._sock = sock
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._handlers: dict[str, list[Callable[[Any], None]]] = defaultdict(list)
|
|
36
|
+
self._read_buffer = b""
|
|
37
|
+
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
38
|
+
self._reader_thread.start()
|
|
39
|
+
|
|
40
|
+
def _read_loop(self) -> None:
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
chunk = self._sock.recv(4096)
|
|
44
|
+
except OSError:
|
|
45
|
+
return
|
|
46
|
+
if not chunk:
|
|
47
|
+
return
|
|
48
|
+
self._read_buffer += chunk
|
|
49
|
+
while len(self._read_buffer) >= 4:
|
|
50
|
+
length = int.from_bytes(self._read_buffer[:4], "big")
|
|
51
|
+
if len(self._read_buffer) < 4 + length:
|
|
52
|
+
break
|
|
53
|
+
frame = self._read_buffer[4 : 4 + length]
|
|
54
|
+
self._read_buffer = self._read_buffer[4 + length :]
|
|
55
|
+
self._handle_frame(frame)
|
|
56
|
+
|
|
57
|
+
def _handle_frame(self, frame: bytes) -> None:
|
|
58
|
+
envelope = pb.Envelope()
|
|
59
|
+
envelope.ParseFromString(frame)
|
|
60
|
+
if envelope.HasField("event"):
|
|
61
|
+
topic = envelope.event.topic
|
|
62
|
+
raw_payload = envelope.event.payload
|
|
63
|
+
payload = json.loads(raw_payload.decode("utf-8")) if raw_payload else None
|
|
64
|
+
for handler in list(self._handlers.get(topic, [])):
|
|
65
|
+
handler(payload)
|
|
66
|
+
|
|
67
|
+
def _send(self, **kwargs: Any) -> None:
|
|
68
|
+
envelope = pb.Envelope(**kwargs)
|
|
69
|
+
encoded = envelope.SerializeToString()
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._sock.sendall(len(encoded).to_bytes(4, "big") + encoded)
|
|
72
|
+
|
|
73
|
+
# Plain synchronous methods, not `async def` — unlike the TS SDK
|
|
74
|
+
# (Node's I/O model makes everything naturally async), this runtime has
|
|
75
|
+
# no event loop: app hooks are called as plain sync functions, and the
|
|
76
|
+
# underlying socket write is already a blocking sendall(). Making these
|
|
77
|
+
# `async def` would just produce un-awaited coroutine objects that never
|
|
78
|
+
# actually run when called from a sync hook.
|
|
79
|
+
def register(self, app: str) -> None:
|
|
80
|
+
self._send(register=pb.RegisterRequest(app=app))
|
|
81
|
+
|
|
82
|
+
def publish(self, topic: str, payload: Any) -> None:
|
|
83
|
+
encoded_payload = json.dumps(payload if payload is not None else None).encode("utf-8")
|
|
84
|
+
self._send(publish=pb.PublishRequest(topic=topic, payload=encoded_payload))
|
|
85
|
+
|
|
86
|
+
def subscribe(self, topic: str, handler: Callable[[Any], None]) -> Callable[[], None]:
|
|
87
|
+
is_first = topic not in self._handlers or len(self._handlers[topic]) == 0
|
|
88
|
+
self._handlers[topic].append(handler)
|
|
89
|
+
if is_first:
|
|
90
|
+
self._send(subscribe=pb.SubscribeRequest(topic=topic))
|
|
91
|
+
|
|
92
|
+
def unsubscribe() -> None:
|
|
93
|
+
handlers = self._handlers.get(topic, [])
|
|
94
|
+
if handler in handlers:
|
|
95
|
+
handlers.remove(handler)
|
|
96
|
+
if not handlers:
|
|
97
|
+
self._send(unsubscribe=pb.UnsubscribeRequest(topic=topic))
|
|
98
|
+
|
|
99
|
+
return unsubscribe
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_unix_socket_context_bus(socket_path: str) -> ContextBusClient:
|
|
103
|
+
deadline = time.monotonic() + CONNECT_TIMEOUT_S
|
|
104
|
+
last_error: Optional[OSError] = None
|
|
105
|
+
while time.monotonic() < deadline:
|
|
106
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
107
|
+
try:
|
|
108
|
+
sock.connect(socket_path)
|
|
109
|
+
return ContextBusClient(sock)
|
|
110
|
+
except OSError as err:
|
|
111
|
+
last_error = err
|
|
112
|
+
sock.close()
|
|
113
|
+
time.sleep(CONNECT_RETRY_INTERVAL_S)
|
|
114
|
+
raise TimeoutError(f"timed out connecting to {socket_path} after {CONNECT_TIMEOUT_S}s: {last_error}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: context_bus.proto
|
|
5
|
+
# Protobuf Python Version: 6.31.1
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
6,
|
|
15
|
+
31,
|
|
16
|
+
1,
|
|
17
|
+
'',
|
|
18
|
+
'context_bus.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11\x63ontext_bus.proto\x12\x10\x62\x65rth.contextbus\"\x1e\n\x0fRegisterRequest\x12\x0b\n\x03\x61pp\x18\x01 \x01(\t\"0\n\x0ePublishRequest\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x0f\n\x07payload\x18\x02 \x01(\x0c\"!\n\x10SubscribeRequest\x12\r\n\x05topic\x18\x01 \x01(\t\"#\n\x12UnsubscribeRequest\x12\r\n\x05topic\x18\x01 \x01(\t\"\'\n\x05\x45vent\x12\r\n\x05topic\x18\x01 \x01(\t\x12\x0f\n\x07payload\x18\x02 \x01(\x0c\" \n\x03\x41\x63k\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xc4\x02\n\x08\x45nvelope\x12\x35\n\x08register\x18\x01 \x01(\x0b\x32!.berth.contextbus.RegisterRequestH\x00\x12\x33\n\x07publish\x18\x02 \x01(\x0b\x32 .berth.contextbus.PublishRequestH\x00\x12\x37\n\tsubscribe\x18\x03 \x01(\x0b\x32\".berth.contextbus.SubscribeRequestH\x00\x12;\n\x0bunsubscribe\x18\x04 \x01(\x0b\x32$.berth.contextbus.UnsubscribeRequestH\x00\x12(\n\x05\x65vent\x18\x05 \x01(\x0b\x32\x17.berth.contextbus.EventH\x00\x12$\n\x03\x61\x63k\x18\x06 \x01(\x0b\x32\x15.berth.contextbus.AckH\x00\x42\x06\n\x04kindb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'context_bus_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_REGISTERREQUEST']._serialized_start=39
|
|
35
|
+
_globals['_REGISTERREQUEST']._serialized_end=69
|
|
36
|
+
_globals['_PUBLISHREQUEST']._serialized_start=71
|
|
37
|
+
_globals['_PUBLISHREQUEST']._serialized_end=119
|
|
38
|
+
_globals['_SUBSCRIBEREQUEST']._serialized_start=121
|
|
39
|
+
_globals['_SUBSCRIBEREQUEST']._serialized_end=154
|
|
40
|
+
_globals['_UNSUBSCRIBEREQUEST']._serialized_start=156
|
|
41
|
+
_globals['_UNSUBSCRIBEREQUEST']._serialized_end=191
|
|
42
|
+
_globals['_EVENT']._serialized_start=193
|
|
43
|
+
_globals['_EVENT']._serialized_end=232
|
|
44
|
+
_globals['_ACK']._serialized_start=234
|
|
45
|
+
_globals['_ACK']._serialized_end=266
|
|
46
|
+
_globals['_ENVELOPE']._serialized_start=269
|
|
47
|
+
_globals['_ENVELOPE']._serialized_end=593
|
|
48
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Mirrors @berthos/sdk's generate-capability-policy.ts exactly (same policy
|
|
2
|
+
shape, same deny-by-default network/opt-in read-path rules, same
|
|
3
|
+
per-app baseline write/read paths) — agent-init (Rust) reads whichever
|
|
4
|
+
one ran, TypeScript or Python, without caring which wrote it. Invoked as
|
|
5
|
+
`python3 -m berth_sdk.generate_capability_policy`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .manifest import load_manifest, parse_capability
|
|
15
|
+
|
|
16
|
+
# Per-app, not container-wide: this used to be all of /tmp for every app, which
|
|
17
|
+
# let one app reach another's RPC socket. See the TypeScript original for why a
|
|
18
|
+
# narrower Landlock policy is only half the fix (DAC is the other half) and for
|
|
19
|
+
# the socket layout these two directories belong to.
|
|
20
|
+
#
|
|
21
|
+
# /dev/null is the one entry that stays shared, and it is here rather than in
|
|
22
|
+
# TERMINAL_WRITE_PATHS because opening it read-write is what any process does
|
|
23
|
+
# when it redirects a child's stdio to it — see the TypeScript original for the
|
|
24
|
+
# strace this came from, and for why /dev/tty is deliberately absent
|
|
25
|
+
#
|
|
26
|
+
def _baseline_write_paths(app_name: str) -> list[str]:
|
|
27
|
+
return ["/dev/null", f"/tmp/{app_name}", f"/run/berth/{app_name}"]
|
|
28
|
+
|
|
29
|
+
# Added only for an app declaring terminal:* — the one thing that capability
|
|
30
|
+
# compiles into the kernel policy. Without it a tmux server cannot allocate a
|
|
31
|
+
# pty on a Landlock-enforcing kernel.
|
|
32
|
+
TERMINAL_WRITE_PATHS = ["/dev/pts", "/dev/ptmx"]
|
|
33
|
+
|
|
34
|
+
# Where github-api-broker.cjs writes the CA an app declaring github:* is told
|
|
35
|
+
# to trust. Read-granted only for such an app, and only because that CA moved
|
|
36
|
+
# out of /tmp (which the read baseline covers in full) into /run/berth —
|
|
37
|
+
# Kept in step with that script's own default.
|
|
38
|
+
GITHUB_BROKER_CERT_DIR = "/run/berth/github-api-broker"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# /tmp stays fully readable even though it is no longer fully writable: reads
|
|
42
|
+
# are not what 1.4 was about, and statting a daemon control socket before
|
|
43
|
+
# connecting to it needs them.
|
|
44
|
+
def _baseline_read_paths(app_name: str) -> list[str]:
|
|
45
|
+
return ["/usr", "/lib", "/etc", "/proc", "/dev", "/tmp", f"/run/berth/{app_name}", str(Path.cwd())]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _strip_trailing_glob(scope: str) -> str:
|
|
49
|
+
return scope[:-2] if scope.endswith("/*") else scope
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
manifest_path = os.environ.get("BERTH_MANIFEST_PATH", str(Path.cwd() / "berth.yml"))
|
|
55
|
+
policy_path = Path(os.environ.get("BERTH_CAPABILITY_POLICY", str(Path.cwd() / ".berth" / "capability-policy.json")))
|
|
56
|
+
|
|
57
|
+
manifest = load_manifest(manifest_path)
|
|
58
|
+
effective_capabilities = list(manifest.capabilities)
|
|
59
|
+
|
|
60
|
+
write_paths = set(_baseline_write_paths(manifest.name))
|
|
61
|
+
declared_read_paths: set[str] = set()
|
|
62
|
+
network_ports: set[int] = set()
|
|
63
|
+
network_unrestricted = False
|
|
64
|
+
needs_github_broker_ca = False
|
|
65
|
+
|
|
66
|
+
for capability in effective_capabilities:
|
|
67
|
+
parsed = parse_capability(capability)
|
|
68
|
+
if parsed.namespace == "filesystem" and parsed.action == "write":
|
|
69
|
+
write_paths.add(_strip_trailing_glob(parsed.scope))
|
|
70
|
+
elif parsed.namespace == "filesystem" and parsed.action == "read":
|
|
71
|
+
declared_read_paths.add(_strip_trailing_glob(parsed.scope))
|
|
72
|
+
elif parsed.namespace == "network" and parsed.action == "connect":
|
|
73
|
+
if parsed.scope == "*":
|
|
74
|
+
network_unrestricted = True
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
port = int(parsed.scope)
|
|
78
|
+
except ValueError:
|
|
79
|
+
port = -1
|
|
80
|
+
if 0 < port <= 65535:
|
|
81
|
+
network_ports.add(port)
|
|
82
|
+
else:
|
|
83
|
+
print(f'[berth:capability-policy] WARNING: ignoring invalid network:connect scope "{parsed.scope}" (expected a port 1-65535, or "*")')
|
|
84
|
+
elif parsed.namespace == "terminal":
|
|
85
|
+
write_paths.update(TERMINAL_WRITE_PATHS)
|
|
86
|
+
elif parsed.namespace == "github":
|
|
87
|
+
needs_github_broker_ca = True
|
|
88
|
+
|
|
89
|
+
baseline_reads = set(_baseline_read_paths(manifest.name))
|
|
90
|
+
if needs_github_broker_ca:
|
|
91
|
+
baseline_reads.add(GITHUB_BROKER_CERT_DIR)
|
|
92
|
+
read_paths = sorted(baseline_reads | declared_read_paths) if declared_read_paths else []
|
|
93
|
+
|
|
94
|
+
policy = {
|
|
95
|
+
"appName": manifest.name,
|
|
96
|
+
"declaredCapabilities": effective_capabilities,
|
|
97
|
+
"writePaths": sorted(write_paths),
|
|
98
|
+
"readPaths": read_paths,
|
|
99
|
+
"networkPorts": sorted(network_ports),
|
|
100
|
+
"networkUnrestricted": network_unrestricted,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
policy_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
policy_path.write_text(json.dumps(policy, indent=2))
|
|
105
|
+
|
|
106
|
+
if network_unrestricted:
|
|
107
|
+
network_summary = "networkPorts=* (unrestricted)"
|
|
108
|
+
elif network_ports:
|
|
109
|
+
network_summary = f"networkPorts={', '.join(str(p) for p in sorted(network_ports))}"
|
|
110
|
+
else:
|
|
111
|
+
network_summary = "networkPorts=(none — network denied by default)"
|
|
112
|
+
read_summary = f"; readPaths={', '.join(read_paths)}" if read_paths else ""
|
|
113
|
+
print(f"[berth:capability-policy] wrote {policy_path}: writePaths={', '.join(sorted(write_paths))}{read_summary}; {network_summary}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Mirrors @berthos/sdk's context-bus/local.ts — an in-process no-op fallback
|
|
2
|
+
used when the real Unix-socket daemon isn't reachable (e.g. running an app
|
|
3
|
+
outside a sandbox during a quick script/test)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("berth.context_bus.local")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalContextBus:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._handlers: dict[str, list[Callable[[Any], None]]] = defaultdict(list)
|
|
17
|
+
|
|
18
|
+
# Plain synchronous methods — see context_bus.py's own comment on why
|
|
19
|
+
# these deliberately aren't `async def` in this runtime.
|
|
20
|
+
def register(self, app: str) -> None:
|
|
21
|
+
logger.debug('register app="%s" (no-op — real daemon not connected)', app)
|
|
22
|
+
|
|
23
|
+
def publish(self, topic: str, payload: Any) -> None:
|
|
24
|
+
logger.debug('publish topic="%s"', topic)
|
|
25
|
+
for handler in list(self._handlers.get(topic, [])):
|
|
26
|
+
handler(payload)
|
|
27
|
+
|
|
28
|
+
def subscribe(self, topic: str, handler: Callable[[Any], None]) -> Callable[[], None]:
|
|
29
|
+
logger.debug('subscribe topic="%s"', topic)
|
|
30
|
+
self._handlers[topic].append(handler)
|
|
31
|
+
|
|
32
|
+
def unsubscribe() -> None:
|
|
33
|
+
if handler in self._handlers[topic]:
|
|
34
|
+
self._handlers[topic].remove(handler)
|
|
35
|
+
|
|
36
|
+
return unsubscribe
|
berth_sdk/manifest.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Mirrors @berthos/manifest-schema's schema.ts/capability.ts — the manifest
|
|
2
|
+
shape and capability-string grammar are plain data (YAML + a
|
|
3
|
+
namespace:action:scope string), not TypeScript-specific, so a Python
|
|
4
|
+
implementation validates the exact same shape rather than porting any code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, Field, field_validator
|
|
14
|
+
|
|
15
|
+
CAPABILITY_RE = re.compile(r"^[a-z0-9_-]+:[a-z0-9_-]+:.+$")
|
|
16
|
+
NAME_RE = re.compile(r"^[a-z0-9-]+$")
|
|
17
|
+
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
18
|
+
|
|
19
|
+
JsonPrimitiveType = Literal["string", "number", "boolean", "object", "array"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExportSpec(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
input: dict[str, JsonPrimitiveType] = Field(default_factory=dict)
|
|
25
|
+
output: dict[str, JsonPrimitiveType] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BerthManifest(BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
version: str
|
|
31
|
+
description: str = ""
|
|
32
|
+
capabilities: list[str] = Field(default_factory=list)
|
|
33
|
+
exports: list[ExportSpec] = Field(default_factory=list)
|
|
34
|
+
on_install: list[str] = Field(default_factory=list)
|
|
35
|
+
on_agent_ready: list[str] = Field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
@field_validator("name")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _validate_name(cls, v: str) -> str:
|
|
40
|
+
if not NAME_RE.match(v):
|
|
41
|
+
raise ValueError("name must be lowercase alphanumeric with dashes")
|
|
42
|
+
return v
|
|
43
|
+
|
|
44
|
+
@field_validator("version")
|
|
45
|
+
@classmethod
|
|
46
|
+
def _validate_version(cls, v: str) -> str:
|
|
47
|
+
if not VERSION_RE.match(v):
|
|
48
|
+
raise ValueError("version must be semver (x.y.z)")
|
|
49
|
+
return v
|
|
50
|
+
|
|
51
|
+
@field_validator("capabilities")
|
|
52
|
+
@classmethod
|
|
53
|
+
def _validate_capabilities(cls, v: list[str]) -> list[str]:
|
|
54
|
+
for cap in v:
|
|
55
|
+
if not CAPABILITY_RE.match(cap):
|
|
56
|
+
raise ValueError(f"capability must be 'namespace:action:scope', got {cap!r}")
|
|
57
|
+
return v
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_manifest(path: str) -> BerthManifest:
|
|
61
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
62
|
+
raw = yaml.safe_load(f) or {}
|
|
63
|
+
return BerthManifest.model_validate(raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ParsedCapability:
|
|
67
|
+
__slots__ = ("namespace", "action", "scope")
|
|
68
|
+
|
|
69
|
+
def __init__(self, namespace: str, action: str, scope: str) -> None:
|
|
70
|
+
self.namespace = namespace
|
|
71
|
+
self.action = action
|
|
72
|
+
self.scope = scope
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_capability(capability: str) -> ParsedCapability:
|
|
76
|
+
parts = capability.split(":")
|
|
77
|
+
if len(parts) < 3:
|
|
78
|
+
raise ValueError(f'invalid capability string "{capability}": expected \'namespace:action:scope\'')
|
|
79
|
+
namespace, action, *scope_parts = parts
|
|
80
|
+
return ParsedCapability(namespace, action, ":".join(scope_parts))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _glob_to_regex(glob: str) -> re.Pattern[str]:
|
|
84
|
+
escaped = re.escape(glob).replace(r"\*", ".*")
|
|
85
|
+
return re.compile(f"^{escaped}$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def matches_capability(granted: str, requested: str) -> bool:
|
|
89
|
+
g = parse_capability(granted)
|
|
90
|
+
r = parse_capability(requested)
|
|
91
|
+
if g.namespace != r.namespace or g.action != r.action:
|
|
92
|
+
return False
|
|
93
|
+
return bool(_glob_to_regex(g.scope).match(r.scope))
|
berth_sdk/rpc.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The identical line-delimited JSON RPC protocol from @berthos/sdk's rpc.ts —
|
|
2
|
+
{id, export, input} in, {id, result} or {id, error} out — over stdio and,
|
|
3
|
+
optionally, a Unix socket. No length-prefix, no protobuf: this is the
|
|
4
|
+
simplest of the two wire protocols this SDK reuses (see context_bus.py for
|
|
5
|
+
the other, protobuf-framed one), so a straight re-implementation rather than
|
|
6
|
+
anything requiring codegen.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socketserver
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from .app import BerthApp
|
|
19
|
+
|
|
20
|
+
RpcRequest = dict[str, Any]
|
|
21
|
+
RpcResponse = dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def invoke_export(app: BerthApp, request: RpcRequest) -> RpcResponse:
|
|
25
|
+
export_def = app.exports.get(request.get("export"))
|
|
26
|
+
if export_def is None:
|
|
27
|
+
return {"id": request.get("id"), "error": f'no such export "{request.get("export")}"'}
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
raw_input = request.get("input")
|
|
31
|
+
parsed_input = export_def.input_model.model_validate(raw_input) if export_def.input_model else raw_input
|
|
32
|
+
result = export_def.handler(parsed_input)
|
|
33
|
+
|
|
34
|
+
if export_def.output_model is not None and not isinstance(result, export_def.output_model):
|
|
35
|
+
result = export_def.output_model.model_validate(result)
|
|
36
|
+
output_payload = result.model_dump() if hasattr(result, "model_dump") else result
|
|
37
|
+
|
|
38
|
+
return {"id": request.get("id"), "result": output_payload}
|
|
39
|
+
except Exception as err: # matches rpc.ts's catch-all — reported back to the caller, not raised
|
|
40
|
+
return {"id": request.get("id"), "error": str(err)}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _handle_line(app: BerthApp, line: str, write: Callable[[str], None]) -> None:
|
|
44
|
+
line = line.strip()
|
|
45
|
+
if not line:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
request = json.loads(line)
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
print(f"[berth:runtime] ignoring non-JSON RPC line: {line}", file=sys.stderr)
|
|
51
|
+
return
|
|
52
|
+
response = invoke_export(app, request)
|
|
53
|
+
write(json.dumps(response))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _RpcSocketHandler(socketserver.StreamRequestHandler):
|
|
57
|
+
def handle(self) -> None:
|
|
58
|
+
app: BerthApp = self.server.app # type: ignore[attr-defined]
|
|
59
|
+
|
|
60
|
+
def write(resp: str) -> None:
|
|
61
|
+
self.wfile.write((resp + "\n").encode("utf-8"))
|
|
62
|
+
self.wfile.flush()
|
|
63
|
+
|
|
64
|
+
while True:
|
|
65
|
+
raw = self.rfile.readline()
|
|
66
|
+
if not raw:
|
|
67
|
+
break
|
|
68
|
+
line = raw.decode("utf-8")
|
|
69
|
+
if not line.strip():
|
|
70
|
+
continue
|
|
71
|
+
_handle_line(app, line, write)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _RpcUnixStreamServer(socketserver.ThreadingUnixStreamServer):
|
|
75
|
+
daemon_threads = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def start_socket_server(app: BerthApp, socket_path: str) -> None:
|
|
79
|
+
try:
|
|
80
|
+
os.unlink(socket_path)
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
server = _RpcUnixStreamServer(socket_path, _RpcSocketHandler)
|
|
85
|
+
server.app = app # type: ignore[attr-defined]
|
|
86
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
87
|
+
thread.start()
|
|
88
|
+
print(f"[berth:runtime] RPC server also listening on {socket_path}", file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def start_rpc_server(app: BerthApp, socket_path: Optional[str] = None) -> None:
|
|
92
|
+
"""Starts the (optional) socket server and logs readiness — does NOT
|
|
93
|
+
block. serve_stdio_forever() is the blocking call, run last in
|
|
94
|
+
runtime.py's boot sequence so "ready" logs before it, matching rpc.ts's
|
|
95
|
+
ordering (its own startRpcServer() is non-blocking; Node's event loop
|
|
96
|
+
is what keeps the process alive)."""
|
|
97
|
+
print("[berth:runtime] RPC server listening on stdio", file=sys.stderr)
|
|
98
|
+
if socket_path:
|
|
99
|
+
start_socket_server(app, socket_path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def serve_stdio_forever(app: BerthApp) -> None:
|
|
103
|
+
def write(resp: str) -> None:
|
|
104
|
+
sys.stdout.write(resp + "\n")
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
|
|
107
|
+
for line in sys.stdin:
|
|
108
|
+
_handle_line(app, line, write)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Mirrors @berthos/sdk's run-lifecycle.ts: reports whether a browser:*
|
|
2
|
+
capability is declared, and separately whether a browser:navigate:*/
|
|
3
|
+
network:host:* capability is declared (entrypoint.sh's own trigger for
|
|
4
|
+
starting the egress broker), via the last stdout line ("1,1" / "0,1" / etc.)
|
|
5
|
+
— entrypoint.sh's Python branch parses this exactly like it already does for
|
|
6
|
+
the Node lifecycle script.
|
|
7
|
+
|
|
8
|
+
It used to also execute the manifest's on_install commands, once, tracked by
|
|
9
|
+
a marker file. That ran as uid 0 with no Landlock domain applied, before one
|
|
10
|
+
could exist; on_install is now a Docker build layer for
|
|
11
|
+
both build targets, and nothing runs it at container boot. See the fuller
|
|
12
|
+
note in run-lifecycle.ts. The marker file went with it — there is no longer a
|
|
13
|
+
boot-time action to run at most once.
|
|
14
|
+
|
|
15
|
+
Invoked as `python3 -m berth_sdk.run_lifecycle`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .manifest import load_manifest
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
manifest_path = os.environ.get("BERTH_MANIFEST_PATH", str(Path.cwd() / "berth.yml"))
|
|
28
|
+
|
|
29
|
+
manifest = load_manifest(manifest_path)
|
|
30
|
+
|
|
31
|
+
needs_browser = any(cap.startswith("browser:") for cap in manifest.capabilities)
|
|
32
|
+
needs_egress_broker = any(cap.startswith("browser:navigate:") or cap.startswith("network:host:") for cap in manifest.capabilities)
|
|
33
|
+
print(f"{'1' if needs_browser else '0'},{'1' if needs_egress_broker else '0'}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
berth_sdk/runtime.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Mirrors @berthos/sdk's runtime.ts boot sequence: load manifest -> import the
|
|
2
|
+
app module -> assert exports match manifest -> run hooks -> serve RPC. The
|
|
3
|
+
orchestration itself is idiomatic Python (importlib, not a port of Node's
|
|
4
|
+
dynamic import()) — only the wire protocols (manifest shape, RPC framing)
|
|
5
|
+
are shared with the TypeScript SDK, not this glue code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .app import AppContext, BerthApp
|
|
16
|
+
from .context_bus import create_unix_socket_context_bus
|
|
17
|
+
from .local_context_bus import LocalContextBus
|
|
18
|
+
from .manifest import load_manifest
|
|
19
|
+
from .rpc import serve_stdio_forever, start_rpc_server
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _create_context_bus():
|
|
23
|
+
"""Real context-bus daemon if reachable (entrypoint.sh starts it before
|
|
24
|
+
this runtime — same file/env contract @berthos/sdk's runtime.ts uses);
|
|
25
|
+
falls back to a local no-op otherwise, so an app never hard-fails just
|
|
26
|
+
because it's running outside a sandbox with the daemon (e.g. a bare
|
|
27
|
+
`python3 -m berth_sdk.runtime` during a quick script)."""
|
|
28
|
+
socket_path = os.environ.get("BERTH_CONTEXT_BUS_SOCKET", "/tmp/berth-context-bus.sock")
|
|
29
|
+
try:
|
|
30
|
+
return create_unix_socket_context_bus(socket_path)
|
|
31
|
+
except Exception as err: # noqa: BLE001 - genuinely any connection failure should fall back, not crash boot
|
|
32
|
+
print(
|
|
33
|
+
f"[berth:runtime] context-bus daemon not reachable at {socket_path} ({err}) — falling back to local no-op",
|
|
34
|
+
file=sys.stderr,
|
|
35
|
+
)
|
|
36
|
+
return LocalContextBus()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _assert_exports_match_manifest(app: BerthApp, declared_exports: list[str]) -> None:
|
|
40
|
+
code_exports = set(app.exports.keys())
|
|
41
|
+
manifest_exports = set(declared_exports)
|
|
42
|
+
|
|
43
|
+
missing_in_code = sorted(manifest_exports - code_exports)
|
|
44
|
+
missing_in_manifest = sorted(code_exports - manifest_exports)
|
|
45
|
+
|
|
46
|
+
if missing_in_code or missing_in_manifest:
|
|
47
|
+
problems = []
|
|
48
|
+
if missing_in_code:
|
|
49
|
+
problems.append(f"declared in berth.yml but not implemented: {', '.join(missing_in_code)}")
|
|
50
|
+
if missing_in_manifest:
|
|
51
|
+
problems.append(f"implemented in code but not declared in berth.yml: {', '.join(missing_in_manifest)}")
|
|
52
|
+
raise RuntimeError(f"exports mismatch between berth.yml and app code — {'; '.join(problems)}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
app_root = Path(os.getcwd())
|
|
57
|
+
manifest_path = os.environ.get("BERTH_MANIFEST_PATH", str(app_root / "berth.yml"))
|
|
58
|
+
app_entry = os.environ.get("BERTH_APP_ENTRY", str(app_root / "src" / "app.py"))
|
|
59
|
+
|
|
60
|
+
print(f"[berth:runtime] loading manifest from {manifest_path}", file=sys.stderr)
|
|
61
|
+
manifest = load_manifest(manifest_path)
|
|
62
|
+
|
|
63
|
+
print(f"[berth:runtime] loading app entry {app_entry}", file=sys.stderr)
|
|
64
|
+
spec = importlib.util.spec_from_file_location("berth_app_entry", app_entry)
|
|
65
|
+
if spec is None or spec.loader is None:
|
|
66
|
+
raise RuntimeError(f"could not load a Python module from {app_entry}")
|
|
67
|
+
module = importlib.util.module_from_spec(spec)
|
|
68
|
+
spec.loader.exec_module(module)
|
|
69
|
+
|
|
70
|
+
app = getattr(module, "app", None)
|
|
71
|
+
if not isinstance(app, BerthApp):
|
|
72
|
+
raise RuntimeError(f"{app_entry} must define a module-level `app = define_app(...)`")
|
|
73
|
+
|
|
74
|
+
_assert_exports_match_manifest(app, [e.name for e in manifest.exports])
|
|
75
|
+
|
|
76
|
+
for hook in app.on_install_hooks:
|
|
77
|
+
hook()
|
|
78
|
+
|
|
79
|
+
context_bus = _create_context_bus()
|
|
80
|
+
ctx = AppContext(manifest, context_bus)
|
|
81
|
+
for hook in app.on_agent_ready_hooks:
|
|
82
|
+
hook(ctx)
|
|
83
|
+
|
|
84
|
+
start_rpc_server(app, socket_path=os.environ.get("BERTH_RPC_SOCKET"))
|
|
85
|
+
print(f'[berth:runtime] "{manifest.name}" ready', file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
# Blocks forever, reading stdio RPC requests — this is what keeps the
|
|
88
|
+
# process alive, same role Node's active readline listener plays in
|
|
89
|
+
# rpc.ts (its event loop just never empties).
|
|
90
|
+
serve_stdio_forever(app)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: berthos-sdk
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Python resident-app SDK for Berth — reuses the same wire protocols as @berthos/sdk (RPC newline-JSON framing, the manifest shape), with idiomatic Python runtime glue rather than a port of the TypeScript code.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: protobuf>=6.30
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# berthos-sdk
|
|
15
|
+
|
|
16
|
+
Build a Berth resident app in Python. It uses the same `berth.yml` manifest, RPC protocol and context bus as the TypeScript SDK, `@berthos/sdk`.
|
|
17
|
+
|
|
18
|
+
[Berth](https://github.com/Ash20pk/BerthOS) runs an AI agent's tools in a sandbox. Each tool declares what it may touch in a `berth.yml` manifest, and the Linux kernel enforces it.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install berthos-sdk
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Needs Python 3.11+. The import name is `berth_sdk`.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from berth_sdk import define_app
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class GreetInput(BaseModel):
|
|
34
|
+
name: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GreetOutput(BaseModel):
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def setup(app):
|
|
42
|
+
app.export(
|
|
43
|
+
"greet",
|
|
44
|
+
lambda inp: GreetOutput(message=f"hello {inp.name}"),
|
|
45
|
+
input_model=GreetInput,
|
|
46
|
+
output_model=GreetOutput,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
app = define_app(setup)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Declare `greet` under `exports` in `berth.yml` too. Without `input_model`, a handler receives the raw input dict. Run the app with `berth dev` from [`@berthos/cli`](https://www.npmjs.com/package/@berthos/cli); the [`hello-world-py`](https://github.com/Ash20pk/BerthOS/tree/main/apps/hello-world-py) app is a complete example.
|
|
54
|
+
|
|
55
|
+
## Docs
|
|
56
|
+
|
|
57
|
+
[Python SDK reference](https://github.com/Ash20pk/BerthOS/blob/main/docs/sdk-python-reference.md) · [Python and the context bus](https://github.com/Ash20pk/BerthOS/blob/main/docs/sdk-python-context-bus-reference.md) · [Repo](https://github.com/Ash20pk/BerthOS)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
berth_sdk/__init__.py,sha256=tI0S0C0EfRSaKn9QSWSMAuCruGThSsk6nCV_EvPTetY,328
|
|
2
|
+
berth_sdk/app.py,sha256=VByQwnbsoTSJYGmt5Bik9nqS7BkvMz-MKRFPAB0Nep4,2251
|
|
3
|
+
berth_sdk/context_bus.py,sha256=rx6btr9vwNRD_VbNa-xmReEv2h9IQusxiZiYi5GRw_8,4713
|
|
4
|
+
berth_sdk/context_bus_pb2.py,sha256=L-r3ITTpSH6me9OKIbDe0P8OjBftQZPq9e1nrVxAh0s,2798
|
|
5
|
+
berth_sdk/generate_capability_policy.py,sha256=aXPWoG80lV8tWx79bZPNFw5_UWOeshxtKidnmCug9Rg,5075
|
|
6
|
+
berth_sdk/local_context_bus.py,sha256=IwOsteW9vzKuHiiKMKX4JY7nFxstYaBK6NePrEBkayU,1340
|
|
7
|
+
berth_sdk/manifest.py,sha256=Dy6gUS7pfOhy0VRJnusofoum8OsUUJUofkrfcqDFhFc,3066
|
|
8
|
+
berth_sdk/rpc.py,sha256=jq-dVPz5cjeqYourUxqneLfyeqewZUNAzVpobmvBX5k,3851
|
|
9
|
+
berth_sdk/run_lifecycle.py,sha256=L5EL_1oA1epSVAGQAtPeTX-K9X53rSKajQ2s2Bhmz4M,1462
|
|
10
|
+
berth_sdk/runtime.py,sha256=aXNabghZbv35qkOnoUcc90wfMMwSJr0G5z0crfTwX58,3924
|
|
11
|
+
berthos_sdk-0.2.1.dist-info/licenses/LICENSE,sha256=GBf0DItgJwnSPuTQIRW-LWf9Qy_5TYJNY2lPSYFdXBM,6264
|
|
12
|
+
berthos_sdk-0.2.1.dist-info/METADATA,sha256=kuyDkDaZ0JnMyueSGlhq2VmK9dqJsV6zpq8_z35Iat4,1925
|
|
13
|
+
berthos_sdk-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
berthos_sdk-0.2.1.dist-info/top_level.txt,sha256=qlYc7VHzBbl7mwViEObDLpeuGPz4X73M84Oa69VflKI,10
|
|
15
|
+
berthos_sdk-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship.
|
|
44
|
+
|
|
45
|
+
"Contribution" shall mean any work of authorship, including
|
|
46
|
+
the original version of the Work and any modifications or additions
|
|
47
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
48
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
49
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
50
|
+
the copyright owner.
|
|
51
|
+
|
|
52
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
53
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
54
|
+
subsequently incorporated within the Work.
|
|
55
|
+
|
|
56
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
57
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
58
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
59
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
60
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
61
|
+
Work and such Derivative Works in Source or Object form.
|
|
62
|
+
|
|
63
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
64
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
65
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
66
|
+
(except as stated in this section) patent license to make, have made,
|
|
67
|
+
use, offer to sell, sell, import, and otherwise transfer the Work.
|
|
68
|
+
|
|
69
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
70
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
71
|
+
modifications, and in Source or Object form, provided that You
|
|
72
|
+
meet the following conditions: (a) You must give any other
|
|
73
|
+
recipients of the Work or Derivative Works a copy of this License;
|
|
74
|
+
(b) You must cause any modified files to carry prominent notices
|
|
75
|
+
stating that You changed the files; (c) You must retain, in the
|
|
76
|
+
Source form of any Derivative Works that You distribute, all
|
|
77
|
+
copyright, patent, trademark, and attribution notices from the
|
|
78
|
+
Source form of the Work; (d) If the Work includes a "NOTICE" text
|
|
79
|
+
file, any Derivative Works You distribute must include a readable
|
|
80
|
+
copy of the attribution notices contained within such NOTICE file.
|
|
81
|
+
|
|
82
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
83
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
84
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
85
|
+
this License, without any additional terms or conditions.
|
|
86
|
+
|
|
87
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
88
|
+
names, trademarks, service marks, or product names of the Licensor.
|
|
89
|
+
|
|
90
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
91
|
+
agreed to in writing, Licensor provides the Work on an "AS IS" BASIS,
|
|
92
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
93
|
+
implied.
|
|
94
|
+
|
|
95
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
96
|
+
whether in tort, negligence, contract, or otherwise, shall any
|
|
97
|
+
Contributor be liable to You for damages, including any direct,
|
|
98
|
+
indirect, special, incidental, or consequential damages arising
|
|
99
|
+
as a result of this License or out of the use or inability to use
|
|
100
|
+
the Work.
|
|
101
|
+
|
|
102
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
103
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
104
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
105
|
+
or other liability obligations consistent with this License.
|
|
106
|
+
|
|
107
|
+
END OF TERMS AND CONDITIONS
|
|
108
|
+
|
|
109
|
+
Copyright 2026 Berth Contributors
|
|
110
|
+
|
|
111
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
112
|
+
you may not use this file except in compliance with the License.
|
|
113
|
+
You may obtain a copy of the License at
|
|
114
|
+
|
|
115
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
116
|
+
|
|
117
|
+
Unless required by applicable law or agreed to in writing, software
|
|
118
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
119
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
120
|
+
See the License for the specific language governing permissions and
|
|
121
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
berth_sdk
|