python-codex 0.1.0__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 CHANGED
@@ -28,6 +28,7 @@ from .runtime_services import (
28
28
  RequestPermissionsManager,
29
29
  RequestUserInputManager,
30
30
  SubAgentManager,
31
+ create_runtime_environment,
31
32
  get_runtime_environment,
32
33
  )
33
34
  from .tools import (
@@ -91,6 +92,7 @@ __all__ = [
91
92
  "AssistantMessage",
92
93
  "BaseTool",
93
94
  "CloseAgentTool",
95
+ "create_runtime_environment",
94
96
  "CodeModeManager",
95
97
  "ContextConfig",
96
98
  "ContextManager",
pycodex/cli.py CHANGED
@@ -5,7 +5,9 @@ import argparse
5
5
  import asyncio
6
6
  import json
7
7
  import os
8
+ import shlex
8
9
  import sys
10
+ import tempfile
9
11
  from dataclasses import asdict, replace
10
12
  from pathlib import Path
11
13
  from typing import Literal, Sequence
@@ -14,9 +16,10 @@ from .agent import AgentLoop
14
16
  from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
15
17
  from .context import ContextManager
16
18
  from .model import ResponsesModelClient, ResponsesProviderConfig
19
+ from .portable import bootstrap_called_home, upload_codex_home
17
20
  from .protocol import AgentEvent
18
21
  from .runtime import AgentRuntime
19
- from .runtime_services import get_runtime_environment
22
+ from .runtime_services import RuntimeEnvironment, create_runtime_environment
20
23
  from .utils import CliSessionView, load_codex_dotenv
21
24
  from responses_server import launch_chat_completion_compat_server
22
25
 
@@ -59,6 +62,22 @@ def build_parser() -> argparse.ArgumentParser:
59
62
  parser.add_argument(
60
63
  "prompt", nargs="*", help="Prompt text. If omitted, read from stdin."
61
64
  )
65
+ parser.add_argument(
66
+ "--put",
67
+ default=None,
68
+ metavar="PATH@SERVER",
69
+ help=(
70
+ "Upload a Codex home using `--put @host:port` or "
71
+ "`--put /path/.codex@host:port`."
72
+ ),
73
+ )
74
+ parser.add_argument(
75
+ "--call",
76
+ default=None,
77
+ help=(
78
+ "Download and use a stored Codex home via <secret>-<call_id>@<host:port>."
79
+ ),
80
+ )
62
81
  parser.add_argument(
63
82
  "--config",
64
83
  default=str(Path.home() / ".codex" / "config.toml"),
@@ -121,7 +140,10 @@ def resolve_prompt_text(prompt_parts: Sequence[str]) -> str:
121
140
  raise ValueError("prompt is required either as argv text or stdin")
122
141
 
123
142
 
124
- def get_tools(exec_mode: bool = False):
143
+ def get_tools(
144
+ runtime_environment: RuntimeEnvironment | None = None,
145
+ exec_mode: bool = False,
146
+ ):
125
147
  from .tools import (
126
148
  ApplyPatchTool,
127
149
  CloseAgentTool,
@@ -148,7 +170,7 @@ def get_tools(exec_mode: bool = False):
148
170
  WriteStdinTool,
149
171
  )
150
172
 
151
- runtime_environment = get_runtime_environment()
173
+ runtime_environment = runtime_environment or create_runtime_environment()
152
174
  registry = Registry()
153
175
  code_mode_manager = CodeModeManager(registry)
154
176
  unified_exec_manager = UnifiedExecManager()
@@ -214,7 +236,7 @@ def get_tools(exec_mode: bool = False):
214
236
  return registry
215
237
 
216
238
 
217
- def get_subagent_tools():
239
+ def get_subagent_tools(runtime_environment: RuntimeEnvironment | None = None):
218
240
  from .tools import (
219
241
  ApplyPatchTool,
220
242
  ExecCommandTool,
@@ -226,7 +248,7 @@ def get_subagent_tools():
226
248
  WriteStdinTool,
227
249
  )
228
250
 
229
- runtime_environment = get_runtime_environment()
251
+ runtime_environment = runtime_environment or create_runtime_environment()
230
252
  registry = Registry()
231
253
  unified_exec_manager = UnifiedExecManager()
232
254
  registry.register(ExecCommandTool(unified_exec_manager))
@@ -260,32 +282,50 @@ def build_runtime(
260
282
  base_instructions_override=system_prompt,
261
283
  include_collaboration_instructions=False,
262
284
  )
263
- runtime_environment = get_runtime_environment()
285
+ runtime_environment = create_runtime_environment()
264
286
  runtime_environment.request_user_input_manager.set_handler(None)
265
287
  runtime_environment.request_permissions_manager.set_handler(None)
266
288
 
267
- def build_nested_runtime(
268
- model_override: str | None,
269
- reasoning_effort_override: str | None,
270
- initial_history=(),
271
- session_id: str | None = None,
272
- ) -> AgentRuntime:
273
- nested_client = client.with_overrides(
274
- model_override,
275
- reasoning_effort_override,
276
- session_id=session_id,
277
- openai_subagent="collab_spawn",
278
- )
279
- nested_agent = AgentLoop(
280
- nested_client,
281
- get_subagent_tools(),
282
- subagent_context_manager,
283
- initial_history=tuple(initial_history),
284
- )
285
- return AgentRuntime(nested_agent)
289
+ def make_subagent_runtime_builder(base_client):
290
+ def build_subagent_runtime(
291
+ model_override: str | None,
292
+ reasoning_effort_override: str | None,
293
+ initial_history=(),
294
+ session_id: str | None = None,
295
+ ) -> AgentRuntime:
296
+ nested_client = base_client.with_overrides(
297
+ model_override,
298
+ reasoning_effort_override,
299
+ session_id=session_id,
300
+ openai_subagent="collab_spawn",
301
+ )
302
+ subagent_runtime_environment = create_runtime_environment()
303
+ subagent_runtime_environment.request_user_input_manager.set_handler(None)
304
+ subagent_runtime_environment.request_permissions_manager.set_handler(None)
305
+ subagent_runtime_environment.subagent_manager.set_runtime_builder(
306
+ make_subagent_runtime_builder(nested_client)
307
+ )
308
+ sub_agent = AgentLoop(
309
+ nested_client,
310
+ get_subagent_tools(subagent_runtime_environment),
311
+ subagent_context_manager,
312
+ initial_history=tuple(initial_history),
313
+ )
314
+ return AgentRuntime(
315
+ sub_agent, runtime_environment=subagent_runtime_environment
316
+ )
286
317
 
287
- runtime_environment.configure_runtime_builder(build_nested_runtime)
288
- return AgentRuntime(AgentLoop(client, get_tools(exec_mode=True), context_manager))
318
+ return build_subagent_runtime
319
+
320
+ runtime_environment.subagent_manager.set_runtime_builder(
321
+ make_subagent_runtime_builder(client)
322
+ )
323
+ return AgentRuntime(
324
+ AgentLoop(
325
+ client, get_tools(runtime_environment, exec_mode=True), context_manager
326
+ ),
327
+ runtime_environment=runtime_environment,
328
+ )
289
329
 
290
330
 
291
331
  def format_turn_output(result, json_mode: bool) -> str:
@@ -456,7 +496,10 @@ async def run_interactive_session(
456
496
  model_client = runtime._agent_loop._model_client
457
497
  runtime.set_event_handler(view.handle_event)
458
498
  pending_turn_tasks: set[asyncio.Task[None]] = set()
459
- runtime_environment = get_runtime_environment()
499
+ runtime_environment = runtime.runtime_environment
500
+ if runtime_environment is None:
501
+ runtime_environment = create_runtime_environment()
502
+ runtime.runtime_environment = runtime_environment
460
503
  runtime_environment.request_user_input_manager.set_handler(
461
504
  lambda payload: prompt_request_user_input(view, payload)
462
505
  )
@@ -466,6 +509,7 @@ async def run_interactive_session(
466
509
  view.write_line("pycodex interactive mode. Type /exit to quit.")
467
510
  view.write_line("Extra commands: /history, /title, /model")
468
511
  try:
512
+
469
513
  def has_pending_turn_tasks() -> bool:
470
514
  pending_turn_tasks.difference_update(
471
515
  task for task in tuple(pending_turn_tasks) if task.done()
@@ -568,10 +612,30 @@ async def run_interactive_session(
568
612
 
569
613
 
570
614
  async def run_cli(args: argparse.Namespace) -> int:
571
- configure_loguru()
572
615
  runtime = None
573
616
  worker = None
574
617
  try:
618
+ if args.put is not None and args.call:
619
+ raise ValueError("--put and --call cannot be combined")
620
+ if args.put is not None and args.prompt:
621
+ raise ValueError("--put does not accept prompt text")
622
+ configure_loguru()
623
+ if args.put is not None:
624
+ def emit_put_log(message: str) -> None:
625
+ print(message, flush=True)
626
+
627
+ call_spec = upload_codex_home(args.put, event_handler=emit_put_log)
628
+ emit_put_log(f"[put] testing call: {call_spec}")
629
+ with tempfile.TemporaryDirectory(prefix="pycodex-put-call-test-") as tmpdir:
630
+ config_path = bootstrap_called_home(call_spec, storage_root=tmpdir)
631
+ emit_put_log(f"[put] call test ok: {config_path.name}")
632
+ print("[put] one-click start:", flush=True)
633
+ print(f"pycodex --call {shlex.quote(call_spec)}", flush=True)
634
+ return 0
635
+ if args.call:
636
+ config_path = bootstrap_called_home(args.call)
637
+ args.config = str(config_path)
638
+ os.environ["CODEX_HOME"] = str(config_path.parent)
575
639
  client = _build_model_client(
576
640
  args.config,
577
641
  args.profile,
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()