-
Notifications
You must be signed in to change notification settings - Fork 12k
Expand file tree
/
Copy path_commands.py
More file actions
3383 lines (2987 loc) · 133 KB
/
Copy path_commands.py
File metadata and controls
3383 lines (2987 loc) · 133 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""specify workflow * command handlers — app objects and register().
Moved out of __init__.py (PR-8/8). Handlers reference `_require_specify_project`
(kept in the package root) through the thin shim below, which re-fetches from
the parent package at call time so test monkeypatching of
`specify_cli._require_specify_project` keeps working.
"""
from __future__ import annotations
import contextlib
import json
import os
import re
import sys
from pathlib import Path, PurePosixPath
from typing import Any
import typer
import yaml
from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
)
from .._project import _resolve_init_dir_override
workflow_app = typer.Typer(
name="workflow",
help="Manage and run automation workflows",
add_completion=False,
)
workflow_catalog_app = typer.Typer(
name="catalog",
help="Manage workflow catalogs",
add_completion=False,
)
workflow_app.add_typer(workflow_catalog_app, name="catalog")
workflow_step_app = typer.Typer(
name="step",
help="Manage workflow step types",
add_completion=False,
)
workflow_app.add_typer(workflow_step_app, name="step")
workflow_step_catalog_app = typer.Typer(
name="catalog",
help="Manage step catalogs",
add_completion=False,
)
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
workflow_overlay_app = typer.Typer(
name="overlay",
help="Manage workflow overlays",
add_completion=False,
)
workflow_app.add_typer(workflow_overlay_app, name="overlay")
def _error_console(json_output: bool):
"""Console for error text: stderr under ``--json`` so the JSON stdout
stream stays parseable, the normal console otherwise. Mirrors the
stderr-only error routing already used by ``specify bundle``.
"""
return err_console if json_output else console
def _open_workflow_registry(project_root: Path, out=None):
"""Construct a WorkflowRegistry, exiting cleanly on an unreadable file.
WorkflowRegistry fails closed (raises OSError) at construction when its
file can't be read, rather than falling back to an empty registry a
caller could mistake for "nothing installed". Every CLI command that
opens a registry needs this same clean-error boundary.
"""
from .catalog import WorkflowRegistry
try:
return WorkflowRegistry(project_root)
except OSError as exc:
(out or console).print(
f"[red]Error:[/red] Failed to read workflow registry: {_escape_markup(str(exc))}"
)
raise typer.Exit(1)
def _require_enabled_workflow(
registry_root: Path, workflow_id: str, out: Any
) -> bool:
"""Fail closed for corrupted or explicitly disabled registry entries."""
metadata = _open_workflow_registry(registry_root, out).get(workflow_id)
if metadata is not None and not isinstance(metadata, dict):
out.print(
f"[red]Error:[/red] Registry entry for "
f"'{_escape_markup(workflow_id)}' is corrupted"
)
raise typer.Exit(1)
if isinstance(metadata, dict) and not metadata.get("enabled", True):
out.print(
f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is disabled. "
f"Enable with: specify workflow enable {_escape_markup(workflow_id)}"
)
raise typer.Exit(1)
return metadata is not None
def _path_has_symlink_component(path: Path) -> bool:
"""Return whether any component of an absolute path is a symlink."""
absolute = Path(os.path.abspath(path))
current = Path(absolute.anchor)
for part in absolute.parts[1:]:
current /= part
if current.is_symlink():
return True
return False
def _same_existing_path(left: Path, right: Path) -> bool:
"""Return whether two existing paths identify the same filesystem entry."""
try:
return os.path.samefile(left, right)
except OSError:
return left == right
def _resolve_run_owner_root(
installed_registry_root: str | None, project_root: Path
) -> Path:
"""Determine which project's registry gates resuming a run.
``installed_registry_root`` is only ever persisted when the run's
installed workflow genuinely belongs to a *different* project than the
one whose ``runs/`` directory holds this run's own state (a direct
external workflow-file invocation) -- see ``workflow_run``. The common
case (an installed workflow run from its own project) stores ``None``,
so a later project rename/move is transparently picked up here by
falling back to the *current* ``project_root`` instead of a stale
absolute path baked in at run start.
A persisted cross-project root that no longer exists cannot be safely
rediscovered and must fail closed instead of consulting the unrelated
project that happens to store the run state.
"""
if installed_registry_root:
candidate = Path(installed_registry_root)
if (
candidate.is_absolute()
and not _path_has_symlink_component(candidate)
and candidate.is_dir()
):
return candidate
raise ValueError(
"Installed workflow owner is unavailable; cannot safely resume"
)
return project_root
def _parse_input_values(
input_values: list[str] | None, *, json_output: bool = False
) -> dict[str, Any]:
"""Parse repeated ``key=value`` CLI inputs into a dict.
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
on any entry missing ``=``.
"""
inputs: dict[str, Any] = {}
for kv in input_values or []:
if "=" not in kv:
_error_console(json_output).print(
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
)
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
return inputs
def _reject_unsafe_dir(path: Path, label: str) -> None:
"""Refuse to proceed when *path* is a symlink or an existing non-directory.
A symlinked ``.specify`` (or ``.specify/workflows``) could redirect
workflow writes outside the project root, so any command that creates or
writes files beneath it must bail first. Absence is tolerated — the caller
creates the directory — only an existing-but-wrong target is rejected.
"""
if path.is_symlink():
err_console.print(f"[red]Error:[/red] Refusing to use symlinked {label} path")
raise typer.Exit(1)
if path.exists() and not path.is_dir():
err_console.print(f"[red]Error:[/red] {label} path exists but is not a directory")
raise typer.Exit(1)
def _reject_unsafe_workflow_storage(project_root: Path) -> None:
"""Refuse symlinked workflow storage directories before workflow commands run."""
_reject_unsafe_dir(project_root / ".specify", ".specify")
_reject_unsafe_dir(project_root / ".specify" / "workflows", ".specify/workflows")
_reject_unsafe_dir(
project_root / ".specify" / "workflows" / "runs",
".specify/workflows/runs",
)
_reject_unsafe_dir(
project_root / ".specify" / "workflows" / "overlays",
".specify/workflows/overlays",
)
def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None:
"""Find the *nearest* (innermost) ``.specify/workflows/<id>`` owner in
*parts*, scanning from the end of the path.
Scanning from the end (rather than stopping at the first match from the
start) matters for a project nested beneath an unrelated outer path that
happens to reuse the same ``.specify``/``workflows`` segment names: the
first-from-start match would pick the outer directory and the wrong
workflow ID, silently missing the real (inner) owner's disabled check.
Returns the index of the owning ``.specify`` segment, or ``None`` if no
owner segment is present.
"""
for i in range(len(parts) - 3, -1, -1):
if (
parts[i].casefold() == ".specify"
and parts[i + 1].casefold() == "workflows"
):
return i
return None
def _expand_first_symlink_target(path: Path) -> Path | None:
"""Expand one symlink component while preserving the remaining path."""
parts = path.parts
current = Path(path.anchor) if path.is_absolute() else Path()
start = 1 if path.is_absolute() else 0
for index in range(start, len(parts)):
current = current / parts[index]
if not current.is_symlink():
continue
try:
target = Path(os.readlink(current))
except OSError:
return None
if not target.is_absolute():
target = current.parent / target
expanded = target.joinpath(*parts[index + 1 :])
return Path(os.path.normpath(str(expanded.absolute())))
return None
def _resolve_installed_workflow_ownership(
source_path: Path, err
) -> tuple[Path | None, str | None]:
"""Map a direct ``workflow.yml`` *source_path* back to the installed
workflow (``registry_root``, ``registered_id``) it belongs to, if any.
A registered path can point at installed storage three ways, all of
which must receive the same registry disabled-check:
1. Lexically: the path's own (symlink-preserving) segments identify
``.specify/workflows/<id>`` -- collapsing ``..``/``.`` but
never resolving symlinks, so a symlinked ``workflow.yml`` leaf (or
symlinked ``<id>`` directory) inside the owned tree is caught by the
inward-symlink refusal below rather than silently followed.
2. Via an intermediate alias target whose lexical path identifies
``.specify/workflows/<id>`` before a symlinked storage ancestor is
resolved away.
3. Via an outward-pointing alias whose fully resolved target lands
inside legitimate installed storage, even though the raw invocation
path has no ownership segments.
Returns ``(None, None)`` when neither applies -- a genuinely standalone
external workflow file, which is allowed to run unchecked.
"""
def ownership_for(candidate: Path) -> tuple[Path, str] | None:
parts = candidate.parts
i = _scan_for_workflow_owner(parts)
if i is None:
return None
registry_root = (
Path(*parts[:i]) if i else Path(candidate.anchor or ".")
)
candidate_specify = Path(*parts[: i + 1])
candidate_workflows = Path(*parts[: i + 2])
candidate_id_dir = Path(*parts[: i + 3])
canonical_specify = registry_root / ".specify"
canonical_workflows = canonical_specify / "workflows"
# The path-derived registry_root here may differ from the cwd's
# project_root already checked by _reject_unsafe_workflow_storage
# (e.g. this path points into another project entirely, or this
# project's own .specify is itself a symlink to an
# attacker-controlled tree) -- check it explicitly rather than
# trusting that cwd-scoped guard, and don't rely on
# WorkflowRegistry's own symlinked-parent handling as the safety
# signal here: it fails closed by raising OSError at construction
# time (see catalog.py's _load), but that surfaces as an opaque
# exception rather than this guard's clean, specific CLI error for
# the actual owning project root.
_reject_unsafe_dir(canonical_specify, ".specify")
_reject_unsafe_dir(canonical_workflows, ".specify/workflows")
_reject_unsafe_dir(candidate_specify, ".specify")
_reject_unsafe_dir(candidate_workflows, ".specify/workflows")
try:
if not os.path.samefile(candidate_specify, canonical_specify):
return None
if not os.path.samefile(
candidate_workflows, canonical_workflows
):
return None
except OSError:
return None
registry = _open_workflow_registry(registry_root, err)
registered_id = None
for workflow_id in registry.list():
if (
not isinstance(workflow_id, str)
or workflow_id in _RESERVED_WORKFLOW_IDS
or not _WORKFLOW_ID_PATTERN.fullmatch(workflow_id)
):
continue
try:
if os.path.samefile(
candidate_id_dir,
canonical_workflows / workflow_id,
):
registered_id = workflow_id
break
except OSError:
continue
if registered_id is None:
return None
# A legitimately installed workflow's own directory tree never
# contains a symlink (workflow add/remove both refuse one at
# install time); one appearing here means the file actually loaded
# below would not be the file this ownership match is based on, so
# refuse rather than silently mismatch.
for k in range(i + 2, len(parts) + 1):
if Path(*parts[:k]).is_symlink():
err.print(
"[red]Error:[/red] Refusing to run: "
f".specify/workflows/{_escape_markup(registered_id)} "
"contains a symlinked path component"
)
raise typer.Exit(1)
return registry_root, registered_id
lexical = Path(os.path.normpath(str(source_path.absolute())))
ownership = ownership_for(lexical)
if ownership is not None:
return ownership
# Inspect each intermediate symlink target before fully resolving it.
# Full resolution can erase .specify/workflows ownership segments when
# one of those storage directories is itself a symlink.
candidate = lexical
seen = {candidate}
for _ in range(40):
expanded = _expand_first_symlink_target(candidate)
if expanded is None or expanded in seen:
break
ownership = ownership_for(expanded)
if ownership is not None:
return ownership
seen.add(expanded)
candidate = expanded
# A fully resolved target may still land in legitimate installed
# storage through an unrelated-looking alias.
try:
resolved = source_path.resolve(strict=False)
except (OSError, RuntimeError):
return None, None
if resolved == lexical:
# Nothing on this path is a symlink; already covered above.
return None, None
ownership = ownership_for(resolved)
return ownership if ownership is not None else (None, None)
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
"""Reject insecure redirects before they are followed."""
import urllib.error
if is_safe_download_redirect(old_url, new_url):
return
raise urllib.error.URLError(
"redirect target must use HTTPS without entering a local target; "
"loopback HTTP may only redirect from another loopback URL"
)
# Workflow YAML definitions are small step/metadata text, not binaries, so
# this is generous headroom against a malicious or misbehaving server -- not
# a ceiling any legitimate workflow definition should ever approach.
_MAX_WORKFLOW_YAML_BYTES = 5 * 1024 * 1024 # 5 MiB
_DOWNLOAD_CHUNK_SIZE = 65536
# Custom step packages contain executable Python, metadata, and optional helper
# files downloaded one-by-one rather than as an archive. Mirror the archive
# ceilings so a catalog cannot turn individually valid files into an unbounded
# aggregate download.
_MAX_STEP_PACKAGE_FILES = 512
_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB
def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes:
"""Read *response* fully, enforcing *max_bytes* via bounded streaming.
A ``Content-Length`` header is checked up front to fail fast, but it is
never trusted alone: the actual bytes read are also counted as they
stream in, so a chunked or ``Content-Length``-less response that lies
about (or omits) its size still cannot exceed the limit.
``max_bytes`` defaults to ``None`` (resolved to the module-level
``_MAX_WORKFLOW_YAML_BYTES`` at call time, not at function-definition
time) so tests can override the effective limit via monkeypatching the
module attribute.
"""
if max_bytes is None:
max_bytes = _MAX_WORKFLOW_YAML_BYTES
content_length = None
getheader = getattr(response, "getheader", None)
if callable(getheader):
try:
raw_length = getheader("Content-Length")
except Exception:
raw_length = None
if raw_length is not None:
try:
content_length = int(raw_length)
except (TypeError, ValueError):
content_length = None
if content_length is not None and content_length > max_bytes:
raise ValueError(
f"response declared {content_length} bytes, exceeding the "
f"{max_bytes}-byte workflow size limit"
)
chunks: list[bytes] = []
total = 0
while True:
chunk = response.read(_DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise ValueError(f"response exceeds the {max_bytes}-byte workflow size limit")
chunks.append(chunk)
return b"".join(chunks)
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
if (
workflow_id in _RESERVED_WORKFLOW_IDS
or not _WORKFLOW_ID_PATTERN.fullmatch(workflow_id)
):
console.print(
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
)
raise typer.Exit(1)
def _safe_workflow_id_dir(workflows_dir: Path, workflow_id: str) -> Path:
"""Validate the per-id install directory before any write and return it.
Installs write to ``workflows_dir / <id> / workflow.yml``. The ``<id>``
segment comes from a workflow YAML or catalog key, so it must be checked
before ``mkdir``/copy/download follows a symlink outside the project root.
Rejects, with a clean ``typer.Exit``:
- an ``<id>`` that is a symlink or an existing non-directory
(the latter would otherwise make ``mkdir`` raise);
- an ``<id>`` that is not a single workflow-id path segment or collides
with internal workflow storage directories;
- an ``<id>`` that escapes ``workflows_dir`` (path traversal);
- an ``<id>/workflow.yml`` leaf that is a symlink or an existing
non-file (either would otherwise make the later write/copy raise).
The symlink/non-directory check runs *before* ``resolve()`` so a symlinked
``<id>`` reports as a symlink rather than misleadingly as path traversal.
``workflow_id`` is markup-escaped in output to avoid Rich markup injection.
"""
safe_id = _escape_markup(workflow_id)
_validate_workflow_id_or_exit(workflow_id)
dest_dir = workflows_dir / workflow_id
_reject_unsafe_dir(dest_dir, f".specify/workflows/{safe_id}")
try:
dest_dir.resolve().relative_to(workflows_dir.resolve())
except ValueError:
# Escape the repr (not the raw id) so backslashes added by repr cannot
# re-expose markup brackets to Rich.
console.print(
f"[red]Error:[/red] Invalid workflow ID: {_escape_markup(repr(workflow_id))}"
)
raise typer.Exit(1)
workflow_yml = dest_dir / "workflow.yml"
if workflow_yml.is_symlink():
console.print(
"[red]Error:[/red] Refusing to write through symlinked "
f".specify/workflows/{safe_id}/workflow.yml"
)
raise typer.Exit(1)
if workflow_yml.exists() and not workflow_yml.is_file():
console.print(
"[red]Error:[/red] "
f".specify/workflows/{safe_id}/workflow.yml exists but is not a file"
)
raise typer.Exit(1)
return dest_dir
class _StagedWorkflowFile:
"""Exclusive staging inode kept open until its atomic commit."""
def __init__(self, path: Path, fd: int) -> None:
self.path = path
self.fd = fd
def _write(self, chunks) -> None:
os.lseek(self.fd, 0, os.SEEK_SET)
os.ftruncate(self.fd, 0)
for chunk in chunks:
view = memoryview(chunk)
while view:
written = os.write(self.fd, view)
if written <= 0:
raise OSError("Failed to write staged workflow file")
view = view[written:]
def write_bytes(self, data: bytes) -> None:
self._write((data,))
def verify_path(self) -> None:
import stat
try:
path_stat = self.path.stat(follow_symlinks=False)
open_stat = os.fstat(self.fd)
except OSError as exc:
raise OSError(
"Staged workflow file changed before commit"
) from exc
if (
not stat.S_ISREG(path_stat.st_mode)
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError("Staged workflow file changed before commit")
def set_mode(self, mode: int) -> None:
if hasattr(os, "fchmod"):
os.fchmod(self.fd, mode)
def close(self) -> None:
if self.fd < 0:
return
fd, self.fd = self.fd, -1
try:
os.close(fd)
except OSError:
pass
def _stage_workflow_file(
dest_dir: Path, *, use_project_file_mode: bool = False
) -> _StagedWorkflowFile:
"""Reserve a same-directory staging file so new/updated workflow.yml
content can be written and validated without ever touching (and risking
truncating) an existing destination file before the final atomic swap.
Shared by the local-install and catalog-install paths.
If dest_dir did not already exist, this call creates it; if mkstemp then
fails (disk full/EMFILE/quota), the freshly-created directory is removed
again via a guarded rmdir (never a broad rmtree, so any concurrently
written content is left untouched) before the original OSError is
re-raised unchanged. A pre-existing dest_dir (reinstall) is never
touched by this cleanup. For catalog-created files,
``use_project_file_mode`` recreates the reserved path exclusively with
mode 0666 so the process umask supplies the normal project-file mode.
The final descriptor remains open so callers write to and verify the
reserved inode rather than reopening a replaceable pathname."""
import tempfile
created_dir = not dest_dir.exists()
dest_dir.mkdir(parents=True, exist_ok=True)
fd = -1
staged_file: Path | None = None
try:
fd, tmp_name = tempfile.mkstemp(dir=dest_dir, prefix=".workflow.yml.", suffix=".tmp")
staged_file = Path(tmp_name)
if use_project_file_mode:
os.close(fd)
fd = -1
staged_file.unlink()
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
fd = os.open(staged_file, flags, 0o666)
except OSError:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
if staged_file is not None:
try:
staged_file.unlink(missing_ok=True)
except OSError:
pass
if created_dir:
try:
dest_dir.rmdir()
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Failed to remove incomplete "
f"workflow directory: {_escape_markup(str(cleanup_exc))}"
)
raise
assert staged_file is not None
return _StagedWorkflowFile(staged_file, fd)
@contextlib.contextmanager
def _workflow_install_transaction(project_root: Path):
"""Serialize workflow file swaps with their registry updates."""
from ..shared_infra import _ensure_safe_shared_directory
lock_dir = project_root / ".specify"
try:
_ensure_safe_shared_directory(
project_root, lock_dir, context="workflow install lock directory"
)
except ValueError as exc:
raise OSError(str(exc)) from exc
lock_file = lock_dir / ".workflow-install.lock"
if lock_file.is_symlink():
raise OSError(f"Refusing to use symlinked workflow install lock: {lock_file}")
flags = os.O_RDWR | os.O_CREAT
flags |= getattr(os, "O_NOFOLLOW", 0)
flags |= getattr(os, "O_CLOEXEC", 0)
fd = os.open(lock_file, flags, 0o600)
try:
if lock_file.is_symlink():
raise OSError(
f"Refusing to use symlinked workflow install lock: {lock_file}"
)
if os.name == "nt":
import errno
import msvcrt
import time
if os.fstat(fd).st_size == 0:
os.write(fd, b"\0")
while True:
os.lseek(fd, 0, os.SEEK_SET)
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
break
except OSError as exc:
if exc.errno not in (errno.EACCES, errno.EDEADLK):
raise
time.sleep(0.05)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def _commit_workflow_file(
staged_file: Path | _StagedWorkflowFile,
dest_file: Path,
existed_before: bool,
) -> Path | None:
"""Atomically swap ``staged_file`` onto ``dest_file``. If a prior file
existed, it is first renamed to a unique sibling (path returned) so a
later failure (e.g. registry.add()) can restore it via rename instead
of a content rewrite -- the destination is never truncated/overwritten
in place. If the second rename fails after the first succeeded, the
prior file is put back immediately so dest_file is never left simply
missing."""
staged_path = (
staged_file.path
if isinstance(staged_file, _StagedWorkflowFile)
else staged_file
)
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.verify_path()
if existed_before and dest_file.exists():
import tempfile
dest_state = dest_file.stat(follow_symlinks=False)
mode = dest_state.st_mode & 0o7777
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.set_mode(mode)
else:
staged_path.chmod(mode)
fd, backup_name = tempfile.mkstemp(
dir=dest_file.parent,
prefix=f".{dest_file.name}.",
suffix=".bak",
)
try:
placeholder_state = os.fstat(fd)
finally:
os.close(fd)
backup_file = Path(backup_name)
try:
os.replace(dest_file, backup_file)
except BaseException as move_exc:
backup_state = None
try:
backup_state = backup_file.stat(follow_symlinks=False)
except OSError:
pass
if (
backup_state is not None
and os.path.samestat(dest_state, backup_state)
):
try:
os.replace(backup_file, dest_file)
except OSError as restore_exc:
raise OSError(
f"Failed to stage prior workflow ({move_exc}); failed "
f"to restore it from {backup_file} ({restore_exc}). "
f"The prior workflow remains at {backup_file}."
) from restore_exc
elif (
backup_state is not None
and os.path.samestat(placeholder_state, backup_state)
):
try:
backup_file.unlink(missing_ok=True)
except OSError:
pass
raise
try:
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.verify_path()
# Windows cannot replace an open file. Verify through the
# exclusive descriptor, then close immediately before rename.
staged_file.close()
os.replace(staged_path, dest_file)
except BaseException as commit_exc:
try:
os.replace(backup_file, dest_file)
except OSError as restore_exc:
raise OSError(
f"Failed to commit workflow file ({commit_exc}); failed "
f"to restore the prior workflow from {backup_file} "
f"({restore_exc}). The prior workflow remains at "
f"{backup_file}."
) from restore_exc
raise
return backup_file
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.verify_path()
staged_file.close()
os.replace(staged_path, dest_file)
return None
def _discard_staged_workflow_file(
staged_file: Path | _StagedWorkflowFile,
dest_dir: Path,
existed_before: bool,
) -> None:
"""Clean up after a pre-commit failure (staged_file was never swapped
onto dest_file): remove the staged file, and for a fresh install (no
prior directory) remove the now-orphaned dest_dir too. A genuine
removal failure must propagate (not be swallowed) so the safe wrapper
below can warn instead of silently leaving an orphan; a dest_dir
already absent is not itself an error."""
staged_path = (
staged_file.path
if isinstance(staged_file, _StagedWorkflowFile)
else staged_file
)
if isinstance(staged_file, _StagedWorkflowFile):
staged_file.close()
staged_path.unlink(missing_ok=True)
if not existed_before and dest_dir.exists():
import errno
try:
dest_dir.rmdir()
except OSError as exc:
# Another concurrent install may already have committed content
# into this once-fresh directory. Never recursively delete it.
if exc.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
def _rollback_committed_workflow_file(
dest_file: Path, dest_dir: Path, existed_before: bool, backup_file: Path | None
) -> None:
"""Undo a successful _commit_workflow_file swap after a later failure
(registry.add()): restore the prior file via rename, remove the newly
committed file for a reinstall over a pre-existing empty directory
(no backup), or remove the new file and then its directory when empty
for a fresh install. A genuine removal failure must propagate (not be
swallowed) so the safe wrapper below can warn instead of silently
leaving an orphan; a dest_dir already absent is not itself an error."""
if backup_file is not None:
os.replace(backup_file, dest_file)
else:
dest_file.unlink(missing_ok=True)
if not existed_before and dest_dir.exists():
import errno
try:
dest_dir.rmdir()
except OSError as exc:
# Another installer may have staged a sibling before taking
# the transaction lock. Preserve it rather than recursively
# deleting the shared directory during this rollback.
if exc.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
def _safe_discard_staged_workflow_file(
staged_file: Path | _StagedWorkflowFile,
dest_dir: Path,
existed_before: bool,
) -> None:
"""Guarded wrapper: a cleanup failure must be reported, never crash or
silently mask the original install error that triggered it."""
try:
_discard_staged_workflow_file(staged_file, dest_dir, existed_before)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Failed to clean up incomplete workflow "
f"install: {_escape_markup(str(exc))}"
)
def _safe_rollback_committed_workflow_file(
dest_file: Path, dest_dir: Path, existed_before: bool, backup_file: Path | None
) -> None:
"""Guarded wrapper: a rollback failure must be reported, never crash or
silently claim the prior workflow file was restored when it wasn't."""
try:
_rollback_committed_workflow_file(dest_file, dest_dir, existed_before, backup_file)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Failed to restore prior workflow file "
f"after registry update failure: {_escape_markup(str(exc))}"
)
def _discard_committed_backup_file(backup_file: Path | None) -> None:
"""Once registry.add()/registry.remove() has durably succeeded after a
_commit_workflow_file() swap, the renamed-aside prior file is no longer
needed for rollback -- it must be discarded, not left as a permanent
orphan sibling that every future reinstall would silently accumulate or
clobber. A cleanup failure here must not turn an already-successful
install into a reported failure; it's reported as a warning, consistent
with workflow_remove's post-commit cleanup semantics. A fresh install
(backup_file is None) is a no-op."""
if backup_file is None:
return
try:
backup_file.unlink(missing_ok=True)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Workflow installed, but its backup file "
f"could not be cleaned up: {_escape_markup(str(exc))}. Remove it "
f"manually: {_escape_markup(str(backup_file))}"
)
# Root helper re-fetched at call time so test monkeypatching of
# `specify_cli._require_specify_project` keeps working after the move.
def _require_specify_project(*args, **kwargs):
from .. import _require_specify_project as _f
project_root = _f(*args, **kwargs)
_reject_unsafe_workflow_storage(project_root)
return project_root
def _failed_step_error(state: Any) -> str | None:
"""Terminal error for a failed/aborted run, if any.
Returns the run-level error persisted by the engine at the moment
the run terminated. Returns ``None`` for non-terminal statuses so
the caller can print unconditionally.
"""
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
return None
return getattr(state, "error", None)
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
payload = {
"run_id": state.run_id,
"workflow_id": state.workflow_id,
"status": state.status.value,
"current_step_id": state.current_step_id,
"current_step_index": state.current_step_index,
}
gate = _gate_outcome(state)
if gate is not None:
payload["gate"] = gate
error = _failed_step_error(state)
if error is not None:
payload["error"] = error
return payload
def _is_gate_step(step: dict[str, Any]) -> bool:
"""Whether a recorded step result is a gate.
Prefers the persisted ``type`` field, but when it is absent — a run paused
by an older version, whose step record predates ``type`` being stored —
falls back to the gate's unique output signature: only ``GateStep`` writes
an ``on_reject`` key. A record carrying a *different* known ``type`` is not
a gate, so the fallback applies only when ``type`` is missing entirely.
"""
step_type = step.get("type")
if step_type == "gate":
return True
if step_type:
return False
output = step.get("output")
return isinstance(output, dict) and "on_reject" in output
def _gate_outcome(state: Any) -> dict[str, Any] | None:
"""Gate detail for the structured outcome, when the run rests at a gate.
A paused or gate-aborted run is otherwise indistinguishable from any
other pause/abort in the machine-readable payload; surfacing the gate's
prompt, options, and (after an interactive choice) the decision lets
orchestrators drive review gates without parsing the human-facing stream.
"""
# Two run states rest *on* a gate: `paused` (awaiting a decision) and
# `aborted` (a gate rejected with `on_reject: abort` — the only path that
# sets ABORTED, leaving current_step_id on that gate). Any other status —
# notably `completed`/`failed` — must be suppressed: current_step_id is
# not cleared when a run whose last executed step was a gate moves on, so
# without this guard it would surface stale detail (run/resume/status).
if getattr(state.status, "value", state.status) not in ("paused", "aborted"):
return None
step = (getattr(state, "step_results", None) or {}).get(state.current_step_id)
if not isinstance(step, dict) or not _is_gate_step(step):
return None
output = step.get("output") or {}
# `message`, `options`, and `choice` may be non-string YAML literals in an
# unvalidated workflow (GateStep coerces none of them for the payload), so
# normalise all three for a stable JSON schema: message → str, options →
# list[str] | None, choice → str | None (None means no decision yet).
message = output.get("message")
choice = output.get("choice")
return {
"step_id": state.current_step_id,
"message": None if message is None else str(message),
"options": _normalize_gate_options(output.get("options")),
"choice": None if choice is None else str(choice),
}
def _normalize_gate_options(options: Any) -> list[str] | None:
"""Normalise a gate's ``options`` to a stable ``list[str]`` (or ``None``).
A valid gate stores a list, but an unvalidated workflow could leave a
scalar or tuple. ``None`` stays ``None`` (no options); a list/tuple maps
each element through ``str``; any other scalar becomes a single-element
list — so the emitted JSON schema is always ``list[str] | None``. A bare
string is treated as one option, never iterated character-by-character.
"""
if options is None:
return None
if isinstance(options, (list, tuple)):
return [str(o) for o in options]
return [str(options)]
def _run_outcome_exit_code(status_value: str) -> int:
"""Exit code for a finished run/resume: non-zero on terminal failure.
``failed`` and ``aborted`` map to 1 so scripts and orchestrators can
rely on the process exit code; ``completed`` and ``paused`` map to 0
(paused is a legitimate waiting state, not a failure).
"""
return 1 if status_value in ("failed", "aborted") else 0
def _emit_workflow_json(payload: dict[str, Any]) -> None: