agent-orchestration-process 0.2.1__tar.gz → 0.2.2__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. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/PKG-INFO +1 -1
  2. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/pyproject.toml +1 -1
  3. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/pyproject.toml.orig +1 -1
  4. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/cli.py +61 -2
  5. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/models.py +13 -0
  6. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/provider_versions.py +6 -0
  7. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/runner.py +198 -34
  8. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/LICENSE +0 -0
  9. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/README.md +0 -0
  10. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/__init__.py +0 -0
  11. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/__main__.py +0 -0
  12. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/batch.py +0 -0
  13. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/codex_auth.py +0 -0
  14. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/codex_routes.py +0 -0
  15. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/dsh_runner.mjs +0 -0
  16. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/egress.py +0 -0
  17. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/egress_bridge.py +0 -0
  18. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/integration.py +0 -0
  19. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/isolation.py +0 -0
  20. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/locks.py +0 -0
  21. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/migration.py +0 -0
  22. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/model_catalog.py +0 -0
  23. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/model_listing.py +0 -0
  24. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/pricing.py +0 -0
  25. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/prompt_transport.py +0 -0
  26. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/worktrees.py +0 -0
  27. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/zcode.py +0 -0
  28. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.2.2}/src/agent_orchestration_process/zcode_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-orchestration-process
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance
5
5
  Keywords: agent-orchestration,ai-agents,autoresearch,coding-agents,git-worktrees
6
6
  Author: AOP contributors
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "agent-orchestration-process"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "agent-orchestration-process"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -23,7 +23,13 @@ from .locks import exclusive_lock, task_lock_path
23
23
  from .model_catalog import ModelCatalog, ensure_catalog_fresh
24
24
  from .model_listing import AGENTS, AvailableModel, list_models
25
25
  from .models import PromptPart, RunRequest, RunResult
26
- from .runner import AgentRunner, RunStore, adapter_for, provider_runtime_record
26
+ from .runner import (
27
+ AgentRunner,
28
+ RunStore,
29
+ adapter_for,
30
+ plan_inputs,
31
+ provider_runtime_record,
32
+ )
27
33
  from .migration import migrate_runs, migration_roots, print_counts
28
34
  from .worktrees import (
29
35
  AOPError,
@@ -396,6 +402,25 @@ def main(argv: Sequence[str] | None = None) -> int:
396
402
  input_names=tuple(Path(path).name for path in args.input_paths),
397
403
  ).to_dict()
398
404
  preview["provider"] = args.agent
405
+ planned = plan_inputs(
406
+ args.input_paths,
407
+ excludes=args.input_excludes,
408
+ skip_external_links=args.skip_external_links,
409
+ protected=(manager.state_dir, manager.sealed_runtime_dir),
410
+ )
411
+ preview["inputs"]["planned"] = [
412
+ {
413
+ "name": plan.alias,
414
+ "source": os.fspath(plan.source),
415
+ "files": len(plan.entries) if plan.entries else 1,
416
+ "size_bytes": sum(path.stat().st_size for _, path in plan.entries)
417
+ if plan.source.is_dir()
418
+ else plan.source.stat().st_size,
419
+ "excluded": list(plan.excluded),
420
+ "links": list(plan.links),
421
+ }
422
+ for plan in planned
423
+ ]
399
424
  preview["provider_runtime"] = provider_runtime_record(
400
425
  adapter_for(args.agent)
401
426
  )
@@ -424,6 +449,8 @@ def main(argv: Sequence[str] | None = None) -> int:
424
449
  timeout_seconds=args.timeout,
425
450
  artifacts=args.artifact,
426
451
  input_paths=args.input_paths,
452
+ input_excludes=args.input_excludes,
453
+ skip_external_links=args.skip_external_links,
427
454
  system_prompt=_read_system_prompt(args),
428
455
  tools=args.tools,
429
456
  provider_base_url=args.provider_base_url,
@@ -465,7 +492,11 @@ def main(argv: Sequence[str] | None = None) -> int:
465
492
  print(f"aop: {error}", file=sys.stderr)
466
493
  return 2
467
494
  except FileNotFoundError as error:
468
- print(f"aop: command not found: {error.filename}", file=sys.stderr)
495
+ print(
496
+ f"aop: command not found: {error.filename} "
497
+ f"(PATH={os.environ.get('PATH', '')})",
498
+ file=sys.stderr,
499
+ )
469
500
  return 127
470
501
  except KeyboardInterrupt:
471
502
  print("aop: interrupted", file=sys.stderr)
@@ -514,6 +545,25 @@ def _add_input_arguments(
514
545
  metavar="PATH",
515
546
  help=help_text,
516
547
  )
548
+ if resume:
549
+ return
550
+ parser.add_argument(
551
+ "--input-exclude",
552
+ action="append",
553
+ default=[],
554
+ dest="input_excludes",
555
+ metavar="GLOB",
556
+ help="leave matching paths out of every input directory; a pattern "
557
+ "without '/' matches names at any depth, one with '/' matches paths "
558
+ "from the input's root; repeat for more",
559
+ )
560
+ parser.add_argument(
561
+ "--skip-external-links",
562
+ action="store_true",
563
+ help="leave out symlinks that point outside their input instead of "
564
+ "failing; links that stay inside an input are always copied as their "
565
+ "targets",
566
+ )
517
567
 
518
568
 
519
569
  def _tool_list(value: str) -> tuple[str, ...]:
@@ -606,6 +656,15 @@ def _report_policy(
606
656
  f"{prefix}inputs: {inputs['mode']} ({len(inputs.get('names', []))} declared)",
607
657
  file=stream,
608
658
  )
659
+ for item in inputs.get("planned", []):
660
+ skipped = sum(link["action"] == "skipped" for link in item["links"])
661
+ print(
662
+ f"{prefix} {item['name']}: {item['files']} files, "
663
+ f"{item['size_bytes'] / 1e6:.1f} MB; {len(item['excluded'])} paths "
664
+ f"excluded; {len(item['links']) - skipped} symlinks followed, "
665
+ f"{skipped} skipped",
666
+ file=stream,
667
+ )
609
668
  print(
610
669
  f"{prefix}inherited local instructions: {instructions['inherited_local']}; "
611
670
  f"provider built-in prompt: {instructions['provider_builtin']}; "
@@ -28,11 +28,19 @@ class InputSnapshot:
28
28
  size_bytes: int
29
29
  sha256: str
30
30
  files: tuple[InputFile, ...]
31
+ # Paths left out of the snapshot, relative to the input: matches of the
32
+ # run's input excludes and AOP's own state directories.
33
+ excluded: tuple[str, ...] = ()
34
+ # Symlinks found in the input: {"path", "target", "action"}, where action
35
+ # is "followed" for a target inside the input and "skipped" otherwise.
36
+ links: tuple[dict[str, str], ...] = ()
31
37
 
32
38
  @classmethod
33
39
  def from_dict(cls, value: dict[str, Any]) -> InputSnapshot:
34
40
  fields = dict(value)
35
41
  fields["files"] = tuple(InputFile(**item) for item in fields.get("files", ()))
42
+ fields["excluded"] = tuple(fields.get("excluded", ()))
43
+ fields["links"] = tuple(fields.get("links", ()))
36
44
  return cls(**fields)
37
45
 
38
46
 
@@ -102,6 +110,10 @@ class RunRequest:
102
110
  # The prompt split into content blocks, in order; their lengths add up to
103
111
  # len(prompt). None sends the prompt as one block.
104
112
  prompt_parts: tuple[PromptPart, ...] | None = None
113
+ # Glob patterns left out of every input directory, and whether symlinks
114
+ # that leave their input are skipped instead of failing the run.
115
+ input_excludes: tuple[str, ...] = ()
116
+ skip_external_links: bool = False
105
117
  # How `aop migrate-results` converted an older record, if it did.
106
118
  migration: dict[str, Any] | None = None
107
119
 
@@ -118,6 +130,7 @@ class RunRequest:
118
130
  if fields.get("tools") is not None:
119
131
  fields["tools"] = tuple(fields["tools"])
120
132
  fields["allowed_urls"] = tuple(fields.get("allowed_urls", ()))
133
+ fields["input_excludes"] = tuple(fields.get("input_excludes", ()))
121
134
  if fields.get("prompt_parts") is not None:
122
135
  fields["prompt_parts"] = tuple(
123
136
  PromptPart(**part) for part in fields["prompt_parts"]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import re
6
7
  import subprocess
7
8
 
@@ -24,6 +25,11 @@ def require_supported_agy(binary: str) -> str:
24
25
  capture_output=True,
25
26
  timeout=5,
26
27
  )
28
+ except FileNotFoundError as error:
29
+ raise AOPError(
30
+ f"{binary} not found on PATH ({os.environ.get('PATH', '')}); set "
31
+ "AOP_AGY_BIN to its absolute path"
32
+ ) from error
27
33
  except OSError as error:
28
34
  raise AOPError(f"could not query Agy version: {error}") from error
29
35
  except subprocess.TimeoutExpired as error:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import ast
6
6
  import contextlib
7
+ import fnmatch
7
8
  import hashlib
8
9
  import json
9
10
  import os
@@ -1075,7 +1076,10 @@ def _capture_process(
1075
1076
  prompt: str | None,
1076
1077
  timeout_seconds: float | None,
1077
1078
  is_response: Callable[[str], bool],
1079
+ live_dir: Path | None = None,
1078
1080
  ) -> _ProcessCapture:
1081
+ """Run a harness to completion; with `live_dir`, its stdout and stderr also
1082
+ grow in live_dir/events.jsonl and live_dir/stderr.log while it runs."""
1079
1083
  started = time.monotonic()
1080
1084
  timed_out = False
1081
1085
  first_event_seconds = None
@@ -1106,6 +1110,11 @@ def _capture_process(
1106
1110
 
1107
1111
  stdout_parts: list[str] = []
1108
1112
  stderr_parts: list[str] = []
1113
+ live = contextlib.ExitStack()
1114
+ live_stdout = live_stderr = None
1115
+ if live_dir is not None:
1116
+ live_stdout = live.enter_context((live_dir / "events.jsonl").open("w"))
1117
+ live_stderr = live.enter_context((live_dir / "stderr.log").open("w"))
1109
1118
 
1110
1119
  def read_stdout() -> None:
1111
1120
  nonlocal first_event_seconds, first_response_seconds
@@ -1117,10 +1126,17 @@ def _capture_process(
1117
1126
  if first_response_seconds is None and is_response(line):
1118
1127
  first_response_seconds = elapsed
1119
1128
  stdout_parts.append(line)
1129
+ if live_stdout is not None:
1130
+ live_stdout.write(line)
1131
+ live_stdout.flush()
1120
1132
 
1121
1133
  def read_stderr() -> None:
1122
1134
  assert process.stderr is not None
1123
- stderr_parts.extend(process.stderr)
1135
+ for line in process.stderr:
1136
+ stderr_parts.append(line)
1137
+ if live_stderr is not None:
1138
+ live_stderr.write(line)
1139
+ live_stderr.flush()
1124
1140
 
1125
1141
  stdout_reader = threading.Thread(target=read_stdout, daemon=True)
1126
1142
  stderr_reader = threading.Thread(target=read_stderr, daemon=True)
@@ -1150,6 +1166,8 @@ def _capture_process(
1150
1166
  _close_finished_streams(
1151
1167
  (stdout_reader, process.stdout), (stderr_reader, process.stderr)
1152
1168
  )
1169
+ if not (stdout_reader.is_alive() or stderr_reader.is_alive()):
1170
+ live.close()
1153
1171
  return _ProcessCapture(
1154
1172
  stdout="".join(stdout_parts),
1155
1173
  stderr="".join(stderr_parts),
@@ -1229,6 +1247,7 @@ class ClaudeAdapter:
1229
1247
  ),
1230
1248
  timeout_seconds=request.timeout_seconds,
1231
1249
  is_response=self._is_response,
1250
+ live_dir=run_dir,
1232
1251
  )
1233
1252
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
1234
1253
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -1868,6 +1887,7 @@ class GrokAdapter:
1868
1887
  prompt=None,
1869
1888
  timeout_seconds=request.timeout_seconds,
1870
1889
  is_response=self._is_response,
1890
+ live_dir=run_dir,
1871
1891
  )
1872
1892
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
1873
1893
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -2201,6 +2221,7 @@ class CursorAdapter:
2201
2221
  prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
2202
2222
  timeout_seconds=request.timeout_seconds,
2203
2223
  is_response=self._is_response,
2224
+ live_dir=run_dir,
2204
2225
  )
2205
2226
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
2206
2227
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -2419,6 +2440,7 @@ class DevinAdapter:
2419
2440
  prompt=None,
2420
2441
  timeout_seconds=request.timeout_seconds,
2421
2442
  is_response=lambda line: bool(line.strip()),
2443
+ live_dir=run_dir,
2422
2444
  )
