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.
- akernel_sdk/__init__.py +71 -0
- akernel_sdk/_addresses.py +166 -0
- akernel_sdk/_instance.py +416 -0
- akernel_sdk/_openyuanrong.py +320 -0
- akernel_sdk/_pty_transport.py +304 -0
- akernel_sdk/cli.py +549 -0
- akernel_sdk/commands.py +207 -0
- akernel_sdk/filesystem.py +291 -0
- akernel_sdk/pty.py +250 -0
- akernel_sdk/py.typed +0 -0
- akernel_sdk/sandbox.py +354 -0
- akernel_sdk/types.py +204 -0
- akernel_sdk-0.1.0.dist-info/METADATA +390 -0
- akernel_sdk-0.1.0.dist-info/RECORD +17 -0
- akernel_sdk-0.1.0.dist-info/WHEEL +5 -0
- akernel_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- akernel_sdk-0.1.0.dist-info/top_level.txt +1 -0
akernel_sdk/__init__.py
ADDED
|
@@ -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()
|
akernel_sdk/_instance.py
ADDED
|
@@ -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
|
+
}
|