forgexa-cli 1.48.2__tar.gz → 1.48.3__tar.gz

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 (28) hide show
  1. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/PKG-INFO +1 -1
  2. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/daemon.py +332 -2
  4. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/pyproject.toml +1 -1
  6. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/README.md +0 -0
  7. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  15. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  16. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
  17. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/requires.txt +0 -0
  18. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/top_level.txt +0 -0
  19. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/setup.cfg +0 -0
  20. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_auth_and_runtime_commands.py +0 -0
  21. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.48.2 → forgexa_cli-1.48.3}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.48.2
3
+ Version: 1.48.3
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.48.2"
2
+ __version__ = "1.48.3"
@@ -1120,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
1120
1120
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1121
1121
  # Kept in sync with pyproject.toml version via bump-version.sh.
1122
1122
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1123
- DAEMON_VERSION = "1.48.2"
1123
+ DAEMON_VERSION = "1.48.3"
1124
1124
 
1125
1125
 
1126
1126
  def _detect_client_type() -> str:
@@ -5189,6 +5189,10 @@ class _IdleTimeoutError(asyncio.TimeoutError):
5189
5189
  self.stderr = ""
5190
5190
 
5191
5191
 
5192
+ class _TaskAttachmentDownloadError(RuntimeError):
5193
+ """Raised when a required task attachment cannot be staged safely."""
5194
+
5195
+
5192
5196
  def _workspace_has_recent_activity(
5193
5197
  workspace_path: "Path", since_monotonic: float, max_depth: int = 4
5194
5198
  ) -> bool:
@@ -8224,6 +8228,7 @@ class ServerConnection:
8224
8228
  "platform": platform.platform(),
8225
8229
  "python_version": platform.python_version(),
8226
8230
  "local_workspace_bindings": {"protocol_version": 3},
8231
+ "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
8227
8232
  },
8228
8233
  "server_url": self.server_url,
8229
8234
  "expires_in_days": 90,
@@ -8623,7 +8628,7 @@ class ServerConnection:
8623
8628
  "python_version": platform.python_version(),
8624
8629
  "local_workspace_bindings": {"protocol_version": 3},
8625
8630
  "local_evidence": {"protocol_version": 1, "structured_summary": True},
8626
- "local_evidence": {"protocol_version": 1, "structured_summary": True},
8631
+ "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
8627
8632
  },
8628
8633
  "server_url": self.server_url,
8629
8634
  }
@@ -9257,6 +9262,10 @@ class RuntimeDaemon:
9257
9262
  """
9258
9263
 
9259
9264
  AGENT_REFRESH_INTERVAL_SECONDS = 60
9265
+ _TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS = 120
9266
+ _TASK_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
9267
+ _TASK_ATTACHMENT_MAX_COUNT = 20
9268
+ _TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
9260
9269
 
9261
9270
  def __init__(self):
9262
9271
  # Compute stable hardware fingerprint from machine-id / MAC address.
@@ -9448,6 +9457,296 @@ class RuntimeDaemon:
9448
9457
 
9449
9458
  target_dir.mkdir(parents=True, exist_ok=True)
9450
9459
 
9460
+ @staticmethod
9461
+ def _task_attachments_to_stage(task: TaskInfo) -> list[dict]:
9462
+ input_data = task.input_data if isinstance(task.input_data, dict) else {}
9463
+ attachment_groups = [input_data.get("attachments")]
9464
+ context = input_data.get("context")
9465
+ if isinstance(context, dict):
9466
+ attachment_groups.append(context.get("attachments"))
9467
+ fix_context = input_data.get("fix_context")
9468
+ if isinstance(fix_context, dict):
9469
+ attachment_groups.append(fix_context.get("gate_attachments"))
9470
+
9471
+ attachments: list[dict] = []
9472
+ attachments_by_artifact_id: dict[str, dict] = {}
9473
+ attachments_by_url: dict[str, dict] = {}
9474
+ for group in attachment_groups:
9475
+ if not isinstance(group, list):
9476
+ continue
9477
+ for attachment in group:
9478
+ if not isinstance(attachment, dict):
9479
+ continue
9480
+ artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "").strip()
9481
+ raw_download_url = attachment.get("download_url")
9482
+ download_url = raw_download_url.strip() if isinstance(raw_download_url, str) else ""
9483
+ if not artifact_id and not download_url:
9484
+ continue
9485
+ existing = (
9486
+ attachments_by_artifact_id.get(artifact_id)
9487
+ if artifact_id else None
9488
+ ) or (attachments_by_url.get(download_url) if download_url else None)
9489
+ if existing is None:
9490
+ existing = dict(attachment)
9491
+ attachments.append(existing)
9492
+ else:
9493
+ for field, value in attachment.items():
9494
+ if value and not existing.get(field):
9495
+ existing[field] = value
9496
+ existing_artifact_id = str(
9497
+ existing.get("artifact_id") or existing.get("id") or ""
9498
+ ).strip()
9499
+ if existing_artifact_id:
9500
+ attachments_by_artifact_id[existing_artifact_id] = existing
9501
+ if download_url:
9502
+ attachments_by_url[download_url] = existing
9503
+
9504
+ def _artifact_id_is_valid(attachment: dict) -> bool:
9505
+ try:
9506
+ UUID(str(attachment.get("artifact_id") or attachment.get("id") or ""))
9507
+ return True
9508
+ except (TypeError, ValueError, AttributeError):
9509
+ return False
9510
+
9511
+ def _legacy_key(attachment: dict) -> tuple[str, str, int]:
9512
+ try:
9513
+ size_bytes = int(attachment.get("size_bytes") or 0)
9514
+ except (TypeError, ValueError):
9515
+ size_bytes = 0
9516
+ return (
9517
+ str(attachment.get("file_path") or attachment.get("file_name") or ""),
9518
+ str(attachment.get("mime_type") or ""),
9519
+ size_bytes,
9520
+ )
9521
+
9522
+ identified_by_key: dict[tuple[str, str, int], list[dict]] = {}
9523
+ for attachment in attachments:
9524
+ if _artifact_id_is_valid(attachment):
9525
+ identified_by_key.setdefault(_legacy_key(attachment), []).append(attachment)
9526
+
9527
+ deduplicated: list[dict] = []
9528
+ for attachment in attachments:
9529
+ if _artifact_id_is_valid(attachment):
9530
+ deduplicated.append(attachment)
9531
+ continue
9532
+ candidates = identified_by_key.get(_legacy_key(attachment), [])
9533
+ if len(candidates) != 1:
9534
+ deduplicated.append(attachment)
9535
+ continue
9536
+ identified = candidates[0]
9537
+ for field, value in attachment.items():
9538
+ if value and not identified.get(field):
9539
+ identified[field] = value
9540
+ return deduplicated
9541
+
9542
+ @staticmethod
9543
+ def _task_attachment_filename(attachment: dict, position: int) -> str:
9544
+ raw_name = str(
9545
+ attachment.get("file_path") or attachment.get("file_name") or "attachment"
9546
+ )
9547
+ file_name = Path(raw_name.replace("\\", "/")).name
9548
+ file_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file_name).strip("._")
9549
+ return f"{position:02d}-{file_name[:160] or 'attachment'}"
9550
+
9551
+ @staticmethod
9552
+ def _rewrite_staged_attachment_references(
9553
+ prompt: str,
9554
+ staged: list[tuple[str, str, str]],
9555
+ ) -> str:
9556
+ if not staged:
9557
+ return prompt
9558
+
9559
+ local_files = []
9560
+ for file_name, download_url, local_path in staged:
9561
+ if download_url:
9562
+ command_pattern = r"`curl -L '" + re.escape(download_url) + r"' -o /tmp/[^`]+`"
9563
+ prompt = re.sub(command_pattern, lambda _match, path=local_path: f"`{path}`", prompt)
9564
+ prompt = prompt.replace(f"Download: {download_url}", f"Staged local file: `{local_path}`")
9565
+ prompt = prompt.replace(download_url, local_path)
9566
+ local_files.append(f"- `{local_path}` ({file_name})")
9567
+
9568
+ preamble = "\n".join([
9569
+ "## Runtime-Prepared Attachments",
9570
+ "The runtime downloaded these files before this task began. Use the local copies directly; do not download attachment URLs yourself.",
9571
+ *local_files,
9572
+ ])
9573
+ return f"{preamble}\n\n{prompt}" if prompt else preamble
9574
+
9575
+ async def _download_task_attachment(
9576
+ self,
9577
+ client: httpx.AsyncClient,
9578
+ url: str,
9579
+ destination: Path,
9580
+ *,
9581
+ attachment_name: str,
9582
+ expected_size: int | None,
9583
+ expected_checksum: str | None,
9584
+ max_bytes: int,
9585
+ timeout_seconds: float,
9586
+ ) -> None:
9587
+ parsed_url = urlparse(url)
9588
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
9589
+ raise _TaskAttachmentDownloadError(
9590
+ f"Attachment '{attachment_name}' has an unsupported download URL"
9591
+ )
9592
+ if expected_size is not None and expected_size < 0:
9593
+ raise _TaskAttachmentDownloadError(
9594
+ f"Attachment '{attachment_name}' has an invalid size"
9595
+ )
9596
+ if expected_size is not None and expected_size > max_bytes:
9597
+ raise _TaskAttachmentDownloadError(
9598
+ f"Attachment '{attachment_name}' exceeds the task download budget"
9599
+ )
9600
+ if expected_checksum and not re.fullmatch(r"[0-9a-fA-F]{64}", expected_checksum):
9601
+ raise _TaskAttachmentDownloadError(
9602
+ f"Attachment '{attachment_name}' has an invalid integrity checksum"
9603
+ )
9604
+
9605
+ temporary_path = destination.with_name(f"{destination.name}.part")
9606
+
9607
+ async def download() -> None:
9608
+ try:
9609
+ async with client.stream(
9610
+ "GET",
9611
+ url,
9612
+ timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
9613
+ ) as response:
9614
+ if not 200 <= response.status_code < 300:
9615
+ raise _TaskAttachmentDownloadError(
9616
+ f"Attachment '{attachment_name}' download failed with HTTP {response.status_code}"
9617
+ )
9618
+ content_length = response.headers.get("content-length")
9619
+ if content_length:
9620
+ try:
9621
+ if int(content_length) > max_bytes:
9622
+ raise _TaskAttachmentDownloadError(
9623
+ f"Attachment '{attachment_name}' exceeds the task download budget"
9624
+ )
9625
+ except ValueError:
9626
+ pass
9627
+
9628
+ total_bytes = 0
9629
+ digest = hashlib.sha256()
9630
+ with temporary_path.open("wb") as output:
9631
+ try:
9632
+ os.chmod(temporary_path, 0o600)
9633
+ except OSError:
9634
+ pass
9635
+ async for chunk in response.aiter_bytes():
9636
+ total_bytes += len(chunk)
9637
+ if total_bytes > max_bytes:
9638
+ raise _TaskAttachmentDownloadError(
9639
+ f"Attachment '{attachment_name}' exceeds the task download budget"
9640
+ )
9641
+ output.write(chunk)
9642
+ digest.update(chunk)
9643
+ if expected_checksum:
9644
+ if not secrets.compare_digest(digest.hexdigest(), expected_checksum.lower()):
9645
+ raise _TaskAttachmentDownloadError(
9646
+ f"Attachment '{attachment_name}' failed its integrity check"
9647
+ )
9648
+ temporary_path.replace(destination)
9649
+ except _TaskAttachmentDownloadError:
9650
+ raise
9651
+ except httpx.HTTPError as exc:
9652
+ raise _TaskAttachmentDownloadError(
9653
+ f"Attachment '{attachment_name}' download failed ({type(exc).__name__})"
9654
+ ) from exc
9655
+ except OSError as exc:
9656
+ raise _TaskAttachmentDownloadError(
9657
+ f"Attachment '{attachment_name}' could not be saved ({exc})"
9658
+ ) from exc
9659
+
9660
+ try:
9661
+ await asyncio.wait_for(
9662
+ download(),
9663
+ timeout=timeout_seconds,
9664
+ )
9665
+ except asyncio.TimeoutError as exc:
9666
+ raise _TaskAttachmentDownloadError(
9667
+ f"Attachment '{attachment_name}' download timed out after "
9668
+ f"{timeout_seconds:.0f}s"
9669
+ ) from exc
9670
+ finally:
9671
+ temporary_path.unlink(missing_ok=True)
9672
+
9673
+ async def _stage_task_attachments(
9674
+ self,
9675
+ task: TaskInfo,
9676
+ conn: ServerConnection,
9677
+ ) -> Path | None:
9678
+ attachments = self._task_attachments_to_stage(task)
9679
+ if not attachments:
9680
+ return None
9681
+ if len(attachments) > self._TASK_ATTACHMENT_MAX_COUNT:
9682
+ raise _TaskAttachmentDownloadError(
9683
+ f"Task has {len(attachments)} attachments; the runtime limit is "
9684
+ f"{self._TASK_ATTACHMENT_MAX_COUNT}"
9685
+ )
9686
+
9687
+ staging_dir = Path(tempfile.mkdtemp(prefix=f"forgexa-attachments-{task.task_id[:8]}-"))
9688
+ staged: list[tuple[str, str, str]] = []
9689
+ deadline = time.monotonic() + self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS
9690
+ remaining_bytes = self._TASK_ATTACHMENT_MAX_TOTAL_BYTES
9691
+ try:
9692
+ for position, attachment in enumerate(attachments, start=1):
9693
+ file_name = str(
9694
+ attachment.get("file_path") or attachment.get("file_name") or "attachment"
9695
+ )
9696
+ download_url = str(attachment.get("download_url") or "")
9697
+ destination = staging_dir / self._task_attachment_filename(attachment, position)
9698
+ try:
9699
+ expected_size = int(attachment.get("size_bytes") or 0)
9700
+ except (TypeError, ValueError):
9701
+ expected_size = 0
9702
+ if expected_size > remaining_bytes:
9703
+ raise _TaskAttachmentDownloadError(
9704
+ f"Attachment '{file_name}' exceeds the task download budget"
9705
+ )
9706
+ artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "")
9707
+ try:
9708
+ UUID(artifact_id)
9709
+ except (TypeError, ValueError, AttributeError):
9710
+ raise _TaskAttachmentDownloadError(
9711
+ f"Attachment '{file_name}' cannot be staged securely because it has no artifact ID"
9712
+ )
9713
+ remaining_seconds = deadline - time.monotonic()
9714
+ if remaining_seconds <= 0:
9715
+ raise _TaskAttachmentDownloadError(
9716
+ f"Task attachment staging timed out after "
9717
+ f"{self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS}s"
9718
+ )
9719
+ if not conn.runtime_id:
9720
+ raise _TaskAttachmentDownloadError(
9721
+ f"Attachment '{file_name}' cannot be staged because this runtime is not registered"
9722
+ )
9723
+ source_url = (
9724
+ f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/"
9725
+ f"tasks/{task.task_id}/attachments/{artifact_id}"
9726
+ )
9727
+ await self._download_task_attachment(
9728
+ conn.client,
9729
+ source_url,
9730
+ destination,
9731
+ attachment_name=file_name,
9732
+ expected_size=expected_size or None,
9733
+ expected_checksum=str(attachment.get("checksum") or ""),
9734
+ max_bytes=min(self._TASK_ATTACHMENT_MAX_BYTES, remaining_bytes),
9735
+ timeout_seconds=remaining_seconds,
9736
+ )
9737
+ remaining_bytes -= destination.stat().st_size
9738
+ staged.append((file_name, download_url, str(destination)))
9739
+ except Exception:
9740
+ shutil.rmtree(staging_dir, ignore_errors=True)
9741
+ raise
9742
+
9743
+ task.input_prompt = self._rewrite_staged_attachment_references(task.input_prompt, staged)
9744
+ input_data = dict(task.input_data or {})
9745
+ input_data["prompt"] = task.input_prompt
9746
+ task.input_data = input_data
9747
+ logger.info("Prepared %d attachment(s) for task %s", len(staged), task.task_id)
9748
+ return staging_dir
9749
+
9451
9750
  @staticmethod
9452
9751
  async def _mint_local_dev_token() -> str:
9453
9752
  """Mint a JWT for local development when no explicit token is configured.