2423
2445
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
2424
2446
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -2680,6 +2702,7 @@ class OpenCodeAdapter:
2680
2702
  prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
2681
2703
  timeout_seconds=request.timeout_seconds,
2682
2704
  is_response=self._is_response,
2705
+ live_dir=run_dir,
2683
2706
  )
2684
2707
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
2685
2708
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -3040,6 +3063,7 @@ class AgyAdapter:
3040
3063
  prompt=prompt_transport.stdin_prompt(self.provider, request.prompt),
3041
3064
  timeout_seconds=request.timeout_seconds,
3042
3065
  is_response=self._is_response,
3066
+ live_dir=run_dir,
3043
3067
  )
3044
3068
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
3045
3069
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -3324,6 +3348,7 @@ class HermesAdapter:
3324
3348
  prompt=None,
3325
3349
  timeout_seconds=request.timeout_seconds,
3326
3350
  is_response=lambda line: bool(line.strip()),
3351
+ live_dir=run_dir,
3327
3352
  )
3328
3353
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
3329
3354
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -3772,6 +3797,7 @@ class DeepSeekHarnessAdapter:
3772
3797
  prompt=None,
3773
3798
  timeout_seconds=request.timeout_seconds,
3774
3799
  is_response=self._is_response,
3800
+ live_dir=run_dir,
3775
3801
  )
3776
3802
  _atomic_write(run_dir / "events.jsonl", capture.stdout)
3777
3803
  _atomic_write(run_dir / "stderr.log", capture.stderr)
@@ -5786,9 +5812,19 @@ class AgentRunner:
5786
5812
  writable_workspace: bool = False,
5787
5813
  thinking_display: str | None = None,
5788
5814
  prompt_parts: Sequence[PromptPart] | None = None,
5815
+ input_excludes: Sequence[str] = (),
5816
+ skip_external_links: bool = False,
5789
5817
  ) -> RunResult:
5790
5818
  self._validate_mode(mode)
5791
5819
  prompt_parts = self._validated_prompt_parts(prompt, prompt_parts)
5820
+ input_excludes = tuple(input_excludes)
5821
+ # Every input problem fails here, before any state is created.
5822
+ plan_inputs(
5823
+ input_paths,
5824
+ excludes=input_excludes,
5825
+ skip_external_links=skip_external_links,
5826
+ protected=self._state_roots(),
5827
+ )
5792
5828
  if writable_workspace and profile != "sealed":
5793
5829
  raise AOPError("--writable-workspace applies only to the sealed profile")
5794
5830
  if thinking_display is not None:
@@ -5835,6 +5871,8 @@ class AgentRunner:
5835
5871
  writable_workspace=writable_workspace,
5836
5872
  thinking_display=thinking_display,
5837
5873
  prompt_parts=prompt_parts,
5874
+ input_excludes=input_excludes,
5875
+ skip_external_links=skip_external_links,
5838
5876
  )
