akernel-sdk 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.
@@ -0,0 +1,71 @@
1
+ # Copyright (c) 2026 Ant Group Corporation.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Public API for the AKernel Python SDK."""
16
+
17
+ import importlib
18
+
19
+ from .types import (
20
+ CommandInfo,
21
+ CommandResult,
22
+ EntryInfo,
23
+ HttpReverseTunnel,
24
+ Mount,
25
+ NodeInfo,
26
+ S3Config,
27
+ SandboxInfo,
28
+ )
29
+
30
+ __all__ = [
31
+ "Sandbox",
32
+ "S3Config",
33
+ "Mount",
34
+ "HttpReverseTunnel",
35
+ "CommandResult",
36
+ "CommandInfo",
37
+ "CommandHandle",
38
+ "EntryInfo",
39
+ "SandboxInfo",
40
+ "NodeInfo",
41
+ "Pty",
42
+ "PtySession",
43
+ "PtyError",
44
+ "resources",
45
+ ]
46
+
47
+ _LAZY_IMPORTS = {
48
+ "Sandbox": (".sandbox", "Sandbox"),
49
+ "CommandHandle": (".commands", "CommandHandle"),
50
+ "Pty": (".pty", "Pty"),
51
+ "PtySession": (".pty", "PtySession"),
52
+ "PtyError": (".pty", "PtyError"),
53
+ "resources": ("._openyuanrong", "resources"),
54
+ }
55
+
56
+
57
+ def __getattr__(name: str) -> object:
58
+ """Load backend-dependent public objects only when they are requested."""
59
+
60
+ target = _LAZY_IMPORTS.get(name)
61
+ if target is None:
62
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
63
+ module_name, attribute_name = target
64
+ module = importlib.import_module(module_name, __package__)
65
+ value = getattr(module, attribute_name)
66
+ globals()[name] = value
67
+ return value
68
+
69
+
70
+ def __dir__() -> list[str]:
71
+ return sorted(set(globals()).union(__all__))
@@ -0,0 +1,166 @@
1
+ # Copyright (c) 2026 Ant Group Corporation.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Address normalization helpers for the AKernel Python SDK.
16
+
17
+ The public SDK accepts a compact ``AKERNEL_SERVER_ADDRESS`` value:
18
+
19
+ * ``host``: public mode. Frontend API and exec WebSocket use 443/TLS; public
20
+ port-forward URLs use 80/plain HTTP.
21
+ * ``host:port``: shared-port mode. Frontend API, exec WebSocket, and public
22
+ port-forward URLs all use the explicit port with TLS by default.
23
+
24
+ ``AKERNEL_GATEWAY_ADDRESS`` remains an explicit override for standalone or
25
+ custom network topologies. When it is set without a scheme, it is treated as a
26
+ plain HTTP/WebSocket gateway.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from dataclasses import dataclass
33
+ from urllib.parse import urlparse
34
+
35
+ DEFAULT_API_PORT = 443
36
+ DEFAULT_PUBLIC_PORT = 80
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Endpoint:
41
+ host: str
42
+ port: int
43
+ scheme: str
44
+ explicit_port: bool
45
+
46
+ @property
47
+ def use_tls(self) -> bool:
48
+ return self.scheme in ("https", "wss")
49
+
50
+ @property
51
+ def websocket_scheme(self) -> str:
52
+ return "wss" if self.use_tls else "ws"
53
+
54
+ def authority(self, *, omit_default_port: bool = False) -> str:
55
+ host = self.host
56
+ if ":" in host and not host.startswith("["):
57
+ host = f"[{host}]"
58
+ if omit_default_port and (
59
+ (self.scheme in ("http", "ws") and self.port == 80)
60
+ or (self.scheme in ("https", "wss") and self.port == 443)
61
+ ):
62
+ return host
63
+ return f"{host}:{self.port}"
64
+
65
+ def base_url(self, *, omit_default_port: bool = True) -> str:
66
+ return f"{self.scheme}://{self.authority(omit_default_port=omit_default_port)}"
67
+
68
+
69
+ def _parse_endpoint(
70
+ raw: str,
71
+ *,
72
+ default_port: int,
73
+ default_scheme: str,
74
+ ) -> Endpoint:
75
+ value = raw.strip()
76
+ if not value:
77
+ raise RuntimeError("address is empty")
78
+
79
+ parsed = urlparse(value if "://" in value else f"//{value}")
80
+ if not parsed.hostname:
81
+ raise RuntimeError(f"invalid address: {raw!r}")
82
+
83
+ try:
84
+ port = parsed.port
85
+ except ValueError as e:
86
+ raise RuntimeError(f"invalid address port: {raw!r}") from e
87
+
88
+ explicit_port = port is not None
89
+ return Endpoint(
90
+ host=parsed.hostname,
91
+ port=port or default_port,
92
+ scheme=parsed.scheme or default_scheme,
93
+ explicit_port=explicit_port,
94
+ )
95
+
96
+
97
+ def _server_address_raw() -> str:
98
+ raw = os.environ.get("AKERNEL_SERVER_ADDRESS", "").strip()
99
+ if not raw:
100
+ raise RuntimeError("AKERNEL_SERVER_ADDRESS is not set")
101
+ return raw
102
+
103
+
104
+ def _gateway_override_raw() -> str:
105
+ return (
106
+ os.environ.get("AKERNEL_GATEWAY_ADDRESS", "").strip()
107
+ or os.environ.get("YR_GATEWAY_ADDRESS", "").strip()
108
+ )
109
+
110
+
111
+ def api_endpoint_from_env() -> Endpoint:
112
+ """Return the frontend API endpoint derived from AKERNEL_SERVER_ADDRESS."""
113
+ return _parse_endpoint(
114
+ _server_address_raw(),
115
+ default_port=DEFAULT_API_PORT,
116
+ default_scheme="https",
117
+ )
118
+
119
+
120
+ def gateway_endpoint_from_env() -> Endpoint:
121
+ """Return the public port-forwarding gateway endpoint.
122
+
123
+ An explicit gateway override is parsed as plain HTTP by default because
124
+ standalone exposes Traefik's web entrypoint without TLS. Without an
125
+ explicit gateway, host-only server addresses use public 80, while
126
+ host:port server addresses share the API port and TLS setting.
127
+ """
128
+ override = _gateway_override_raw()
129
+ if override:
130
+ return _parse_endpoint(
131
+ override,
132
+ default_port=DEFAULT_PUBLIC_PORT,
133
+ default_scheme="http",
134
+ )
135
+
136
+ server = api_endpoint_from_env()
137
+ if server.explicit_port:
138
+ return Endpoint(
139
+ host=server.host,
140
+ port=server.port,
141
+ scheme=server.scheme,
142
+ explicit_port=True,
143
+ )
144
+ return Endpoint(
145
+ host=server.host,
146
+ port=DEFAULT_PUBLIC_PORT,
147
+ scheme="http",
148
+ explicit_port=False,
149
+ )
150
+
151
+
152
+ def exec_endpoint_from_env() -> Endpoint:
153
+ """Return the endpoint used by the exec WebSocket (/terminal/ws).
154
+
155
+ File copy uses the frontend exec WebSocket. If users set an explicit
156
+ gateway override, respect it; otherwise use the API endpoint so the
157
+ default cluster/public path is WSS on 443 or the explicit server port.
158
+ """
159
+ override = _gateway_override_raw()
160
+ if override:
161
+ return _parse_endpoint(
162
+ override,
163
+ default_port=DEFAULT_PUBLIC_PORT,
164
+ default_scheme="http",
165
+ )
166
+ return api_endpoint_from_env()
@@ -0,0 +1,416 @@
1
+ # Copyright (c) 2026 Ant Group Corporation.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Internal openYuanrong actor executed inside an AKernel sandbox."""
16
+
17
+ import yr
18
+
19
+
20
+ @yr.instance
21
+ class _SandboxInstance:
22
+ """Remote sandbox instance running inside the container.
23
+
24
+ All methods use local imports to survive yr serialization.
25
+ Returns dicts for yr RPC serialization compatibility.
26
+ """
27
+
28
+ def __init__(self, cwd=None):
29
+ import os
30
+ import tempfile
31
+
32
+ if cwd is not None:
33
+ os.makedirs(cwd, exist_ok=True)
34
+ self._cwd = cwd
35
+ else:
36
+ self._cwd = tempfile.mkdtemp(prefix="sandbox_")
37
+ self._procs = {}
38
+
39
+ # ── filesystem methods ─────────────────────────────────────────────
40
+
41
+ def fs_read(self, path, binary=False):
42
+ try:
43
+ mode = "rb" if binary else "r"
44
+ with open(path, mode) as f:
45
+ data = f.read()
46
+ if binary:
47
+ return {"data": data.hex(), "error": None}
48
+ return {"data": data, "error": None}
49
+ except Exception as e:
50
+ return {"data": None, "error": str(e)}
51
+
52
+ def fs_write(self, path, data, binary=False):
53
+ import os
54
+
55
+ try:
56
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
57
+ if binary:
58
+ with open(path, "wb") as f:
59
+ f.write(bytes.fromhex(data))
60
+ else:
61
+ with open(path, "w") as f:
62
+ f.write(data)
63
+ st = os.stat(path)
64
+ return {
65
+ "path": path,
66
+ "name": os.path.basename(path),
67
+ "type": "file",
68
+ "size": st.st_size,
69
+ "error": None,
70
+ }
71
+ except Exception as e:
72
+ return {"path": path, "name": "", "type": "", "size": 0, "error": str(e)}
73
+
74
+ def fs_list(self, path, depth=1):
75
+ import os
76
+ import stat as stat_mod
77
+
78
+ def _scan(p, current_depth):
79
+ entries = []
80
+ try:
81
+ for entry in os.scandir(p):
82
+ try:
83
+ st = entry.stat(follow_symlinks=False)
84
+ if entry.is_symlink():
85
+ etype = "symlink"
86
+ elif entry.is_dir(follow_symlinks=False):
87
+ etype = "dir"
88
+ else:
89
+ etype = "file"
90
+ entries.append(
91
+ {
92
+ "name": entry.name,
93
+ "path": entry.path,
94
+ "type": etype,
95
+ "size": st.st_size,
96
+ "permissions": stat_mod.filemode(st.st_mode)[1:],
97
+ "modified_time": st.st_mtime,
98
+ }
99
+ )
100
+ if etype == "dir" and current_depth < depth:
101
+ entries.extend(_scan(entry.path, current_depth + 1))
102
+ except OSError:
103
+ continue
104
+ except OSError:
105
+ pass
106
+ return entries
107
+
108
+ return {"entries": _scan(path, 1), "error": None}
109
+
110
+ def fs_exists(self, path):
111
+ import os
112
+
113
+ return {"exists": os.path.exists(path)}
114
+
115
+ def fs_remove(self, path):
116
+ import os
117
+ import shutil
118
+
119
+ try:
120
+ if os.path.isdir(path):
121
+ shutil.rmtree(path)
122
+ else:
123
+ os.remove(path)
124
+ return {"error": None}
125
+ except Exception as e:
126
+ return {"error": str(e)}
127
+
128
+ def fs_rename(self, old_path, new_path):
129
+ import os
130
+ import stat as stat_mod
131
+
132
+ try:
133
+ os.makedirs(os.path.dirname(new_path) or ".", exist_ok=True)
134
+ os.rename(old_path, new_path)
135
+ st = os.stat(new_path)
136
+ if os.path.isdir(new_path):
137
+ etype = "dir"
138
+ elif os.path.islink(new_path):
139
+ etype = "symlink"
140
+ else:
141
+ etype = "file"
142
+ return {
143
+ "name": os.path.basename(new_path),
144
+ "path": new_path,
145
+ "type": etype,
146
+ "size": st.st_size,
147
+ "permissions": stat_mod.filemode(st.st_mode)[1:],
148
+ "modified_time": st.st_mtime,
149
+ "error": None,
150
+ }
151
+ except Exception as e:
152
+ return {"error": str(e)}
153
+
154
+ def fs_make_dir(self, path):
155
+ import os
156
+
157
+ try:
158
+ existed = os.path.exists(path)
159
+ os.makedirs(path, exist_ok=True)
160
+ return {"created": not existed, "error": None}
161
+ except Exception as e:
162
+ return {"created": False, "error": str(e)}
163
+
164
+ def fs_get_info(self, path):
165
+ import os
166
+ import stat as stat_mod
167
+
168
+ try:
169
+ st = os.stat(path)
170
+ if os.path.islink(path):
171
+ etype = "symlink"
172
+ elif os.path.isdir(path):
173
+ etype = "dir"
174
+ else:
175
+ etype = "file"
176
+ return {
177
+ "name": os.path.basename(path),
178
+ "path": path,
179
+ "type": etype,
180
+ "size": st.st_size,
181
+ "permissions": stat_mod.filemode(st.st_mode)[1:],
182
+ "modified_time": st.st_mtime,
183
+ "error": None,
184
+ }
185
+ except Exception as e:
186
+ return {"error": str(e)}
187
+
188
+ # ── command execution methods ──────────────────────────────────────
189
+
190
+ def cmd_run(self, cmd, envs=None, cwd=None, timeout=60):
191
+ import os
192
+ import subprocess
193
+
194
+ try:
195
+ env = os.environ.copy()
196
+ if envs:
197
+ env.update(envs)
198
+ result = subprocess.run(
199
+ cmd,
200
+ shell=True,
201
+ capture_output=True,
202
+ text=True,
203
+ cwd=cwd or self._cwd,
204
+ env=env,
205
+ timeout=timeout,
206
+ # Foreground one-shot commands cannot receive stdin (no handle
207
+ # is returned), so detach stdin to /dev/null. Otherwise the
208
+ # child inherits the runtime's stdin and any interactive
209
+ # prompt (e.g. apt/debconf tzdata) blocks forever on read().
210
+ stdin=subprocess.DEVNULL,
211
+ )
212
+ return {
213
+ "stdout": result.stdout,
214
+ "stderr": result.stderr,
215
+ "exit_code": result.returncode,
216
+ }
217
+ except subprocess.TimeoutExpired as e:
218
+ return {
219
+ "stdout": e.stdout.decode() if e.stdout else "",
220
+ "stderr": f"Command timed out after {timeout} seconds",
221
+ "exit_code": -1,
222
+ }
223
+ except Exception as e:
224
+ return {"stdout": "", "stderr": str(e), "exit_code": -1}
225
+
226
+ def cmd_start(self, cmd, envs=None, cwd=None, want_stdin=False):
227
+ import os
228
+ import subprocess
229
+ import threading
230
+
231
+ try:
232
+ env = os.environ.copy()
233
+ if envs:
234
+ env.update(envs)
235
+ # Default stdin to /dev/null: an open PIPE with no writer never
236
+ # reaches EOF, so any interactive prompt (apt/debconf tzdata, etc.)
237
+ # blocks forever on read(). Callers that need send_stdin must opt
238
+ # in via want_stdin=True.
239
+ proc = subprocess.Popen(
240
+ cmd,
241
+ shell=True,
242
+ stdout=subprocess.PIPE,
243
+ stderr=subprocess.PIPE,
244
+ stdin=subprocess.PIPE if want_stdin else subprocess.DEVNULL,
245
+ cwd=cwd or self._cwd,
246
+ env=env,
247
+ )
248
+
249
+ stdout_chunks: list[bytes] = []
250
+ stderr_chunks: list[bytes] = []
251
+
252
+ def _read_stream(stream, chunks):
253
+ try:
254
+ while True:
255
+ data = stream.read(4096)
256
+ if not data:
257
+ break
258
+ chunks.append(data)
259
+ except Exception:
260
+ pass
261
+
262
+ stdout_thread = threading.Thread(
263
+ target=_read_stream, args=(proc.stdout, stdout_chunks), daemon=True
264
+ )
265
+ stderr_thread = threading.Thread(
266
+ target=_read_stream, args=(proc.stderr, stderr_chunks), daemon=True
267
+ )
268
+ stdout_thread.start()
269
+ stderr_thread.start()
270
+
271
+ self._procs[proc.pid] = {
272
+ "proc": proc,
273
+ "cmd": cmd,
274
+ "stdout_chunks": stdout_chunks,
275
+ "stderr_chunks": stderr_chunks,
276
+ "stdout_thread": stdout_thread,
277
+ "stderr_thread": stderr_thread,
278
+ }
279
+ return {"pid": proc.pid, "error": None}
280
+ except Exception as e:
281
+ return {"pid": -1, "error": str(e)}
282
+
283
+ def _collect_proc_output(self, entry):
284
+ """Wait for reader threads and return collected stdout/stderr."""
285
+ entry["stdout_thread"].join(timeout=5)
286
+ entry["stderr_thread"].join(timeout=5)
287
+ stdout = b"".join(entry["stdout_chunks"]).decode("utf-8", errors="replace")
288
+ stderr = b"".join(entry["stderr_chunks"]).decode("utf-8", errors="replace")
289
+ proc = entry["proc"]
290
+ for stream in (proc.stdout, proc.stderr):
291
+ try:
292
+ stream.close()
293
+ except Exception:
294
+ pass
295
+ return stdout, stderr
296
+
297
+ def cmd_wait(self, pid, timeout=None):
298
+ try:
299
+ entry = self._procs.get(pid)
300
+ if entry is None:
301
+ return {
302
+ "stdout": "",
303
+ "stderr": f"No process with pid {pid}",
304
+ "exit_code": -1,
305
+ }
306
+ proc = entry["proc"]
307
+ try:
308
+ proc.wait(timeout=timeout)
309
+ except Exception as e:
310
+ return {"stdout": "", "stderr": str(e), "exit_code": -1}
311
+ stdout, stderr = self._collect_proc_output(entry)
312
+ return {
313
+ "stdout": stdout,
314
+ "stderr": stderr,
315
+ "exit_code": proc.returncode,
316
+ }
317
+ except Exception as e:
318
+ return {"stdout": "", "stderr": str(e), "exit_code": -1}
319
+
320
+ def cmd_list(self):
321
+ processes = []
322
+ for pid, entry in self._procs.items():
323
+ proc = entry["proc"]
324
+ processes.append(
325
+ {
326
+ "pid": pid,
327
+ "cmd": entry["cmd"],
328
+ "running": proc.poll() is None,
329
+ }
330
+ )
331
+ return {"processes": processes}
332
+
333
+ def cmd_kill(self, pid):
334
+ try:
335
+ entry = self._procs.get(pid)
336
+ if entry is None:
337
+ return {"killed": False, "error": f"No process with pid {pid}"}
338
+ entry["proc"].kill()
339
+ return {"killed": True, "error": None}
340
+ except Exception as e:
341
+ return {"killed": False, "error": str(e)}
342
+
343
+ def cmd_send_stdin(self, pid, data, eof=False):
344
+ try:
345
+ entry = self._procs.get(pid)
346
+ if entry is None:
347
+ return {"error": f"No process with pid {pid}"}
348
+ proc = entry["proc"]
349
+ if proc.stdin is None:
350
+ # stdin was detached to /dev/null (default). Fail loudly
351
+ # instead of silently dropping the data.
352
+ return {
353
+ "error": (
354
+ f"process {pid} was not started with stdin enabled; "
355
+ "start it with stdin=True to use send_stdin"
356
+ )
357
+ }
358
+ if proc.stdin.closed:
359
+ return {"error": f"stdin of process {pid} is already closed"}
360
+ if data:
361
+ proc.stdin.write(data.encode())
362
+ proc.stdin.flush()
363
+ if eof:
364
+ # Close the write end so the child sees EOF on its next
365
+ # read(). Required for processes that only act on EOF
366
+ # (cat, sort, wc, python3 -, ...).
367
+ proc.stdin.close()
368
+ return {"error": None}
369
+ except Exception as e:
370
+ return {"error": str(e)}
371
+
372
+ # ── tunnel methods ────────────────────────────────────────────────
373
+
374
+ def start_tunnel_server(self, ws_port=8765, http_port=8766):
375
+ """Start TunnelServer in a background thread within this sandbox instance.
376
+
377
+ Port A (ws_port): WebSocket endpoint for TunnelClient connection.
378
+ Port B (http_port): HTTP proxy for sandbox code to call.
379
+ """
380
+ import asyncio
381
+ import socket as _socket
382
+ import threading
383
+ import time
384
+
385
+ from yr.sandbox.tunnel_server import TunnelServer
386
+
387
+ def _run():
388
+ loop = asyncio.new_event_loop()
389
+ asyncio.set_event_loop(loop)
390
+ server = TunnelServer(ws_port=ws_port, http_port=http_port)
391
+ loop.run_until_complete(server.start())
392
+ loop.run_forever()
393
+
394
+ t = threading.Thread(target=_run, name="tunnel-server", daemon=True)
395
+ t.start()
396
+ # Wait until both ports are actually bound (up to 5s)
397
+ deadline = time.time() + 5.0
398
+ for port in (ws_port, http_port):
399
+ while time.time() < deadline:
400
+ try:
401
+ _socket.create_connection(("127.0.0.1", port), timeout=0.1).close()
402
+ break
403
+ except OSError:
404
+ time.sleep(0.1)
405
+ return {"error": None}
406
+
407
+ # ── lifecycle methods ──────────────────────────────────────────────
408
+
409
+ def ping(self):
410
+ return {"status": "ok"}
411
+
412
+ def get_info(self):
413
+ return {
414
+ "state": "running",
415
+ "cwd": self._cwd,
416
+ }