delimit-cli 4.5.2 → 4.5.4
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.
- package/README.md +13 -3
- package/gateway/ai/remote_resolve.py +422 -0
- package/gateway/ai/server.py +173 -111
- package/gateway/ai/social_capability/capability_validator.py +107 -13
- package/gateway/ai/social_capability/fit_floor.py +360 -0
- package/gateway/ai/vendor_news/__init__.py +14 -0
- package/gateway/ai/vendor_news/drafter.py +562 -0
- package/gateway/ai/vendor_news/sensor.py +509 -0
- package/gateway/ai/vendor_news/watchlist.yaml +71 -0
- package/gateway/ai/x_ranker.py +146 -5
- package/package.json +19 -3
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `</>` Delimit
|
|
2
2
|
|
|
3
|
-
**The merge gate for AI-written code
|
|
3
|
+
**The merge gate for AI-written code, with signed, replayable attestation.**
|
|
4
4
|
|
|
5
5
|
Wrap any AI coding assistant (Claude Code, Codex, Cursor, Gemini CLI) with a governance chain that runs your gates, records what changed, and signs a replayable receipt for every merge.
|
|
6
6
|
|
|
@@ -25,11 +25,21 @@ $ delimit wrap -- claude "fix the flaky test in tests/api.spec.ts"
|
|
|
25
25
|
Every wrapped run emits a `delimit.attestation.v1` bundle: repo head before/after, changed files, gate results, HMAC-SHA256 signature, and a replay URL. Advisory by default; flip to enforcing when you're ready.
|
|
26
26
|
|
|
27
27
|
<p align="center">
|
|
28
|
-
<a href="https://
|
|
28
|
+
<a href="https://delimit.ai/methodology/mcp-attestation">Methodology</a> · <a href="https://delimit.ai/reports/cal-com-v2-attestation">cal.com v2 worked example</a> · <a href="https://delimit.ai/docs/workflow">Workflow guide</a> · <a href="https://delimit.ai">Website</a>
|
|
29
29
|
</p>
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
33
|
+
## See it in action
|
|
34
|
+
|
|
35
|
+
Worked example, real OSS repo, every claim verifiable:
|
|
36
|
+
|
|
37
|
+
- **[cal.com v2 API attestation](https://delimit.ai/reports/cal-com-v2-attestation)**: full diff, signed verdict, replayable bundle. Runs the same chain you get on day one.
|
|
38
|
+
|
|
39
|
+
For the schema and signing methodology behind every report, see **[delimit.ai/methodology/mcp-attestation](https://delimit.ai/methodology/mcp-attestation)**.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
33
43
|
## Think and Build
|
|
34
44
|
|
|
35
45
|
Beyond the merge gate, Delimit orchestrates multi-model deliberation and autonomous builds. `delimit think` dispatches a strategic question to Claude, Codex, Gemini, and Grok; `delimit build` activates a background daemon that executes ledger tasks through the gate chain. `delimit vault` manages local secrets (AES-256).
|
|
@@ -364,7 +374,7 @@ Yes. Delimit works with Claude Code, Codex (OpenAI), Gemini CLI (Google), and Cu
|
|
|
364
374
|
|
|
365
375
|
**Is this free?**
|
|
366
376
|
|
|
367
|
-
The free tier includes API governance, persistent memory, zero-spec extraction, project scanning, and 3 multi-model deliberations. Pro ($10/mo) adds unlimited deliberation, security audit, test verification, deploy pipeline, and agent orchestration.
|
|
377
|
+
The free tier includes API governance, persistent memory, zero-spec extraction, project scanning, and 3 multi-model deliberations. Pro ($10/mo) adds unlimited deliberation, security audit, test verification, deploy pipeline, and agent orchestration. Premium ($50-100/mo) adds priority support and team features. Enterprise is custom: see [delimit.ai/pricing](https://delimit.ai/pricing).
|
|
368
378
|
|
|
369
379
|
---
|
|
370
380
|
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Remote-input resolution helpers for MCP tools (LED-1237).
|
|
2
|
+
|
|
3
|
+
Tools like delimit_repo_analyze and delimit_lint historically only
|
|
4
|
+
accepted local filesystem paths. When a multi-model deliberation panel
|
|
5
|
+
emits ``[TOOL: delimit_repo_analyze target="calcom/cal.com"]`` the
|
|
6
|
+
target was resolved against the cwd and silently returned an empty
|
|
7
|
+
analysis (total_files=0).
|
|
8
|
+
|
|
9
|
+
This module adds **additive** remote-input handling:
|
|
10
|
+
|
|
11
|
+
* ``resolve_repo_target(target)`` — context manager that accepts either
|
|
12
|
+
a local path, a ``<owner>/<repo>`` shorthand, or a full
|
|
13
|
+
https/ssh GitHub URL, and yields ``(local_path, metadata)``.
|
|
14
|
+
|
|
15
|
+
* ``resolve_spec_input(spec)`` — context manager that accepts either a
|
|
16
|
+
local path or an http(s) URL, fetches the URL into a tempfile with
|
|
17
|
+
the right extension, and yields ``(local_path, metadata)``.
|
|
18
|
+
|
|
19
|
+
Both helpers are no-ops on local input (passthrough). All cleanup is
|
|
20
|
+
handled automatically. Network/clone failures raise
|
|
21
|
+
``RemoteResolveError`` which callers should convert into a clean
|
|
22
|
+
``{"error": "...", ...}`` response.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import ipaddress
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import socket
|
|
34
|
+
import subprocess
|
|
35
|
+
import tempfile
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, Iterator, Optional, Tuple
|
|
38
|
+
from urllib.parse import urlparse
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("delimit.ai.remote_resolve")
|
|
41
|
+
|
|
42
|
+
# ─── Constants ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
GIT_CLONE_TIMEOUT_S = 120
|
|
45
|
+
HTTP_FETCH_TIMEOUT_S = 60
|
|
46
|
+
HTTP_FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
47
|
+
|
|
48
|
+
# <owner>/<repo> regex: each segment is a non-empty token without
|
|
49
|
+
# whitespace, slashes (other than the single separator), or path
|
|
50
|
+
# traversal. We deliberately keep this conservative — anything fancy
|
|
51
|
+
# falls back to local-path semantics.
|
|
52
|
+
_OWNER_REPO_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)$")
|
|
53
|
+
|
|
54
|
+
_GITHUB_URL_PREFIXES = (
|
|
55
|
+
"https://github.com/",
|
|
56
|
+
"http://github.com/",
|
|
57
|
+
"git@github.com:",
|
|
58
|
+
"ssh://git@github.com/",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─── Exceptions ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RemoteResolveError(Exception):
|
|
66
|
+
"""Raised when a remote target cannot be resolved.
|
|
67
|
+
|
|
68
|
+
Carries an ``error`` code and a human-readable detail. Callers
|
|
69
|
+
should translate this into a structured response dict — never let
|
|
70
|
+
it bubble up as a stack trace into an MCP response.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, error: str, detail: str, target: Optional[str] = None) -> None:
|
|
74
|
+
super().__init__(detail)
|
|
75
|
+
self.error = error
|
|
76
|
+
self.detail = detail
|
|
77
|
+
self.target = target
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
80
|
+
out: Dict[str, Any] = {"error": self.error, "detail": self.detail}
|
|
81
|
+
if self.target is not None:
|
|
82
|
+
out["target"] = self.target
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ─── Repo target classification ──────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_github_url(target: str) -> bool:
|
|
90
|
+
return any(target.startswith(p) for p in _GITHUB_URL_PREFIXES)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_github_url(target: str) -> str:
|
|
94
|
+
"""Convert any accepted GitHub URL form to an https clone URL.
|
|
95
|
+
|
|
96
|
+
Handles ``git@github.com:owner/repo(.git)`` and
|
|
97
|
+
``ssh://git@github.com/owner/repo(.git)`` plus pre-formed
|
|
98
|
+
``https://github.com/owner/repo``.
|
|
99
|
+
"""
|
|
100
|
+
if target.startswith("git@github.com:"):
|
|
101
|
+
path = target[len("git@github.com:"):]
|
|
102
|
+
return "https://github.com/" + path.rstrip("/")
|
|
103
|
+
if target.startswith("ssh://git@github.com/"):
|
|
104
|
+
path = target[len("ssh://git@github.com/"):]
|
|
105
|
+
return "https://github.com/" + path.rstrip("/")
|
|
106
|
+
if target.startswith("http://github.com/"):
|
|
107
|
+
return "https://" + target[len("http://"):]
|
|
108
|
+
return target.rstrip("/")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _looks_like_owner_repo(target: str) -> bool:
|
|
112
|
+
"""Detect ``owner/repo`` shorthand.
|
|
113
|
+
|
|
114
|
+
Required:
|
|
115
|
+
* exactly one '/' separator
|
|
116
|
+
* both halves non-empty and match a conservative slug pattern
|
|
117
|
+
* no path traversal ('..'); does not start with '/' or '.'
|
|
118
|
+
* the literal ``./<owner>/<repo>`` does not exist on disk
|
|
119
|
+
(backwards-compat guard so a real local subdir wins)
|
|
120
|
+
"""
|
|
121
|
+
if not target or target.startswith(("/", ".")):
|
|
122
|
+
return False
|
|
123
|
+
if ".." in target.split("/"):
|
|
124
|
+
return False
|
|
125
|
+
if not _OWNER_REPO_RE.match(target):
|
|
126
|
+
return False
|
|
127
|
+
if Path(target).exists():
|
|
128
|
+
# A real local dir/file with this name exists — treat as local.
|
|
129
|
+
return False
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def classify_repo_target(target: str) -> str:
|
|
134
|
+
"""Return one of: ``"github_url"``, ``"owner_repo"``, ``"local"``.
|
|
135
|
+
|
|
136
|
+
Pure function, no I/O beyond a single ``Path.exists`` check for the
|
|
137
|
+
backwards-compat guard. Exposed for tests.
|
|
138
|
+
"""
|
|
139
|
+
if _is_github_url(target):
|
|
140
|
+
return "github_url"
|
|
141
|
+
if _looks_like_owner_repo(target):
|
|
142
|
+
return "owner_repo"
|
|
143
|
+
return "local"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ─── Git clone ───────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _git_clone_shallow(url: str, dest: str) -> None:
|
|
150
|
+
"""Run ``git clone --depth 1 <url> <dest>``.
|
|
151
|
+
|
|
152
|
+
Raises ``RemoteResolveError`` on failure (non-zero exit, timeout, or
|
|
153
|
+
git binary missing).
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
proc = subprocess.run(
|
|
157
|
+
["git", "clone", "--depth", "1", url, dest],
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=GIT_CLONE_TIMEOUT_S,
|
|
161
|
+
)
|
|
162
|
+
except FileNotFoundError as e:
|
|
163
|
+
raise RemoteResolveError(
|
|
164
|
+
"clone_failed",
|
|
165
|
+
f"git binary not found: {e}",
|
|
166
|
+
target=url,
|
|
167
|
+
) from e
|
|
168
|
+
except subprocess.TimeoutExpired as e:
|
|
169
|
+
raise RemoteResolveError(
|
|
170
|
+
"clone_failed",
|
|
171
|
+
f"git clone timed out after {GIT_CLONE_TIMEOUT_S}s",
|
|
172
|
+
target=url,
|
|
173
|
+
) from e
|
|
174
|
+
except Exception as e: # pragma: no cover - defensive
|
|
175
|
+
raise RemoteResolveError(
|
|
176
|
+
"clone_failed",
|
|
177
|
+
f"git clone failed: {e}",
|
|
178
|
+
target=url,
|
|
179
|
+
) from e
|
|
180
|
+
|
|
181
|
+
if proc.returncode != 0:
|
|
182
|
+
# stderr tends to carry the useful "Repository not found" /
|
|
183
|
+
# "Authentication failed" message. Trim it so we don't dump
|
|
184
|
+
# huge output into MCP responses.
|
|
185
|
+
stderr = (proc.stderr or "").strip()
|
|
186
|
+
if len(stderr) > 500:
|
|
187
|
+
stderr = stderr[:500] + "..."
|
|
188
|
+
raise RemoteResolveError(
|
|
189
|
+
"clone_failed",
|
|
190
|
+
stderr or f"git clone exited with code {proc.returncode}",
|
|
191
|
+
target=url,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@contextlib.contextmanager
|
|
196
|
+
def resolve_repo_target(target: str) -> Iterator[Tuple[str, Dict[str, Any]]]:
|
|
197
|
+
"""Resolve a repo target to a local path.
|
|
198
|
+
|
|
199
|
+
Yields ``(path, metadata)``. Metadata always includes
|
|
200
|
+
``resolved_from`` (one of ``"local"``, ``"remote_clone"``) and may
|
|
201
|
+
include ``upstream_url`` for remote clones.
|
|
202
|
+
|
|
203
|
+
Cleanup of any temporary clone happens automatically when the
|
|
204
|
+
``with`` block exits, even on exception inside the block.
|
|
205
|
+
|
|
206
|
+
Raises ``RemoteResolveError`` if the input cannot be resolved
|
|
207
|
+
(clone failure, malformed URL). Local-path targets always succeed
|
|
208
|
+
here — the existence check stays the responsibility of the
|
|
209
|
+
downstream backend so that ``analyze("nonexistent/")`` keeps its
|
|
210
|
+
pre-existing behavior.
|
|
211
|
+
"""
|
|
212
|
+
kind = classify_repo_target(target)
|
|
213
|
+
|
|
214
|
+
if kind == "local":
|
|
215
|
+
yield target, {"resolved_from": "local"}
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Remote: normalize to an https URL.
|
|
219
|
+
if kind == "github_url":
|
|
220
|
+
url = _normalize_github_url(target)
|
|
221
|
+
else: # owner_repo
|
|
222
|
+
url = f"https://github.com/{target}"
|
|
223
|
+
|
|
224
|
+
tmp = tempfile.mkdtemp(prefix="delimit-repo-")
|
|
225
|
+
# mkdtemp creates the dir; git clone refuses to clone into an
|
|
226
|
+
# existing non-empty dir, so we point it at a child path.
|
|
227
|
+
clone_dir = os.path.join(tmp, "repo")
|
|
228
|
+
try:
|
|
229
|
+
_git_clone_shallow(url, clone_dir)
|
|
230
|
+
meta: Dict[str, Any] = {
|
|
231
|
+
"resolved_from": "remote_clone",
|
|
232
|
+
"upstream_url": url,
|
|
233
|
+
}
|
|
234
|
+
yield clone_dir, meta
|
|
235
|
+
finally:
|
|
236
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ─── Spec (URL) input resolution ─────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_ALLOWED_SPEC_SCHEMES = ("http", "https")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _classify_spec_input(spec: str) -> str:
|
|
246
|
+
"""Return ``"url"`` or ``"local"``.
|
|
247
|
+
|
|
248
|
+
Anything starting with ``http://`` or ``https://`` is a URL.
|
|
249
|
+
Everything else is a local path. Unknown schemes (file://, ftp://,
|
|
250
|
+
javascript:) are rejected up front — but only if they actually look
|
|
251
|
+
like a URL, otherwise a local path beginning with ``foo:`` would
|
|
252
|
+
misclassify on Windows-style drives etc. We're strict: any
|
|
253
|
+
``<scheme>://`` prefix that isn't http(s) is rejected.
|
|
254
|
+
"""
|
|
255
|
+
if spec.startswith(("http://", "https://")):
|
|
256
|
+
return "url"
|
|
257
|
+
# Reject other URL-ish schemes outright.
|
|
258
|
+
m = re.match(r"^([a-zA-Z][a-zA-Z0-9+\-.]*)://", spec)
|
|
259
|
+
if m:
|
|
260
|
+
raise RemoteResolveError(
|
|
261
|
+
"invalid_scheme",
|
|
262
|
+
f"only http(s) URLs are accepted; got scheme '{m.group(1)}'",
|
|
263
|
+
target=spec,
|
|
264
|
+
)
|
|
265
|
+
# javascript: / data: — no '//' after the colon, treat as local
|
|
266
|
+
# path candidate (will fail downstream if invalid).
|
|
267
|
+
return "local"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _ensure_public_host(url: str) -> None:
|
|
271
|
+
"""SSRF guard: reject URLs that resolve to private/loopback IPs.
|
|
272
|
+
|
|
273
|
+
Resolves the hostname and checks the resulting IP against the
|
|
274
|
+
standard private/loopback/link-local/reserved ranges. Raises
|
|
275
|
+
``RemoteResolveError`` on any disallowed host.
|
|
276
|
+
"""
|
|
277
|
+
parsed = urlparse(url)
|
|
278
|
+
host = parsed.hostname
|
|
279
|
+
if not host:
|
|
280
|
+
raise RemoteResolveError(
|
|
281
|
+
"invalid_url",
|
|
282
|
+
"URL is missing a hostname",
|
|
283
|
+
target=url,
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
ip_str = socket.gethostbyname(host)
|
|
287
|
+
except OSError as e:
|
|
288
|
+
raise RemoteResolveError(
|
|
289
|
+
"fetch_failed",
|
|
290
|
+
f"DNS resolution failed for {host}: {e}",
|
|
291
|
+
target=url,
|
|
292
|
+
) from e
|
|
293
|
+
try:
|
|
294
|
+
ip = ipaddress.ip_address(ip_str)
|
|
295
|
+
except ValueError as e: # pragma: no cover - defensive
|
|
296
|
+
raise RemoteResolveError(
|
|
297
|
+
"fetch_failed",
|
|
298
|
+
f"could not parse resolved IP {ip_str}: {e}",
|
|
299
|
+
target=url,
|
|
300
|
+
) from e
|
|
301
|
+
if (
|
|
302
|
+
ip.is_private
|
|
303
|
+
or ip.is_loopback
|
|
304
|
+
or ip.is_link_local
|
|
305
|
+
or ip.is_reserved
|
|
306
|
+
or ip.is_multicast
|
|
307
|
+
or ip.is_unspecified
|
|
308
|
+
):
|
|
309
|
+
raise RemoteResolveError(
|
|
310
|
+
"blocked_host",
|
|
311
|
+
f"refusing to fetch from non-public host {host} ({ip_str})",
|
|
312
|
+
target=url,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _spec_extension_for(url: str, content_type: str) -> str:
|
|
317
|
+
"""Pick a tempfile extension based on URL or response Content-Type."""
|
|
318
|
+
parsed = urlparse(url)
|
|
319
|
+
path = parsed.path.lower()
|
|
320
|
+
for ext in (".yaml", ".yml", ".json"):
|
|
321
|
+
if path.endswith(ext):
|
|
322
|
+
return ext
|
|
323
|
+
ct = (content_type or "").lower()
|
|
324
|
+
if "yaml" in ct or "yml" in ct:
|
|
325
|
+
return ".yaml"
|
|
326
|
+
if "json" in ct:
|
|
327
|
+
return ".json"
|
|
328
|
+
# Fall back: many OpenAPI specs are served with text/plain — use
|
|
329
|
+
# .json because the loader auto-detects, and json parsers fail
|
|
330
|
+
# fast and clean on yaml.
|
|
331
|
+
return ".json"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _http_fetch(url: str) -> Tuple[bytes, str]:
|
|
335
|
+
"""Fetch ``url`` with size/timeout caps. Returns (body, content_type).
|
|
336
|
+
|
|
337
|
+
Raises ``RemoteResolveError`` on any failure (network, timeout,
|
|
338
|
+
oversize body, non-2xx status).
|
|
339
|
+
"""
|
|
340
|
+
# Lazy import so test envs without urllib quirks don't choke at
|
|
341
|
+
# module import time.
|
|
342
|
+
from urllib.error import HTTPError, URLError
|
|
343
|
+
from urllib.request import Request, urlopen
|
|
344
|
+
|
|
345
|
+
req = Request(url, headers={"User-Agent": "delimit-gateway-remote-resolve/1"})
|
|
346
|
+
try:
|
|
347
|
+
with urlopen(req, timeout=HTTP_FETCH_TIMEOUT_S) as resp:
|
|
348
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
349
|
+
# Read up to MAX+1 to detect oversize.
|
|
350
|
+
body = resp.read(HTTP_FETCH_MAX_BYTES + 1)
|
|
351
|
+
except HTTPError as e:
|
|
352
|
+
raise RemoteResolveError(
|
|
353
|
+
"fetch_failed",
|
|
354
|
+
f"HTTP {e.code}: {e.reason}",
|
|
355
|
+
target=url,
|
|
356
|
+
) from e
|
|
357
|
+
except URLError as e:
|
|
358
|
+
raise RemoteResolveError(
|
|
359
|
+
"fetch_failed",
|
|
360
|
+
f"network error: {e.reason}",
|
|
361
|
+
target=url,
|
|
362
|
+
) from e
|
|
363
|
+
except Exception as e:
|
|
364
|
+
raise RemoteResolveError(
|
|
365
|
+
"fetch_failed",
|
|
366
|
+
f"fetch failed: {e}",
|
|
367
|
+
target=url,
|
|
368
|
+
) from e
|
|
369
|
+
|
|
370
|
+
if len(body) > HTTP_FETCH_MAX_BYTES:
|
|
371
|
+
raise RemoteResolveError(
|
|
372
|
+
"fetch_failed",
|
|
373
|
+
f"response body exceeds {HTTP_FETCH_MAX_BYTES} bytes",
|
|
374
|
+
target=url,
|
|
375
|
+
)
|
|
376
|
+
return body, content_type
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@contextlib.contextmanager
|
|
380
|
+
def resolve_spec_input(spec: str) -> Iterator[Tuple[str, Dict[str, Any]]]:
|
|
381
|
+
"""Resolve a spec input (local path or URL) to a local path.
|
|
382
|
+
|
|
383
|
+
Yields ``(path, metadata)``. Metadata always includes
|
|
384
|
+
``resolved_from`` (``"local"`` or ``"url"``) and, for URLs,
|
|
385
|
+
``upstream_url`` and ``content_type``.
|
|
386
|
+
|
|
387
|
+
Tempfiles are cleaned up automatically when the ``with`` block
|
|
388
|
+
exits.
|
|
389
|
+
"""
|
|
390
|
+
kind = _classify_spec_input(spec)
|
|
391
|
+
|
|
392
|
+
if kind == "local":
|
|
393
|
+
yield spec, {"resolved_from": "local"}
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# URL path: SSRF guard, fetch, write to tempfile.
|
|
397
|
+
_ensure_public_host(spec)
|
|
398
|
+
body, content_type = _http_fetch(spec)
|
|
399
|
+
ext = _spec_extension_for(spec, content_type)
|
|
400
|
+
fd, tmp_path = tempfile.mkstemp(prefix="delimit-spec-", suffix=ext)
|
|
401
|
+
try:
|
|
402
|
+
with os.fdopen(fd, "wb") as f:
|
|
403
|
+
f.write(body)
|
|
404
|
+
meta: Dict[str, Any] = {
|
|
405
|
+
"resolved_from": "url",
|
|
406
|
+
"upstream_url": spec,
|
|
407
|
+
"content_type": content_type,
|
|
408
|
+
}
|
|
409
|
+
yield tmp_path, meta
|
|
410
|
+
finally:
|
|
411
|
+
try:
|
|
412
|
+
os.unlink(tmp_path)
|
|
413
|
+
except OSError:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
__all__ = [
|
|
418
|
+
"RemoteResolveError",
|
|
419
|
+
"classify_repo_target",
|
|
420
|
+
"resolve_repo_target",
|
|
421
|
+
"resolve_spec_input",
|
|
422
|
+
]
|