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 +2 -0
- pycodex/cli.py +93 -29
- pycodex/portable.py +390 -0
- pycodex/portable_server.py +205 -0
- pycodex/runtime.py +6 -2
- pycodex/runtime_services.py +4 -3
- python_codex-0.1.1.dist-info/METADATA +355 -0
- {python_codex-0.1.0.dist-info → python_codex-0.1.1.dist-info}/RECORD +11 -9
- python_codex-0.1.0.dist-info/METADATA +0 -267
- {python_codex-0.1.0.dist-info → python_codex-0.1.1.dist-info}/WHEEL +0 -0
- {python_codex-0.1.0.dist-info → python_codex-0.1.1.dist-info}/entry_points.txt +0 -0
- {python_codex-0.1.0.dist-info → python_codex-0.1.1.dist-info}/licenses/LICENSE +0 -0
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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 =
|
|
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()
|