agh 0.3.2__tar.gz → 0.3.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 (73) hide show
  1. {agh-0.3.2/agh.egg-info → agh-0.3.3}/PKG-INFO +1 -1
  2. {agh-0.3.2 → agh-0.3.3}/agh/server/routes/packs.py +56 -17
  3. {agh-0.3.2 → agh-0.3.3}/agh/server/routes/projects.py +170 -34
  4. {agh-0.3.2 → agh-0.3.3/agh.egg-info}/PKG-INFO +1 -1
  5. {agh-0.3.2 → agh-0.3.3}/tests/test_pack_routes.py +100 -0
  6. {agh-0.3.2 → agh-0.3.3}/tests/test_pull_manifest_routes.py +230 -0
  7. {agh-0.3.2 → agh-0.3.3}/CONTRIBUTING.md +0 -0
  8. {agh-0.3.2 → agh-0.3.3}/LICENSE +0 -0
  9. {agh-0.3.2 → agh-0.3.3}/MANIFEST.in +0 -0
  10. {agh-0.3.2 → agh-0.3.3}/README.es.md +0 -0
  11. {agh-0.3.2 → agh-0.3.3}/README.md +0 -0
  12. {agh-0.3.2 → agh-0.3.3}/SECURITY.md +0 -0
  13. {agh-0.3.2 → agh-0.3.3}/agh/__init__.py +0 -0
  14. {agh-0.3.2 → agh-0.3.3}/agh/cli/__init__.py +0 -0
  15. {agh-0.3.2 → agh-0.3.3}/agh/cli/agent_integrations.py +0 -0
  16. {agh-0.3.2 → agh-0.3.3}/agh/cli/config.py +0 -0
  17. {agh-0.3.2 → agh-0.3.3}/agh/cli/main.py +0 -0
  18. {agh-0.3.2 → agh-0.3.3}/agh/cli/pack_init.py +0 -0
  19. {agh-0.3.2 → agh-0.3.3}/agh/cli/pack_publish.py +0 -0
  20. {agh-0.3.2 → agh-0.3.3}/agh/cli/pack_refs.py +0 -0
  21. {agh-0.3.2 → agh-0.3.3}/agh/cli/project_refs.py +0 -0
  22. {agh-0.3.2 → agh-0.3.3}/agh/cli/pull_markers.py +0 -0
  23. {agh-0.3.2 → agh-0.3.3}/agh/cli/pull_plan.py +0 -0
  24. {agh-0.3.2 → agh-0.3.3}/agh/cli/user_refs.py +0 -0
  25. {agh-0.3.2 → agh-0.3.3}/agh/cli/workspace_pull.py +0 -0
  26. {agh-0.3.2 → agh-0.3.3}/agh/cli/workspace_sync.py +0 -0
  27. {agh-0.3.2 → agh-0.3.3}/agh/common/__init__.py +0 -0
  28. {agh-0.3.2 → agh-0.3.3}/agh/common/checksums.py +0 -0
  29. {agh-0.3.2 → agh-0.3.3}/agh/common/ids.py +0 -0
  30. {agh-0.3.2 → agh-0.3.3}/agh/common/pack_manifest.py +0 -0
  31. {agh-0.3.2 → agh-0.3.3}/agh/common/repo_url.py +0 -0
  32. {agh-0.3.2 → agh-0.3.3}/agh/common/validation.py +0 -0
  33. {agh-0.3.2 → agh-0.3.3}/agh/server/__init__.py +0 -0
  34. {agh-0.3.2 → agh-0.3.3}/agh/server/app.py +0 -0
  35. {agh-0.3.2 → agh-0.3.3}/agh/server/auth.py +0 -0
  36. {agh-0.3.2 → agh-0.3.3}/agh/server/db.py +0 -0
  37. {agh-0.3.2 → agh-0.3.3}/agh/server/migrations/001_initial_schema.sql +0 -0
  38. {agh-0.3.2 → agh-0.3.3}/agh/server/migrations/002_unique_project_names.sql +0 -0
  39. {agh-0.3.2 → agh-0.3.3}/agh/server/migrations/__init__.py +0 -0
  40. {agh-0.3.2 → agh-0.3.3}/agh/server/routes/__init__.py +0 -0
  41. {agh-0.3.2 → agh-0.3.3}/agh/server/routes/users.py +0 -0
  42. {agh-0.3.2 → agh-0.3.3}/agh.egg-info/SOURCES.txt +0 -0
  43. {agh-0.3.2 → agh-0.3.3}/agh.egg-info/dependency_links.txt +0 -0
  44. {agh-0.3.2 → agh-0.3.3}/agh.egg-info/entry_points.txt +0 -0
  45. {agh-0.3.2 → agh-0.3.3}/agh.egg-info/requires.txt +0 -0
  46. {agh-0.3.2 → agh-0.3.3}/agh.egg-info/top_level.txt +0 -0
  47. {agh-0.3.2 → agh-0.3.3}/assets/agh-workspace-demo.gif +0 -0
  48. {agh-0.3.2 → agh-0.3.3}/assets/agh-workspace-demo.tape +0 -0
  49. {agh-0.3.2 → agh-0.3.3}/pyproject.toml +0 -0
  50. {agh-0.3.2 → agh-0.3.3}/scripts/install.sh +0 -0
  51. {agh-0.3.2 → agh-0.3.3}/setup.cfg +0 -0
  52. {agh-0.3.2 → agh-0.3.3}/tests/conftest.py +0 -0
  53. {agh-0.3.2 → agh-0.3.3}/tests/test_agent_command.py +0 -0
  54. {agh-0.3.2 → agh-0.3.3}/tests/test_api_errors.py +0 -0
  55. {agh-0.3.2 → agh-0.3.3}/tests/test_auth_bootstrap.py +0 -0
  56. {agh-0.3.2 → agh-0.3.3}/tests/test_cli_admin_commands.py +0 -0
  57. {agh-0.3.2 → agh-0.3.3}/tests/test_cli_login.py +0 -0
  58. {agh-0.3.2 → agh-0.3.3}/tests/test_cli_pack_commands.py +0 -0
  59. {agh-0.3.2 → agh-0.3.3}/tests/test_cli_pull.py +0 -0
  60. {agh-0.3.2 → agh-0.3.3}/tests/test_common_helpers.py +0 -0
  61. {agh-0.3.2 → agh-0.3.3}/tests/test_db_migrations.py +0 -0
  62. {agh-0.3.2 → agh-0.3.3}/tests/test_docs_guidance.py +0 -0
  63. {agh-0.3.2 → agh-0.3.3}/tests/test_install_script.py +0 -0
  64. {agh-0.3.2 → agh-0.3.3}/tests/test_integration_smoke.py +0 -0
  65. {agh-0.3.2 → agh-0.3.3}/tests/test_project_pack_assignments.py +0 -0
  66. {agh-0.3.2 → agh-0.3.3}/tests/test_project_routes.py +0 -0
  67. {agh-0.3.2 → agh-0.3.3}/tests/test_pull_markers.py +0 -0
  68. {agh-0.3.2 → agh-0.3.3}/tests/test_pull_plan.py +0 -0
  69. {agh-0.3.2 → agh-0.3.3}/tests/test_scaffold.py +0 -0
  70. {agh-0.3.2 → agh-0.3.3}/tests/test_user_routes.py +0 -0
  71. {agh-0.3.2 → agh-0.3.3}/tests/test_workspace_pull.py +0 -0
  72. {agh-0.3.2 → agh-0.3.3}/tests/test_workspace_sync.py +0 -0
  73. {agh-0.3.2 → agh-0.3.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agh
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Self-hosted guidance distribution for coding agents
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/giulianotesta7/AgentGuidanceHub
@@ -7,8 +7,9 @@ import json
7
7
  import os
8
8
  import shutil
9
9
  import sqlite3
10
+ import stat as stat_module
10
11
  from pathlib import Path, PurePosixPath
11
- from typing import Any
12
+ from typing import Any, NoReturn
12
13
 
13
14
  from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
14
15
  from pydantic import BaseModel, ValidationError
@@ -31,6 +32,8 @@ MAX_PACK_PATH_LENGTH = 240
31
32
  MAX_PACK_FILE_BYTES = 256 * 1024
32
33
  MAX_PACK_TOTAL_BYTES = 1024 * 1024
33
34
  MAX_PACK_PUBLISH_BODY_BYTES = MAX_PACK_TOTAL_BYTES + (MAX_PACK_FILES * 128)
35
+ PACK_ARTIFACT_MISSING_DETAIL = "pack file not found"
36
+ PACK_ARTIFACT_STORAGE_DETAIL = "pack artifact storage unavailable"
34
37
 
35
38
 
36
39
  class PackPublish(BaseModel):
@@ -76,6 +79,31 @@ def _pack_version_resolve_response(row: sqlite3.Row) -> dict[str, str]:
76
79
  }