@@ -10292,6 +10591,7 @@ class RuntimeDaemon:
10292
10591
  await project_lock.acquire()
10293
10592
  workspace_execution_lock: _WorkspaceFileLock | None = None
10294
10593
  local_bind_file_lock = None
10594
+ attachment_staging_dir: Path | None = None
10295
10595
  if is_local_bind and task.local_workspace_binding_id:
10296
10596
  # OS-level file lock keyed by binding_id (docs/designs/forgexa-
10297
10597
  # ide-design.md §6.3): the DB lease (orchestrator.py) prevents
@@ -10387,6 +10687,34 @@ class RuntimeDaemon:
10387
10687
  logger.info("Workspace ready: %s", workspace_path)
10388
10688
  self._ensure_task_output_dir(workspace_path, task)
10389
10689
 
10690
+ try:
10691
+ if self._task_attachments_to_stage(task):
10692
+ await reporter.report_progress(
10693
+ task.task_id,
10694
+ 8,
10695
+ "preparing_attachments",
10696
+ output_lines=["[daemon] Preparing task attachments"],
10697
+ )
10698
+ attachment_staging_dir = await self._stage_task_attachments(task, conn)
10699
+ except _TaskAttachmentDownloadError as exc:
10700
+ logger.warning("Task %s could not stage attachments: %s", task.task_id, exc)
10701
+ await reporter.report_progress(
10702
+ task.task_id,
10703
+ 8,
10704
+ "attachment_download_failed",
10705
+ output_lines=["[daemon] Required task attachment could not be downloaded"],
10706
+ )
10707
+ await report_task_complete(TaskResult(
10708
+ status="failed",
10709
+ exit_code=-1,
10710
+ stdout="",
10711
+ stderr="",
10712
+ error=str(exc),
10713
+ failure_code="attachment_download_failed",
10714
+ execution_attempt_id=task.execution_attempt_id,
10715
+ ))
10716
+ return
10717
+
10390
10718
  # 2.1 Workspace health check: detect broken checkout — skipped for
10391
10719
  # local_bind (the user's own real directory, not a disposable
10392
10720
  # checkout the daemon controls). See
@@ -11131,6 +11459,8 @@ class RuntimeDaemon:
11131
11459
  execution_attempt_id=task.execution_attempt_id,
11132
11460
  ))
11133
11461
  finally:
11462
+ if attachment_staging_dir is not None:
11463
+ shutil.rmtree(attachment_staging_dir, ignore_errors=True)
11134
11464
  if workspace_execution_lock is not None:
11135
11465
  workspace_execution_lock.release()
11136
11466
  project_lock.release()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.48.2
3
+ Version: 1.48.3
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.48.2"
3
+ version = "1.48.3"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes