python-codex 0.0.1__py3-none-any.whl → 0.1.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.
- pycodex/__init__.py +141 -2
- pycodex/agent.py +290 -0
- pycodex/cli.py +705 -0
- pycodex/collaboration.py +21 -0
- pycodex/context.py +580 -0
- pycodex/doctor.py +360 -0
- pycodex/model.py +533 -0
- pycodex/portable.py +390 -0
- pycodex/portable_server.py +205 -0
- pycodex/prompts/collaboration_default.md +11 -0
- pycodex/prompts/collaboration_plan.md +128 -0
- pycodex/prompts/default_base_instructions.md +275 -0
- pycodex/prompts/exec_tools.json +411 -0
- pycodex/prompts/models.json +847 -0
- pycodex/prompts/permissions/approval_policy/never.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
- pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
- pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
- pycodex/prompts/subagent_tools.json +163 -0
- pycodex/protocol.py +347 -0
- pycodex/runtime.py +204 -0
- pycodex/runtime_services.py +409 -0
- pycodex/tools/__init__.py +58 -0
- pycodex/tools/agent_tool_schemas.py +70 -0
- pycodex/tools/apply_patch_tool.py +363 -0
- pycodex/tools/base_tool.py +168 -0
- pycodex/tools/close_agent_tool.py +55 -0
- pycodex/tools/code_mode_manager.py +519 -0
- pycodex/tools/exec_command_tool.py +96 -0
- pycodex/tools/exec_runtime.js +161 -0
- pycodex/tools/exec_tool.py +48 -0
- pycodex/tools/grep_files_tool.py +150 -0
- pycodex/tools/list_dir_tool.py +135 -0
- pycodex/tools/read_file_tool.py +217 -0
- pycodex/tools/request_permissions_tool.py +95 -0
- pycodex/tools/request_user_input_tool.py +167 -0
- pycodex/tools/resume_agent_tool.py +56 -0
- pycodex/tools/send_input_tool.py +106 -0
- pycodex/tools/shell_command_tool.py +107 -0
- pycodex/tools/shell_tool.py +112 -0
- pycodex/tools/spawn_agent_tool.py +97 -0
- pycodex/tools/unified_exec_manager.py +380 -0
- pycodex/tools/update_plan_tool.py +79 -0
- pycodex/tools/view_image_tool.py +111 -0
- pycodex/tools/wait_agent_tool.py +75 -0
- pycodex/tools/wait_tool.py +68 -0
- pycodex/tools/web_search_tool.py +30 -0
- pycodex/tools/write_stdin_tool.py +75 -0
- pycodex/utils/__init__.py +40 -0
- pycodex/utils/dotenv.py +64 -0
- pycodex/utils/get_env.py +218 -0
- pycodex/utils/random_ids.py +19 -0
- pycodex/utils/visualize.py +978 -0
- python_codex-0.1.1.dist-info/METADATA +355 -0
- python_codex-0.1.1.dist-info/RECORD +62 -0
- python_codex-0.1.1.dist-info/entry_points.txt +2 -0
- python_codex-0.1.1.dist-info/licenses/LICENSE +201 -0
- python_codex-0.0.1.dist-info/METADATA +0 -30
- python_codex-0.0.1.dist-info/RECORD +0 -4
- {python_codex-0.0.1.dist-info → python_codex-0.1.1.dist-info}/WHEEL +0 -0
pycodex/portable.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from urllib.parse import quote, urlparse
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from cryptography.exceptions import InvalidTag
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import tomllib
|
|
20
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 path
|
|
21
|
+
import tomli as tomllib
|
|
22
|
+
|
|
23
|
+
DEFAULT_STORAGE_SERVER = "127.0.0.1:5577"
|
|
24
|
+
STORAGE_SERVER_ENV = "PYCODEX_STORAGE_SERVER"
|
|
25
|
+
STORAGE_ROOT_ENV = "PYCODEX_STORAGE_ROOT"
|
|
26
|
+
STORAGE_CACHE_DIRNAME = ".pycodex-storage"
|
|
27
|
+
DEFAULT_ENTRY_CONFIG = "config.toml"
|
|
28
|
+
STORAGE_API_PREFIX = "/v1/storage"
|
|
29
|
+
HEALTHCHECK_PATH = "/healthz"
|
|
30
|
+
ENCRYPTED_BUNDLE_MAGIC = b"PCX1"
|
|
31
|
+
NONCE_LENGTH = 12
|
|
32
|
+
TOKEN_BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
33
|
+
ALLOWED_TOP_LEVEL_FILES = (
|
|
34
|
+
DEFAULT_ENTRY_CONFIG,
|
|
35
|
+
".env",
|
|
36
|
+
"AGENTS.md",
|
|
37
|
+
"AGENTS.override.md",
|
|
38
|
+
)
|
|
39
|
+
ALLOWED_TOP_LEVEL_DIRS = ("skills",)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RemoteStorageError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ProgressHandler = Callable[[str], None]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def upload_codex_home(
|
|
50
|
+
put_text: str | None = None,
|
|
51
|
+
event_handler: ProgressHandler | None = None,
|
|
52
|
+
) -> str:
|
|
53
|
+
source_dir, server = _parse_put_spec(put_text)
|
|
54
|
+
resolved_source_dir = resolve_put_source_dir(source_dir)
|
|
55
|
+
server_address, base_url = resolve_storage_server(server)
|
|
56
|
+
emit = event_handler or (lambda _message: None)
|
|
57
|
+
emit(f"[put] source: {resolved_source_dir}")
|
|
58
|
+
emit(f"[put] checking server: {server_address}")
|
|
59
|
+
_check_storage_server(server_address, base_url)
|
|
60
|
+
bundle_bytes = _build_bundle_bytes(resolved_source_dir, emit)
|
|
61
|
+
secret = _base58_encode(os.urandom(16))
|
|
62
|
+
encrypted_bundle = _encrypt_bundle(bundle_bytes, secret)
|
|
63
|
+
sha256 = hashlib.sha256(encrypted_bundle).hexdigest()
|
|
64
|
+
call_id = _call_id_from_payload(encrypted_bundle)
|
|
65
|
+
call_spec = f"{secret}-{call_id}@{server_address}"
|
|
66
|
+
emit(f"[put] bundle: {len(bundle_bytes)} bytes plaintext, sha256={sha256}")
|
|
67
|
+
emit(f"[put] uploading ciphertext to {server_address}")
|
|
68
|
+
try:
|
|
69
|
+
response = requests.post(
|
|
70
|
+
f"{base_url}/put",
|
|
71
|
+
headers={
|
|
72
|
+
"Content-Type": "application/octet-stream",
|
|
73
|
+
"Content-Length": str(len(encrypted_bundle)),
|
|
74
|
+
"X-Pycodex-Sha256": sha256,
|
|
75
|
+
},
|
|
76
|
+
data=encrypted_bundle,
|
|
77
|
+
timeout=(5.0, 120.0),
|
|
78
|
+
)
|
|
79
|
+
except requests.RequestException as exc:
|
|
80
|
+
raise RemoteStorageError(f"storage upload failed: {exc}") from exc
|
|
81
|
+
if response.status_code >= 400:
|
|
82
|
+
raise RemoteStorageError(
|
|
83
|
+
f"storage upload failed with status {response.status_code}"
|
|
84
|
+
)
|
|
85
|
+
emit(f"[put] uploaded: {call_spec}")
|
|
86
|
+
return call_spec
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def bootstrap_called_home(
|
|
90
|
+
call_text: str,
|
|
91
|
+
storage_root: str | Path | None = None,
|
|
92
|
+
) -> Path:
|
|
93
|
+
secret, call_id, server_address, base_url = _parse_call_spec(call_text)
|
|
94
|
+
root = resolve_storage_root(storage_root)
|
|
95
|
+
cache_key = hashlib.sha256(call_text.strip().encode("utf-8")).hexdigest()[:16]
|
|
96
|
+
cache_dir = root / cache_key
|
|
97
|
+
metadata_path = cache_dir / "metadata.json"
|
|
98
|
+
home_dir = cache_dir / "home"
|
|
99
|
+
metadata = _load_cached_metadata(metadata_path)
|
|
100
|
+
cached_config_path = home_dir / DEFAULT_ENTRY_CONFIG
|
|
101
|
+
if (
|
|
102
|
+
cached_config_path.is_file()
|
|
103
|
+
and metadata.get("call_id") == call_id
|
|
104
|
+
and metadata.get("server_address") == server_address
|
|
105
|
+
):
|
|
106
|
+
return cached_config_path
|
|
107
|
+
|
|
108
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
encrypted_bundle = _download_encrypted_bundle(base_url, call_id)
|
|
110
|
+
bundle_bytes = _decrypt_bundle(encrypted_bundle, secret)
|
|
111
|
+
with tempfile.TemporaryDirectory(prefix="pycodex-home-") as tmpdir:
|
|
112
|
+
extract_root = Path(tmpdir)
|
|
113
|
+
_extract_bundle_bytes(bundle_bytes, extract_root)
|
|
114
|
+
extracted_home = _resolve_extracted_home(extract_root)
|
|
115
|
+
if home_dir.exists():
|
|
116
|
+
shutil.rmtree(home_dir)
|
|
117
|
+
shutil.move(str(extracted_home), str(home_dir))
|
|
118
|
+
metadata_path.write_text(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{
|
|
121
|
+
"call_id": call_id,
|
|
122
|
+
"server_address": server_address,
|
|
123
|
+
},
|
|
124
|
+
ensure_ascii=False,
|
|
125
|
+
indent=2,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return home_dir / DEFAULT_ENTRY_CONFIG
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_put_source_dir(source_dir: str | Path | None) -> Path:
|
|
132
|
+
if source_dir is None or str(source_dir).strip() == "":
|
|
133
|
+
candidate = Path.home() / ".codex"
|
|
134
|
+
else:
|
|
135
|
+
candidate = Path(source_dir).expanduser()
|
|
136
|
+
resolved = candidate.resolve()
|
|
137
|
+
config_path = resolved / DEFAULT_ENTRY_CONFIG
|
|
138
|
+
if not resolved.is_dir():
|
|
139
|
+
raise RemoteStorageError(f"Codex home is not a directory: {resolved}")
|
|
140
|
+
if not config_path.is_file():
|
|
141
|
+
raise RemoteStorageError(
|
|
142
|
+
f"Codex home is missing required file: {config_path}"
|
|
143
|
+
)
|
|
144
|
+
return resolved
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_storage_root(storage_root: str | Path | None = None) -> Path:
|
|
148
|
+
if storage_root is not None:
|
|
149
|
+
return Path(storage_root).expanduser().resolve()
|
|
150
|
+
env_value = os.environ.get(STORAGE_ROOT_ENV, "").strip()
|
|
151
|
+
if env_value:
|
|
152
|
+
return Path(env_value).expanduser().resolve()
|
|
153
|
+
return _discover_project_root() / STORAGE_CACHE_DIRNAME
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_storage_server(server: str | None = None) -> tuple[str, str]:
|
|
157
|
+
raw_value = (server or os.environ.get(STORAGE_SERVER_ENV) or "").strip()
|
|
158
|
+
if not raw_value:
|
|
159
|
+
raw_value = DEFAULT_STORAGE_SERVER
|
|
160
|
+
if "://" in raw_value:
|
|
161
|
+
parsed = urlparse(raw_value)
|
|
162
|
+
if not parsed.scheme or not parsed.netloc:
|
|
163
|
+
raise RemoteStorageError(f"invalid storage server: {raw_value}")
|
|
164
|
+
return parsed.netloc, f"{parsed.scheme}://{parsed.netloc}{STORAGE_API_PREFIX}"
|
|
165
|
+
if "/" in raw_value:
|
|
166
|
+
raise RemoteStorageError(f"invalid storage server: {raw_value}")
|
|
167
|
+
return raw_value, f"http://{raw_value}{STORAGE_API_PREFIX}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _build_bundle_bytes(root: Path, emit: ProgressHandler) -> bytes:
|
|
171
|
+
files = _collect_upload_files(root)
|
|
172
|
+
emit("[put] mode: whitelist")
|
|
173
|
+
emit(f"[put] packing {len(files)} files")
|
|
174
|
+
buffer = BytesIO()
|
|
175
|
+
with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
176
|
+
for relative_path in files:
|
|
177
|
+
emit(f"[put] file: {relative_path}")
|
|
178
|
+
archive.write(root / relative_path, relative_path)
|
|
179
|
+
return buffer.getvalue()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _collect_upload_files(root: Path) -> list[str]:
|
|
183
|
+
included: set[str] = set()
|
|
184
|
+
for relative_name in ALLOWED_TOP_LEVEL_FILES:
|
|
185
|
+
candidate = root / relative_name
|
|
186
|
+
if candidate.is_file():
|
|
187
|
+
included.add(relative_name)
|
|
188
|
+
for relative_dir in ALLOWED_TOP_LEVEL_DIRS:
|
|
189
|
+
candidate = root / relative_dir
|
|
190
|
+
if candidate.is_dir():
|
|
191
|
+
for path in sorted(candidate.rglob("*")):
|
|
192
|
+
if path.is_file():
|
|
193
|
+
included.add(path.relative_to(root).as_posix())
|
|
194
|
+
included.update(_collect_config_referenced_files(root))
|
|
195
|
+
return sorted(included)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _collect_config_referenced_files(root: Path) -> set[str]:
|
|
199
|
+
config_path = root / DEFAULT_ENTRY_CONFIG
|
|
200
|
+
if not config_path.is_file():
|
|
201
|
+
return set()
|
|
202
|
+
data = tomllib.loads(config_path.read_text())
|
|
203
|
+
referenced: set[str] = set()
|
|
204
|
+
candidates = [data]
|
|
205
|
+
profiles = data.get("profiles")
|
|
206
|
+
if isinstance(profiles, dict):
|
|
207
|
+
candidates.extend(
|
|
208
|
+
profile_data for profile_data in profiles.values() if isinstance(profile_data, dict)
|
|
209
|
+
)
|
|
210
|
+
for candidate in candidates:
|
|
211
|
+
model_instructions_file = candidate.get("model_instructions_file")
|
|
212
|
+
if not isinstance(model_instructions_file, str):
|
|
213
|
+
continue
|
|
214
|
+
normalized = _normalize_optional_relative_file(root, model_instructions_file)
|
|
215
|
+
if normalized is not None:
|
|
216
|
+
referenced.add(normalized)
|
|
217
|
+
return referenced
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _normalize_optional_relative_file(root: Path, value: str) -> str | None:
|
|
221
|
+
candidate = Path(value)
|
|
222
|
+
if candidate.is_absolute():
|
|
223
|
+
return None
|
|
224
|
+
normalized = _normalize_member_path(candidate.as_posix(), field_name="config path")
|
|
225
|
+
root_resolved = root.resolve()
|
|
226
|
+
resolved = (root / normalized).resolve()
|
|
227
|
+
if resolved != root_resolved and root_resolved not in resolved.parents:
|
|
228
|
+
raise RemoteStorageError(f"config path points outside Codex home: {value}")
|
|
229
|
+
if not resolved.is_file():
|
|
230
|
+
return None
|
|
231
|
+
return resolved.relative_to(root_resolved).as_posix()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _encrypt_bundle(bundle_bytes: bytes, secret: str) -> bytes:
|
|
235
|
+
nonce = os.urandom(NONCE_LENGTH)
|
|
236
|
+
ciphertext = AESGCM(_encryption_key(secret)).encrypt(nonce, bundle_bytes, None)
|
|
237
|
+
return ENCRYPTED_BUNDLE_MAGIC + nonce + ciphertext
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _decrypt_bundle(payload: bytes, secret: str) -> bytes:
|
|
241
|
+
if not payload.startswith(ENCRYPTED_BUNDLE_MAGIC):
|
|
242
|
+
raise RemoteStorageError("stored bundle is not a recognized encrypted payload")
|
|
243
|
+
nonce = payload[len(ENCRYPTED_BUNDLE_MAGIC) : len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH]
|
|
244
|
+
ciphertext = payload[len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH :]
|
|
245
|
+
try:
|
|
246
|
+
return AESGCM(_encryption_key(secret)).decrypt(nonce, ciphertext, None)
|
|
247
|
+
except InvalidTag as exc:
|
|
248
|
+
raise RemoteStorageError("call secret is invalid or bundle is corrupted") from exc
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _encryption_key(secret: str) -> bytes:
|
|
252
|
+
return hashlib.sha256(secret.encode("utf-8")).digest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _call_id_from_payload(payload: bytes) -> str:
|
|
256
|
+
return _base58_encode(hashlib.sha256(payload).digest()[:8])
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _base58_encode(payload: bytes) -> str:
|
|
260
|
+
number = int.from_bytes(payload, "big")
|
|
261
|
+
if number == 0:
|
|
262
|
+
return TOKEN_BASE58_ALPHABET[0]
|
|
263
|
+
encoded: list[str] = []
|
|
264
|
+
while number:
|
|
265
|
+
number, remainder = divmod(number, 58)
|
|
266
|
+
encoded.append(TOKEN_BASE58_ALPHABET[remainder])
|
|
267
|
+
encoded.reverse()
|
|
268
|
+
prefix = TOKEN_BASE58_ALPHABET[0] * (len(payload) - len(payload.lstrip(b"\x00")))
|
|
269
|
+
return prefix + "".join(encoded)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _parse_put_spec(put_text: str | None) -> tuple[str | None, str | None]:
|
|
273
|
+
raw_value = (put_text or "").strip()
|
|
274
|
+
if not raw_value:
|
|
275
|
+
return None, None
|
|
276
|
+
if raw_value.startswith("@"):
|
|
277
|
+
server = raw_value[1:].strip()
|
|
278
|
+
if not server:
|
|
279
|
+
raise RemoteStorageError("put spec is missing server after @")
|
|
280
|
+
return None, server
|
|
281
|
+
if "@" in raw_value:
|
|
282
|
+
source_text, server = raw_value.rsplit("@", 1)
|
|
283
|
+
if not source_text.strip():
|
|
284
|
+
raise RemoteStorageError("put spec is missing source before @")
|
|
285
|
+
if not server.strip():
|
|
286
|
+
raise RemoteStorageError("put spec is missing server after @")
|
|
287
|
+
return source_text.strip(), server.strip()
|
|
288
|
+
return raw_value, None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_call_spec(call_text: str) -> tuple[str, str, str, str]:
|
|
292
|
+
raw_value = call_text.strip()
|
|
293
|
+
if not raw_value or "@" not in raw_value:
|
|
294
|
+
raise RemoteStorageError("call spec must look like <secret>-<call_id>@<host:port>")
|
|
295
|
+
secret_and_call_id, server_text = raw_value.rsplit("@", 1)
|
|
296
|
+
if "-" not in secret_and_call_id:
|
|
297
|
+
raise RemoteStorageError("call spec must include secret and call_id")
|
|
298
|
+
secret, call_id = secret_and_call_id.rsplit("-", 1)
|
|
299
|
+
if not secret or not call_id:
|
|
300
|
+
raise RemoteStorageError("call spec is missing secret or call_id")
|
|
301
|
+
server_address, base_url = resolve_storage_server(server_text)
|
|
302
|
+
return secret, call_id, server_address, base_url
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _download_encrypted_bundle(base_url: str, call_id: str) -> bytes:
|
|
306
|
+
url = f"{base_url}/call/{quote(call_id, safe='')}"
|
|
307
|
+
try:
|
|
308
|
+
response = requests.get(url, timeout=(5.0, 120.0))
|
|
309
|
+
except requests.RequestException as exc:
|
|
310
|
+
raise RemoteStorageError(f"call download failed: {exc}") from exc
|
|
311
|
+
if response.status_code == 404:
|
|
312
|
+
raise RemoteStorageError(f"call id not found: {call_id}")
|
|
313
|
+
if response.status_code >= 400:
|
|
314
|
+
raise RemoteStorageError(f"call download failed with status {response.status_code}")
|
|
315
|
+
payload = response.content
|
|
316
|
+
expected_sha256 = response.headers.get("X-Pycodex-Sha256", "").strip().lower() or None
|
|
317
|
+
if expected_sha256 is not None and hashlib.sha256(payload).hexdigest() != expected_sha256:
|
|
318
|
+
raise RemoteStorageError("downloaded bundle checksum mismatch")
|
|
319
|
+
return payload
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _extract_bundle_bytes(bundle_bytes: bytes, destination: Path) -> None:
|
|
323
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
destination_resolved = destination.resolve()
|
|
325
|
+
try:
|
|
326
|
+
archive = zipfile.ZipFile(BytesIO(bundle_bytes))
|
|
327
|
+
except zipfile.BadZipFile as exc:
|
|
328
|
+
raise RemoteStorageError("decrypted bundle is not a valid zip archive") from exc
|
|
329
|
+
with archive:
|
|
330
|
+
for info in archive.infolist():
|
|
331
|
+
member_name = info.filename
|
|
332
|
+
if not member_name or member_name.endswith("/"):
|
|
333
|
+
continue
|
|
334
|
+
_normalize_member_path(member_name, field_name="bundle member")
|
|
335
|
+
target_path = (destination_resolved / member_name).resolve()
|
|
336
|
+
if target_path != destination_resolved and destination_resolved not in target_path.parents:
|
|
337
|
+
raise RemoteStorageError("bundle contains unsafe paths")
|
|
338
|
+
archive.extractall(destination)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _resolve_extracted_home(extract_root: Path) -> Path:
|
|
342
|
+
direct_config = extract_root / DEFAULT_ENTRY_CONFIG
|
|
343
|
+
if direct_config.is_file():
|
|
344
|
+
return extract_root
|
|
345
|
+
children = [child for child in extract_root.iterdir() if child.name != "__MACOSX"]
|
|
346
|
+
if len(children) == 1 and children[0].is_dir() and (children[0] / DEFAULT_ENTRY_CONFIG).is_file():
|
|
347
|
+
return children[0]
|
|
348
|
+
raise RemoteStorageError("bundle is missing required config file after extraction")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _load_cached_metadata(metadata_path: Path) -> dict[str, object]:
|
|
352
|
+
if not metadata_path.is_file():
|
|
353
|
+
return {}
|
|
354
|
+
try:
|
|
355
|
+
payload = json.loads(metadata_path.read_text())
|
|
356
|
+
except (ValueError, OSError):
|
|
357
|
+
return {}
|
|
358
|
+
return payload if isinstance(payload, dict) else {}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _check_storage_server(server_address: str, base_url: str) -> None:
|
|
362
|
+
parsed = urlparse(base_url)
|
|
363
|
+
health_url = f"{parsed.scheme}://{parsed.netloc}{HEALTHCHECK_PATH}"
|
|
364
|
+
try:
|
|
365
|
+
response = requests.get(health_url, timeout=(3.0, 3.0))
|
|
366
|
+
except requests.RequestException as exc:
|
|
367
|
+
raise RemoteStorageError(
|
|
368
|
+
f"storage server preflight failed for {server_address}: {exc}"
|
|
369
|
+
) from exc
|
|
370
|
+
if response.status_code >= 400:
|
|
371
|
+
raise RemoteStorageError(
|
|
372
|
+
f"storage server preflight failed for {server_address}: status {response.status_code}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _discover_project_root(start: Path | None = None) -> Path:
|
|
377
|
+
current = (start or Path.cwd()).resolve()
|
|
378
|
+
for candidate in (current, *current.parents):
|
|
379
|
+
if (candidate / "pyproject.toml").is_file() and (candidate / "pycodex").is_dir():
|
|
380
|
+
return candidate
|
|
381
|
+
return current
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _normalize_member_path(value: str, field_name: str) -> str:
|
|
385
|
+
path = PurePosixPath(value)
|
|
386
|
+
if not value or path.is_absolute():
|
|
387
|
+
raise RemoteStorageError(f"{field_name} must be relative")
|
|
388
|
+
if any(part in {"", ".", ".."} for part in path.parts):
|
|
389
|
+
raise RemoteStorageError(f"{field_name} contains invalid path segments")
|
|
390
|
+
return path.as_posix()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from .portable import (
|
|
12
|
+
DEFAULT_STORAGE_SERVER,
|
|
13
|
+
HEALTHCHECK_PATH,
|
|
14
|
+
STORAGE_API_PREFIX,
|
|
15
|
+
_call_id_from_payload,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodexStorageServer:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
root: str | Path,
|
|
23
|
+
host: str = "127.0.0.1",
|
|
24
|
+
port: int = 5577,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._root = Path(root).resolve()
|
|
27
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._objects_dir = self._root / "objects"
|
|
29
|
+
self._objects_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
self._server = ThreadingHTTPServer((host, port), self._build_handler())
|
|
31
|
+
self._thread: threading.Thread | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def host(self) -> str:
|
|
35
|
+
return str(self._server.server_address[0])
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def port(self) -> int:
|
|
39
|
+
return int(self._server.server_address[1])
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def server_address(self) -> str:
|
|
43
|
+
return f"{self.host}:{self.port}"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def base_url(self) -> str:
|
|
47
|
+
return f"http://{self.server_address}{STORAGE_API_PREFIX}"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def root(self) -> Path:
|
|
51
|
+
return self._root
|
|
52
|
+
|
|
53
|
+
def start(self) -> None:
|
|
54
|
+
if self._thread is not None:
|
|
55
|
+
return
|
|
56
|
+
self._thread = threading.Thread(
|
|
57
|
+
target=self._server.serve_forever,
|
|
58
|
+
name="pycodex-storage-server",
|
|
59
|
+
daemon=True,
|
|
60
|
+
)
|
|
61
|
+
self._thread.start()
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._server.shutdown()
|
|
65
|
+
self._server.server_close()
|
|
66
|
+
if self._thread is not None:
|
|
67
|
+
self._thread.join(timeout=5.0)
|
|
68
|
+
self._thread = None
|
|
69
|
+
|
|
70
|
+
def _build_handler(self):
|
|
71
|
+
server = self
|
|
72
|
+
|
|
73
|
+
class Handler(BaseHTTPRequestHandler):
|
|
74
|
+
def do_GET(self) -> None: # noqa: N802
|
|
75
|
+
path = urlparse(self.path).path
|
|
76
|
+
if path == HEALTHCHECK_PATH:
|
|
77
|
+
self._send_json(200, {"ok": True})
|
|
78
|
+
return
|
|
79
|
+
if not path.startswith(f"{STORAGE_API_PREFIX}/call/"):
|
|
80
|
+
self._send_json(404, {"error": "not found"})
|
|
81
|
+
return
|
|
82
|
+
call_id = unquote(path[len(f"{STORAGE_API_PREFIX}/call/") :]).strip()
|
|
83
|
+
if not call_id:
|
|
84
|
+
self._send_json(400, {"error": "missing call_id"})
|
|
85
|
+
return
|
|
86
|
+
object_path = server._object_path(call_id)
|
|
87
|
+
if not object_path.is_file():
|
|
88
|
+
self._send_json(404, {"error": "not found"})
|
|
89
|
+
return
|
|
90
|
+
payload = object_path.read_bytes()
|
|
91
|
+
print(
|
|
92
|
+
"[server] call: "
|
|
93
|
+
f"client={self.client_address[0]} call_id={call_id} path={object_path}",
|
|
94
|
+
flush=True,
|
|
95
|
+
)
|
|
96
|
+
self.send_response(200)
|
|
97
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
98
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
99
|
+
self.send_header("X-Pycodex-Sha256", hashlib.sha256(payload).hexdigest())
|
|
100
|
+
self.send_header("X-Pycodex-Call-Id", call_id)
|
|
101
|
+
self.end_headers()
|
|
102
|
+
self.wfile.write(payload)
|
|
103
|
+
|
|
104
|
+
def do_POST(self) -> None: # noqa: N802
|
|
105
|
+
path = urlparse(self.path).path
|
|
106
|
+
if path != f"{STORAGE_API_PREFIX}/put":
|
|
107
|
+
self._send_json(404, {"error": "not found"})
|
|
108
|
+
return
|
|
109
|
+
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
|
110
|
+
if content_length <= 0:
|
|
111
|
+
self._send_json(400, {"error": "empty body"})
|
|
112
|
+
return
|
|
113
|
+
payload = self.rfile.read(content_length)
|
|
114
|
+
sha256 = hashlib.sha256(payload).hexdigest()
|
|
115
|
+
expected_sha256 = self.headers.get("X-Pycodex-Sha256", "").strip().lower()
|
|
116
|
+
if expected_sha256 and expected_sha256 != sha256:
|
|
117
|
+
self._send_json(400, {"error": "checksum mismatch"})
|
|
118
|
+
return
|
|
119
|
+
call_id = _call_id_from_payload(payload)
|
|
120
|
+
object_path = server._object_path(call_id)
|
|
121
|
+
if not object_path.is_file():
|
|
122
|
+
object_path.write_bytes(payload)
|
|
123
|
+
status = "stored"
|
|
124
|
+
else:
|
|
125
|
+
status = "reused"
|
|
126
|
+
print(
|
|
127
|
+
"[server] put: "
|
|
128
|
+
f"client={self.client_address[0]} "
|
|
129
|
+
f"call_id={call_id} status={status} path={object_path}",
|
|
130
|
+
flush=True,
|
|
131
|
+
)
|
|
132
|
+
host_header = self.headers.get("Host", server.server_address).strip() or server.server_address
|
|
133
|
+
self._send_json(
|
|
134
|
+
200,
|
|
135
|
+
{
|
|
136
|
+
"call_id": call_id,
|
|
137
|
+
"call": f"{call_id}@{host_header}",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def log_message(self, _format: str, *_args) -> None:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
def _send_json(self, status: int, payload: dict[str, object]) -> None:
|
|
145
|
+
body = json.dumps(payload).encode("utf-8")
|
|
146
|
+
self.send_response(status)
|
|
147
|
+
self.send_header("Content-Type", "application/json")
|
|
148
|
+
self.send_header("Content-Length", str(len(body)))
|
|
149
|
+
self.end_headers()
|
|
150
|
+
self.wfile.write(body)
|
|
151
|
+
|
|
152
|
+
return Handler
|
|
153
|
+
|
|
154
|
+
def _object_path(self, call_id: str) -> Path:
|
|
155
|
+
return self._objects_dir / f"{call_id}.bin"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
159
|
+
parser = argparse.ArgumentParser(
|
|
160
|
+
prog="python -m pycodex.portable_server",
|
|
161
|
+
description="Run a pycodex remote storage service for --put/--call testing.",
|
|
162
|
+
)
|
|
163
|
+
parser.add_argument(
|
|
164
|
+
"--root",
|
|
165
|
+
default=str(Path(".tmp") / "pycodex_storage"),
|
|
166
|
+
help="Directory used to store uploaded encrypted bundles.",
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--host",
|
|
170
|
+
default=DEFAULT_STORAGE_SERVER.split(":", 1)[0],
|
|
171
|
+
help="Host interface to bind.",
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--port",
|
|
175
|
+
type=int,
|
|
176
|
+
default=int(DEFAULT_STORAGE_SERVER.split(":", 1)[1]),
|
|
177
|
+
help="Port to bind.",
|
|
178
|
+
)
|
|
179
|
+
return parser
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: list[str] | None = None) -> int:
|
|
183
|
+
parser = build_parser()
|
|
184
|
+
args = parser.parse_args(argv)
|
|
185
|
+
server = CodexStorageServer(args.root, host=args.host, port=args.port)
|
|
186
|
+
server.start()
|
|
187
|
+
print(f"storage server listening on {server.base_url}", flush=True)
|
|
188
|
+
print(f"storage root: {server.root}", flush=True)
|
|
189
|
+
print(f"put current home: pycodex --put @{server.server_address}", flush=True)
|
|
190
|
+
print(
|
|
191
|
+
f"put custom home: pycodex --put /data/.codex/@{server.server_address}",
|
|
192
|
+
flush=True,
|
|
193
|
+
)
|
|
194
|
+
try:
|
|
195
|
+
if server._thread is not None:
|
|
196
|
+
server._thread.join()
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
return 130
|
|
199
|
+
finally:
|
|
200
|
+
server.stop()
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Collaboration Mode: Default
|
|
2
|
+
|
|
3
|
+
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
|
|
4
|
+
|
|
5
|
+
Your active mode changes only when new developer instructions with a different `<collaboration_mode>...</collaboration_mode>` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan.
|
|
6
|
+
|
|
7
|
+
## request_user_input availability
|
|
8
|
+
|
|
9
|
+
The `request_user_input` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.
|
|
10
|
+
|
|
11
|
+
In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
|