77
80
 
78
81
 
82
+ def _raise_pack_artifact_missing() -> NoReturn:
83
+ raise HTTPException(
84
+ status_code=status.HTTP_404_NOT_FOUND,
85
+ detail=PACK_ARTIFACT_MISSING_DETAIL,
86
+ )
87
+
88
+
89
+ def _raise_pack_artifact_storage_unavailable(
90
+ exc: OSError | UnicodeDecodeError,
91
+ ) -> NoReturn:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
94
+ detail=PACK_ARTIFACT_STORAGE_DETAIL,
95
+ ) from exc
96
+
97
+
98
+ def _read_published_pack_file(storage_dir: Path, safe_path: Path) -> str:
99
+ candidate = storage_dir / safe_path
100
+ _require_pack_artifact_read_target(storage_dir, candidate)
101
+ try:
102
+ return candidate.read_text(encoding="utf-8")
103
+ except (OSError, UnicodeDecodeError) as exc:
104
+ _raise_pack_artifact_storage_unavailable(exc)
105
+
106
+
79
107
  def _parse_pack_version_ref_or_400(value: str) -> PackVersionRef:
80
108
  try:
81
109
  return parse_pack_version_ref(value, allow_latest=False)
@@ -280,23 +308,16 @@ def get_pack_file(
280
308
  safe_path = _safe_relative_path(file_path)
281
309
  except PackManifestError as exc:
282
310
  raise HTTPException(
283
- status_code=status.HTTP_404_NOT_FOUND, detail="pack file not found"
311
+ status_code=status.HTTP_404_NOT_FOUND, detail=PACK_ARTIFACT_MISSING_DETAIL
284
312
  ) from exc
285
313
  connection = _connect(request)
286
314
  try:
287
315
  row = _find_pack_version(connection, domain, name, version)
288
316
  if row is None:
289
- raise HTTPException(
290
- status_code=status.HTTP_404_NOT_FOUND, detail="pack file not found"
291
- )
317
+ _raise_pack_artifact_missing()
292
318
  storage_dir = Path(row["storage_path"])
293
- candidate = storage_dir / safe_path
294
- if not _is_safe_pack_file(storage_dir, candidate):
295
- raise HTTPException(
296
- status_code=status.HTTP_404_NOT_FOUND, detail="pack file not found"
297
- )
298
319
  return Response(
299
- candidate.read_text(encoding="utf-8"),
320
+ _read_published_pack_file(storage_dir, safe_path),
300
321
  media_type="text/plain; charset=utf-8",
301
322
  )
302
323
  finally:
@@ -557,19 +578,37 @@ def _pack_checksum(pack_dir: Path) -> str:
557
578
  return f"sha256:{digest.hexdigest()}"
558
579
 
559
580
 
560
- def _is_safe_pack_file(storage_dir: Path, candidate: Path) -> bool:
581
+ def _require_pack_artifact_read_target(storage_dir: Path, candidate: Path) -> None:
561
582
  try:
562
583
  resolved_root = storage_dir.resolve(strict=True)
563
584
  resolved_candidate = candidate.resolve(strict=True)
564
585
  resolved_candidate.relative_to(resolved_root)
565
- except (OSError, ValueError):
566
- return False
567
- if not resolved_candidate.is_file():
568
- return False
586
+ except ValueError:
587
+ _raise_pack_artifact_missing()
588
+ except (FileNotFoundError, NotADirectoryError):
589
+ _raise_pack_artifact_missing()
590
+ except OSError as exc:
591
+ _raise_pack_artifact_storage_unavailable(exc)
592
+ try:
593
+ candidate_stat = resolved_candidate.stat()
594
+ except (FileNotFoundError, NotADirectoryError):
595
+ _raise_pack_artifact_missing()
596
+ except OSError as exc:
597
+ _raise_pack_artifact_storage_unavailable(exc)
598
+ if not stat_module.S_ISREG(candidate_stat.st_mode):
599
+ _raise_pack_artifact_missing()
569
600
  current = candidate
570
601
  paths: list[Path] = []
571
602
  while current != storage_dir:
572
603
  paths.append(current)
573
604
  current = current.parent
574
605
  paths.append(storage_dir)
575
- return not any(path.is_symlink() for path in paths)
606
+ for path in paths:
607
+ try:
608
+ path_stat = path.lstat()
609
+ except (FileNotFoundError, NotADirectoryError):
610
+ _raise_pack_artifact_missing()
611
+ except OSError as exc:
612
+ _raise_pack_artifact_storage_unavailable(exc)
613
+ if stat_module.S_ISLNK(path_stat.st_mode):
614
+ _raise_pack_artifact_missing()
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import sqlite3
7
7
  from pathlib import Path
8
- from typing import Any
8
+ from typing import Any, NoReturn
9
9
  from urllib.parse import quote
10
10
 
11
11
  from fastapi import ( # pyright: ignore[reportMissingImports]
@@ -329,23 +329,78 @@ def _pack_file_download_url(domain: str, name: str, version: str, path: str) ->
329
329
  return f"/api/v1/packs/{domain}/{name}/versions/{version}/files/{quoted_path}"
330
330
 
331
331
 
332
- def _read_pack_file(storage_dir: Path, relative_path: str) -> str | None:
332
+ def _raise_pack_artifact_missing() -> NoReturn:
333
+ raise HTTPException(
334
+ status_code=status.HTTP_404_NOT_FOUND,
335
+ detail="pack file not found",
336
+ )
337
+
338
+
339
+ def _raise_pack_artifact_storage_unavailable(
340
+ exc: OSError | UnicodeDecodeError,
341
+ ) -> NoReturn:
342
+ raise HTTPException(
343
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
344
+ detail="pack artifact storage unavailable",
345
+ ) from exc
346
+
347
+
348
+ def _missing_pack_artifact_or_none(required: bool) -> None:
349
+ if required:
350
+ _raise_pack_artifact_missing()
351
+ return None
352
+
353
+
354
+ def _read_pack_file(
355
+ storage_dir: Path, relative_path: str, *, required: bool
356
+ ) -> str | None:
333
357
  candidate = storage_dir / relative_path
334
358
  try:
335
359
  resolved_root = storage_dir.resolve(strict=True)
336
360
  resolved_candidate = candidate.resolve(strict=True)
337
361
  resolved_candidate.relative_to(resolved_root)
338
- except (OSError, ValueError):
339
- return None
362
+ except ValueError:
363
+ return _missing_pack_artifact_or_none(required)
364
+ except (FileNotFoundError, NotADirectoryError):
365
+ return _missing_pack_artifact_or_none(required)
366
+ except OSError as exc:
367
+ _raise_pack_artifact_storage_unavailable(exc)
340
368
  if not resolved_candidate.is_file():
341
- return None
342
- return resolved_candidate.read_text(encoding="utf-8")
369
+ return _missing_pack_artifact_or_none(required)
370
+ if _pack_artifact_path_has_symlink_component(storage_dir, candidate):
371
+ return _missing_pack_artifact_or_none(required)
372
+ try:
373
+ return resolved_candidate.read_text(encoding="utf-8")
374
+ except (OSError, UnicodeDecodeError) as exc:
375
+ _raise_pack_artifact_storage_unavailable(exc)
376
+
377
+
378
+ def _pack_artifact_path_has_symlink_component(
379
+ storage_dir: Path, candidate: Path
380
+ ) -> bool:
381
+ current = candidate
382
+ while True:
383
+ try:
384
+ is_symlink = current.is_symlink()
385
+ except OSError as exc:
386
+ _raise_pack_artifact_storage_unavailable(exc)
387
+ if is_symlink:
388
+ return True
389
+ if current == storage_dir:
390
+ return False
391
+ current = current.parent
343
392
 
344
393
 
345
394
  def _instruction_artifact(
346
- *, domain: str, name: str, version: str, storage_dir: Path, path: str
395
+ *,
396
+ domain: str,
397
+ name: str,
398
+ version: str,
399
+ storage_dir: Path,
400
+ path: str,
401
+ required: bool,
347
402
  ) -> dict[str, str] | None:
348
- content = _read_pack_file(storage_dir, path)
403
+ content = _read_pack_file(storage_dir, path, required=required)
349
404
  if content is None:
350
405
  return None
351
406
  target_agent = "opencode" if path.endswith("AGENTS.md") else "claude"
@@ -361,42 +416,109 @@ def _instruction_artifact(
361
416
 
362
417
 
363
418
  def _skill_artifacts(
364
- *, domain: str, name: str, version: str, storage_dir: Path
419
+ *,
420
+ domain: str,
421
+ name: str,
422
+ version: str,
423
+ storage_dir: Path,
424
+ expected_paths: list[str] | None = None,
365
425
  ) -> list[dict[str, str]]:
426
+ if expected_paths is not None:
427
+ artifacts: list[dict[str, str]] = []
428
+ for path in sorted(expected_paths):
429
+ artifacts.extend(
430
+ _skill_artifacts_for_path(
431
+ domain=domain,
432
+ name=name,
433
+ version=version,
434
+ storage_dir=storage_dir,
435
+ path=path,
436
+ required=True,
437
+ )
438
+ )
439
+ return artifacts
440
+
366
441
  skills_dir = storage_dir / "skills"
367
442
  if not skills_dir.is_dir() or skills_dir.is_symlink():
368
443
  return []
369
444
  artifacts: list[dict[str, str]] = []
370
445
  for skill_dir in sorted(item for item in skills_dir.iterdir() if item.is_dir()):
371
446
  path = f"skills/{skill_dir.name}/SKILL.md"
372
- content = _read_pack_file(storage_dir, path)
373
- if content is None:
374
- continue
375
- checksum = managed_payload_checksum(content)
376
- download_url = _pack_file_download_url(domain, name, version, path)
377
447
  artifacts.extend(
378
- [
379
- {
380
- "kind": "skill",
381
- "path": path,
382
- "target_agent": "opencode",
383
- "target_path": f".opencode/skills/{skill_dir.name}/SKILL.md",
384
- "checksum": checksum,
385
- "download_url": download_url,
386
- },
387
- {
388
- "kind": "skill",
389
- "path": path,
390
- "target_agent": "claude",
391
- "target_path": f".claude/skills/{skill_dir.name}/SKILL.md",
392
- "checksum": checksum,
393
- "download_url": download_url,
394
- },
395
- ]
448
+ _skill_artifacts_for_path(
449
+ domain=domain,
450
+ name=name,
451
+ version=version,
452
+ storage_dir=storage_dir,
453
+ path=path,
454
+ required=False,
455
+ )
396
456
  )
397
457
  return artifacts
398
458
 
399
459
 
460
+ def _skill_artifacts_for_path(
461
+ *,
462
+ domain: str,
463
+ name: str,
464
+ version: str,
465
+ storage_dir: Path,
466
+ path: str,
467
+ required: bool,
468
+ ) -> list[dict[str, str]]:
469
+ skill_name = _skill_name_from_artifact_path(path)
470
+ if skill_name is None:
471
+ return []
472
+ content = _read_pack_file(storage_dir, path, required=required)
473
+ if content is None:
474
+ return []
475
+ checksum = managed_payload_checksum(content)
476
+ download_url = _pack_file_download_url(domain, name, version, path)
477
+ return [
478
+ {
479
+ "kind": "skill",
480
+ "path": path,
481
+ "target_agent": "opencode",
482
+ "target_path": f".opencode/skills/{skill_name}/SKILL.md",
483
+ "checksum": checksum,
484
+ "download_url": download_url,
485
+ },
486
+ {
487
+ "kind": "skill",
488
+ "path": path,
489
+ "target_agent": "claude",
490
+ "target_path": f".claude/skills/{skill_name}/SKILL.md",
491
+ "checksum": checksum,
492
+ "download_url": download_url,
493
+ },
494
+ ]
495
+
496
+
497
+ def _skill_name_from_artifact_path(path: str) -> str | None:
498
+ parts = path.split("/")
499
+ if len(parts) == 3 and parts[0] == "skills" and parts[1] and parts[2] == "SKILL.md":
500
+ return parts[1]
501
+ return None
502
+
503
+
504
+ def _expected_artifact_paths(manifest: dict[str, Any]) -> set[str] | None:
505
+ raw_paths = manifest.get("artifact_paths")
506
+ if raw_paths is None:
507
+ return None
508
+ if not isinstance(raw_paths, list):
509
+ return None
510
+ if not raw_paths or not all(
511
+ isinstance(path, str)
512
+ and (
513
+ path in {"instructions/AGENTS.md", "instructions/CLAUDE.md"}
514
+ or _skill_name_from_artifact_path(path) is not None
515
+ )
516
+ for path in raw_paths
517
+ ):
518
+ return None
519
+ return set(raw_paths)
520
+
521
+
400
522
  def _pull_manifest_pack(
401
523
  connection: sqlite3.Connection, row: sqlite3.Row
402
524
  ) -> dict[str, Any]:
@@ -407,6 +529,8 @@ def _pull_manifest_pack(
407
529
  domain = str(row["domain"])
408
530
  name = str(row["name"])
409
531
  storage_dir = Path(str(version_row["storage_path"]))
532
+ manifest = json.loads(str(version_row["manifest_json"]))
533
+ expected_paths = _expected_artifact_paths(manifest)
410
534
  artifacts: list[dict[str, str]] = []
411
535
  for instruction_path in ["instructions/AGENTS.md", "instructions/CLAUDE.md"]:
412
536
  artifact = _instruction_artifact(
@@ -415,19 +539,31 @@ def _pull_manifest_pack(
415
539
  version=version,
416
540
  storage_dir=storage_dir,
417
541
  path=instruction_path,
542
+ required=expected_paths is not None and instruction_path in expected_paths,
418
543
  )
419
544
  if artifact is not None:
420
545
  artifacts.append(artifact)
546
+ expected_skill_paths = None
547
+ if expected_paths is not None:
548
+ expected_skill_paths = [
549
+ path for path in expected_paths if _skill_name_from_artifact_path(path)
550
+ ]
421
551
  artifacts.extend(
422
552
  _skill_artifacts(
423
- domain=domain, name=name, version=version, storage_dir=storage_dir
553
+ domain=domain,
554
+ name=name,
555
+ version=version,
556
+ storage_dir=storage_dir,
557
+ expected_paths=expected_skill_paths,
424
558
  )
425
559
  )
426
560
  return {
427
561
  "id": f"{domain}/{name}@{version}",
428
562
  "assignment_id": row["id"],
429
563
  "position": int(row["position"]),
430
- "manifest": json.loads(str(version_row["manifest_json"])),
564
+ "manifest": {
565
+ key: value for key, value in manifest.items() if key != "artifact_paths"
566
+ },
431
567
  "artifacts": artifacts,
432
568
  }
433
569
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agh
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Self-hosted guidance distribution for coding agents
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/giulianotesta7/AgentGuidanceHub
@@ -62,6 +62,18 @@ def _publish(client: TestClient, token: str, files: dict[str, str]):
62
62
  return client.post("/api/v1/packs", json={"files": files}, headers=_auth(token))
63
63
 
64
64
 
65
+ def _stored_agents_file(tmp_path: Path) -> Path:
66
+ return (
67
+ tmp_path
68
+ / "packs"
69
+ / "acme"
70
+ / "onboarding"
71
+ / "1.0.0"
72
+ / "instructions"
73
+ / "AGENTS.md"
74
+ )
75
+
76
+
65
77
  def test_owner_publishes_lists_and_downloads_pack_files(
66
78
  tmp_path: Path, monkeypatch
67
79
  ) -> None:
@@ -561,6 +573,7 @@ def test_pack_file_download_rejects_traversal_and_symlinks(
561
573
  headers=_auth(owner_token),
562
574
  )
563
575
  assert traversal.status_code == 404
576
+ assert traversal.json() == {"detail": "pack file not found"}
564
577
 
565
578
  outside = tmp_path / "outside-secret.txt"
566
579
  outside.write_text("secret", encoding="utf-8")
@@ -571,3 +584,90 @@ def test_pack_file_download_rejects_traversal_and_symlinks(
571
584
  headers=_auth(owner_token),
572
585
  )
573
586
  assert leak.status_code == 404
587
+ assert leak.json() == {"detail": "pack file not found"}
588
+
589
+
590
+ def test_pack_file_download_missing_artifact_returns_json_404(
591
+ tmp_path: Path, monkeypatch
592
+ ) -> None:
593
+ client, owner_token = _client_with_owner(tmp_path, monkeypatch)
594
+ assert _publish(client, owner_token, _pack_files()).status_code == 201
595
+ stored_file = _stored_agents_file(tmp_path)
596
+ stored_file.unlink()
597
+
598
+ response = client.get(
599
+ "/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
600
+ headers=_auth(owner_token),
601
+ )
602
+
603
+ assert response.status_code == 404
604
+ assert response.json() == {"detail": "pack file not found"}
605
+
606
+
607
+ def test_pack_file_download_unreadable_artifact_returns_json_503(
608
+ tmp_path: Path, monkeypatch
609
+ ) -> None:
610
+ client, owner_token = _client_with_owner(tmp_path, monkeypatch)
611
+ assert _publish(client, owner_token, _pack_files()).status_code == 201
612
+ stored_file = _stored_agents_file(tmp_path)
613
+ stored_file.write_bytes(b"\xff\xfe\x00")
614
+
615
+ response = client.get(
616
+ "/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
617
+ headers=_auth(owner_token),
618
+ )
619
+
620
+ assert response.status_code == 503
621
+ assert response.json() == {"detail": "pack artifact storage unavailable"}
622
+
623
+
624
+ def test_pack_file_download_read_error_returns_json_503(
625
+ tmp_path: Path, monkeypatch
626
+ ) -> None:
627
+ client, owner_token = _client_with_owner(tmp_path, monkeypatch)
628
+ assert _publish(client, owner_token, _pack_files()).status_code == 201
629
+ stored_file = _stored_agents_file(tmp_path)
630
+ original_read_text = Path.read_text
631
+
632
+ def fail_candidate_read_text(
633
+ self: Path,
634
+ encoding: str | None = None,
635
+ errors: str | None = None,
636
+ ) -> str:
637
+ if self == stored_file and encoding == "utf-8":
638
+ raise OSError("simulated read failure")
639
+ return original_read_text(self, encoding=encoding, errors=errors)
640
+
641
+ monkeypatch.setattr(Path, "read_text", fail_candidate_read_text)
642
+
643
+ response = client.get(
644
+ "/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
645
+ headers=_auth(owner_token),
646
+ )
647
+
648
+ assert response.status_code == 503
649
+ assert response.json() == {"detail": "pack artifact storage unavailable"}
650
+
651
+
652
+ def test_pack_file_download_path_resolution_error_returns_json_503(
653
+ tmp_path: Path, monkeypatch
654
+ ) -> None:
655
+ client, owner_token = _client_with_owner(tmp_path, monkeypatch)
656
+ assert _publish(client, owner_token, _pack_files()).status_code == 201
657
+ stored_file = _stored_agents_file(tmp_path)
658
+ original_resolve = Path.resolve
659
+
660
+ def fail_candidate_resolve(self: Path, strict: bool = False) -> Path:
661
+ if self == stored_file and strict:
662
+ raise OSError("simulated path resolution failure")
663
+ return original_resolve(self, strict=strict)
664
+
665
+ monkeypatch.setattr(Path, "resolve", fail_candidate_resolve)
666
+
667
+ response = client.get(
668
+ "/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
669
+ headers=_auth(owner_token),
670
+ )
671
+
672
+ assert response.status_code == 503
673
+ assert response.json() == {"detail": "pack artifact storage unavailable"}
@@ -2,13 +2,17 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import shutil
5
7
  from pathlib import Path
6
8
  from typing import Any
7
9
 
10
+ import pytest
8
11
  from fastapi.testclient import TestClient
9
12
 
10
13
  from agh.common.checksums import managed_payload_checksum
11
14
  from agh.server.app import create_app
15
+ from agh.server.db import connect_database, get_database_path
12
16
 
13
17
 
14
18
  def _client_with_owner(tmp_path: Path, monkeypatch) -> tuple[TestClient, str]:
@@ -118,6 +122,57 @@ def _assign_pack(
118
122
  return response.json()
119
123
 
120
124
 
125
+ def _publish_and_assign(
126
+ tmp_path: Path, monkeypatch, ref: str, **files: str | None
127
+ ) -> tuple[TestClient, str, dict[str, Any]]:
128
+ client, owner_token = _client_with_owner(tmp_path, monkeypatch)
129
+ project = _create_project(client, owner_token)
130
+ _publish_pack(client, owner_token, ref, **files)
131
+ _assign_pack(client, owner_token, project["id"], ref)
132
+ return client, owner_token, project
133
+
134
+
135
+ def _pull_manifest(client: TestClient, token: str, project: dict[str, Any]) -> Any:
136
+ return client.get(
137
+ f"/api/v1/projects/{project['id']}/pull-manifest",
138
+ headers=_auth(token),
139
+ )
140
+
141
+
142
+ def _assert_skill_artifacts(artifacts: list[dict[str, Any]], content: str) -> None:
143
+ assert {artifact["target_path"] for artifact in artifacts} == {
144
+ ".opencode/skills/lint/SKILL.md",
145
+ ".claude/skills/lint/SKILL.md",
146
+ }
147
+ assert all(
148
+ artifact["checksum"] == managed_payload_checksum(content)
149
+ for artifact in artifacts
150
+ )
151
+
152
+
153
+ def _store_manifest_artifact_paths(tmp_path: Path, artifact_paths: Any) -> None:
154
+ connection = connect_database(get_database_path(tmp_path))
155
+ try:
156
+ row = connection.execute(
157
+ "SELECT id, manifest_json FROM pack_versions"
158
+ ).fetchone()
159
+ manifest = json.loads(row["manifest_json"])
160
+ manifest["artifact_paths"] = artifact_paths
161
+ connection.execute(
162
+ "UPDATE pack_versions SET manifest_json = ? WHERE id = ?",
163
+ (json.dumps(manifest, sort_keys=True), row["id"]),
164
+ )
165
+ connection.commit()
166
+ finally:
167
+ connection.close()
168
+
169
+
170
+ def _stored_pack_path(tmp_path: Path, ref: str, relative_path: str) -> Path:
171
+ pair, version = ref.split("@", 1)
172
+ domain, name = pair.split("/", 1)
173
+ return tmp_path / "packs" / domain / name / version / relative_path
174
+
175
+
121
176
  def test_pull_manifest_resolves_latest_orders_assignments_and_builds_artifacts(
122
177
  tmp_path: Path, monkeypatch
123
178
  ) -> None:
@@ -221,6 +276,181 @@ def test_pull_manifest_includes_skill_only_pack_artifacts(
221
276
  )
222
277
 
223
278
 
279
+ def test_pull_manifest_legacy_missing_discovered_skill_file_is_skipped(
280
+ tmp_path: Path, monkeypatch
281
+ ) -> None:
282
+ agents = "# Guide\n"
283
+ client, owner_token, project = _publish_and_assign(
284
+ tmp_path,
285
+ monkeypatch,
286
+ "acme/legacy@1.0.0",
287
+ agents=agents,
288
+ skill="# Lint\n",
289
+ )
290
+ stored_skill = _stored_pack_path(
291
+ tmp_path, "acme/legacy@1.0.0", "skills/lint/SKILL.md"
292
+ )
293
+ stored_skill.unlink()
294
+
295
+ response = _pull_manifest(client, owner_token, project)
296
+
297
+ assert response.status_code == 200, response.text
298
+ artifacts = response.json()["packs"][0]["artifacts"]
299
+ assert [artifact["path"] for artifact in artifacts] == ["instructions/AGENTS.md"]
300
+ assert artifacts[0]["checksum"] == managed_payload_checksum(agents)
301
+
302
+
303
+ def test_pull_manifest_legacy_missing_optional_instruction_file_is_skipped(
304
+ tmp_path: Path, monkeypatch
305
+ ) -> None:
306
+ agents = "# OpenCode Guide\n"
307
+ client, owner_token, project = _publish_and_assign(
308
+ tmp_path,
309
+ monkeypatch,
310
+ "acme/legacy@1.0.0",
311
+ agents=agents,
312
+ claude="# Claude Guide\n",
313
+ skill=None,
314
+ )
315
+ stored_claude = _stored_pack_path(
316
+ tmp_path, "acme/legacy@1.0.0", "instructions/CLAUDE.md"
317
+ )
318
+ stored_claude.unlink()
319
+
320
+ response = _pull_manifest(client, owner_token, project)
321
+
322
+ assert response.status_code == 200, response.text
323
+ artifacts = response.json()["packs"][0]["artifacts"]
324
+ assert [artifact["path"] for artifact in artifacts] == ["instructions/AGENTS.md"]
325
+ assert artifacts[0]["checksum"] == managed_payload_checksum(agents)
326
+
327
+
328
+ def test_pull_manifest_expected_instruction_read_oserror_returns_json_503(
329
+ tmp_path: Path, monkeypatch
330
+ ) -> None:
331
+ client, owner_token, project = _publish_and_assign(
332
+ tmp_path,
333
+ monkeypatch,
334
+ "acme/current@1.0.0",
335
+ agents="# OpenCode Guide\n",
336
+ claude=None,
337
+ skill=None,
338
+ )
339
+ _store_manifest_artifact_paths(tmp_path, ["instructions/AGENTS.md"])
340
+ stored_agents = _stored_pack_path(
341
+ tmp_path, "acme/current@1.0.0", "instructions/AGENTS.md"
342
+ )
343
+ original_read_text = Path.read_text
344
+
345
+ def fail_expected_agents_read(path: Path, *args: Any, **kwargs: Any) -> str:
346
+ if path == stored_agents:
347
+ raise OSError("simulated storage read failure")
348
+ return original_read_text(path, *args, **kwargs)
349
+
350
+ monkeypatch.setattr(Path, "read_text", fail_expected_agents_read)
351
+
352
+ response = _pull_manifest(client, owner_token, project)
353
+
354
+ assert response.status_code == 503
355
+ assert response.json() == {"detail": "pack artifact storage unavailable"}
356
+
357
+
358
+ @pytest.mark.parametrize("artifact_paths", ["skills/lint/SKILL.md", ["not/a/thing"]])
359
+ def test_pull_manifest_malformed_artifact_paths_uses_legacy_discovery(
360
+ tmp_path: Path, monkeypatch, artifact_paths: Any
361
+ ) -> None:
362
+ skill_content = "# Lint\nUse lint skill.\n"
363
+ client, owner_token, project = _publish_and_assign(
364
+ tmp_path, monkeypatch, "acme/legacy@1.0.0", skill=skill_content
365
+ )
366
+ _store_manifest_artifact_paths(tmp_path, artifact_paths)
367
+
368
+ response = _pull_manifest(client, owner_token, project)
369
+
370
+ assert response.status_code == 200, response.text
371
+ _assert_skill_artifacts(response.json()["packs"][0]["artifacts"], skill_content)
372
+
373
+
374
+ @pytest.mark.parametrize(
375
+ ("ref", "files", "artifact_paths", "missing_path", "remove_tree"),
376
+ [
377
+ (
378
+ "acme/guide@1.0.0",
379
+ {"agents": "# Guide\n"},
380
+ ["instructions/AGENTS.md"],
381
+ "instructions/AGENTS.md",
382
+ False,
383
+ ),
384
+ (
385
+ "acme/mixed@1.0.0",
386
+ {"agents": "# Guide\n", "skill": "# Lint\n"},
387
+ ["instructions/AGENTS.md", "skills/lint/SKILL.md"],
388
+ "skills",
389
+ True,
390
+ ),
391
+ ],
392
+ )
393
+ def test_pull_manifest_expected_missing_storage_returns_json_404(
394
+ tmp_path: Path,
395
+ monkeypatch,
396
+ ref: str,
397
+ files: dict[str, str],
398
+ artifact_paths: list[str],
399
+ missing_path: str,
400
+ remove_tree: bool,
401
+ ) -> None:
402
+ client, owner_token, project = _publish_and_assign(
403
+ tmp_path, monkeypatch, ref, **files
404
+ )
405
+ _store_manifest_artifact_paths(tmp_path, artifact_paths)
406
+ stored_path = _stored_pack_path(tmp_path, ref, missing_path)
407
+ shutil.rmtree(stored_path) if remove_tree else stored_path.unlink()
408
+
409
+ response = _pull_manifest(client, owner_token, project)
410
+
411
+ assert response.status_code == 404
412
+ assert response.json() == {"detail": "pack file not found"}
413
+
414
+
415
+ def test_pull_manifest_expected_symlink_artifact_returns_json_404(
416
+ tmp_path: Path, monkeypatch
417
+ ) -> None:
418
+ client, owner_token, project = _publish_and_assign(
419
+ tmp_path, monkeypatch, "acme/skills@1.0.0", skill="# Lint\n"
420
+ )
421
+ _store_manifest_artifact_paths(tmp_path, ["skills/lint/SKILL.md"])
422
+ stored_skill = _stored_pack_path(
423
+ tmp_path, "acme/skills@1.0.0", "skills/lint/SKILL.md"
424
+ )
425
+ stored_skill.unlink()
426
+ stored_skill.symlink_to(
427
+ _stored_pack_path(tmp_path, "acme/skills@1.0.0", "agh.pack.toml")
428
+ )
429
+
430
+ response = _pull_manifest(client, owner_token, project)
431
+
432
+ assert response.status_code == 404
433
+ assert response.json() == {"detail": "pack file not found"}
434
+
435
+
436
+ def test_pull_manifest_unreadable_expected_artifact_returns_json_503(
437
+ tmp_path: Path, monkeypatch
438
+ ) -> None:
439
+ client, owner_token, project = _publish_and_assign(
440
+ tmp_path, monkeypatch, "acme/skills@1.0.0", skill="# Lint\n"
441
+ )
442
+ _store_manifest_artifact_paths(tmp_path, ["skills/lint/SKILL.md"])
443
+ stored_skill = _stored_pack_path(
444
+ tmp_path, "acme/skills@1.0.0", "skills/lint/SKILL.md"
445
+ )
446
+ stored_skill.write_bytes(b"\xff\xfe\x00")
447
+
448
+ response = _pull_manifest(client, owner_token, project)
449
+
450
+ assert response.status_code == 503
451
+ assert response.json() == {"detail": "pack artifact storage unavailable"}
452
+
453
+
224
454
  def test_pull_manifest_developer_access_and_non_member_denial(
225
455
  tmp_path: Path, monkeypatch
226
456
  ) -> None:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes