@andespindola/crow-skills 0.40.0 → 0.41.0
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/crow_skills/__init__.py +1 -1
- package/package.json +1 -1
- package/release.json +33 -8
- package/skills/crow-mcp-maker/SKILL.md +6 -6
- package/skills/crow-mcp-maker/references/full-runtime.md +88 -0
- package/skills/crow-mcp-maker/references/security-and-cost.md +1 -1
- package/skills/crow-mcp-maker/scripts/engines.py +732 -0
- package/skills/crow-skill-maker/SKILL.md +6 -5
- package/skills/crow-skill-maker/references/full-runtime.md +108 -0
- package/skills/crow-skill-maker/references/security-and-cost.md +2 -2
- package/skills/crow-skill-maker/scripts/creator.py +839 -0
- package/skills/crow-skill-maker/scripts/engines.py +732 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Explicit isolated upstream runtimes; stdlib manager, NOT a security sandbox.
|
|
3
|
+
|
|
4
|
+
status never executes third-party code. setup requires --yes; run never installs.
|
|
5
|
+
All upstream arguments after -- pass unchanged. Native TUI needs --interactive.
|
|
6
|
+
Private stdout artifacts retain complete upstream output (possibly sensitive).
|
|
7
|
+
Operational reports never include argv, environment, raw stdout or raw stderr.
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import base64
|
|
11
|
+
import contextlib
|
|
12
|
+
import functools
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
import selectors
|
|
19
|
+
import signal
|
|
20
|
+
import stat
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tarfile
|
|
24
|
+
import time
|
|
25
|
+
import threading
|
|
26
|
+
import urllib.request
|
|
27
|
+
import uuid
|
|
28
|
+
|
|
29
|
+
SCHEMA = 'crow-maker-runtime/1'
|
|
30
|
+
MAX_ARCHIVE = 64 * 1024 * 1024
|
|
31
|
+
MAX_EXTRACTED = 256 * 1024 * 1024
|
|
32
|
+
MAX_OUTPUT = 64 * 1024 * 1024
|
|
33
|
+
SPECS = {
|
|
34
|
+
'skill-scanner': {
|
|
35
|
+
'kind': 'python', 'repository': 'cisco-ai-defense/skill-scanner',
|
|
36
|
+
'extras': ['google', 'bedrock', 'vertex', 'azure', 'all'],
|
|
37
|
+
'commit': 'c558c421157c546ec417cf23778ce81a3a8f35fd',
|
|
38
|
+
'url': 'https://api.github.com/repos/cisco-ai-defense/skill-scanner/tarball/c558c421157c546ec417cf23778ce81a3a8f35fd',
|
|
39
|
+
'sha256': 'aa747556f5179aa213f49c897549052c8b13f0744b78b335921e377774ed3e5f',
|
|
40
|
+
'distribution': 'cisco-ai-skill-scanner', 'version': '0.0.0+git.c558c421157c',
|
|
41
|
+
'versionMeaning': 'Synthetic source version, not an upstream release version.',
|
|
42
|
+
'entrypoints': {'skill-scanner': 'venv/bin/skill-scanner',
|
|
43
|
+
'skill-scanner-api': 'venv/bin/skill-scanner-api',
|
|
44
|
+
'skill-scanner-pre-commit': 'venv/bin/skill-scanner-pre-commit'}},
|
|
45
|
+
'mcp-scanner': {
|
|
46
|
+
'kind': 'python', 'repository': 'cisco-ai-defense/mcp-scanner',
|
|
47
|
+
'extras': ['js', 'go', 'java', 'kotlin', 'csharp', 'ruby', 'rust', 'php', 'all-languages'],
|
|
48
|
+
'commit': '9ff8fc84b792aae315a838cf4e37b40ef46a0293',
|
|
49
|
+
'url': 'https://api.github.com/repos/cisco-ai-defense/mcp-scanner/tarball/9ff8fc84b792aae315a838cf4e37b40ef46a0293',
|
|
50
|
+
'sha256': '8f32cf61afbb74f9d60292f1e059fd79eeaefd99572a7bb9624471ec2c252aaf',
|
|
51
|
+
'distribution': 'cisco-ai-mcp-scanner', 'version': '4.8.7',
|
|
52
|
+
'entrypoints': {'mcp-scanner': 'venv/bin/mcp-scanner', 'mcp-scanner-api': 'venv/bin/mcp-scanner-api'}},
|
|
53
|
+
'inspector': {
|
|
54
|
+
'kind': 'npm', 'repository': 'modelcontextprotocol/inspector', 'extras': [],
|
|
55
|
+
'commit': '488c30d9d16e4993bc548ad298eb7450f5125c93',
|
|
56
|
+
'sourceArchiveSha256': '2f3b60cd98e500d2934581ffe7c81c4588e02c3d2742bee6d34c1b5d415500fe',
|
|
57
|
+
'url': 'https://registry.npmjs.org/@modelcontextprotocol/inspector/-/inspector-2.15.0.tgz',
|
|
58
|
+
'integrity': 'sha512-TWdX2oJNVTan3B0/0YLpVJWbeuuii3cgDnc7uIwAiCQ9q7QMvZuQrapkQXOC2MKsmyNWj0tplWjza3IolBU+wA==',
|
|
59
|
+
'distribution': '@modelcontextprotocol/inspector', 'version': '2.15.0',
|
|
60
|
+
'entrypoints': {'inspector': 'npm/node_modules/@modelcontextprotocol/inspector/clients/launcher/build/index.js',
|
|
61
|
+
'mcp-inspector': 'npm/node_modules/@modelcontextprotocol/inspector/clients/launcher/build/index.js',
|
|
62
|
+
'mcpdo': 'npm/node_modules/@modelcontextprotocol/inspector/clients/mcpdo/build/mcp-bin.js'}},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RuntimeErrorSafe(Exception):
|
|
67
|
+
def __init__(self, code):
|
|
68
|
+
super().__init__(code)
|
|
69
|
+
self.code = code
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class OperationCancelled(BaseException):
|
|
73
|
+
"""Cancellation crosses setup cleanup before becoming a structured result."""
|
|
74
|
+
def __init__(self, signum):
|
|
75
|
+
self.signum = int(signum)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cancel_result(signum, engine=None):
|
|
79
|
+
result = {'schema': SCHEMA, 'state': 'cancelled', 'cancelled': True,
|
|
80
|
+
'signal': signal.Signals(signum).name, 'signalNumber': signum,
|
|
81
|
+
'exitCode': 128 + signum, 'timedOut': False, 'ready': False}
|
|
82
|
+
if engine is not None:
|
|
83
|
+
result['engine'] = engine
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _SignalGuard:
|
|
88
|
+
def __init__(self):
|
|
89
|
+
self.deferred = False
|
|
90
|
+
self.pending = None
|
|
91
|
+
|
|
92
|
+
def handler(self, signum, frame):
|
|
93
|
+
# Do not raise between fork and assignment of the Popen object: that
|
|
94
|
+
# would orphan a newly created process before its owner can clean it.
|
|
95
|
+
if self.deferred:
|
|
96
|
+
self.pending = signum
|
|
97
|
+
else:
|
|
98
|
+
raise OperationCancelled(signum)
|
|
99
|
+
|
|
100
|
+
@contextlib.contextmanager
|
|
101
|
+
def deferred_signals(self):
|
|
102
|
+
self.deferred = True
|
|
103
|
+
try:
|
|
104
|
+
yield
|
|
105
|
+
finally:
|
|
106
|
+
self.deferred = False
|
|
107
|
+
if self.pending is not None:
|
|
108
|
+
signum, self.pending = self.pending, None
|
|
109
|
+
raise OperationCancelled(signum)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextlib.contextmanager
|
|
113
|
+
def _termination_scope():
|
|
114
|
+
"""Only main-thread operations temporarily own SIGTERM; always restore it.
|
|
115
|
+
|
|
116
|
+
Worker-thread calls cannot replace process signal handlers. Their embedding
|
|
117
|
+
main thread remains responsible for cancellation policy; no global handler
|
|
118
|
+
is installed or leaked by those calls.
|
|
119
|
+
"""
|
|
120
|
+
guard = _SignalGuard()
|
|
121
|
+
installed = threading.current_thread() is threading.main_thread()
|
|
122
|
+
previous = signal.getsignal(signal.SIGTERM) if installed else None
|
|
123
|
+
if installed:
|
|
124
|
+
signal.signal(signal.SIGTERM, guard.handler)
|
|
125
|
+
try:
|
|
126
|
+
yield guard
|
|
127
|
+
finally:
|
|
128
|
+
if installed:
|
|
129
|
+
signal.signal(signal.SIGTERM, previous)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cancellable(function):
|
|
133
|
+
@functools.wraps(function)
|
|
134
|
+
def wrapped(engine, *args, **kwargs):
|
|
135
|
+
with _termination_scope():
|
|
136
|
+
try:
|
|
137
|
+
return function(engine, *args, **kwargs)
|
|
138
|
+
except OperationCancelled as cancelled:
|
|
139
|
+
return _cancel_result(cancelled.signum, engine)
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
return _cancel_result(signal.SIGINT, engine)
|
|
142
|
+
return wrapped
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _json(value):
|
|
146
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False).encode()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _digest(raw):
|
|
150
|
+
return hashlib.sha256(raw).hexdigest()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _open(path, directory=False):
|
|
154
|
+
"""No-follow each absolute component; never accept special-file reads."""
|
|
155
|
+
if not hasattr(os, 'O_NOFOLLOW') or not hasattr(os, 'O_DIRECTORY'):
|
|
156
|
+
raise RuntimeErrorSafe('posix-no-follow-required')
|
|
157
|
+
path = Path(os.path.abspath(path))
|
|
158
|
+
fd = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
159
|
+
try:
|
|
160
|
+
for i, part in enumerate(path.parts[1:]):
|
|
161
|
+
final = i == len(path.parts[1:]) - 1
|
|
162
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_NONBLOCK', 0)
|
|
163
|
+
if not final or directory:
|
|
164
|
+
flags |= os.O_DIRECTORY
|
|
165
|
+
new = os.open(part, flags, dir_fd=fd)
|
|
166
|
+
os.close(fd)
|
|
167
|
+
fd = new
|
|
168
|
+
return fd
|
|
169
|
+
except BaseException:
|
|
170
|
+
os.close(fd)
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read(path, limit):
|
|
175
|
+
fd = _open(path)
|
|
176
|
+
try:
|
|
177
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
178
|
+
raise RuntimeErrorSafe('regular-file-required')
|
|
179
|
+
chunks, left = [], limit + 1
|
|
180
|
+
while left:
|
|
181
|
+
chunk = os.read(fd, min(65536, left))
|
|
182
|
+
if not chunk:
|
|
183
|
+
break
|
|
184
|
+
chunks.append(chunk)
|
|
185
|
+
left -= len(chunk)
|
|
186
|
+
raw = b''.join(chunks)
|
|
187
|
+
if len(raw) > limit:
|
|
188
|
+
raise RuntimeErrorSafe('file-byte-limit')
|
|
189
|
+
return raw
|
|
190
|
+
finally:
|
|
191
|
+
os.close(fd)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _private_dir(path, create=False):
|
|
195
|
+
path = Path(os.path.abspath(path))
|
|
196
|
+
if create:
|
|
197
|
+
parent = _open(path.parent, True)
|
|
198
|
+
try:
|
|
199
|
+
try:
|
|
200
|
+
os.mkdir(path.name, 0o700, dir_fd=parent)
|
|
201
|
+
except FileExistsError:
|
|
202
|
+
pass
|
|
203
|
+
finally:
|
|
204
|
+
os.close(parent)
|
|
205
|
+
fd = _open(path, True)
|
|
206
|
+
try:
|
|
207
|
+
info = os.fstat(fd)
|
|
208
|
+
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
|
|
209
|
+
raise RuntimeErrorSafe('runtime-directory-must-be-owned-and-private')
|
|
210
|
+
finally:
|
|
211
|
+
os.close(fd)
|
|
212
|
+
return path
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _atomic(path, raw):
|
|
216
|
+
fd = _open(Path(path).parent, True)
|
|
217
|
+
temp = '.write-' + uuid.uuid4().hex
|
|
218
|
+
try:
|
|
219
|
+
child = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
|
|
220
|
+
with os.fdopen(child, 'wb') as stream:
|
|
221
|
+
stream.write(raw)
|
|
222
|
+
stream.flush()
|
|
223
|
+
os.fsync(stream.fileno())
|
|
224
|
+
os.replace(temp, Path(path).name, src_dir_fd=fd, dst_dir_fd=fd)
|
|
225
|
+
finally:
|
|
226
|
+
try:
|
|
227
|
+
os.unlink(temp, dir_fd=fd)
|
|
228
|
+
except FileNotFoundError:
|
|
229
|
+
pass
|
|
230
|
+
os.close(fd)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _source_identity(spec):
|
|
234
|
+
if spec['kind'] == 'npm':
|
|
235
|
+
return {'installedDistribution': {key: spec[key] for key in ('distribution', 'version', 'integrity')},
|
|
236
|
+
'reviewedSource': {key: spec[key] for key in ('repository', 'commit', 'sourceArchiveSha256')},
|
|
237
|
+
'distributionToReviewedCommitMapping': 'Unverified: registry metadata supplies no gitHead; matching version is not commit proof.'}
|
|
238
|
+
return {key: spec[key] for key in ('repository', 'commit', 'sha256', 'sourceArchiveSha256', 'integrity', 'distribution', 'version') if key in spec}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _verify_archive(raw, spec):
|
|
242
|
+
if spec['kind'] == 'python':
|
|
243
|
+
valid = _digest(raw) == spec['sha256']
|
|
244
|
+
else:
|
|
245
|
+
valid = 'sha512-' + base64.b64encode(hashlib.sha512(raw).digest()).decode() == spec['integrity']
|
|
246
|
+
if not valid:
|
|
247
|
+
raise RuntimeErrorSafe('archive-hash-mismatch')
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _download(url, timeout=120):
|
|
251
|
+
deadline = time.monotonic() + timeout
|
|
252
|
+
request = urllib.request.Request(url, headers={'User-Agent': 'crow-maker-runtime/1'})
|
|
253
|
+
with urllib.request.urlopen(request, timeout=min(30, timeout)) as response:
|
|
254
|
+
if not response.geturl().startswith('https://'):
|
|
255
|
+
raise RuntimeErrorSafe('insecure-download-redirect')
|
|
256
|
+
chunks, count = [], 0
|
|
257
|
+
while True:
|
|
258
|
+
if time.monotonic() >= deadline:
|
|
259
|
+
raise RuntimeErrorSafe('download-timeout')
|
|
260
|
+
chunk = response.read(65536)
|
|
261
|
+
if not chunk:
|
|
262
|
+
break
|
|
263
|
+
count += len(chunk)
|
|
264
|
+
if count > MAX_ARCHIVE:
|
|
265
|
+
raise RuntimeErrorSafe('archive-byte-limit')
|
|
266
|
+
chunks.append(chunk)
|
|
267
|
+
return b''.join(chunks)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _extract(archive, destination):
|
|
271
|
+
"""Validate entire tar before writes: no links, devices, escapes or duplicates."""
|
|
272
|
+
parent_fd = _open(destination.parent, True)
|
|
273
|
+
try:
|
|
274
|
+
try:
|
|
275
|
+
os.mkdir(destination.name, 0o700, dir_fd=parent_fd)
|
|
276
|
+
except FileExistsError:
|
|
277
|
+
raise RuntimeErrorSafe('extraction-destination-must-be-new') from None
|
|
278
|
+
finally:
|
|
279
|
+
os.close(parent_fd)
|
|
280
|
+
_private_dir(destination)
|
|
281
|
+
with tarfile.open(archive, 'r:gz') as tar:
|
|
282
|
+
members, seen, total, roots = [], set(), 0, set()
|
|
283
|
+
for member in tar:
|
|
284
|
+
path = PurePosixPath(member.name)
|
|
285
|
+
if (not member.name or path.is_absolute() or '..' in path.parts or
|
|
286
|
+
'\\' in member.name or ':' in member.name or
|
|
287
|
+
not (member.isfile() or member.isdir()) or member.name in seen):
|
|
288
|
+
raise RuntimeErrorSafe('unsafe-archive-member')
|
|
289
|
+
if not path.parts or path.parts[0] in ('.', ''):
|
|
290
|
+
raise RuntimeErrorSafe('unsafe-archive-member')
|
|
291
|
+
roots.add(path.parts[0])
|
|
292
|
+
seen.add(member.name)
|
|
293
|
+
total += member.size
|
|
294
|
+
if len(seen) > 30000 or total > MAX_EXTRACTED or member.size > MAX_ARCHIVE:
|
|
295
|
+
raise RuntimeErrorSafe('archive-extraction-limit')
|
|
296
|
+
members.append(member)
|
|
297
|
+
if len(roots) != 1:
|
|
298
|
+
raise RuntimeErrorSafe('archive-must-have-single-source-root')
|
|
299
|
+
for member in members:
|
|
300
|
+
target = destination.joinpath(*PurePosixPath(member.name).parts)
|
|
301
|
+
# Destination was created by us and archive cannot introduce symlinks.
|
|
302
|
+
if member.isdir():
|
|
303
|
+
target.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
304
|
+
else:
|
|
305
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
306
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o700 if member.mode & 0o111 else 0o600)
|
|
307
|
+
with os.fdopen(descriptor, 'wb') as output, tar.extractfile(member) as source:
|
|
308
|
+
left = member.size
|
|
309
|
+
while left:
|
|
310
|
+
chunk = source.read(min(65536, left))
|
|
311
|
+
if not chunk:
|
|
312
|
+
raise RuntimeErrorSafe('archive-truncated-member')
|
|
313
|
+
output.write(chunk)
|
|
314
|
+
left -= len(chunk)
|
|
315
|
+
return destination / next(iter(roots))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _redact_stderr(raw, argv, environment):
|
|
319
|
+
# Never record argv/environment. Redact known credential values if a tool
|
|
320
|
+
# repeats them on stderr; stdout is an explicitly private raw result artifact.
|
|
321
|
+
candidates = []
|
|
322
|
+
for key, value in environment.items():
|
|
323
|
+
if any(word in key.upper() for word in ('TOKEN', 'SECRET', 'PASSWORD', 'API_KEY', 'AUTHORIZATION')) and len(value) >= 4:
|
|
324
|
+
candidates.append(value)
|
|
325
|
+
for i, value in enumerate(argv):
|
|
326
|
+
if value.startswith('-') and any(word in value.lower() for word in ('token', 'password', 'secret', 'api-key', 'authorization', 'header')):
|
|
327
|
+
if '=' in value:
|
|
328
|
+
candidates.append(value.split('=', 1)[1])
|
|
329
|
+
elif i + 1 < len(argv):
|
|
330
|
+
candidates.append(argv[i + 1])
|
|
331
|
+
for value in sorted(set(candidates), key=len, reverse=True):
|
|
332
|
+
if value:
|
|
333
|
+
raw = raw.replace(value.encode(), b'[REDACTED]')
|
|
334
|
+
return raw
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _kill_group_impl(process):
|
|
338
|
+
try:
|
|
339
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
340
|
+
except ProcessLookupError:
|
|
341
|
+
return
|
|
342
|
+
try:
|
|
343
|
+
process.wait(timeout=2)
|
|
344
|
+
except subprocess.TimeoutExpired:
|
|
345
|
+
pass
|
|
346
|
+
# Kill the group even if its leader already exited on TERM.
|
|
347
|
+
try:
|
|
348
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
349
|
+
except ProcessLookupError:
|
|
350
|
+
pass
|
|
351
|
+
process.wait()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _kill_group(process):
|
|
355
|
+
# A repeated SIGTERM must not interrupt the TERM -> grace -> KILL cleanup
|
|
356
|
+
# and leave a TERM-ignoring child alive. Deliver it only after cleanup.
|
|
357
|
+
with _termination_scope() as guard:
|
|
358
|
+
with guard.deferred_signals():
|
|
359
|
+
_kill_group_impl(process)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _invoke_impl(argv, home, timeout, environment=None, interactive=False, cwd=None, guard=None):
|
|
363
|
+
if not math.isfinite(timeout) or timeout <= 0 or timeout > 86400:
|
|
364
|
+
raise RuntimeErrorSafe('timeout-must-be-finite-positive-at-most-one-day')
|
|
365
|
+
env = os.environ.copy() if environment is None else environment
|
|
366
|
+
working_directory = home if cwd is None else cwd
|
|
367
|
+
logs = _private_dir(home / 'logs', True)
|
|
368
|
+
stem = uuid.uuid4().hex
|
|
369
|
+
output_path, error_path = logs / (stem + '.stdout'), logs / (stem + '.stderr')
|
|
370
|
+
start = time.monotonic()
|
|
371
|
+
if interactive:
|
|
372
|
+
process = None
|
|
373
|
+
try:
|
|
374
|
+
with guard.deferred_signals():
|
|
375
|
+
process = subprocess.Popen(argv, cwd=working_directory, env=env, shell=False, start_new_session=True)
|
|
376
|
+
code = process.wait(timeout=timeout)
|
|
377
|
+
return {'returnCode': code, 'timedOut': False, 'stdoutRef': None, 'stderrRef': None, 'interactive': True}
|
|
378
|
+
except subprocess.TimeoutExpired:
|
|
379
|
+
return {'returnCode': 124, 'timedOut': True, 'stdoutRef': None, 'stderrRef': None, 'interactive': True}
|
|
380
|
+
finally:
|
|
381
|
+
if process is not None:
|
|
382
|
+
_kill_group(process)
|
|
383
|
+
output_fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
384
|
+
error_fd = os.open(error_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
385
|
+
capture = bytearray()
|
|
386
|
+
stderr_chunks = []
|
|
387
|
+
truncated, timed_out, totals = False, False, {'out': 0, 'err': 0}
|
|
388
|
+
process = None
|
|
389
|
+
try:
|
|
390
|
+
with guard.deferred_signals():
|
|
391
|
+
process = subprocess.Popen(argv, cwd=working_directory, env=env, stdin=subprocess.DEVNULL,
|
|
392
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
393
|
+
shell=False, start_new_session=True)
|
|
394
|
+
selector = selectors.DefaultSelector()
|
|
395
|
+
selector.register(process.stdout, selectors.EVENT_READ, 'out')
|
|
396
|
+
selector.register(process.stderr, selectors.EVENT_READ, 'err')
|
|
397
|
+
try:
|
|
398
|
+
while selector.get_map():
|
|
399
|
+
if time.monotonic() - start >= timeout:
|
|
400
|
+
timed_out = True
|
|
401
|
+
_kill_group(process)
|
|
402
|
+
break
|
|
403
|
+
for key, _ in selector.select(timeout=min(.1, timeout)):
|
|
404
|
+
raw = os.read(key.fileobj.fileno(), 65536)
|
|
405
|
+
if not raw:
|
|
406
|
+
selector.unregister(key.fileobj)
|
|
407
|
+
continue
|
|
408
|
+
kind = key.data
|
|
409
|
+
remaining = max(0, MAX_OUTPUT - totals[kind])
|
|
410
|
+
if len(raw) > remaining:
|
|
411
|
+
truncated = True
|
|
412
|
+
kept = raw[:remaining]
|
|
413
|
+
totals[kind] += len(raw)
|
|
414
|
+
if kind == 'out':
|
|
415
|
+
offset = 0
|
|
416
|
+
while offset < len(kept):
|
|
417
|
+
offset += os.write(output_fd, kept[offset:])
|
|
418
|
+
if len(capture) < 8*1024*1024:
|
|
419
|
+
capture.extend(kept[:8*1024*1024-len(capture)])
|
|
420
|
+
else:
|
|
421
|
+
# Stderr chunks are private and known credential values
|
|
422
|
+
# are scrubbed. Arbitrary unknown secrets remain possible.
|
|
423
|
+
stderr_chunks.append(kept)
|
|
424
|
+
if not timed_out:
|
|
425
|
+
try:
|
|
426
|
+
process.wait(timeout=max(.001, timeout - (time.monotonic()-start)))
|
|
427
|
+
except subprocess.TimeoutExpired:
|
|
428
|
+
timed_out = True
|
|
429
|
+
_kill_group(process)
|
|
430
|
+
if not timed_out:
|
|
431
|
+
_kill_group(process)
|
|
432
|
+
scrubbed = _redact_stderr(b''.join(stderr_chunks), argv, env)
|
|
433
|
+
offset = 0
|
|
434
|
+
while offset < len(scrubbed):
|
|
435
|
+
offset += os.write(error_fd, scrubbed[offset:])
|
|
436
|
+
finally:
|
|
437
|
+
selector.close()
|
|
438
|
+
process.stdout.close()
|
|
439
|
+
process.stderr.close()
|
|
440
|
+
except BaseException as failure:
|
|
441
|
+
if process is not None:
|
|
442
|
+
_kill_group(process)
|
|
443
|
+
for stream in (process.stdout, process.stderr):
|
|
444
|
+
if stream is not None and not stream.closed:
|
|
445
|
+
stream.close()
|
|
446
|
+
if isinstance(failure, (OperationCancelled, KeyboardInterrupt)):
|
|
447
|
+
failure.stdout_ref = 'logs/' + output_path.name
|
|
448
|
+
failure.stderr_ref = 'logs/' + error_path.name
|
|
449
|
+
raise
|
|
450
|
+
finally:
|
|
451
|
+
os.close(output_fd)
|
|
452
|
+
os.close(error_fd)
|
|
453
|
+
return {'returnCode': 124 if timed_out else process.returncode, 'timedOut': timed_out,
|
|
454
|
+
'outputTruncated': truncated, 'stdoutRef': 'logs/' + output_path.name,
|
|
455
|
+
'stderrRef': 'logs/' + error_path.name, '_stdout': bytes(capture)}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _invoke(argv, home, timeout, environment=None, interactive=False, cwd=None):
|
|
459
|
+
with _termination_scope() as guard:
|
|
460
|
+
try:
|
|
461
|
+
return _invoke_impl(argv, home, timeout, environment, interactive, cwd, guard)
|
|
462
|
+
except OperationCancelled as cancelled:
|
|
463
|
+
result = _cancel_result(cancelled.signum)
|
|
464
|
+
refs = (getattr(cancelled, 'stdout_ref', None), getattr(cancelled, 'stderr_ref', None))
|
|
465
|
+
except KeyboardInterrupt as cancelled:
|
|
466
|
+
result = _cancel_result(signal.SIGINT)
|
|
467
|
+
refs = (getattr(cancelled, 'stdout_ref', None), getattr(cancelled, 'stderr_ref', None))
|
|
468
|
+
result.update({'returnCode': result['exitCode'], 'stdoutRef': refs[0], 'stderrRef': refs[1],
|
|
469
|
+
'outputTruncated': False, 'interactive': interactive, '_stdout': b''})
|
|
470
|
+
return result
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _public_result(result):
|
|
474
|
+
return {key: value for key, value in result.items() if not key.startswith('_')}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _install_env(home):
|
|
478
|
+
env = {key: value for key, value in os.environ.items() if key in ('PATH', 'SYSTEMROOT', 'LANG', 'LC_ALL', 'SSL_CERT_FILE', 'SSL_CERT_DIR')}
|
|
479
|
+
for name in ('home', 'tmp', 'cache'):
|
|
480
|
+
_private_dir(home / name, True)
|
|
481
|
+
# npm rejects loading one path twice as both user and global config.
|
|
482
|
+
# Separate owned empty files also prevent reading the caller's npmrc.
|
|
483
|
+
user_config, global_config = home / 'npm-user.npmrc', home / 'npm-global.npmrc'
|
|
484
|
+
_atomic(user_config, b'')
|
|
485
|
+
_atomic(global_config, b'')
|
|
486
|
+
env.update({'HOME': str(home / 'home'), 'TMPDIR': str(home / 'tmp'),
|
|
487
|
+
'PIP_CONFIG_FILE': os.devnull, 'PIP_DISABLE_PIP_VERSION_CHECK': '1',
|
|
488
|
+
'PIP_CACHE_DIR': str(home / 'cache'), 'PYTHONNOUSERSITE': '1',
|
|
489
|
+
'npm_config_cache': str(home / 'cache'), 'npm_config_userconfig': str(user_config),
|
|
490
|
+
'npm_config_globalconfig': str(global_config), 'npm_config_audit': 'false',
|
|
491
|
+
'npm_config_fund': 'false'})
|
|
492
|
+
return env
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _checked(argv, home, timeout, env):
|
|
496
|
+
result = _invoke(argv, home, timeout, environment=env)
|
|
497
|
+
if result.get('cancelled'):
|
|
498
|
+
raise OperationCancelled(result['signalNumber'])
|
|
499
|
+
if result['returnCode'] != 0 or result.get('outputTruncated'):
|
|
500
|
+
raise RuntimeErrorSafe('installer-or-verification-command-failed-see-private-logs')
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _installed_version(home, spec):
|
|
505
|
+
if spec['kind'] == 'npm':
|
|
506
|
+
data = json.loads(_read(home / 'npm/node_modules/@modelcontextprotocol/inspector/package.json', 1024*1024))
|
|
507
|
+
if data.get('name') != spec['distribution']:
|
|
508
|
+
raise RuntimeErrorSafe('installed-package-name-mismatch')
|
|
509
|
+
return data.get('version')
|
|
510
|
+
site = home / 'venv/lib'
|
|
511
|
+
# Static metadata reads: status does not start Python or import packages.
|
|
512
|
+
versions = []
|
|
513
|
+
for library in site.iterdir():
|
|
514
|
+
if not library.name.startswith('python') or library.is_symlink():
|
|
515
|
+
continue
|
|
516
|
+
packages = library / 'site-packages'
|
|
517
|
+
if not packages.is_dir() or packages.is_symlink():
|
|
518
|
+
continue
|
|
519
|
+
for item in packages.iterdir():
|
|
520
|
+
if item.name.endswith('.dist-info') and not item.is_symlink():
|
|
521
|
+
metadata = _read(item / 'METADATA', 2*1024*1024).decode('utf-8')
|
|
522
|
+
name, version = None, None
|
|
523
|
+
for line in metadata.splitlines():
|
|
524
|
+
if line.startswith('Name: '): name = line[6:].strip()
|
|
525
|
+
if line.startswith('Version: '): version = line[9:].strip()
|
|
526
|
+
if name and version: break
|
|
527
|
+
if name and name.lower().replace('_', '-') == spec['distribution']:
|
|
528
|
+
versions.append(version)
|
|
529
|
+
if len(versions) != 1:
|
|
530
|
+
raise RuntimeErrorSafe('installed-package-metadata-missing-or-ambiguous')
|
|
531
|
+
return versions[0]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def status(engine, runtime_root):
|
|
535
|
+
spec = SPECS[engine]
|
|
536
|
+
root = Path(os.path.abspath(runtime_root))
|
|
537
|
+
result = {'schema': SCHEMA, 'engine': engine, 'installed': False,
|
|
538
|
+
'configured': False, 'verified': False, 'ready': False,
|
|
539
|
+
'configurationScope': 'Runtime environment and extras only.',
|
|
540
|
+
'providerConfiguration': 'not-checked', 'providerCredentialsVerified': False,
|
|
541
|
+
'source': _source_identity(spec), 'availableExtras': spec['extras'], 'exitCode': 1,
|
|
542
|
+
'verificationScope': 'Pinned source identity, installed target version, entrypoint hashes and resolved-inventory hash; not all dependency file contents.',
|
|
543
|
+
'limitations': ['Installed dependencies are resolved and recorded, not fully transitive hash-locked.',
|
|
544
|
+
'Status does not execute third-party code or validate provider credentials, networking or every upstream mode.']}
|
|
545
|
+
try:
|
|
546
|
+
_private_dir(root)
|
|
547
|
+
home = _private_dir(root / engine)
|
|
548
|
+
result['installed'] = True
|
|
549
|
+
marker = json.loads(_read(home / 'READY.json', 2*1024*1024))
|
|
550
|
+
if (marker.get('schema') != SCHEMA or marker.get('state') != 'complete' or
|
|
551
|
+
marker.get('source') != _source_identity(spec)):
|
|
552
|
+
raise RuntimeErrorSafe('marker-identity-or-state-mismatch')
|
|
553
|
+
extras = marker.get('extras', [])
|
|
554
|
+
if not isinstance(extras, list) or any(value not in spec['extras'] for value in extras):
|
|
555
|
+
raise RuntimeErrorSafe('marker-extras-invalid')
|
|
556
|
+
result['configured'] = True
|
|
557
|
+
result['configuredExtras'] = extras
|
|
558
|
+
if _installed_version(home, spec) != spec['version']:
|
|
559
|
+
raise RuntimeErrorSafe('installed-version-mismatch')
|
|
560
|
+
if _digest(_read(home / 'resolved-inventory.json', 16*1024*1024)) != marker['inventorySha256']:
|
|
561
|
+
raise RuntimeErrorSafe('inventory-changed')
|
|
562
|
+
expected = set(spec['entrypoints'].values())
|
|
563
|
+
if set(marker.get('entrypointSha256', {})) != expected:
|
|
564
|
+
raise RuntimeErrorSafe('entrypoint-set-mismatch')
|
|
565
|
+
for rel, digest in marker['entrypointSha256'].items():
|
|
566
|
+
if _digest(_read(home / rel, 16*1024*1024)) != digest:
|
|
567
|
+
raise RuntimeErrorSafe('entrypoint-changed')
|
|
568
|
+
result.update({'verified': True, 'ready': True, 'exitCode': 0, 'entrypoints': sorted(spec['entrypoints'])})
|
|
569
|
+
if spec['kind'] == 'python':
|
|
570
|
+
result['runtimePython'] = str(home / 'venv/bin/python')
|
|
571
|
+
except (OSError, ValueError, KeyError, TypeError, RuntimeErrorSafe):
|
|
572
|
+
result['reason'] = 'not-installed-partial-or-verification-failed'
|
|
573
|
+
return result
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@_cancellable
|
|
577
|
+
def setup(engine, runtime_root, yes=False, archive=None, timeout=1800, python=sys.executable, extras=()):
|
|
578
|
+
if not yes:
|
|
579
|
+
raise RuntimeErrorSafe('setup-requires-explicit-yes')
|
|
580
|
+
if not math.isfinite(timeout) or timeout <= 0 or timeout > 86400:
|
|
581
|
+
raise RuntimeErrorSafe('invalid-setup-timeout')
|
|
582
|
+
spec = SPECS[engine]
|
|
583
|
+
if not isinstance(extras, (list, tuple)) or not all(isinstance(value, str) and value in spec['extras'] for value in extras):
|
|
584
|
+
raise RuntimeErrorSafe('unknown-upstream-extra')
|
|
585
|
+
extras = sorted(set(extras))
|
|
586
|
+
root = _private_dir(runtime_root, True)
|
|
587
|
+
home = root / engine
|
|
588
|
+
if home.exists() or home.is_symlink():
|
|
589
|
+
current = status(engine, root)
|
|
590
|
+
if current['ready']:
|
|
591
|
+
if not set(extras).issubset(current.get('configuredExtras', [])):
|
|
592
|
+
raise RuntimeErrorSafe('additional-extras-require-new-private-runtime-root')
|
|
593
|
+
current['setup'] = 'already-ready-no-install-performed'
|
|
594
|
+
return current
|
|
595
|
+
raise RuntimeErrorSafe('partial-or-existing-runtime-use-new-private-root')
|
|
596
|
+
home = _private_dir(home, True)
|
|
597
|
+
try:
|
|
598
|
+
raw = _read(archive, MAX_ARCHIVE) if archive is not None else _download(spec['url'])
|
|
599
|
+
_verify_archive(raw, spec)
|
|
600
|
+
source_archive = home / 'source.tar.gz'
|
|
601
|
+
_atomic(source_archive, raw)
|
|
602
|
+
source = _extract(source_archive, home / 'source')
|
|
603
|
+
env = _install_env(home)
|
|
604
|
+
if spec['kind'] == 'python':
|
|
605
|
+
env['SETUPTOOLS_SCM_PRETEND_VERSION'] = spec['version']
|
|
606
|
+
_checked([str(python), '-I', '-m', 'venv', str(home / 'venv')], home, timeout, env)
|
|
607
|
+
interpreter = str(home / 'venv/bin/python')
|
|
608
|
+
_checked([interpreter, '-I', '-m', 'pip', 'install', '--disable-pip-version-check', '--no-input',
|
|
609
|
+
'--report', str(home / 'pip-install-report.json'), str(source) + ('[' + ','.join(extras) + ']' if extras else '')], home, timeout, env)
|
|
610
|
+
inventory = _checked([interpreter, '-I', '-m', 'pip', 'list', '--format=json', '--disable-pip-version-check'], home, timeout, env)
|
|
611
|
+
parsed_inventory = json.loads(inventory['_stdout'])
|
|
612
|
+
if not isinstance(parsed_inventory, list):
|
|
613
|
+
raise RuntimeErrorSafe('invalid-resolved-inventory')
|
|
614
|
+
else:
|
|
615
|
+
package = json.loads(_read(source / 'package.json', 1024*1024))
|
|
616
|
+
if package.get('name') != spec['distribution'] or package.get('version') != spec['version']:
|
|
617
|
+
raise RuntimeErrorSafe('archive-package-version-mismatch')
|
|
618
|
+
npm = _private_dir(home / 'npm', True)
|
|
619
|
+
_checked(['npm', '--prefix', str(npm), 'install', '--ignore-scripts', '--no-audit', '--no-fund',
|
|
620
|
+
'--save-exact', str(source_archive)], home, timeout, env)
|
|
621
|
+
inventory = _checked(['npm', '--prefix', str(npm), 'ls', '--json', '--all'], home, timeout, env)
|
|
622
|
+
parsed_inventory = json.loads(inventory['_stdout'])
|
|
623
|
+
if not isinstance(parsed_inventory, dict):
|
|
624
|
+
raise RuntimeErrorSafe('invalid-resolved-inventory')
|
|
625
|
+
if _installed_version(home, spec) != spec['version']:
|
|
626
|
+
raise RuntimeErrorSafe('installed-version-mismatch')
|
|
627
|
+
inventory_raw = _json(parsed_inventory)
|
|
628
|
+
_atomic(home / 'resolved-inventory.json', inventory_raw)
|
|
629
|
+
entries = {rel: _digest(_read(home / rel, 16*1024*1024)) for rel in set(spec['entrypoints'].values())}
|
|
630
|
+
# Only this final atomic write means ready; any preceding failure stays partial.
|
|
631
|
+
marker = {'schema': SCHEMA, 'state': 'complete', 'source': _source_identity(spec),
|
|
632
|
+
'archiveSha256': _digest(raw), 'extras': extras, 'inventorySha256': _digest(inventory_raw),
|
|
633
|
+
'entrypointSha256': entries, 'createdAtUnix': time.time(),
|
|
634
|
+
'dependencyPolicy': 'Normal upstream dependency resolution isolated and inventoried; not a transitive lock guarantee.'}
|
|
635
|
+
_atomic(home / 'READY.json', _json(marker))
|
|
636
|
+
result = status(engine, root)
|
|
637
|
+
if not result['ready']:
|
|
638
|
+
raise RuntimeErrorSafe('post-setup-verification-failed')
|
|
639
|
+
result['setup'] = 'installed-and-verified-within-scope'
|
|
640
|
+
return result
|
|
641
|
+
except BaseException as failure:
|
|
642
|
+
# Retain partial state and private logs, never publish a READY marker.
|
|
643
|
+
try:
|
|
644
|
+
(home / 'READY.json').unlink(missing_ok=True)
|
|
645
|
+
_atomic(home / 'FAILED.json', _json({'schema': SCHEMA, 'state': 'partial-setup', 'source': _source_identity(spec), 'cancelled': isinstance(failure, (OperationCancelled, KeyboardInterrupt))}))
|
|
646
|
+
except OSError:
|
|
647
|
+
pass
|
|
648
|
+
raise
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@_cancellable
|
|
652
|
+
def run(engine, runtime_root, args, timeout=300, entrypoint=None, interactive=False, cwd=None):
|
|
653
|
+
current = status(engine, runtime_root)
|
|
654
|
+
if not current['ready']:
|
|
655
|
+
raise RuntimeErrorSafe('engine-not-ready-run-never-installs')
|
|
656
|
+
spec = SPECS[engine]
|
|
657
|
+
entrypoint = entrypoint or engine
|
|
658
|
+
if entrypoint not in spec['entrypoints']:
|
|
659
|
+
raise RuntimeErrorSafe('unknown-engine-entrypoint')
|
|
660
|
+
if not isinstance(args, list) or not all(isinstance(arg, str) and '\x00' not in arg for arg in args):
|
|
661
|
+
raise RuntimeErrorSafe('invalid-upstream-arguments')
|
|
662
|
+
home = Path(os.path.abspath(runtime_root)) / engine
|
|
663
|
+
target = str(home / spec['entrypoints'][entrypoint])
|
|
664
|
+
command = (['node', target] if spec['kind'] == 'npm' else [target]) + args
|
|
665
|
+
working_directory = Path(os.path.abspath(os.getcwd() if cwd is None else cwd))
|
|
666
|
+
directory_fd = _open(working_directory, True)
|
|
667
|
+
os.close(directory_fd)
|
|
668
|
+
result = _invoke(command, home, timeout, interactive=interactive, cwd=working_directory)
|
|
669
|
+
return {'schema': SCHEMA, 'engine': engine, 'entrypoint': entrypoint,
|
|
670
|
+
'state': 'cancelled' if result.get('cancelled') else 'completed',
|
|
671
|
+
'cancelled': bool(result.get('cancelled')), 'signal': result.get('signal'),
|
|
672
|
+
'exitCode': 2 if result.get('outputTruncated') else result['returnCode'],
|
|
673
|
+
'execution': _public_result(result),
|
|
674
|
+
'limitations': ['Native upstream execution, not sandboxed; supplied arguments may connect to servers or invoke paid provider APIs.',
|
|
675
|
+
'Private stdout artifacts retain raw results and may contain sensitive data; do not publish them blindly.',
|
|
676
|
+
'Stderr scrubbing is best effort for known credential values, not a secret-detection guarantee.',
|
|
677
|
+
'Timeout cleanup targets the new process group; descendants that deliberately escape it are not contained.']}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _main_impl(argv=None):
|
|
681
|
+
tokens = list(sys.argv[1:] if argv is None else argv)
|
|
682
|
+
upstream = []
|
|
683
|
+
if '--' in tokens:
|
|
684
|
+
cut = tokens.index('--')
|
|
685
|
+
tokens, upstream = tokens[:cut], tokens[cut+1:]
|
|
686
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
687
|
+
parser.add_argument('operation', choices=('status', 'setup', 'run'))
|
|
688
|
+
parser.add_argument('engine', choices=tuple(SPECS))
|
|
689
|
+
parser.add_argument('--runtime-root', required=True)
|
|
690
|
+
parser.add_argument('--yes', action='store_true')
|
|
691
|
+
parser.add_argument('--archive')
|
|
692
|
+
parser.add_argument('--python', default=sys.executable)
|
|
693
|
+
parser.add_argument('--timeout', type=float)
|
|
694
|
+
parser.add_argument('--entrypoint')
|
|
695
|
+
parser.add_argument('--interactive', action='store_true')
|
|
696
|
+
parser.add_argument('--cwd', help='Upstream working directory; defaults to the caller current directory')
|
|
697
|
+
parser.add_argument('--extras', default='', help='Explicit comma-separated declared upstream Python extras')
|
|
698
|
+
args = parser.parse_args(tokens)
|
|
699
|
+
try:
|
|
700
|
+
if upstream and args.operation != 'run':
|
|
701
|
+
raise RuntimeErrorSafe('upstream-arguments-only-valid-for-run')
|
|
702
|
+
if args.operation == 'status':
|
|
703
|
+
result = status(args.engine, args.runtime_root)
|
|
704
|
+
elif args.operation == 'setup':
|
|
705
|
+
result = setup(args.engine, args.runtime_root, args.yes, args.archive,
|
|
706
|
+
args.timeout if args.timeout is not None else 1800, args.python,
|
|
707
|
+
args.extras.split(',') if args.extras else [])
|
|
708
|
+
else:
|
|
709
|
+
result = run(args.engine, args.runtime_root, upstream,
|
|
710
|
+
args.timeout if args.timeout is not None else 300,
|
|
711
|
+
args.entrypoint, args.interactive, args.cwd)
|
|
712
|
+
except (RuntimeErrorSafe, OSError, ValueError, KeyError, TypeError, tarfile.TarError) as error:
|
|
713
|
+
result = {'schema': SCHEMA, 'engine': args.engine, 'exitCode': 2,
|
|
714
|
+
'error': error.code if isinstance(error, RuntimeErrorSafe) else 'operational-setup-or-runtime-error'}
|
|
715
|
+
print(json.dumps(result, sort_keys=True, ensure_ascii=True, allow_nan=False))
|
|
716
|
+
return result['exitCode']
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def main(argv=None):
|
|
720
|
+
with _termination_scope():
|
|
721
|
+
try:
|
|
722
|
+
return _main_impl(argv)
|
|
723
|
+
except OperationCancelled as cancelled:
|
|
724
|
+
result = _cancel_result(cancelled.signum)
|
|
725
|
+
except KeyboardInterrupt:
|
|
726
|
+
result = _cancel_result(signal.SIGINT)
|
|
727
|
+
print(json.dumps(result, sort_keys=True, ensure_ascii=True, allow_nan=False))
|
|
728
|
+
return result['exitCode']
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
if __name__ == '__main__':
|
|
732
|
+
sys.exit(main())
|