aicortexpy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/execenv.py ADDED
@@ -0,0 +1,264 @@
1
+ """Per-task execution environment preparation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .client import Task
13
+ from .codex_config import ensure_codex_multi_agent_config, sanitize_copied_codex_config
14
+ from .openclaw_config import prepare_openclaw_config
15
+ from .pinned_workdir import prepare_pinned_workdir, project_uses_pinned_workdir
16
+ from .prompt import build_prompt
17
+ from .runtime_config import (
18
+ inject_runtime_config,
19
+ task_context_from_task,
20
+ write_project_sidecar,
21
+ )
22
+ from .sandbox import codex_sandbox_policy, prepare_provider_sandbox, write_codex_sandbox_config
23
+
24
+ log = logging.getLogger("aicortexpy.execenv")
25
+
26
+
27
+ @dataclass
28
+ class Environment:
29
+ root_dir: Path
30
+ work_dir: Path
31
+ codex_home: Path | None = None
32
+ pinned: bool = False
33
+ env_updates: dict[str, str] = field(default_factory=dict)
34
+ runtime_brief: str = ""
35
+
36
+
37
+ def provider_needs_inline_system_prompt(provider: str) -> bool:
38
+ """Match Go daemon.providerNeedsInlineSystemPrompt (openclaw/kiro/kimi)."""
39
+ return provider in ("openclaw", "kiro", "kimi")
40
+
41
+
42
+ def _workdir_accessible(path: Path) -> bool:
43
+ try:
44
+ return path.is_dir()
45
+ except OSError:
46
+ return False
47
+
48
+
49
+ def prepare_environment(
50
+ workspaces_root: Path,
51
+ task: Task,
52
+ provider: str,
53
+ ) -> Environment:
54
+ ws = task.workspace_id or "default"
55
+ env_updates: dict[str, str] = {}
56
+
57
+ if project_uses_pinned_workdir(task):
58
+ work_dir = prepare_pinned_workdir(workspaces_root, task)
59
+ root_dir = work_dir
60
+ pinned = True
61
+ else:
62
+ pinned = False
63
+ prior = (task.prior_work_dir or "").strip()
64
+ prior_path = Path(prior) if prior else None
65
+ if prior_path is not None and _workdir_accessible(prior_path):
66
+ work_dir = prior_path
67
+ root_dir = prior_path.parent
68
+ log.info("reusing prior workdir %s", work_dir)
69
+ else:
70
+ if prior:
71
+ log.warning(
72
+ "prior workdir unavailable, preparing fresh env path=%s",
73
+ prior,
74
+ )
75
+ root_dir = workspaces_root / ws / task.id
76
+ work_dir = root_dir / "workdir"
77
+ for d in (work_dir, root_dir / "output", root_dir / "logs"):
78
+ d.mkdir(parents=True, exist_ok=True)
79
+
80
+ prompt = build_prompt(task, provider)
81
+ (work_dir / "PROMPT.md").write_text(prompt, encoding="utf-8")
82
+
83
+ agent = task.agent or {}
84
+ ctx = task_context_from_task(task)
85
+ runtime_brief = inject_runtime_config(work_dir, provider, ctx)
86
+ write_project_sidecar(work_dir, ctx)
87
+
88
+ if task.project_prompt:
89
+ (work_dir / "PROJECT.md").write_text(task.project_prompt, encoding="utf-8")
90
+
91
+ write_agent_skills(work_dir, provider, agent.get("skills"))
92
+ inject_default_skills(work_dir, provider)
93
+
94
+ codex_home: Path | None = None
95
+ if provider == "codex":
96
+ codex_home = root_dir / "codex-home" if not pinned else work_dir / ".codex-home"
97
+ policy = codex_sandbox_policy()
98
+ write_codex_sandbox_config(codex_home, policy)
99
+ hydrate_codex_home(codex_home, agent.get("skills"))
100
+ # Codex discovers skills from CODEX_HOME/skills — mirror default skills there too.
101
+ _copy_defaults_into(codex_home / "skills")
102
+ # prepare_provider_sandbox may rewrite config.toml — apply multi_agent last.
103
+ prepare_provider_sandbox(provider, work_dir)
104
+ ensure_codex_multi_agent_config(codex_home / "config.toml")
105
+ sanitize_copied_codex_config(codex_home / "config.toml")
106
+
107
+ if provider == "openclaw":
108
+ oc = prepare_openclaw_config(root_dir, work_dir)
109
+ if oc.env_updates:
110
+ env_updates.update(oc.env_updates)
111
+
112
+ return Environment(
113
+ root_dir=root_dir,
114
+ work_dir=work_dir,
115
+ codex_home=codex_home,
116
+ pinned=pinned,
117
+ env_updates=env_updates,
118
+ runtime_brief=runtime_brief,
119
+ )
120
+
121
+
122
+ def write_agent_skills(work_dir: Path, provider: str, skills: Any) -> None:
123
+ if not isinstance(skills, list):
124
+ return
125
+ base = _skills_root(work_dir, provider)
126
+ base.mkdir(parents=True, exist_ok=True)
127
+ for sk in skills:
128
+ if not isinstance(sk, dict):
129
+ continue
130
+ name = str(sk.get("name") or "skill").strip() or "skill"
131
+ safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
132
+ skill_dir = base / safe
133
+ skill_dir.mkdir(parents=True, exist_ok=True)
134
+ content = str(sk.get("content") or "")
135
+ (skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
136
+ files = sk.get("files")
137
+ if isinstance(files, list):
138
+ for f in files:
139
+ if not isinstance(f, dict):
140
+ continue
141
+ rel = str(f.get("path") or "").lstrip("/\\")
142
+ if not rel or ".." in rel.split("/"):
143
+ continue
144
+ dest = skill_dir / rel
145
+ dest.parent.mkdir(parents=True, exist_ok=True)
146
+ dest.write_text(str(f.get("content") or ""), encoding="utf-8")
147
+
148
+
149
+ def inject_default_skills(work_dir: Path, provider: str) -> None:
150
+ dest = _skills_root(work_dir, provider)
151
+ for src_root in _default_skill_roots():
152
+ _copy_default_skill_dirs(src_root, dest)
153
+
154
+
155
+ def _copy_defaults_into(dest_root: Path) -> None:
156
+ for src_root in _default_skill_roots():
157
+ _copy_default_skill_dirs(src_root, dest_root)
158
+
159
+
160
+ def _default_skill_roots() -> list[Path]:
161
+ roots: list[Path] = []
162
+ bundled = Path(__file__).resolve().parent / "data" / "default_skills"
163
+ if bundled.is_dir():
164
+ roots.append(bundled)
165
+ # Monorepo sibling (dev / source checkout): reuse Go daemon defaultskills.
166
+ mono = (
167
+ Path(__file__).resolve().parents[2]
168
+ / "server"
169
+ / "internal"
170
+ / "daemon"
171
+ / "defaultskills"
172
+ )
173
+ if mono.is_dir():
174
+ roots.append(mono)
175
+ return roots
176
+
177
+
178
+ def _copy_default_skill_dirs(src_root: Path, dest_root: Path) -> None:
179
+ dest_root.mkdir(parents=True, exist_ok=True)
180
+ skip_heavy = (os.environ.get("AICORTEX_INJECT_TENCENT_DOCS") or "").strip().lower() not in (
181
+ "1",
182
+ "true",
183
+ "yes",
184
+ "on",
185
+ )
186
+ for child in src_root.iterdir():
187
+ if not child.is_dir():
188
+ continue
189
+ # Skip Go package helper dirs / tests and huge tencent bundle by default.
190
+ if child.name in ("testdata",):
191
+ continue
192
+ if skip_heavy and child.name == "tencent-docs":
193
+ # Still inject SKILL.md alone so the skill is discoverable.
194
+ skill_md = child / "SKILL.md"
195
+ if skill_md.is_file():
196
+ target = dest_root / child.name
197
+ target.mkdir(parents=True, exist_ok=True)
198
+ dest_md = target / "SKILL.md"
199
+ if not dest_md.exists():
200
+ try:
201
+ shutil.copy2(skill_md, dest_md)
202
+ except OSError as e:
203
+ log.warning("copy tencent-docs SKILL.md failed: %s", e)
204
+ continue
205
+ if not (child / "SKILL.md").is_file():
206
+ continue
207
+ target = dest_root / child.name
208
+ if target.exists():
209
+ continue
210
+ try:
211
+ shutil.copytree(child, target)
212
+ except OSError as e:
213
+ log.warning("copy default skill %s failed: %s", child.name, e)
214
+
215
+
216
+ def hydrate_codex_home(codex_home: Path, skills: Any) -> None:
217
+ user = Path.home() / ".codex"
218
+ codex_home.mkdir(parents=True, exist_ok=True)
219
+ for name in ("auth.json", "config.toml"):
220
+ src = user / name
221
+ dst = codex_home / name
222
+ if src.is_file() and not dst.exists():
223
+ try:
224
+ shutil.copy2(src, dst)
225
+ except OSError:
226
+ pass
227
+ cfg = codex_home / "config.toml"
228
+ sanitize_copied_codex_config(cfg)
229
+
230
+ skills_dir = codex_home / "skills"
231
+ skills_dir.mkdir(parents=True, exist_ok=True)
232
+ if isinstance(skills, list):
233
+ for sk in skills:
234
+ if not isinstance(sk, dict):
235
+ continue
236
+ name = str(sk.get("name") or "skill")
237
+ safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
238
+ d = skills_dir / safe
239
+ d.mkdir(parents=True, exist_ok=True)
240
+ (d / "SKILL.md").write_text(str(sk.get("content") or ""), encoding="utf-8")
241
+
242
+
243
+ def _skills_root(work_dir: Path, provider: str) -> Path:
244
+ if provider == "claude":
245
+ return work_dir / ".claude" / "skills"
246
+ if provider == "cursor":
247
+ return work_dir / ".cursor" / "skills"
248
+ if provider == "copilot":
249
+ return work_dir / ".github" / "skills"
250
+ if provider == "opencode":
251
+ return work_dir / ".opencode" / "skills"
252
+ if provider == "openclaw":
253
+ return work_dir / "skills"
254
+ if provider == "pi":
255
+ return work_dir / ".pi" / "skills"
256
+ if provider == "kimi":
257
+ return work_dir / ".kimi" / "skills"
258
+ if provider == "kiro":
259
+ return work_dir / ".kiro" / "skills"
260
+ if provider == "dsh":
261
+ return work_dir / ".dsh" / "skills"
262
+ # codex / gemini / hermes / unknown → meta skill references .agent_context/skills
263
+ # (Codex also gets skills under per-task CODEX_HOME/skills via hydrate_codex_home)
264
+ return work_dir / ".agent_context" / "skills"
aicortexpy/gc.py ADDED
@@ -0,0 +1,286 @@
1
+ """Periodic workspace GC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import shutil
8
+ import threading
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .client import APIError, DaemonClient, is_not_found
15
+
16
+ log = logging.getLogger("aicortexpy.gc")
17
+
18
+ META_NAME = ".gc_meta.json"
19
+ LEGACY_META_NAME = ".aicortex-gc.json"
20
+ ARTIFACT_PATTERNS = ("node_modules", ".next", ".turbo")
21
+
22
+
23
+ class ActiveEnvRoots:
24
+ """Refcounted set of env roots currently used by running tasks (Go activeEnvRoots)."""
25
+
26
+ def __init__(self) -> None:
27
+ self._mu = threading.Lock()
28
+ self._roots: dict[str, int] = {}
29
+
30
+ @staticmethod
31
+ def _key(path: str | Path) -> str:
32
+ try:
33
+ return str(Path(path).resolve())
34
+ except OSError:
35
+ return str(path)
36
+
37
+ def mark(self, path: str | Path) -> None:
38
+ key = self._key(path)
39
+ if not key:
40
+ return
41
+ with self._mu:
42
+ self._roots[key] = self._roots.get(key, 0) + 1
43
+
44
+ def unmark(self, path: str | Path) -> None:
45
+ key = self._key(path)
46
+ if not key:
47
+ return
48
+ with self._mu:
49
+ n = self._roots.get(key, 0)
50
+ if n <= 1:
51
+ self._roots.pop(key, None)
52
+ else:
53
+ self._roots[key] = n - 1
54
+
55
+ def is_active(self, path: str | Path) -> bool:
56
+ key = self._key(path)
57
+ with self._mu:
58
+ return self._roots.get(key, 0) > 0
59
+
60
+
61
+ def write_gc_meta(env_root: Path, meta: dict[str, Any]) -> None:
62
+ try:
63
+ (env_root / META_NAME).write_text(
64
+ json.dumps(meta, ensure_ascii=False, indent=2),
65
+ encoding="utf-8",
66
+ )
67
+ legacy = env_root / LEGACY_META_NAME
68
+ if legacy.is_file():
69
+ try:
70
+ legacy.unlink()
71
+ except OSError:
72
+ pass
73
+ except OSError as e:
74
+ log.warning("write gc meta failed: %s", e)
75
+
76
+
77
+ def read_gc_meta(task_dir: Path) -> dict[str, Any] | None:
78
+ for name in (META_NAME, LEGACY_META_NAME):
79
+ path = task_dir / name
80
+ if not path.is_file():
81
+ continue
82
+ try:
83
+ data = json.loads(path.read_text(encoding="utf-8"))
84
+ except Exception:
85
+ continue
86
+ if isinstance(data, dict):
87
+ return data
88
+ return None
89
+
90
+
91
+ def gc_meta_for_task(
92
+ task_id: str,
93
+ issue_id: str,
94
+ chat_id: str,
95
+ autopilot_id: str,
96
+ *,
97
+ workspace_id: str = "",
98
+ quick_create: bool = False,
99
+ ) -> dict[str, Any]:
100
+ """Align with Go execenv.GCMeta field names (+ parent_id for local helpers)."""
101
+ completed = datetime.now(timezone.utc).isoformat()
102
+ if issue_id:
103
+ return {
104
+ "kind": "issue",
105
+ "issue_id": issue_id,
106
+ "parent_id": issue_id,
107
+ "task_id": task_id,
108
+ "workspace_id": workspace_id,
109
+ "completed_at": completed,
110
+ }
111
+ if chat_id:
112
+ return {
113
+ "kind": "chat",
114
+ "chat_session_id": chat_id,
115
+ "parent_id": chat_id,
116
+ "task_id": task_id,
117
+ "workspace_id": workspace_id,
118
+ "completed_at": completed,
119
+ }
120
+ if autopilot_id:
121
+ return {
122
+ "kind": "autopilot_run",
123
+ "autopilot_run_id": autopilot_id,
124
+ "parent_id": autopilot_id,
125
+ "task_id": task_id,
126
+ "workspace_id": workspace_id,
127
+ "completed_at": completed,
128
+ }
129
+ if quick_create:
130
+ return {
131
+ "kind": "quick_create",
132
+ "parent_id": task_id,
133
+ "task_id": task_id,
134
+ "workspace_id": workspace_id,
135
+ "completed_at": completed,
136
+ }
137
+ return {
138
+ "kind": "task",
139
+ "parent_id": task_id,
140
+ "task_id": task_id,
141
+ "workspace_id": workspace_id,
142
+ "completed_at": completed,
143
+ }
144
+
145
+
146
+ def _meta_parent(meta: dict[str, Any]) -> tuple[str, str]:
147
+ kind = str(meta.get("kind") or "")
148
+ if kind == "issue":
149
+ return kind, str(meta.get("issue_id") or meta.get("parent_id") or "")
150
+ if kind == "chat":
151
+ return kind, str(meta.get("chat_session_id") or meta.get("parent_id") or "")
152
+ if kind in ("autopilot", "autopilot_run"):
153
+ return "autopilot", str(meta.get("autopilot_run_id") or meta.get("parent_id") or "")
154
+ if kind == "quick_create":
155
+ return kind, str(meta.get("task_id") or meta.get("parent_id") or "")
156
+ return kind or "task", str(meta.get("parent_id") or meta.get("task_id") or "")
157
+
158
+
159
+ def run_gc_loop(
160
+ stop: threading.Event,
161
+ client: DaemonClient,
162
+ workspaces_root: Path,
163
+ *,
164
+ interval: float,
165
+ ttl: float,
166
+ orphan_ttl: float,
167
+ artifact_ttl: float,
168
+ enabled: bool,
169
+ active_roots: ActiveEnvRoots | None = None,
170
+ ) -> None:
171
+ if not enabled:
172
+ log.info("gc: disabled")
173
+ return
174
+ log.info("gc: started interval=%ss ttl=%ss", interval, ttl)
175
+ stop.wait(30.0)
176
+ while not stop.is_set():
177
+ try:
178
+ run_gc_once(
179
+ client,
180
+ workspaces_root,
181
+ ttl,
182
+ orphan_ttl,
183
+ artifact_ttl,
184
+ active_roots=active_roots,
185
+ )
186
+ except Exception as e:
187
+ log.warning("gc cycle failed: %s", e)
188
+ stop.wait(interval)
189
+
190
+
191
+ def run_gc_once(
192
+ client: DaemonClient,
193
+ workspaces_root: Path,
194
+ ttl: float,
195
+ orphan_ttl: float,
196
+ artifact_ttl: float,
197
+ *,
198
+ active_roots: ActiveEnvRoots | None = None,
199
+ ) -> None:
200
+ if not workspaces_root.is_dir():
201
+ return
202
+ now = time.time()
203
+ cleaned = orphaned = artifact_dirs = skipped_active = 0
204
+ for ws in workspaces_root.iterdir():
205
+ if not ws.is_dir() or ws.name in (".repos",):
206
+ continue
207
+ for child in ws.iterdir():
208
+ if not child.is_dir():
209
+ continue
210
+ if child.name == "projects":
211
+ continue # pinned project roots — never GC
212
+ if active_roots is not None and active_roots.is_active(child):
213
+ skipped_active += 1
214
+ continue
215
+
216
+ age = now - child.stat().st_mtime
217
+ meta = read_gc_meta(child)
218
+
219
+ if meta is None:
220
+ if age >= orphan_ttl:
221
+ _rm(child)
222
+ orphaned += 1
223
+ continue
224
+
225
+ kind, parent = _meta_parent(meta)
226
+ try:
227
+ status_info = _status_for(client, kind, parent)
228
+ except APIError as e:
229
+ if is_not_found(e, "not found") and age >= orphan_ttl:
230
+ _rm(child)
231
+ orphaned += 1
232
+ continue
233
+ except Exception:
234
+ continue
235
+
236
+ status = str(status_info.get("status") or "").lower()
237
+ # Chat sessions that are still active must never be reclaimed.
238
+ if kind == "chat" and status in ("active", "open", ""):
239
+ continue
240
+ terminal = status in ("done", "cancelled", "completed", "failed", "archived")
241
+ if terminal and age >= ttl:
242
+ _rm(child)
243
+ cleaned += 1
244
+ continue
245
+
246
+ # artifact reclaim on still-open parents
247
+ if artifact_ttl > 0 and age >= artifact_ttl:
248
+ if _reclaim_artifacts(child):
249
+ artifact_dirs += 1
250
+
251
+ if cleaned or orphaned or artifact_dirs or skipped_active:
252
+ log.info(
253
+ "gc: cleaned=%s orphaned=%s artifact_dirs=%s skipped_active=%s",
254
+ cleaned,
255
+ orphaned,
256
+ artifact_dirs,
257
+ skipped_active,
258
+ )
259
+
260
+
261
+ def _status_for(client: DaemonClient, kind: str, parent: str) -> dict[str, Any]:
262
+ if kind == "issue":
263
+ return client.get_issue_gc_check(parent)
264
+ if kind == "chat":
265
+ return client.get_chat_session_gc_check(parent)
266
+ if kind == "autopilot":
267
+ return client.get_autopilot_run_gc_check(parent)
268
+ return client.get_task_gc_check(parent)
269
+
270
+
271
+ def _reclaim_artifacts(root: Path) -> bool:
272
+ removed = False
273
+ for pat in ARTIFACT_PATTERNS:
274
+ for hit in root.rglob(pat):
275
+ if hit.is_dir() and hit.name == pat:
276
+ _rm(hit)
277
+ removed = True
278
+ return removed
279
+
280
+
281
+ def _rm(path: Path) -> None:
282
+ try:
283
+ shutil.rmtree(path, ignore_errors=True)
284
+ log.info("gc: removed %s", path)
285
+ except OSError as e:
286
+ log.warning("gc: remove failed %s: %s", path, e)
@@ -0,0 +1,65 @@
1
+ """Download + extract design→dev handoff zip bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import logging
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.request import Request, urlopen
11
+
12
+ log = logging.getLogger("aicortexpy.handoff")
13
+
14
+ MAX_BYTES = 50 << 20
15
+
16
+
17
+ def download_handoff_bundle(
18
+ url: str,
19
+ *,
20
+ token: str = "",
21
+ server_base: str = "",
22
+ timeout: float = 120.0,
23
+ ) -> bytes:
24
+ url = (url or "").strip()
25
+ if not url:
26
+ raise ValueError("handoff bundle url is empty")
27
+ req = Request(url, method="GET")
28
+ if token and server_base and url.startswith(server_base.rstrip("/")):
29
+ req.add_header("Authorization", f"Bearer {token}")
30
+ req.add_header("X-Client-Platform", "daemon")
31
+ with urlopen(req, timeout=timeout) as resp: # noqa: S310 — intentional HTTP fetch
32
+ data = resp.read(MAX_BYTES + 1)
33
+ if len(data) > MAX_BYTES:
34
+ raise ValueError("handoff bundle exceeds size limit")
35
+ return data
36
+
37
+
38
+ def extract_zip_archive(data: bytes, dest_dir: Path) -> None:
39
+ dest_dir.mkdir(parents=True, exist_ok=True)
40
+ dest_root = dest_dir.resolve()
41
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
42
+ for info in zf.infolist():
43
+ if info.is_dir():
44
+ continue
45
+ name = info.filename.replace("\\", "/")
46
+ if name.startswith("/") or ".." in name.split("/"):
47
+ raise ValueError(f"unsafe zip path: {info.filename}")
48
+ target = (dest_dir / name).resolve()
49
+ if not str(target).startswith(str(dest_root)):
50
+ raise ValueError(f"zip slip blocked: {info.filename}")
51
+ target.parent.mkdir(parents=True, exist_ok=True)
52
+ target.write_bytes(zf.read(info))
53
+
54
+
55
+ def materialize_handoff_bundle(
56
+ work_dir: Path,
57
+ bundle_url: str,
58
+ *,
59
+ token: str = "",
60
+ server_base: str = "",
61
+ ) -> None:
62
+ log.info("downloading handoff bundle url=%s", bundle_url)
63
+ data = download_handoff_bundle(bundle_url, token=token, server_base=server_base)
64
+ extract_zip_archive(data, work_dir)
65
+ log.info("handoff bundle extracted to %s", work_dir)