5839
5877
  worktree = (
5840
5878
  self._create_sealed_workspace(request.run_id)
@@ -5968,6 +6006,8 @@ class AgentRunner:
5968
6006
  writable_workspace=parent_request.writable_workspace,
5969
6007
  thinking_display=parent_request.thinking_display,
5970
6008
  prompt_parts=prompt_parts,
6009
+ input_excludes=parent_request.input_excludes,
6010
+ skip_external_links=parent_request.skip_external_links,
5971
6011
  )
5972
6012
  return self._execute(
5973
6013
  request,
@@ -6084,7 +6124,12 @@ class AgentRunner:
6084
6124
  cache_dir = self.manager.cache_dir
6085
6125
  cache_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
6086
6126
  declared_inputs = _prepare_inputs(
6087
- input_paths,
6127
+ plan_inputs(
6128
+ input_paths,
6129
+ excludes=request.input_excludes,
6130
+ skip_external_links=request.skip_external_links,
6131
+ protected=self._state_roots() if input_provenance is None else (),
6132
+ ),
6088
6133
  input_dir,
6089
6134
  guest_paths=request.profile != "host",
6090
6135
  provenance=input_provenance,
@@ -6325,6 +6370,8 @@ class AgentRunner:
6325
6370
  writable_workspace: bool = False,
6326
6371
  thinking_display: str | None = None,
6327
6372
  prompt_parts: tuple[PromptPart, ...] | None = None,
6373
+ input_excludes: tuple[str, ...] = (),
6374
+ skip_external_links: bool = False,
6328
6375
  ) -> RunRequest:
6329
6376
  return RunRequest(
6330
6377
  run_id=str(uuid.uuid4()),
@@ -6353,8 +6400,14 @@ class AgentRunner:
6353
6400
  writable_workspace=writable_workspace,
6354
6401
  thinking_display=thinking_display,
6355
6402
  prompt_parts=prompt_parts,
6403
+ input_excludes=input_excludes,
6404
+ skip_external_links=skip_external_links,
6356
6405
  )
6357
6406
 
6407
+ def _state_roots(self) -> tuple[Path, ...]:
6408
+ """AOP's own state, which no input snapshot may copy into itself."""
6409
+ return (self.manager.state_dir, self.manager.sealed_runtime_dir)
6410
+
6358
6411
  def _validate_provider_base_url(self, url: str | None, profile: str) -> None:
6359
6412
  variable = PROVIDER_BASE_URL_VARIABLES.get(self.adapter.provider)
6360
6413
  if url is None:
@@ -6617,18 +6670,39 @@ def normalize_artifacts(artifacts: Sequence[str]) -> tuple[str, ...]:
6617
6670
  return tuple(normalized)
6618
6671
 
6619
6672
 
6620
- def _prepare_inputs(
6673
+ @dataclass(frozen=True)
6674
+ class InputPlan:
6675
+ """One declared input, checked and resolved before anything is copied."""
6676
+
6677
+ source: Path
6678
+ alias: str
6679
+ # (path relative to the input, host file to copy); empty for a file input.
6680
+ entries: tuple[tuple[str, Path], ...]
6681
+ excluded: tuple[str, ...]
6682
+ links: tuple[dict[str, str], ...]
6683
+
6684
+
6685
+ def plan_inputs(
6621
6686
  values: Sequence[str | os.PathLike[str]],
6622
- input_dir: Path,
6623
6687
  *,
6624
- guest_paths: bool = True,
6625
- provenance: Sequence[str] | None = None,
6626
- ) -> tuple[InputSnapshot, ...]:
6627
- if provenance is not None and len(provenance) != len(values):
6628
- raise AOPError("input provenance does not match the input snapshot set")
6629
- prepared: list[InputSnapshot] = []
6688
+ excludes: Sequence[str] = (),
6689
+ skip_external_links: bool = False,
6690
+ protected: Sequence[Path] = (),
6691
+ ) -> tuple[InputPlan, ...]:
6692
+ """Check every input as a run would, without copying.
6693
+
6694
+ Directories under `protected` (AOP's own state) are left out of any input
6695
+ that contains them. A symlink whose target lies inside its input is copied
6696
+ as the target's contents; one that leaves the input, or is dangling, fails
6697
+ the run unless `skip_external_links` is set.
6698
+ """
6699
+ for pattern in excludes:
6700
+ if not pattern or "\0" in pattern or pattern.startswith("/"):
6701
+ raise AOPError(f"input exclude must be a relative glob pattern: {pattern!r}")
6702
+ protected = tuple(path.resolve() for path in protected)
6703
+ plans: list[InputPlan] = []
6630
6704
  aliases: set[str] = set()
6631
- for index, value in enumerate(values):
6705
+ for value in values:
6632
6706
  raw = os.fspath(value)
6633
6707
  if not raw or "\0" in raw:
6634
6708
  raise AOPError("input path must be a non-empty filesystem path")
@@ -6648,46 +6722,136 @@ def _prepare_inputs(
6648
6722
  if alias in aliases:
6649
6723
  raise AOPError(f"input paths have the same basename: {alias}")
6650
6724
  aliases.add(alias)
6651
-
6652
- mounted = input_dir / alias
6725
+ if any(source == root or source.is_relative_to(root) for root in protected):
6726
+ raise AOPError(f"input path is inside AOP's state directory: {source}")
6653
6727
  if source.is_dir():
6654
- _copy_input_directory(source, mounted)
6728
+ plans.append(
6729
+ _plan_directory(source, alias, excludes, skip_external_links, protected)
6730
+ )
6731
+ elif source.is_file():
6732
+ plans.append(InputPlan(source, alias, (), (), ()))
6733
+ else:
6734
+ raise AOPError(f"input path is not a regular file or directory: {source}")
6735
+ return tuple(plans)
6736
+
6737
+
6738
+ def _plan_directory(
6739
+ root: Path,
6740
+ alias: str,
6741
+ excludes: Sequence[str],
6742
+ skip_external_links: bool,
6743
+ protected: tuple[Path, ...],
6744
+ ) -> InputPlan:
6745
+ entries: list[tuple[str, Path]] = []
6746
+ excluded: list[str] = []
6747
+ links: list[dict[str, str]] = []
6748
+
6749
+ def is_excluded(relative: str) -> bool:
6750
+ name = relative.rsplit("/", 1)[-1]
6751
+ return any(
6752
+ fnmatch.fnmatchcase(relative, pattern)
6753
+ or ("/" not in pattern and fnmatch.fnmatchcase(name, pattern))
6754
+ for pattern in excludes
6755
+ )
6756
+
6757
+ def is_protected(path: Path) -> bool:
6758
+ return any(path == item or path.is_relative_to(item) for item in protected)
6759
+
6760
+ def walk(directory: Path, relative: PurePosixPath, chain: tuple[Path, ...]) -> None:
6761
+ try:
6762
+ children = sorted(directory.iterdir(), key=lambda child: child.name)
6763
+ except OSError as error:
6764
+ raise AOPError(
6765
+ f"could not read input directory {directory}: {error}"
6766
+ ) from error
6767
+ for child in children:
6768
+ logical = (relative / child.name).as_posix()
6769
+ if is_excluded(logical):
6770
+ excluded.append(logical)
6771
+ continue
6772
+ status = child.lstat()
6773
+ path = child
6774
+ if stat.S_ISLNK(status.st_mode):
6775
+ target = child.resolve()
6776
+ inside = target.is_relative_to(root) and target.exists()
6777
+ if not inside:
6778
+ if not skip_external_links:
6779
+ raise AOPError(
6780
+ f"input contains a symlink that leaves it: {child} -> "
6781
+ f"{os.readlink(child)}; pass --skip-external-links "
6782
+ "to leave such links out, or --input-exclude to "
6783
+ "exclude them"
6784
+ )
6785
+ links.append(
6786
+ {"path": logical, "target": os.readlink(child), "action": "skipped"}
6787
+ )
6788
+ continue
6789
+ if target in chain:
6790
+ raise AOPError(f"input contains a symlink loop: {child}")
6791
+ links.append(
6792
+ {"path": logical, "target": os.readlink(child), "action": "followed"}
6793
+ )
6794
+ path = target
6795
+ status = target.stat()
6796
+ if stat.S_ISDIR(status.st_mode):
6797
+ resolved = path.resolve()
6798
+ if is_protected(resolved):
6799
+ excluded.append(logical)
6800
+ elif resolved in chain:
6801
+ raise AOPError(f"input contains a symlink loop: {child}")
6802
+ else:
6803
+ walk(path, PurePosixPath(logical), (*chain, resolved))
6804
+ elif stat.S_ISREG(status.st_mode):
6805
+ entries.append((logical, path))
6806
+ else:
6807
+ raise AOPError(f"read path contains a special file: {child}")
6808
+
6809
+ walk(root, PurePosixPath(), (root,))
6810
+ return InputPlan(root, alias, tuple(entries), tuple(excluded), tuple(links))
6811
+
6812
+
6813
+ def _prepare_inputs(
6814
+ plans: Sequence[InputPlan],
6815
+ input_dir: Path,
6816
+ *,
6817
+ guest_paths: bool = True,
6818
+ provenance: Sequence[str] | None = None,
6819
+ ) -> tuple[InputSnapshot, ...]:
6820
+ if provenance is not None and len(provenance) != len(plans):
6821
+ raise AOPError("input provenance does not match the input snapshot set")
6822
+ prepared: list[InputSnapshot] = []
6823
+ for index, plan in enumerate(plans):
6824
+ mounted = input_dir / plan.alias
6825
+ if plan.source.is_dir():
6826
+ mounted.mkdir()
6827
+ for relative, path in plan.entries:
6828
+ target = mounted / relative
6829
+ target.parent.mkdir(parents=True, exist_ok=True)
6830
+ shutil.copyfile(path, target)
6655
6831
  else:
6656
- shutil.copyfile(source, mounted)
6832
+ shutil.copyfile(plan.source, mounted)
6657
6833
  kind, files = _inspect_read_path(mounted)
6658
- size_bytes = sum(item.size_bytes for item in files)
6659
6834
  prepared.append(
6660
6835
  InputSnapshot(
6661
6836
  source_path=(
6662
- provenance[index] if provenance is not None else os.fspath(source)
6837
+ provenance[index]
6838
+ if provenance is not None
6839
+ else os.fspath(plan.source)
6663
6840
  ),
6664
6841
  mounted_path=(
6665
- f"/inputs/{alias}" if guest_paths else os.fspath(mounted)
6842
+ f"/inputs/{plan.alias}" if guest_paths else os.fspath(mounted)
6666
6843
  ),
6667
6844
  kind=kind,
6668
- size_bytes=size_bytes,
6845
+ size_bytes=sum(item.size_bytes for item in files),
6669
6846
  sha256=_read_path_digest(kind, files),
6670
6847
  files=files,
6848
+ excluded=plan.excluded,
6849
+ links=plan.links,
6671
6850
  )
6672
6851
  )
6673
6852
  return tuple(prepared)
6674
6853
 
6675
6854
 
6676
- def _copy_input_directory(source: Path, destination: Path) -> None:
6677
- destination.mkdir()
6678
- for child in sorted(source.iterdir(), key=lambda item: item.name):
6679
- status = child.lstat()
6680
- target = destination / child.name
6681
- if stat.S_ISLNK(status.st_mode):
6682
- raise AOPError(f"read path contains a symlink: {child}")
6683
- if stat.S_ISDIR(status.st_mode):
6684
- _copy_input_directory(child, target)
6685
- elif stat.S_ISREG(status.st_mode):
6686
- shutil.copyfile(child, target)
6687
- else:
6688
- raise AOPError(f"read path contains a special file: {child}")
6689
-
6690
-
6691
6855
  def _make_snapshot_private(root: Path) -> None:
6692
6856
  for directory, names, files in os.walk(root):
6693
6857
  path = Path(directory)