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.
Files changed (64) hide show
  1. pycodex/__init__.py +141 -2
  2. pycodex/agent.py +290 -0
  3. pycodex/cli.py +705 -0
  4. pycodex/collaboration.py +21 -0
  5. pycodex/context.py +580 -0
  6. pycodex/doctor.py +360 -0
  7. pycodex/model.py +533 -0
  8. pycodex/portable.py +390 -0
  9. pycodex/portable_server.py +205 -0
  10. pycodex/prompts/collaboration_default.md +11 -0
  11. pycodex/prompts/collaboration_plan.md +128 -0
  12. pycodex/prompts/default_base_instructions.md +275 -0
  13. pycodex/prompts/exec_tools.json +411 -0
  14. pycodex/prompts/models.json +847 -0
  15. pycodex/prompts/permissions/approval_policy/never.md +1 -0
  16. pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
  17. pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
  18. pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
  19. pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
  20. pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
  21. pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
  22. pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
  23. pycodex/prompts/subagent_tools.json +163 -0
  24. pycodex/protocol.py +347 -0
  25. pycodex/runtime.py +204 -0
  26. pycodex/runtime_services.py +409 -0
  27. pycodex/tools/__init__.py +58 -0
  28. pycodex/tools/agent_tool_schemas.py +70 -0
  29. pycodex/tools/apply_patch_tool.py +363 -0
  30. pycodex/tools/base_tool.py +168 -0
  31. pycodex/tools/close_agent_tool.py +55 -0
  32. pycodex/tools/code_mode_manager.py +519 -0
  33. pycodex/tools/exec_command_tool.py +96 -0
  34. pycodex/tools/exec_runtime.js +161 -0
  35. pycodex/tools/exec_tool.py +48 -0
  36. pycodex/tools/grep_files_tool.py +150 -0
  37. pycodex/tools/list_dir_tool.py +135 -0
  38. pycodex/tools/read_file_tool.py +217 -0
  39. pycodex/tools/request_permissions_tool.py +95 -0
  40. pycodex/tools/request_user_input_tool.py +167 -0
  41. pycodex/tools/resume_agent_tool.py +56 -0
  42. pycodex/tools/send_input_tool.py +106 -0
  43. pycodex/tools/shell_command_tool.py +107 -0
  44. pycodex/tools/shell_tool.py +112 -0
  45. pycodex/tools/spawn_agent_tool.py +97 -0
  46. pycodex/tools/unified_exec_manager.py +380 -0
  47. pycodex/tools/update_plan_tool.py +79 -0
  48. pycodex/tools/view_image_tool.py +111 -0
  49. pycodex/tools/wait_agent_tool.py +75 -0
  50. pycodex/tools/wait_tool.py +68 -0
  51. pycodex/tools/web_search_tool.py +30 -0
  52. pycodex/tools/write_stdin_tool.py +75 -0
  53. pycodex/utils/__init__.py +40 -0
  54. pycodex/utils/dotenv.py +64 -0
  55. pycodex/utils/get_env.py +218 -0
  56. pycodex/utils/random_ids.py +19 -0
  57. pycodex/utils/visualize.py +978 -0
  58. python_codex-0.1.1.dist-info/METADATA +355 -0
  59. python_codex-0.1.1.dist-info/RECORD +62 -0
  60. python_codex-0.1.1.dist-info/entry_points.txt +2 -0
  61. python_codex-0.1.1.dist-info/licenses/LICENSE +201 -0
  62. python_codex-0.0.1.dist-info/METADATA +0 -30
  63. python_codex-0.0.1.dist-info/RECORD +0 -4
  64. {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.