autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1107 @@
1
+ """Which host resources a launch gets, by launch class (ADR 0005 §2.5, #168).
2
+
3
+ The engine-layer mapping of D13.2. The engine supplies its facts as data
4
+ (:class:`LaunchFacts`), a provider declares what its CLI needs as data
5
+ (:class:`ProviderRequirements`, D13.1), and :func:`sandbox_request` turns the
6
+ two into the compiler's :class:`~autoforge.sandbox.SandboxRequest`. The
7
+ launch class alone decides which rows of §2.5's table apply: there is no
8
+ provider branch here and no workflow decision, and a provider never emits a
9
+ mount (D7.1).
10
+
11
+ - **REMOTE agent:** the issue worktree and its own admin dir writable, as
12
+ carve-outs; the worktree's ``.git`` file and the admin dir's ``commondir``
13
+ and ``gitdir`` pinned read-only (D6.6); the common dir read-only with
14
+ ``autoforge/``, ``worktrees/`` and ``modules/`` masked; an overlay at
15
+ ``<common>/objects`` whose upper layer is the issue's private object
16
+ directory (D6.1); the read context read-only; the issue's cache at
17
+ ``~/.cache`` for the launches that produce a change.
18
+ - **LOCAL agent and LOCAL validation command:** the repository root
19
+ writable, its ``.git`` entry, git dir and common dir read-only (so a LOCAL
20
+ agent cannot commit, stash or branch), ``<git dir>/autoforge``,
21
+ ``<common>/autoforge`` and ``<common>/worktrees`` masked (the checkout's own
22
+ admin dir carved out read-only); the checkout's cache at ``~/.cache`` for
23
+ the launches that produce a change and for validation.
24
+ - **Export command (pre-merge verification):** the exported tree writable,
25
+ nothing else of the repository, no persistent cache.
26
+
27
+ Every class gets the system runtime, a private home and temp (the
28
+ compiler's, D8.1), the directory #165 points the child's ``gh``, git, curl,
29
+ wget and zsh at (read-only), and the engine's denied roots. Agents run on
30
+ the host network; a repository-defined command runs on an isolated one when
31
+ the operator asks for it (D9.2).
32
+
33
+ :func:`describe_launch` compiles the request and rewrites the launch's
34
+ path-valued environment (D8.2): a name AutoForge knows to be a path passes
35
+ only when what it names is visible inside, ``PATH`` keeps only its visible
36
+ entries, each is spelled where it is inside (a link on its host path the
37
+ sandbox does not recreate, a relocated projection), and every unset name is
38
+ recorded. A refusal of the compiler becomes a launch refusal
39
+ (:class:`~autoforge.errors.SandboxLaunchRefusedError`), on the path a
40
+ failure to spawn takes.
41
+
42
+ :func:`config_file_problem` answers the converse question for the
43
+ controller's own input (#280): whether the ``--config`` file lies where an
44
+ enforced launch writes, the repository's trees and what the providers'
45
+ declarations make writable (:func:`declared_writable`), so that the next
46
+ command would run on what an agent left there.
47
+
48
+ Pure except for the compiler's ``realpath`` and ``lstat``, ``realpath`` of
49
+ the environment's path values and ``lstat`` and ``readlink`` on their way,
50
+ the first line of an executable read for its interpreter, and the
51
+ configuration file's lookup walk (``lstat`` and ``readlink``, from the
52
+ working directory for a relative path), its ``stat`` and link count;
53
+ nothing is created or launched here.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import dataclasses
59
+ import enum
60
+ import os
61
+ import posixpath
62
+ import re
63
+ import shutil
64
+ from collections.abc import Mapping
65
+ from dataclasses import dataclass
66
+ from pathlib import Path
67
+
68
+ from .child_env import OVERRIDE_PATH_NAMES
69
+ from .errors import SandboxLaunchRefusedError, SandboxPolicyError
70
+ from .redaction import redact
71
+ from .sandbox import (
72
+ Access,
73
+ LaunchDescription,
74
+ NetworkPosition,
75
+ Origin,
76
+ OverlayRequest,
77
+ ProjectionRequest,
78
+ SandboxFacts,
79
+ SandboxRequest,
80
+ compile_launch,
81
+ )
82
+
83
+ # Where the controller-owned sandbox directories live (D8.4): under
84
+ # ``<git common dir>/autoforge/sandbox/<key>`` for REMOTE (the key is the
85
+ # worktree's name) and ``<git dir>/autoforge/sandbox/local`` for LOCAL.
86
+ SANDBOX_DIR = "autoforge/sandbox"
87
+ LOCAL_SANDBOX_KEY = "local"
88
+ PRIVATE_OBJECTS = "objects"
89
+ OVERLAY_WORK = "overlay-work"
90
+ CACHE = "cache"
91
+ # The admin dir's pointer files pinned read-only for a REMOTE agent (D6.6).
92
+ PINNED_ADMIN_FILES = ("commondir", "gitdir")
93
+
94
+ # The CA-bundle names of ``DEFAULT_ENV_ALLOWLIST``: path-valued, so they pass
95
+ # into a sandboxed launch only when what they name is visible (D8.2).
96
+ CA_BUNDLE_NAMES = (
97
+ "SSL_CERT_FILE",
98
+ "SSL_CERT_DIR",
99
+ "NODE_EXTRA_CA_CERTS",
100
+ "REQUESTS_CA_BUNDLE",
101
+ "CURL_CA_BUNDLE",
102
+ )
103
+
104
+ # How much of an executable is read to find its interpreter (``#!``).
105
+ _SHEBANG_BYTES = 256
106
+
107
+
108
+ class LaunchClass(enum.Enum):
109
+ """The rows of ADR 0005 §2.5 a launch gets."""
110
+
111
+ REMOTE_AGENT = "REMOTE agent"
112
+ LOCAL_AGENT = "LOCAL agent"
113
+ LOCAL_VALIDATION = "LOCAL validation command"
114
+ EXPORT_COMMAND = "export command"
115
+
116
+
117
+ class AuthMode(enum.Enum):
118
+ """How a provider's model credential reaches a sandboxed launch (D7.1)."""
119
+
120
+ NONE = "none"
121
+ # A variable of the launch's environment (passed by the allow-list,
122
+ # subject to #165's refusals); nothing to project.
123
+ ENV = "env"
124
+ # One regular file, read-only, at its host path or a declared path in
125
+ # the private home.
126
+ FILE_RO = "file-ro"
127
+ # One provider state directory, writable, never on the provider's
128
+ # operator configuration root (D7.2).
129
+ DIR_RW = "dir-rw"
130
+ # A keychain or Secret Service login: declared so that it is refused.
131
+ KEYRING = "keyring"
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class AuthItem:
136
+ """One declared credential item (D7.1): what it is, never its contents.
137
+
138
+ ``source`` is the host path (``file-ro``, ``dir-rw``) or the login's
139
+ name (``keyring``); ``target`` the path inside, ``None`` for the host
140
+ path; ``env_name`` a variable that names the item, set to its path
141
+ inside.
142
+ """
143
+
144
+ mode: AuthMode
145
+ label: str
146
+ source: str = ""
147
+ target: str | None = None
148
+ env_name: str = ""
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class ProviderRequirements:
153
+ """What a provider's CLI needs from the host, as data (ADR 0005 D13.1).
154
+
155
+ ``executable`` is the resolved host path the launch names (empty: the
156
+ command is left as it is); ``install_roots`` and ``config`` are
157
+ projected read-only where their host paths resolve, ``home_config``
158
+ (files in the operator's home the CLI finds by that path) read-only at
159
+ that path in the private home, wherever it resolves on the host (C7,
160
+ D8.3), ``state`` (directories the adapter made for this launch)
161
+ writable; ``path_dirs`` are put first on ``PATH``; ``path_names`` are
162
+ the provider's path-valued variables (D8.2). ``operator_config_roots``
163
+ are where the CLI loads the operator's own settings, hooks and
164
+ extensions from (D7.2). ``spelled_paths`` are paths the launch names as
165
+ written (a script's absolute ``#!`` interpreter): each leads inside
166
+ where it leads on the host, through the links on its way (#225).
167
+ """
168
+
169
+ executable: str = ""
170
+ install_roots: tuple[str, ...] = ()
171
+ path_dirs: tuple[str, ...] = ()
172
+ spelled_paths: tuple[str, ...] = ()
173
+ config: tuple[str, ...] = ()
174
+ home_config: tuple[str, ...] = ()
175
+ state: tuple[str, ...] = ()
176
+ auth: tuple[AuthItem, ...] = ()
177
+ operator_config_roots: tuple[str, ...] = ()
178
+ path_names: tuple[str, ...] = ()
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class LaunchFacts:
183
+ """The controller's facts about one launch, as data (ADR 0005 D13.2).
184
+
185
+ ``workspace`` is what the launch works in (the issue worktree, the
186
+ repository root, the exported tree). ``git_entry`` is the workspace's
187
+ ``.git``; ``git_dir`` is its own git directory (a REMOTE worktree's
188
+ admin dir, a LOCAL checkout's git dir) and ``common_dir`` the shared
189
+ one. ``private_objects`` and ``overlay_work`` are the issue's object
190
+ overlay layers, ``cache`` the persistent cache when the launch gets one
191
+ (empty: its cache is inside the private home), ``read_context`` the
192
+ launch's read context, ``controller_config`` the directory #165's
193
+ overrides point into. ``home`` is the operator's home and
194
+ ``controller_env`` the controller's environment, for D4.3.
195
+ ``operator_paths`` are ``sandbox.read_only_paths``, as configured
196
+ (D15.4): read-only for every launch class.
197
+ """
198
+
199
+ launch_class: LaunchClass
200
+ workspace: str
201
+ home: str
202
+ controller_env: Mapping[str, str]
203
+ git_entry: str = ""
204
+ git_dir: str = ""
205
+ common_dir: str = ""
206
+ private_objects: str = ""
207
+ overlay_work: str = ""
208
+ cache: str = ""
209
+ read_context: str = ""
210
+ controller_config: str = ""
211
+ denied_roots: tuple[str, ...] = ()
212
+ network: NetworkPosition = NetworkPosition.HOST
213
+ operator_paths: tuple[str, ...] = ()
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class SandboxPlan:
218
+ """What the engine hands a provider for one agent launch: the facts.
219
+
220
+ The provider adds its declaration for each process it starts (the
221
+ agent, Pi's auth preflight) and gets the description to launch it with.
222
+ """
223
+
224
+ facts: LaunchFacts
225
+
226
+ def describe(self, needs: ProviderRequirements, env: Mapping[str, str]) -> LaunchDescription:
227
+ """The description of one process of the launch (:func:`describe_launch`)."""
228
+ return describe_launch(self.facts, needs, env)
229
+
230
+
231
+ def _inside(path: str, root: str) -> bool:
232
+ return path == root or path.startswith(root.rstrip("/") + "/")
233
+
234
+
235
+ def sandbox_request(
236
+ facts: LaunchFacts, needs: ProviderRequirements | None = None
237
+ ) -> SandboxRequest:
238
+ """§2.5's rows for ``facts.launch_class``, with the provider's declaration.
239
+
240
+ ``needs`` applies to an agent launch only; a repository-defined command
241
+ gets no provider resource whatever is passed.
242
+ """
243
+ projections: list[ProjectionRequest] = []
244
+ masks: list[str] = []
245
+ overlays: list[OverlayRequest] = []
246
+ writable, read_only = Access.WRITABLE, Access.READ_ONLY
247
+
248
+ def engine(
249
+ path: str,
250
+ access: Access,
251
+ label: str,
252
+ *,
253
+ carve_out: bool = True,
254
+ target: str | None = None,
255
+ ) -> None:
256
+ projections.append(
257
+ ProjectionRequest(
258
+ path, access, Origin.ENGINE, target=target, carve_out=carve_out, label=label
259
+ )
260
+ )
261
+
262
+ kind = facts.launch_class
263
+ common = facts.common_dir
264
+ cache_target = posixpath.join(facts.home, ".cache")
265
+ if kind is LaunchClass.REMOTE_AGENT:
266
+ # The worktree lies in the masked ``<common>/autoforge`` unless
267
+ # ``execution.worktree_dir`` puts it elsewhere, where it is no carve-out.
268
+ in_common = _inside(facts.workspace, common)
269
+ engine(facts.workspace, writable, "the issue worktree", carve_out=in_common)
270
+ engine(facts.git_entry, read_only, "the worktree's .git file", carve_out=in_common)
271
+ engine(common, read_only, "the git common dir")
272
+ engine(facts.git_dir, writable, "the worktree's admin dir")
273
+ for name in PINNED_ADMIN_FILES:
274
+ pinned = posixpath.join(facts.git_dir, name)
275
+ if os.path.isfile(pinned) and not os.path.islink(pinned):
276
+ engine(pinned, read_only, f"the admin dir's {name}")
277
+ masks += [posixpath.join(common, name) for name in ("autoforge", "worktrees", "modules")]
278
+ overlays.append(
279
+ OverlayRequest(
280
+ posixpath.join(common, "objects"),
281
+ facts.private_objects,
282
+ facts.overlay_work,
283
+ carve_out=True,
284
+ label="the shared object store",
285
+ )
286
+ )
287
+ if facts.read_context:
288
+ engine(facts.read_context, read_only, "the read context")
289
+ elif kind in (LaunchClass.LOCAL_AGENT, LaunchClass.LOCAL_VALIDATION):
290
+ engine(facts.workspace, writable, "the repository root", carve_out=False)
291
+ engine(facts.git_entry, read_only, "the root's .git", carve_out=False)
292
+ if facts.git_dir != facts.git_entry:
293
+ # A linked worktree: its admin dir, carved out of the masked
294
+ # ``<common>/worktrees``.
295
+ engine(facts.git_dir, read_only, "the checkout's git dir")
296
+ if common not in (facts.git_entry, facts.git_dir):
297
+ engine(common, read_only, "the git common dir")
298
+ for path in (
299
+ posixpath.join(facts.git_dir, "autoforge"),
300
+ posixpath.join(common, "autoforge"),
301
+ posixpath.join(common, "worktrees"),
302
+ ):
303
+ if path not in masks:
304
+ masks.append(path)
305
+ else:
306
+ engine(facts.workspace, writable, "the exported tree", carve_out=False)
307
+ if facts.cache and kind is not LaunchClass.EXPORT_COMMAND:
308
+ engine(facts.cache, writable, "the cache", target=cache_target)
309
+ if facts.controller_config:
310
+ engine(
311
+ facts.controller_config,
312
+ read_only,
313
+ "the launch's gh, git and shell configuration",
314
+ carve_out=False,
315
+ )
316
+
317
+ agent = kind in (LaunchClass.REMOTE_AGENT, LaunchClass.LOCAL_AGENT)
318
+ declared = _declared(needs if agent else None, facts.operator_paths, facts.home)
319
+ projections += declared.projections
320
+ network = NetworkPosition.HOST if agent else facts.network
321
+ return SandboxRequest(
322
+ home=facts.home,
323
+ controller_env=facts.controller_env,
324
+ projections=tuple(projections),
325
+ masks=tuple(masks),
326
+ overlays=tuple(overlays),
327
+ denied_roots=tuple(dict.fromkeys(facts.denied_roots)),
328
+ operator_config_roots=declared.operator_roots,
329
+ keyring_items=declared.keyring,
330
+ spelled_paths=declared.spelled,
331
+ network=network,
332
+ env=declared.env,
333
+ )
334
+
335
+
336
+ OPERATOR_PATHS_KEY = "sandbox.read_only_paths"
337
+
338
+
339
+ def operator_path_problem(operator_paths: tuple[str, ...], denied_roots: tuple[str, ...]) -> str:
340
+ """The first operator read-only path that overlaps a denied root, as a reason; "" if none.
341
+
342
+ ADR 0005 D15.4: an operator path may not name, sit inside or contain
343
+ what no launch may see -- the state directory, the lock, the
344
+ controller's directory under the git common dir, another worktree, the
345
+ operator's checkout in REMOTE. The loader cannot know these (they are
346
+ the run's), so the engine's gate and ``doctor`` ask before a launch
347
+ would; the compiler refuses the same overlap at the launch regardless.
348
+ Both sides are compared resolved, as the compiler compares them.
349
+ """
350
+ denied = [(root, os.path.realpath(root)) for root in denied_roots]
351
+ for index, path in enumerate(operator_paths):
352
+ real = os.path.realpath(path)
353
+ for root, resolved in denied:
354
+ if _inside(real, resolved) or _inside(resolved, real):
355
+ return (
356
+ f"{OPERATOR_PATHS_KEY}[{index}] {path!r} overlaps {root!r}, which no "
357
+ "launch may see (ADR 0005 D15.4: the state directory, the lock, the "
358
+ "controller's directory under the git common dir, another worktree, and "
359
+ "in REMOTE the operator's checkout)"
360
+ )
361
+ return ""
362
+
363
+
364
+ # The links a lookup follows before it is a loop, as the kernel counts them
365
+ # (``MAXSYMLINKS``): past it ``open`` fails, so nothing further is reached.
366
+ _MAX_LOOKUP_LINKS = 40
367
+
368
+
369
+ def absolute_spelling(path: str | os.PathLike[str]) -> str:
370
+ """``path`` as the configuration loader opens it, made absolute with every ``..`` kept (#280).
371
+
372
+ The loader opens ``Path(path)``, which drops only ``.`` components and
373
+ repeated slashes, and a relative path from the working directory: so
374
+ the working directory is joined to it and nothing else is collapsed.
375
+ ``os.path.abspath`` would remove ``name/..`` before ``name`` is
376
+ followed, and when ``name`` is a symbolic link what is left names
377
+ another file than the one ``open`` reaches. Whatever checks where the
378
+ file lies, records it or hands it to another process spells it so.
379
+ """
380
+ return os.fspath(Path(path).absolute())
381
+
382
+
383
+ def lookup_directories(path: str) -> tuple[str, ...]:
384
+ """Each directory, resolved, in which opening ``path`` looks up a name (#280).
385
+
386
+ Whoever can write one of them can replace what ``path`` opens: move the
387
+ entry away and put another file or a link in its place. A relative path
388
+ is walked from the working directory, and the path is taken as spelled
389
+ (:func:`absolute_spelling`): a ``..`` leads to the parent of the
390
+ directory the walk has reached, any link before it already followed, as
391
+ the kernel takes it, and a directory a name was looked up in stays
392
+ listed when a ``..`` leads back out of it. Symbolic links are followed
393
+ as the kernel does (a relative target from the directory holding the
394
+ link, an absolute one from ``/``), so a link inside a tree and a link
395
+ into one both show. It stops at a name that is not there, and after more
396
+ links than a lookup follows: nothing past either is reached yet, and the
397
+ directory the name was looked up in is already listed. ``lstat`` and
398
+ ``readlink`` only; nothing is opened.
399
+ """
400
+ return _lookup(path)[0]
401
+
402
+
403
+ def _lookup(path: str) -> tuple[tuple[str, ...], str]:
404
+ """:func:`lookup_directories`, and the entry the walk ends at, resolved.
405
+
406
+ The entry is "" when the walk stops before the last name.
407
+ """
408
+ looked_up: list[str] = []
409
+ current = "/"
410
+ pending = [part for part in absolute_spelling(path).split("/") if part]
411
+ links = 0
412
+ while pending:
413
+ name = pending.pop(0)
414
+ if name == ".":
415
+ continue
416
+ if name == "..":
417
+ current = posixpath.dirname(current)
418
+ continue
419
+ if current not in looked_up:
420
+ looked_up.append(current)
421
+ entry = posixpath.join(current, name)
422
+ try:
423
+ is_link = os.path.islink(entry)
424
+ if not is_link and not os.path.lexists(entry):
425
+ return tuple(looked_up), ""
426
+ target = os.readlink(entry) if is_link else ""
427
+ except OSError:
428
+ return tuple(looked_up), ""
429
+ if not is_link:
430
+ current = entry
431
+ continue
432
+ links += 1
433
+ if links > _MAX_LOOKUP_LINKS:
434
+ return tuple(looked_up), ""
435
+ if target.startswith("/"):
436
+ current = "/"
437
+ pending[:0] = [part for part in target.split("/") if part]
438
+ return tuple(looked_up), current
439
+
440
+
441
+ def declared_writable(needs: ProviderRequirements) -> tuple[tuple[str, str], ...]:
442
+ """Each host directory a provider's declaration makes writable, with what it is (#280).
443
+
444
+ Its state directories and the directory of every ``dir-rw`` credential
445
+ item, as :func:`sandbox_request` projects them read-write: what a
446
+ launch of the provider writes on the host besides the engine's roots,
447
+ a dedicated ``CLAUDE_CONFIG_DIR``, ``PI_CODING_AGENT_DIR`` or OpenCode
448
+ database directory among them. Each is ``(path, phrase)``, the path as
449
+ declared.
450
+ """
451
+ state = [(path, f"a state directory its provider declares ({path})") for path in needs.state]
452
+ auth = [(item.source, item.label) for item in needs.auth if item.mode is AuthMode.DIR_RW]
453
+ return (*state, *auth)
454
+
455
+
456
+ def config_file_problem(
457
+ config_file: str,
458
+ written_roots: tuple[str, ...],
459
+ mode: str,
460
+ declared: Mapping[str, str] | None = None,
461
+ ) -> str:
462
+ """Why an enforced launch could rewrite the configuration file, as a refusal; "" if none.
463
+
464
+ ``written_roots`` are the trees the repository's enforced launches write
465
+ and that outlive the launch: its working trees (the checkout, and every
466
+ worktree registered to it, the agent worktrees included) and its sandbox
467
+ directories (the caches). ``declared`` are
468
+ the directories the providers' declarations make writable
469
+ (:func:`declared_writable`), each with the phrase that says what it is
470
+ and whose launches write it. A file is within reach when opening it
471
+ looks up a name in one of either (:func:`lookup_directories`), and when
472
+ it has a second hard link, which may lie in one (ADR 0005 D4.4). The
473
+ next command would run on what such a file says: the sandbox mode, the
474
+ controller's GitHub command and the commands of every profile
475
+ ``doctor`` checks (#280). Both sides are compared resolved.
476
+
477
+ ``config_file`` is the path the loader was given, relative or not. It
478
+ is walked as spelled (:func:`absolute_spelling`), never normalized
479
+ first: a ``..`` after a link is taken where the link leads, so a file
480
+ in a root is not passed under the name of one beside it, and a path
481
+ that looks up a name in a root and leaves it again is within reach
482
+ there. The file examined is the one the path opens: the walk has to end
483
+ at it, or nothing shows where it is reached from and it is refused, and
484
+ its own link count is read.
485
+ """
486
+ config_file = absolute_spelling(config_file)
487
+ direct = os.path.realpath(config_file) == config_file
488
+ roots = [(root, os.path.realpath(root), "") for root in written_roots]
489
+ roots += [(root, os.path.realpath(root), what) for root, what in (declared or {}).items()]
490
+ walked, reached = _lookup(config_file)
491
+ for directory in walked:
492
+ for root, resolved, what in roots:
493
+ if _inside(directory, resolved):
494
+ # The deepest directory of the walk in this root names the lookup.
495
+ deepest = [d for d in walked if _inside(d, resolved)][-1]
496
+ where = "lies in" if direct else f"is reached through {deepest}, in"
497
+ if what:
498
+ return (
499
+ f"the configuration file {config_file} {where} {root}, {what} "
500
+ f"(sandbox.mode is {mode}): a sandboxed agent could rewrite it, and the "
501
+ "next command would run on what it says, sandbox.mode, github.command "
502
+ "and the profiles' commands included (#280). Move it outside that "
503
+ "directory and every working tree of this repository (for example to "
504
+ "~/.config/autoforge/) and pass that path with --config, give the "
505
+ "provider another directory, or set sandbox.mode: disabled"
506
+ )
507
+ return (
508
+ f"the configuration file {config_file} {where} {root}, a tree that "
509
+ f"launches inside the outer sandbox write (sandbox.mode is {mode}): a "
510
+ "sandboxed agent could rewrite it, and the next command would run on what "
511
+ "it says, sandbox.mode, github.command and the profiles' commands "
512
+ "included (#280). Move it outside every working tree of this repository "
513
+ "(for example to ~/.config/autoforge/) and pass that path with --config, "
514
+ "or set sandbox.mode: disabled"
515
+ )
516
+ try:
517
+ opened = os.stat(config_file)
518
+ except OSError:
519
+ return ""
520
+ try:
521
+ followed = bool(reached) and os.path.samestat(opened, os.stat(reached))
522
+ except OSError:
523
+ followed = False
524
+ if not followed:
525
+ return _not_followed(config_file, mode)
526
+ links = opened.st_nlink
527
+ if links > 1:
528
+ return (
529
+ f"the configuration file {config_file} has {links} hard links, and another one "
530
+ "may lie in a tree that launches inside the outer sandbox write (sandbox.mode is "
531
+ f"{mode}; ADR 0005 D4.4): a sandboxed agent could rewrite it through that link "
532
+ "(#280). Copy it to a file of its own outside every working tree of this "
533
+ "repository and every directory a provider's launches write, and pass that path "
534
+ "with --config, or set sandbox.mode: disabled"
535
+ )
536
+ return ""
537
+
538
+
539
+ def _not_followed(config_file: str, mode: str) -> str:
540
+ return (
541
+ f"the configuration file {config_file} opens another file than its lookup walk was "
542
+ "followed to, so nothing shows that it lies outside every tree that launches inside "
543
+ f"the outer sandbox write (sandbox.mode is {mode}; #280). Pass the file's own path "
544
+ "with --config, or set sandbox.mode: disabled"
545
+ )
546
+
547
+
548
+ @dataclass(frozen=True)
549
+ class _Declared:
550
+ projections: tuple[ProjectionRequest, ...]
551
+ env: tuple[tuple[str, str], ...]
552
+ keyring: tuple[str, ...]
553
+ operator_roots: tuple[str, ...]
554
+ spelled: tuple[str, ...] = ()
555
+
556
+
557
+ def _declared(
558
+ needs: ProviderRequirements | None, operator_paths: tuple[str, ...], home: str
559
+ ) -> _Declared:
560
+ """The declared projections of a launch: the provider's, then the operator's.
561
+
562
+ Every one is :attr:`Origin.DECLARED`, so C6 decides its overlaps. The
563
+ provider's read-only install roots and configuration are kept outermost,
564
+ as before; an operator path outside the home that one of them already
565
+ shows is dropped for this launch, and a provider root inside an
566
+ operator path is dropped in its favour (both are read-only, so the outer
567
+ one shows the inner). An operator path is projected by its spelling
568
+ (the compiler resolves it, C1), so a launch class without a provider
569
+ sees exactly the configured list; one inside the home appears at that
570
+ spelling in the private home (C7, D15.4), as a provider's home
571
+ configuration does.
572
+ """
573
+ projections: list[ProjectionRequest] = []
574
+ env: list[tuple[str, str]] = []
575
+ keyring: list[str] = []
576
+ in_home = [p for p in operator_paths if _inside(p, home) and p != home]
577
+ elsewhere = [p for p in operator_paths if p not in in_home]
578
+ roots = _outermost([*needs.install_roots, *needs.config]) if needs is not None else []
579
+ if roots:
580
+ real = {p: os.path.realpath(p) for p in elsewhere}
581
+ elsewhere = [p for p in elsewhere if not any(_inside(real[p], r) for r in roots)]
582
+ roots = [r for r in roots if not any(_inside(r, real[p]) for p in elsewhere)]
583
+ for path in roots:
584
+ projections.append(
585
+ ProjectionRequest(path, Access.READ_ONLY, Origin.DECLARED, label=f"{path} (provider)")
586
+ )
587
+ for path in elsewhere:
588
+ projections.append(
589
+ ProjectionRequest(
590
+ path, Access.READ_ONLY, Origin.DECLARED, label=f"{path} ({OPERATOR_PATHS_KEY})"
591
+ )
592
+ )
593
+ for path in in_home:
594
+ projections.append(
595
+ ProjectionRequest(
596
+ path,
597
+ Access.READ_ONLY,
598
+ Origin.DECLARED,
599
+ target=path,
600
+ label=f"{path} ({OPERATOR_PATHS_KEY})",
601
+ )
602
+ )
603
+ if needs is None:
604
+ return _Declared(tuple(projections), (), (), ())
605
+ for path in needs.home_config:
606
+ projections.append(
607
+ ProjectionRequest(
608
+ path, Access.READ_ONLY, Origin.DECLARED, target=path, label=f"{path} (provider)"
609
+ )
610
+ )
611
+ for path in needs.state:
612
+ projections.append(
613
+ ProjectionRequest(path, Access.WRITABLE, Origin.DECLARED, label=f"{path} (provider)")
614
+ )
615
+ for item in needs.auth:
616
+ if item.mode is AuthMode.KEYRING:
617
+ keyring.append(item.source or item.label)
618
+ continue
619
+ if item.mode not in (AuthMode.FILE_RO, AuthMode.DIR_RW):
620
+ continue
621
+ access = Access.WRITABLE if item.mode is AuthMode.DIR_RW else Access.READ_ONLY
622
+ projections.append(
623
+ ProjectionRequest(
624
+ item.source,
625
+ access,
626
+ Origin.DECLARED,
627
+ target=item.target,
628
+ label=f"{item.label} ({item.mode.value})",
629
+ )
630
+ )
631
+ if item.env_name:
632
+ env.append((item.env_name, item.target or item.source))
633
+ return _Declared(
634
+ tuple(projections),
635
+ tuple(env),
636
+ tuple(keyring),
637
+ tuple(needs.operator_config_roots),
638
+ tuple(needs.spelled_paths),
639
+ )
640
+
641
+
642
+ def provider_request(
643
+ needs: ProviderRequirements,
644
+ *,
645
+ home: str,
646
+ controller_env: Mapping[str, str],
647
+ workspace: str,
648
+ operator_paths: tuple[str, ...] = (),
649
+ denied_roots: tuple[str, ...] = (),
650
+ ) -> SandboxRequest:
651
+ """A provider's declaration alone, around a scratch ``workspace`` (ADR 0005 D14.1).
652
+
653
+ What ``doctor`` compiles to say whether a declaration is allowed, and
654
+ launches the provider's ``--version`` in to prove it is enough: the
655
+ rows every agent launch gets from its provider and the operator, the
656
+ workspace writable, and none of a workflow's (no repository, no cache,
657
+ no read context). The same rules as :func:`sandbox_request`'s.
658
+ """
659
+ declared = _declared(needs, operator_paths, home)
660
+ workspace_projection = ProjectionRequest(
661
+ workspace, Access.WRITABLE, Origin.ENGINE, label="the scratch workspace"
662
+ )
663
+ return SandboxRequest(
664
+ home=home,
665
+ controller_env=controller_env,
666
+ projections=(workspace_projection, *declared.projections),
667
+ denied_roots=tuple(dict.fromkeys(denied_roots)),
668
+ operator_config_roots=declared.operator_roots,
669
+ keyring_items=declared.keyring,
670
+ spelled_paths=declared.spelled,
671
+ env=declared.env,
672
+ )
673
+
674
+
675
+ # The phrases :func:`class_summary` shows for a role path (:func:`role`).
676
+ _ROLE_RE = re.compile(r"/@([a-z-]+)")
677
+
678
+
679
+ def _shown(path: str) -> str:
680
+ return _ROLE_RE.sub(lambda m: f"<{m.group(1).replace('-', ' ')}>", path)
681
+
682
+
683
+ def summary_facts(
684
+ launch_class: LaunchClass,
685
+ network: NetworkPosition,
686
+ *,
687
+ cache: bool,
688
+ operator_paths: tuple[str, ...] = (),
689
+ ) -> LaunchFacts:
690
+ """A launch class's facts with every controller path a role name, for display.
691
+
692
+ The shape :meth:`ControllerEngine` builds for a launch of the class
693
+ (D13.2), before anything is created: what the dry-run plan and
694
+ ``doctor`` describe. ``cache`` says whether the launch gets the
695
+ persistent cache (§2.5).
696
+ """
697
+ facts = LaunchFacts(
698
+ launch_class=launch_class,
699
+ workspace=role("workspace"),
700
+ home=role("home"),
701
+ controller_env={},
702
+ controller_config=role("controller-config"),
703
+ denied_roots=(role("denied-roots"),),
704
+ network=network,
705
+ operator_paths=operator_paths,
706
+ cache=role("cache") if cache and launch_class is not LaunchClass.EXPORT_COMMAND else "",
707
+ )
708
+ if launch_class is LaunchClass.EXPORT_COMMAND:
709
+ return facts
710
+ if launch_class is LaunchClass.REMOTE_AGENT:
711
+ return dataclasses.replace(
712
+ facts,
713
+ git_entry=posixpath.join(role("workspace"), ".git"),
714
+ git_dir=role("admin-dir"),
715
+ common_dir=role("common-dir"),
716
+ private_objects=role("private-objects"),
717
+ overlay_work=role("overlay-work"),
718
+ read_context=role("read-context"),
719
+ )
720
+ entry = posixpath.join(role("workspace"), ".git")
721
+ return dataclasses.replace(facts, git_entry=entry, git_dir=entry, common_dir=entry)
722
+
723
+
724
+ def class_summary(facts: LaunchFacts) -> str:
725
+ """What a launch of ``facts.launch_class`` gets, in words, from §2.5's mapping.
726
+
727
+ Derived from :func:`sandbox_request` over :func:`summary_facts`, so the
728
+ words cannot drift from what a launch is given: the writable and
729
+ read-only projections by label, the masks, the overlays, what the
730
+ compiler adds to every launch, the denied roots and the network. A
731
+ provider's own declaration is named, never listed: it is per provider
732
+ (``doctor`` lists it).
733
+ """
734
+ request = sandbox_request(facts)
735
+ writable = [p.label for p in request.projections if p.access is Access.WRITABLE]
736
+ read_only = [p.label for p in request.projections if p.access is Access.READ_ONLY]
737
+ parts = []
738
+ if writable:
739
+ parts.append("writable: " + ", ".join(writable))
740
+ if read_only:
741
+ parts.append("read-only: " + ", ".join(read_only))
742
+ if request.masks:
743
+ parts.append("masked: " + ", ".join(_shown(m) for m in request.masks))
744
+ for overlay in request.overlays:
745
+ parts.append(
746
+ f"overlay: {overlay.label or _shown(overlay.lower)}, its writes landing in "
747
+ f"{_shown(overlay.upper)}"
748
+ )
749
+ if facts.launch_class in (LaunchClass.REMOTE_AGENT, LaunchClass.LOCAL_AGENT):
750
+ parts.append(
751
+ "the provider's executable, install roots, configuration and model credential "
752
+ "as its adapter declares them"
753
+ )
754
+ parts.append(
755
+ "always: /usr and /etc read-only, a private home and /tmp per launch, a new /proc, "
756
+ "a minimal /dev, /sys read-only"
757
+ )
758
+ parts.append(
759
+ "absent: the state directory and run logs, the lock, <git common dir>/autoforge "
760
+ "but the launch's own carve-outs, every other worktree"
761
+ + (" and the controller checkout" if facts.launch_class is LaunchClass.REMOTE_AGENT else "")
762
+ + "; credentials, keyrings, /run and everything not listed"
763
+ )
764
+ parts.append(
765
+ "network: "
766
+ + (
767
+ "isolated (no loopback, no abstract socket, no egress)"
768
+ if request.network is NetworkPosition.ISOLATED
769
+ else "the host's (abstract sockets of the host unreachable; not sandboxed)"
770
+ )
771
+ )
772
+ return "; ".join(parts)
773
+
774
+
775
+ # The version label of :func:`launch_template`'s encoding. The compiler's
776
+ # fixed choices (a private home and temp, a new /proc, a minimal /dev) are
777
+ # not in a request, so a change to one of them is a change of this label.
778
+ TEMPLATE_VERSION = "autoforge-sandbox-template-v1"
779
+
780
+
781
+ def role(name: str) -> str:
782
+ """The stand-in path of role ``name`` in a policy template (ADR 0006 D8.1)."""
783
+ return f"/@{name}"
784
+
785
+
786
+ def template_facts(
787
+ launch_class: LaunchClass,
788
+ network: NetworkPosition,
789
+ *,
790
+ separate_git_dir: bool = False,
791
+ linked: bool = False,
792
+ operator_paths: tuple[str, ...] = (),
793
+ ) -> LaunchFacts:
794
+ """A repository-defined command's facts with every concrete path a role name.
795
+
796
+ The role-level inputs the engine passes the compiler for a validator
797
+ (ADR 0006 D8.1), before any path is bound: the export or the repository
798
+ root is ``candidate-root``, and so on, and every denied root is one
799
+ ``denied-roots`` role, since how many worktrees are registered is not a
800
+ fact of the policy. For LOCAL validation, ``separate_git_dir`` says the
801
+ root's ``.git`` is a file naming the git directory and ``linked`` that
802
+ the checkout is a linked worktree with a common dir of its own: the
803
+ layout decides which of them §2.5 projects, never where they are.
804
+ ``operator_paths`` (``sandbox.read_only_paths``, D15.4) are
805
+ configuration, not paths of the launch: they are in the template as
806
+ configured.
807
+ """
808
+ workspace = role("candidate-root")
809
+ facts = LaunchFacts(
810
+ launch_class=launch_class,
811
+ workspace=workspace,
812
+ home=role("home"),
813
+ controller_env={},
814
+ controller_config=role("controller-config"),
815
+ denied_roots=(role("denied-roots"),),
816
+ network=network,
817
+ operator_paths=operator_paths,
818
+ )
819
+ if launch_class is LaunchClass.EXPORT_COMMAND:
820
+ return facts
821
+ if launch_class is not LaunchClass.LOCAL_VALIDATION:
822
+ raise ValueError(f"no policy template for the {launch_class.value}")
823
+ entry = posixpath.join(workspace, ".git")
824
+ git_dir = role("git-dir") if separate_git_dir or linked else entry
825
+ return dataclasses.replace(
826
+ facts,
827
+ git_entry=entry,
828
+ git_dir=git_dir,
829
+ common_dir=role("common-dir") if linked else git_dir,
830
+ cache=role("cache"),
831
+ )
832
+
833
+
834
+ def launch_template(facts: LaunchFacts) -> dict:
835
+ """The path-independent policy template of a command launch (ADR 0006 D8.1).
836
+
837
+ §2.5's request for ``facts`` (:func:`sandbox_request`, pure for a
838
+ command class), as canonical data: the projections and their access,
839
+ the masks, the overlays, the denied roots, the network position and the
840
+ compiler's options. Labels are prose and are left out. Given
841
+ :func:`template_facts`, no concrete path is in it, so two exports of one
842
+ candidate in two temporary directories give one template.
843
+ """
844
+ request = sandbox_request(facts)
845
+ return {
846
+ "version": TEMPLATE_VERSION,
847
+ "launch_class": facts.launch_class.name,
848
+ "projections": [
849
+ {
850
+ "source": p.source,
851
+ "access": p.access.value,
852
+ "origin": p.origin.value,
853
+ "target": p.target,
854
+ "carve_out": p.carve_out,
855
+ }
856
+ for p in request.projections
857
+ ],
858
+ "masks": list(request.masks),
859
+ "overlays": [
860
+ {"lower": o.lower, "upper": o.upper, "work": o.work, "carve_out": o.carve_out}
861
+ for o in request.overlays
862
+ ],
863
+ "denied_roots": list(request.denied_roots),
864
+ "operator_config_roots": list(request.operator_config_roots),
865
+ "system_runtime": request.system_runtime,
866
+ "home_size_bytes": request.home_size_bytes,
867
+ "tmp_size_bytes": request.tmp_size_bytes,
868
+ "network": request.network.value,
869
+ "sys": request.sys.value,
870
+ "env": [list(pair) for pair in request.env],
871
+ "env_unset": list(request.env_unset),
872
+ }
873
+
874
+
875
+ def _outermost(paths: list[str]) -> list[str]:
876
+ """``paths`` resolved, without those inside another one (C6 would refuse them)."""
877
+ resolved = list(dict.fromkeys(os.path.realpath(p) for p in paths if p))
878
+ return [p for p in resolved if not any(q != p and _inside(p, q) for q in resolved)]
879
+
880
+
881
+ def describe_launch(
882
+ facts: LaunchFacts, needs: ProviderRequirements | None, env: Mapping[str, str]
883
+ ) -> LaunchDescription:
884
+ """Compile one launch's description and rewrite its environment (D8.2).
885
+
886
+ ``env`` is the environment the launch would get without a sandbox (the
887
+ allow-listed variables and the controller's overrides). A request the
888
+ compiler refuses is a :class:`SandboxLaunchRefusedError` naming the path
889
+ and the rule, raised before anything is spawned.
890
+ """
891
+ try:
892
+ description = compile_launch(sandbox_request(facts, needs))
893
+ except SandboxPolicyError as exc:
894
+ raise policy_refusal(exc, facts) from exc
895
+ return rewrite_environment(description, env, needs)
896
+
897
+
898
+ def policy_refusal(exc: SandboxPolicyError, facts: LaunchFacts) -> SandboxLaunchRefusedError:
899
+ """A compiler refusal as the launch refusal the engine records (D12.1)."""
900
+ reason = redact(str(exc))
901
+ return SandboxLaunchRefusedError(
902
+ f"the sandbox cannot be established for the {facts.launch_class.value}: {reason}",
903
+ SandboxFacts(enforced=False, backend="", network=facts.network.value, setup_failure=reason),
904
+ )
905
+
906
+
907
+ def inside_spelling(description: LaunchDescription, value: str) -> str | None:
908
+ """The absolute host path ``value`` as a launch must spell it inside (D8.2).
909
+
910
+ ``None`` when nothing of the description shows what it names. Its own
911
+ spelling when that leads inside to where its host target is shown;
912
+ else that place: a link on its host path that the sandbox does not
913
+ recreate (a link in the private home, or anywhere the root is empty),
914
+ or a projection that appears elsewhere than at its host path (the cache).
915
+ """
916
+ if not value.startswith("/"):
917
+ return None
918
+ inner = description.inside_path(os.path.realpath(value))
919
+ if inner is None or description.resolve_inside(value) == inner:
920
+ return None if inner is None else value
921
+ return inner
922
+
923
+
924
+ def _auth_variables(needs: ProviderRequirements | None) -> dict[str, str]:
925
+ """The variables naming a projected auth item at its host path: name to host path."""
926
+ if needs is None:
927
+ return {}
928
+ return {
929
+ item.env_name: item.source
930
+ for item in needs.auth
931
+ if item.env_name
932
+ and item.target is None
933
+ and item.mode in (AuthMode.FILE_RO, AuthMode.DIR_RW)
934
+ }
935
+
936
+
937
+ def rewrite_environment(
938
+ description: LaunchDescription,
939
+ env: Mapping[str, str],
940
+ needs: ProviderRequirements | None = None,
941
+ ) -> LaunchDescription:
942
+ """``description`` with the launch's path-valued names made true inside (D8.2).
943
+
944
+ A name AutoForge knows to be a path (the CA-bundle names, #165's
945
+ overrides, the provider's ``path_names``) whose absolute value names
946
+ nothing visible inside is unset, and one whose spelling leads elsewhere
947
+ inside is set to where what it names is (:func:`inside_spelling`); a
948
+ value that is not an absolute path (``OPENCODE_DB=:memory:``, a name
949
+ relative to the provider's own data directory) passes unchanged, as any
950
+ other variable does. ``PATH`` keeps its visible absolute entries, each so
951
+ spelled, after the provider's ``path_dirs``, and is unset when none is
952
+ left. The compiler's own names (``HOME``, ``XDG_*``, ``TMPDIR``) are
953
+ left alone; the variable an auth item names (``PI_CODING_AGENT_DIR``)
954
+ is set by the compiler to the item's host path and spelled here too.
955
+ """
956
+ compiled = dict(description.env_set)
957
+ auth = {name: path for name, path in _auth_variables(needs).items() if name in compiled}
958
+ owned = (set(compiled) | set(description.env_unset)) - set(auth)
959
+ names = [*CA_BUNDLE_NAMES, *OVERRIDE_PATH_NAMES, *(needs.path_names if needs else ())]
960
+ values = {name: env.get(name, "") for name in dict.fromkeys(names)}
961
+ values.update(auth)
962
+ moved: dict[str, str] = {}
963
+ unset: set[str] = set()
964
+ for name, value in values.items():
965
+ if name in owned or not value.startswith("/"):
966
+ continue
967
+ spelled = inside_spelling(description, value)
968
+ if spelled is None:
969
+ unset.add(name)
970
+ elif spelled != value:
971
+ moved[name] = spelled
972
+ if "PATH" not in owned and ("PATH" in env or (needs and needs.path_dirs)):
973
+ entries = [*(needs.path_dirs if needs else ()), *env.get("PATH", "").split(":")]
974
+ spelled_entries = (inside_spelling(description, entry) for entry in entries)
975
+ kept = list(dict.fromkeys(entry for entry in spelled_entries if entry))
976
+ if kept:
977
+ if ":".join(kept) != env.get("PATH"):
978
+ moved["PATH"] = ":".join(kept)
979
+ elif "PATH" in env:
980
+ unset.add("PATH")
981
+ if not unset and not moved:
982
+ return description
983
+ env_set = {name: value for name, value in compiled.items() if name not in unset}
984
+ env_set.update(moved)
985
+ return dataclasses.replace(
986
+ description,
987
+ env_set=tuple(sorted(env_set.items())),
988
+ env_unset=tuple(sorted(set(description.env_unset) | unset)),
989
+ )
990
+
991
+
992
+ # ------------------------------------------------- executables and install roots
993
+
994
+
995
+ def resolve_command(name: str, search_path: str | None) -> str:
996
+ """The resolved host path ``name`` runs as, or "" when it cannot be told.
997
+
998
+ A bare name is looked up on ``search_path`` (the launch's ``PATH``, as
999
+ the executor's spawn would); a relative path with a separator is left
1000
+ alone, since it would be resolved against the launch's directory.
1001
+ """
1002
+ found = name if "/" in name else shutil.which(name, path=search_path)
1003
+ if not found or not found.startswith("/"):
1004
+ return ""
1005
+ real = os.path.realpath(found)
1006
+ return real if os.path.isfile(real) and os.access(real, os.X_OK) else ""
1007
+
1008
+
1009
+ def install_root(path: str, home: str) -> str:
1010
+ """The read-only root a resolved executable is installed in (D13.1).
1011
+
1012
+ The package directory for a file inside ``node_modules`` (its modules
1013
+ sit below it). Else the prefix of ``<prefix>/bin/<file>`` when the
1014
+ prefix also has a ``lib`` directory (an interpreter's standard library
1015
+ sits there), unless the prefix is the home, one of its ancestors or
1016
+ ``~/.local``. Else the file's directory, unless that directory is the
1017
+ home or one of its ancestors: then the file alone, never all of the home.
1018
+ """
1019
+ parts = path.split("/")
1020
+ if "node_modules" in parts:
1021
+ index = parts.index("node_modules")
1022
+ package = (
1023
+ index + 2
1024
+ if index + 1 < len(parts) and parts[index + 1].startswith("@")
1025
+ else (index + 1)
1026
+ )
1027
+ if package < len(parts) - 1:
1028
+ return "/".join(parts[: package + 1])
1029
+ parent = posixpath.dirname(path)
1030
+ homes = (home, os.path.realpath(home))
1031
+ if posixpath.basename(parent) == "bin":
1032
+ prefix = posixpath.dirname(parent)
1033
+ broad = any(_inside(anchor, prefix) for anchor in homes) or prefix in (
1034
+ posixpath.join(anchor, ".local") for anchor in homes
1035
+ )
1036
+ if prefix != "/" and not broad and os.path.isdir(posixpath.join(prefix, "lib")):
1037
+ return prefix
1038
+ for anchor in homes:
1039
+ if _inside(anchor, parent):
1040
+ return path
1041
+ return path if parent == "/" else parent
1042
+
1043
+
1044
+ def _shebang(executable: str) -> list[str]:
1045
+ """The words of an executable's ``#!`` line, ``[]`` without one. Only
1046
+ the first line is read."""
1047
+ try:
1048
+ with open(executable, "rb") as handle:
1049
+ head = handle.read(_SHEBANG_BYTES)
1050
+ except OSError:
1051
+ return []
1052
+ if not head.startswith(b"#!"):
1053
+ return []
1054
+ return head[2:].split(b"\n", 1)[0].decode("utf-8", "replace").split()
1055
+
1056
+
1057
+ def _interpreter(words: list[str], search_path: str | None) -> str:
1058
+ if not words:
1059
+ return ""
1060
+ program = words[0]
1061
+ if posixpath.basename(program) == "env":
1062
+ names = [word for word in words[1:] if not word.startswith("-") and "=" not in word]
1063
+ if not names:
1064
+ return ""
1065
+ return resolve_command(names[0], search_path)
1066
+ return resolve_command(program, search_path)
1067
+
1068
+
1069
+ def interpreter_of(executable: str, search_path: str | None) -> str:
1070
+ """The resolved interpreter an executable script names (``#!``), or "".
1071
+
1072
+ ``#!/usr/bin/env [-S] name`` is looked up on ``search_path``, as ``env``
1073
+ would do inside. Only the first line is read.
1074
+ """
1075
+ return _interpreter(_shebang(executable), search_path)
1076
+
1077
+
1078
+ def executable_requirements(
1079
+ command: str, *, home: str, search_path: str | None
1080
+ ) -> tuple[str, tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
1081
+ """``(executable, install roots, PATH dirs, spelled paths)`` for the command a launch names.
1082
+
1083
+ The executable resolved on the host; its install root; and, for a
1084
+ script, its interpreter's install root and directory, which goes first
1085
+ on ``PATH`` so that ``#!/usr/bin/env node`` finds it inside. An
1086
+ absolute ``#!`` path, ``env``'s too, is also a spelled path, as
1087
+ written, and its resolved program's install root is projected: the
1088
+ kernel looks it up so, and a link on its way, such as Homebrew's
1089
+ ``opt/`` into ``Cellar/``, need not lie in any root projected (#225).
1090
+ """
1091
+ executable = resolve_command(command, search_path)
1092
+ if not executable:
1093
+ return "", (), (), ()
1094
+ roots = [install_root(executable, home)]
1095
+ path_dirs: list[str] = []
1096
+ spelled: list[str] = []
1097
+ words = _shebang(executable)
1098
+ program = resolve_command(words[0], search_path) if words and words[0].startswith("/") else ""
1099
+ if program:
1100
+ # What the kernel runs, as written: ``env`` is no exception (R1-F1).
1101
+ roots.append(install_root(program, home))
1102
+ spelled.append(words[0])
1103
+ interpreter = _interpreter(words, search_path)
1104
+ if interpreter:
1105
+ roots.append(install_root(interpreter, home))
1106
+ path_dirs.append(posixpath.dirname(interpreter))
1107
+ return executable, tuple(dict.fromkeys(roots)), tuple(path_dirs), tuple(spelled)