@andespindola/crow-skills 0.24.0 → 0.26.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/LICENSE +32 -4
- package/THIRD-PARTY-NOTICES.md +43 -0
- package/catalog.json +162 -40
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/autoupdate.py +6 -1
- package/crow_skills/broadcast.py +15 -11
- package/crow_skills/cli.py +165 -5
- package/crow_skills/guide.py +16 -1
- package/crow_skills/mcp.py +632 -0
- package/docs/cli.md +24 -0
- package/package.json +2 -1
- package/release.json +257 -52
- package/skills/crow-accessibility/SKILL.md +7 -5
- package/skills/crow-accessibility/references/verificacao.md +102 -0
- package/skills/crow-agent-security/SKILL.md +8 -4
- package/skills/crow-agent-security/references/limites-agente.md +19 -0
- package/skills/crow-api-test/SKILL.md +2 -0
- package/skills/crow-api-test/references/casos-negativos.md +16 -0
- package/skills/crow-architecture/SKILL.md +2 -0
- package/skills/crow-architecture/references/modulos-profundos.md +18 -0
- package/skills/crow-backend/SKILL.md +3 -1
- package/skills/crow-backend/references/servico-backend.md +13 -0
- package/skills/crow-be-human/SKILL.md +2 -1
- package/skills/crow-be-human/references/voz-e-registro.md +35 -0
- package/skills/crow-behaviour/SKILL.md +25 -0
- package/skills/crow-behaviour/references/briefing-executavel.md +65 -0
- package/skills/crow-behaviour/references/ciclo-de-revisao.md +104 -0
- package/skills/crow-behaviour/references/cross-session-capacity.md +38 -0
- package/skills/crow-behaviour/references/progress-ledger.md +3 -1
- package/skills/crow-browser/SKILL.md +6 -3
- package/skills/crow-browser/references/mecanismo-de-browser.md +24 -0
- package/skills/crow-browser/references/pagina.md +43 -0
- package/skills/crow-debug/SKILL.md +1 -1
- package/skills/crow-design/SKILL.md +6 -6
- package/skills/crow-design/references/contrato.md +84 -0
- package/skills/crow-discovery/SKILL.md +5 -4
- package/skills/crow-discovery/references/pesquisa-sem-vies.md +33 -0
- package/skills/crow-documents/SKILL.md +9 -3
- package/skills/crow-documents/references/ooxml.md +43 -0
- package/skills/crow-documents/references/pdf.md +31 -0
- package/skills/crow-documents/references/planilha.md +35 -0
- package/skills/crow-domain/SKILL.md +3 -1
- package/skills/crow-domain/references/invariante-e-identidade.md +11 -0
- package/skills/crow-frontend/SKILL.md +6 -5
- package/skills/crow-growth/SKILL.md +2 -1
- package/skills/crow-growth/references/experimento-e-metrica.md +39 -0
- package/skills/crow-infra/SKILL.md +8 -7
- package/skills/crow-infra/references/kubernetes.md +6 -0
- package/skills/crow-interface/SKILL.md +1 -1
- package/skills/crow-interface/reference/adapt.md +8 -0
- package/skills/crow-interface/reference/layout.md +4 -1
- package/skills/crow-interface/reference/workflow.md +2 -0
- package/skills/crow-media/SKILL.md +5 -5
- package/skills/crow-media/references/producao.md +63 -0
- package/skills/crow-native-mobile/SKILL.md +1 -1
- package/skills/crow-observe/SKILL.md +8 -4
- package/skills/crow-observe/references/cardinalidade.md +11 -0
- package/skills/crow-performance/SKILL.md +6 -6
- package/skills/crow-performance/references/measurement.md +6 -0
- package/skills/crow-platform/SKILL.md +4 -4
- package/skills/crow-postgres/SKILL.md +3 -0
- package/skills/crow-postgres/references/migracao-online.md +17 -0
- package/skills/crow-product-leadership/SKILL.md +4 -3
- package/skills/crow-product-leadership/references/decisao-e-pessoas.md +35 -0
- package/skills/crow-qa/SKILL.md +1 -1
- package/skills/crow-release/SKILL.md +10 -5
- package/skills/crow-release/references/transicao.md +13 -0
- package/skills/crow-reverse/SKILL.md +3 -3
- package/skills/crow-reverse/references/binario-nativo.md +30 -0
- package/skills/crow-reverse/references/diferenca-entre-versoes.md +20 -0
- package/skills/crow-reverse/references/javascript-e-bundle.md +22 -0
- package/skills/crow-reverse/references/mecanismo-de-analise.md +23 -0
- package/skills/crow-reverse/references/pacote-e-arquivo.md +19 -0
- package/skills/crow-reverse/references/protocolo-e-formato.md +23 -0
- package/skills/crow-reverse/references/triagem-de-artefato.md +24 -0
- package/skills/crow-secrets/SKILL.md +2 -2
- package/skills/crow-security-review/SKILL.md +12 -5
- package/skills/crow-security-scan/SKILL.md +8 -3
- package/skills/crow-security-scan/references/evidencia-scan.md +17 -0
- package/skills/crow-security-test/SKILL.md +5 -1
- package/skills/crow-security-test/references/prova-dinamica.md +24 -0
- package/skills/crow-seo/SKILL.md +6 -4
- package/skills/crow-seo/references/saida-tecnica.md +78 -0
- package/skills/crow-skill-author/SKILL.md +10 -6
- package/skills/crow-skill-author/references/medicao.md +105 -0
- package/skills/crow-spec/SKILL.md +4 -3
- package/skills/crow-spec/references/criterio-e-fatia.md +27 -0
- package/skills/crow-threat-model/SKILL.md +5 -1
- package/skills/crow-threat-model/references/ciclo-ameaca.md +15 -0
- package/skills/crow-uny-mcp/SKILL.md +43 -0
- package/skills/crow-uny-mcp/references/lifecycle.md +29 -0
- package/skills/crow-uny-mcp/scripts/crow_mcp.py +744 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Opt-in, reversible native-host adapters for crow-uny-mcp.
|
|
2
|
+
|
|
3
|
+
Discovery never starts an MCP or prints commands, environment or credentials.
|
|
4
|
+
Host configuration writes require explicit approval and a private recovery record.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
import hashlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import tomllib
|
|
24
|
+
except ImportError: # Codex TOML is observational-only on Python 3.10.
|
|
25
|
+
tomllib = None
|
|
26
|
+
|
|
27
|
+
SCHEMA = "crow-mcp-adapters/1"
|
|
28
|
+
HOSTS = (
|
|
29
|
+
("codex", ".codex/config.toml", "toml"),
|
|
30
|
+
("claude", ".claude.json", "claude"),
|
|
31
|
+
("devin", ".config/devin/mcp_config.json", "json"),
|
|
32
|
+
("claude-desktop", "Library/Application Support/Claude/claude_desktop_config.json", "json"),
|
|
33
|
+
("agents", ".config/agents/mcp-servers.json", "flat"),
|
|
34
|
+
)
|
|
35
|
+
MAX_CONFIG = 16 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MCPError(ValueError):
|
|
39
|
+
"""Safe-to-display adapter error: never include configuration values."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _digest(data):
|
|
43
|
+
return hashlib.sha256(data).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _home(value):
|
|
47
|
+
return Path(os.path.abspath(os.path.expanduser(str(value))))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _safe(path, home):
|
|
51
|
+
path, home = Path(os.path.abspath(path)), _home(home)
|
|
52
|
+
try:
|
|
53
|
+
rel = path.relative_to(home)
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise MCPError("path outside selected home") from None
|
|
56
|
+
current = home
|
|
57
|
+
if current.is_symlink():
|
|
58
|
+
raise MCPError("symlink home refused")
|
|
59
|
+
for part in rel.parts:
|
|
60
|
+
current = current / part
|
|
61
|
+
if current.is_symlink():
|
|
62
|
+
raise MCPError("symlink configuration/state path refused")
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read(path, home):
|
|
67
|
+
path = _safe(path, home)
|
|
68
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
69
|
+
try:
|
|
70
|
+
st = os.fstat(fd)
|
|
71
|
+
if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or st.st_size > MAX_CONFIG:
|
|
72
|
+
raise MCPError("configuration is not a bounded regular file")
|
|
73
|
+
with os.fdopen(fd, "rb", closefd=False) as f:
|
|
74
|
+
data = f.read(MAX_CONFIG + 1)
|
|
75
|
+
if len(data) > MAX_CONFIG:
|
|
76
|
+
raise MCPError("configuration exceeds size limit")
|
|
77
|
+
return data
|
|
78
|
+
finally:
|
|
79
|
+
os.close(fd)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _mkdir(path, home):
|
|
83
|
+
path = _safe(path, home)
|
|
84
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
85
|
+
os.chmod(path, 0o700)
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _atomic(path, data, home, expected=None, private=False):
|
|
90
|
+
path = _safe(path, home)
|
|
91
|
+
_mkdir(path.parent, home) if private else path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
mode = 0o600 if private else stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
|
|
93
|
+
fd, temp = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent)
|
|
94
|
+
try:
|
|
95
|
+
os.fchmod(fd, mode)
|
|
96
|
+
with os.fdopen(fd, "wb") as handle:
|
|
97
|
+
handle.write(data)
|
|
98
|
+
handle.flush()
|
|
99
|
+
os.fsync(handle.fileno())
|
|
100
|
+
# Optimistic compare immediately before replace. External applications
|
|
101
|
+
# do not share our lock: a filesystem-wide atomic CAS is not claimed.
|
|
102
|
+
if expected is not None and (not path.exists() or _digest(_read(path, home)) != expected):
|
|
103
|
+
raise MCPError("configuration changed during operation; refused overwrite")
|
|
104
|
+
_safe(path, home)
|
|
105
|
+
os.replace(temp, path)
|
|
106
|
+
finally:
|
|
107
|
+
if os.path.lexists(temp):
|
|
108
|
+
os.unlink(temp)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _encode(value):
|
|
112
|
+
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _state(home):
|
|
116
|
+
return _home(home) / ".local/state/crow-skills/mcp"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@contextmanager
|
|
120
|
+
def _mutation_lock(home):
|
|
121
|
+
"""Serialize this manager's mutations; external hosts still need digest checks."""
|
|
122
|
+
directory = _mkdir(_state(home), home)
|
|
123
|
+
path = _safe(directory / "adapter.lock", home)
|
|
124
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
125
|
+
locked = False
|
|
126
|
+
try:
|
|
127
|
+
os.fchmod(fd, 0o600)
|
|
128
|
+
if os.name == "nt":
|
|
129
|
+
import msvcrt
|
|
130
|
+
if os.fstat(fd).st_size == 0:
|
|
131
|
+
os.write(fd, b"0")
|
|
132
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
133
|
+
try:
|
|
134
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
135
|
+
except OSError:
|
|
136
|
+
raise MCPError("another MCP configuration operation is active") from None
|
|
137
|
+
else:
|
|
138
|
+
import fcntl
|
|
139
|
+
try:
|
|
140
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
141
|
+
except OSError:
|
|
142
|
+
raise MCPError("another MCP configuration operation is active") from None
|
|
143
|
+
locked = True
|
|
144
|
+
yield
|
|
145
|
+
finally:
|
|
146
|
+
if locked:
|
|
147
|
+
if os.name == "nt":
|
|
148
|
+
import msvcrt
|
|
149
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
150
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
151
|
+
else:
|
|
152
|
+
import fcntl
|
|
153
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
154
|
+
os.close(fd)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _platform_supported():
|
|
158
|
+
return sys.platform == "darwin" or sys.platform.startswith("linux")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _require_platform():
|
|
162
|
+
if not _platform_supported():
|
|
163
|
+
raise MCPError("unsupported-platform: lifecycle wrapping requires macOS or Linux kernel process identity")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _runtime(runtime=None):
|
|
167
|
+
candidate = Path(runtime) if runtime is not None else Path(__file__).resolve().parents[1] / "skills/crow-uny-mcp/scripts/crow_mcp.py"
|
|
168
|
+
if not candidate.is_absolute() or candidate.name != "crow_mcp.py" or not candidate.is_file():
|
|
169
|
+
raise MCPError("absolute crow_mcp.py runtime file required")
|
|
170
|
+
return str(candidate.resolve())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_managed(config):
|
|
174
|
+
args = config.get("args", [])
|
|
175
|
+
return (isinstance(args, list) and len(args) >= 2 and isinstance(args[0], str)
|
|
176
|
+
and Path(args[0]).name == "crow_mcp.py" and args[1] == "serve")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _tables(text):
|
|
180
|
+
"""Return root [mcp_servers.NAME] spans; nested env tables are untouched."""
|
|
181
|
+
if tomllib is None:
|
|
182
|
+
return {}
|
|
183
|
+
headers = list(re.finditer(r"(?m)^[ \t]*\[[^\n]+\][ \t]*(?:\#[^\n]*)?(?:\n|$)", text))
|
|
184
|
+
result = {}
|
|
185
|
+
for i, match in enumerate(headers):
|
|
186
|
+
header = match.group().strip()
|
|
187
|
+
if header.startswith("[["):
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
parsed = tomllib.loads(header + "\n__crow_probe = true\n")
|
|
191
|
+
servers = parsed.get("mcp_servers")
|
|
192
|
+
if not isinstance(servers, dict) or len(servers) != 1:
|
|
193
|
+
continue
|
|
194
|
+
name, value = next(iter(servers.items()))
|
|
195
|
+
if not isinstance(value, dict) or value != {"__crow_probe": True}:
|
|
196
|
+
continue
|
|
197
|
+
except (ValueError, TypeError):
|
|
198
|
+
continue
|
|
199
|
+
end = headers[i + 1].start() if i + 1 < len(headers) else len(text)
|
|
200
|
+
result[name] = (match.end(), end)
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _toml_replace(text, name, command, args):
|
|
205
|
+
spans = _tables(text)
|
|
206
|
+
if name not in spans:
|
|
207
|
+
raise MCPError("unsupported Codex TOML layout; no changes applied")
|
|
208
|
+
start, end = spans[name]
|
|
209
|
+
body = text[start:end]
|
|
210
|
+
edits = []
|
|
211
|
+
found = set()
|
|
212
|
+
lines = body.splitlines(keepends=True)
|
|
213
|
+
offsets, pos = [], 0
|
|
214
|
+
for line in lines:
|
|
215
|
+
offsets.append(pos)
|
|
216
|
+
pos += len(line)
|
|
217
|
+
for i, line in enumerate(lines):
|
|
218
|
+
match = re.match(r"^[ \t]*(command|args)[ \t]*=", line)
|
|
219
|
+
if not match:
|
|
220
|
+
continue
|
|
221
|
+
key = match.group(1)
|
|
222
|
+
for j in range(i + 1, len(lines) + 1):
|
|
223
|
+
try:
|
|
224
|
+
parsed = tomllib.loads("".join(lines[i:j]))
|
|
225
|
+
except ValueError:
|
|
226
|
+
continue
|
|
227
|
+
if set(parsed) == {key}:
|
|
228
|
+
oldend = offsets[j] if j < len(lines) else len(body)
|
|
229
|
+
value = command if key == "command" else args
|
|
230
|
+
edits.append((offsets[i], oldend, key + " = " + json.dumps(value, ensure_ascii=False) + "\n"))
|
|
231
|
+
found.add(key)
|
|
232
|
+
break
|
|
233
|
+
else:
|
|
234
|
+
raise MCPError("unsupported Codex assignment; no changes applied")
|
|
235
|
+
for left, right, replacement in reversed(edits):
|
|
236
|
+
body = body[:left] + replacement + body[right:]
|
|
237
|
+
for key, value in (("command", command), ("args", args)):
|
|
238
|
+
if key not in found:
|
|
239
|
+
body += ("" if not body or body.endswith("\n") else "\n") + key + " = " + json.dumps(value, ensure_ascii=False) + "\n"
|
|
240
|
+
changed = text[:start] + body + text[end:]
|
|
241
|
+
try:
|
|
242
|
+
tomllib.loads(changed)
|
|
243
|
+
except ValueError:
|
|
244
|
+
raise MCPError("generated Codex TOML rejected") from None
|
|
245
|
+
return changed
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _maps(document, kind):
|
|
249
|
+
if kind == "flat":
|
|
250
|
+
yield "", document
|
|
251
|
+
else:
|
|
252
|
+
servers = document.get("mcpServers", {})
|
|
253
|
+
if isinstance(servers, dict):
|
|
254
|
+
yield "", servers
|
|
255
|
+
if kind == "claude":
|
|
256
|
+
projects = document.get("projects", {})
|
|
257
|
+
if isinstance(projects, dict):
|
|
258
|
+
for index, (_path, project) in enumerate(projects.items()):
|
|
259
|
+
if isinstance(project, dict) and isinstance(project.get("mcpServers"), dict):
|
|
260
|
+
# Do not disclose project paths in observations.
|
|
261
|
+
yield "project-" + str(index), project["mcpServers"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _inventory(home):
|
|
265
|
+
home = _home(home)
|
|
266
|
+
files = []
|
|
267
|
+
rows = []
|
|
268
|
+
for host, rel, kind in HOSTS:
|
|
269
|
+
path = home / rel
|
|
270
|
+
try:
|
|
271
|
+
_safe(path, home)
|
|
272
|
+
if not path.exists():
|
|
273
|
+
continue
|
|
274
|
+
raw = _read(path, home)
|
|
275
|
+
if kind == "toml":
|
|
276
|
+
if tomllib is None:
|
|
277
|
+
rows.append({"host": host, "name": "*", "status": "unsupported-python-toml", "managed": False})
|
|
278
|
+
continue
|
|
279
|
+
doc = tomllib.loads(raw.decode())
|
|
280
|
+
maps = [("", doc.get("mcp_servers", {}))]
|
|
281
|
+
spans = _tables(raw.decode())
|
|
282
|
+
else:
|
|
283
|
+
doc = json.loads(raw)
|
|
284
|
+
if not isinstance(doc, dict):
|
|
285
|
+
raise MCPError("invalid configuration shape")
|
|
286
|
+
maps = list(_maps(doc, kind))
|
|
287
|
+
spans = None
|
|
288
|
+
item = {"path": path, "rel": rel, "kind": kind, "raw": raw, "doc": doc, "entries": []}
|
|
289
|
+
for scope, servers in maps:
|
|
290
|
+
if not isinstance(servers, dict):
|
|
291
|
+
continue
|
|
292
|
+
for name, config in servers.items():
|
|
293
|
+
if not isinstance(config, dict):
|
|
294
|
+
continue
|
|
295
|
+
public_name = str(name) if not scope else scope + ":" + str(name)
|
|
296
|
+
managed = _is_managed(config)
|
|
297
|
+
native_http = "url" in config or config.get("type") in ("http", "sse", "streamable-http", "streamableHttp") or config.get("transport") in ("http", "sse", "streamable-http", "streamableHttp")
|
|
298
|
+
eligible = (not native_http and isinstance(config.get("command"), str) and bool(config["command"]) and isinstance(config.get("args", []), list) and all(isinstance(v, str) for v in config.get("args", [])))
|
|
299
|
+
state = "managed" if managed else "native-http-observed" if native_http else "eligible-stdio" if eligible else "unsupported-config"
|
|
300
|
+
if config.get("enabled") is False:
|
|
301
|
+
state, eligible = "disabled-observed", False
|
|
302
|
+
if kind == "toml" and eligible and name not in spans:
|
|
303
|
+
state, eligible = "unsupported-toml-layout", False
|
|
304
|
+
row = {"host": host, "name": public_name, "status": state, "managed": managed}
|
|
305
|
+
if managed:
|
|
306
|
+
argv = config.get("args", [])
|
|
307
|
+
try:
|
|
308
|
+
row["profile"] = argv[argv.index("--profile") + 1]
|
|
309
|
+
except (ValueError, IndexError):
|
|
310
|
+
row["profile"] = "unknown"
|
|
311
|
+
rows.append(row)
|
|
312
|
+
if state == "eligible-stdio":
|
|
313
|
+
item["entries"].append((scope, name, config, row))
|
|
314
|
+
files.append(item)
|
|
315
|
+
except (OSError, ValueError, UnicodeError) as error:
|
|
316
|
+
# No exception text: parsers can include secret input fragments.
|
|
317
|
+
rows.append({"host": host, "name": "*", "status": "unsafe-or-invalid-config", "managed": False})
|
|
318
|
+
return files, rows
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def plan(home, runtime=None, restartable=(), hosts=None):
|
|
322
|
+
_files, rows = _inventory(home)
|
|
323
|
+
if hosts is not None:
|
|
324
|
+
rows = [row for row in rows if row["host"] in hosts]
|
|
325
|
+
try:
|
|
326
|
+
resolved = _runtime(runtime)
|
|
327
|
+
runtime_ready = _platform_supported()
|
|
328
|
+
runtime_status = "ready" if runtime_ready else "unsupported-platform"
|
|
329
|
+
except MCPError:
|
|
330
|
+
resolved, runtime_ready = None, False
|
|
331
|
+
runtime_status = "missing-runtime" if _platform_supported() else "unsupported-platform"
|
|
332
|
+
names = set(restartable)
|
|
333
|
+
for row in rows:
|
|
334
|
+
if not row["managed"]:
|
|
335
|
+
row["profile"] = "restartable" if row["name"] in names or row["host"] + ":" + row["name"] in names else "session"
|
|
336
|
+
return {"schema": SCHEMA, "action": "plan", "runtimeReady": runtime_ready, "runtimeStatus": runtime_status, "servers": rows,
|
|
337
|
+
"eligible": sum(r["status"] == "eligible-stdio" for r in rows), "mutation": False}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _manifest_path(home):
|
|
341
|
+
return _state(home) / "adapter-manifest.json"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _manifest(home):
|
|
345
|
+
path = _manifest_path(home)
|
|
346
|
+
if not path.exists():
|
|
347
|
+
return {"schema": SCHEMA, "files": []}
|
|
348
|
+
try:
|
|
349
|
+
data = json.loads(_read(path, home))
|
|
350
|
+
if data.get("schema") != SCHEMA or not isinstance(data.get("files"), list):
|
|
351
|
+
raise MCPError("invalid adapter recovery record")
|
|
352
|
+
known = {rel for _host, rel, _kind in HOSTS}
|
|
353
|
+
for record in data["files"]:
|
|
354
|
+
if (not isinstance(record, dict) or record.get("path") not in known
|
|
355
|
+
or not re.fullmatch(r"\.local/state/crow-skills/mcp/backups/[0-9a-f]{32}\.original", str(record.get("backup", "")))
|
|
356
|
+
or any(not re.fullmatch(r"[0-9a-f]{64}", str(record.get(key, ""))) for key in ("originalSha256", "appliedSha256"))
|
|
357
|
+
or record.get("state") not in ("prepared", "applied", "restored")):
|
|
358
|
+
raise MCPError("invalid adapter recovery record")
|
|
359
|
+
return data
|
|
360
|
+
except (OSError, ValueError, TypeError):
|
|
361
|
+
raise MCPError("invalid or unsafe adapter recovery record") from None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _save_manifest(home, data):
|
|
365
|
+
_atomic(_manifest_path(home), _encode(data), home, private=True)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def apply(home, runtime=None, restartable=(), yes=False, hosts=None):
|
|
369
|
+
if not yes:
|
|
370
|
+
raise MCPError("explicit --yes approval required for host configuration changes")
|
|
371
|
+
_require_platform()
|
|
372
|
+
_runtime(runtime)
|
|
373
|
+
with _mutation_lock(home):
|
|
374
|
+
return _apply(home, runtime=runtime, restartable=restartable, yes=yes, hosts=hosts)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _apply(home, runtime=None, restartable=(), yes=False, hosts=None):
|
|
378
|
+
if not yes:
|
|
379
|
+
raise MCPError("explicit --yes approval required for host configuration changes")
|
|
380
|
+
home = _home(home)
|
|
381
|
+
runtime = _runtime(runtime)
|
|
382
|
+
files, rows = _inventory(home)
|
|
383
|
+
if hosts is not None:
|
|
384
|
+
selected = set(hosts)
|
|
385
|
+
supported = {host for host, _rel, _kind in HOSTS}
|
|
386
|
+
if not selected or not selected <= supported:
|
|
387
|
+
raise MCPError("explicit supported host scope required")
|
|
388
|
+
rows = [row for row in rows if row["host"] in selected]
|
|
389
|
+
files = [item for item in files if any(row["host"] in selected for _scope, _name, _config, row in item["entries"])]
|
|
390
|
+
if any(row["status"] == "unsafe-or-invalid-config" for row in rows):
|
|
391
|
+
raise MCPError("unsafe or invalid host configuration; no changes applied")
|
|
392
|
+
manifest = _manifest(home)
|
|
393
|
+
existing = {r["path"] for r in manifest["files"]}
|
|
394
|
+
names = set(restartable)
|
|
395
|
+
for row in rows:
|
|
396
|
+
if row["managed"] and (row["name"] in names or row["host"] + ":" + row["name"] in names) and row.get("profile") != "restartable":
|
|
397
|
+
raise MCPError("managed profile change requires conditional restore before reapply")
|
|
398
|
+
changed = []
|
|
399
|
+
prepared = []
|
|
400
|
+
for item in files:
|
|
401
|
+
entries = item["entries"]
|
|
402
|
+
if entries and not (item["path"].stat().st_mode & 0o222):
|
|
403
|
+
raise MCPError("read-only host configuration; no changes applied")
|
|
404
|
+
if not entries:
|
|
405
|
+
continue
|
|
406
|
+
if item["rel"] in existing:
|
|
407
|
+
raise MCPError("managed configuration has additional changes; restore before reapplying")
|
|
408
|
+
text = item["raw"].decode()
|
|
409
|
+
for _scope, name, config, row in entries:
|
|
410
|
+
profile = "restartable" if row["name"] in names or row["host"] + ":" + row["name"] in names else "session"
|
|
411
|
+
argv = [runtime, "serve", "--name", row["host"] + ":" + row["name"], "--profile", profile, "--state-dir", str(_state(home) / "instances"), "--", config["command"], *config.get("args", [])]
|
|
412
|
+
python = str(Path(sys.executable).resolve())
|
|
413
|
+
if item["kind"] == "toml":
|
|
414
|
+
text = _toml_replace(text, name, python, argv)
|
|
415
|
+
else:
|
|
416
|
+
config["command"], config["args"] = python, argv
|
|
417
|
+
data = text.encode() if item["kind"] == "toml" else _encode(item["doc"])
|
|
418
|
+
prepared.append((item, data))
|
|
419
|
+
# Check every baseline again before creating recovery records or modifying hosts.
|
|
420
|
+
for item, _ in prepared:
|
|
421
|
+
if _read(item["path"], home) != item["raw"]:
|
|
422
|
+
raise MCPError("configuration changed during planning; no changes applied")
|
|
423
|
+
for item, data in prepared:
|
|
424
|
+
backup_rel = ".local/state/crow-skills/mcp/backups/" + uuid.uuid4().hex + ".original"
|
|
425
|
+
_atomic(home / backup_rel, item["raw"], home, private=True)
|
|
426
|
+
record = {"path": item["rel"], "backup": backup_rel, "originalSha256": _digest(item["raw"]),
|
|
427
|
+
"appliedSha256": _digest(data), "state": "prepared", "host": item["entries"][0][3]["host"]}
|
|
428
|
+
manifest["files"].append(record)
|
|
429
|
+
_save_manifest(home, manifest)
|
|
430
|
+
_atomic(item["path"], data, home, expected=_digest(item["raw"]))
|
|
431
|
+
record["state"] = "applied"
|
|
432
|
+
_save_manifest(home, manifest)
|
|
433
|
+
changed.append(item["rel"])
|
|
434
|
+
return {"schema": SCHEMA, "action": "apply", "changed": changed,
|
|
435
|
+
"servers": plan(home, runtime, restartable, hosts=hosts)["servers"], "mutation": bool(changed),
|
|
436
|
+
"runtimeReloadRequired": bool(changed)}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def restore(home, yes=False, hosts=None, dry_run=False):
|
|
440
|
+
if dry_run:
|
|
441
|
+
return _restore(home, yes=yes, hosts=hosts, dry_run=True)
|
|
442
|
+
if not yes:
|
|
443
|
+
raise MCPError("explicit --yes approval required for restore")
|
|
444
|
+
with _mutation_lock(home):
|
|
445
|
+
return _restore(home, yes=yes, hosts=hosts)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _restore(home, yes=False, hosts=None, dry_run=False):
|
|
449
|
+
if not yes and not dry_run:
|
|
450
|
+
raise MCPError("explicit --yes approval required for restore")
|
|
451
|
+
home = _home(home)
|
|
452
|
+
manifest = _manifest(home)
|
|
453
|
+
ready, conflicts = [], []
|
|
454
|
+
if hosts is not None and (not hosts or not set(hosts) <= {host for host, _rel, _kind in HOSTS}):
|
|
455
|
+
raise MCPError("explicit supported host scope required")
|
|
456
|
+
for record in manifest["files"]:
|
|
457
|
+
owner = record.get("host") or next((host for host, rel, _kind in HOSTS if rel == record.get("path")), None)
|
|
458
|
+
if hosts is not None and owner not in hosts:
|
|
459
|
+
continue
|
|
460
|
+
if record.get("state") == "restored":
|
|
461
|
+
continue
|
|
462
|
+
path = _safe(home / record["path"], home)
|
|
463
|
+
try:
|
|
464
|
+
current = _read(path, home)
|
|
465
|
+
except OSError:
|
|
466
|
+
# External deletion or loss of read access is a restore conflict,
|
|
467
|
+
# never authority to recreate, chmod or overwrite the host file.
|
|
468
|
+
conflicts.append(record["path"])
|
|
469
|
+
continue
|
|
470
|
+
if _digest(current) == record["originalSha256"]:
|
|
471
|
+
record["state"] = "restored"
|
|
472
|
+
continue
|
|
473
|
+
if _digest(current) != record["appliedSha256"]:
|
|
474
|
+
conflicts.append(record["path"])
|
|
475
|
+
continue
|
|
476
|
+
try:
|
|
477
|
+
original = _read(home / record["backup"], home)
|
|
478
|
+
except (OSError, MCPError):
|
|
479
|
+
raise MCPError("backup integrity check failed; no restore applied") from None
|
|
480
|
+
if _digest(original) != record["originalSha256"]:
|
|
481
|
+
raise MCPError("backup integrity check failed; no restore applied")
|
|
482
|
+
ready.append((record, path, original))
|
|
483
|
+
if conflicts:
|
|
484
|
+
raise MCPError("configuration changed externally; restore refused without overwriting edits")
|
|
485
|
+
if dry_run:
|
|
486
|
+
return {"schema": SCHEMA, "action": "restore", "changed": [], "wouldRestore": [r["path"] for r, _p, _o in ready],
|
|
487
|
+
"mutation": False, "dry_run": True}
|
|
488
|
+
changed = []
|
|
489
|
+
for record, path, original in ready:
|
|
490
|
+
_atomic(path, original, home, expected=record["appliedSha256"])
|
|
491
|
+
record["state"] = "restored"
|
|
492
|
+
_save_manifest(home, manifest)
|
|
493
|
+
changed.append(record["path"])
|
|
494
|
+
manifest["files"] = [r for r in manifest["files"] if r.get("state") != "restored"]
|
|
495
|
+
if _manifest_path(home).exists():
|
|
496
|
+
_save_manifest(home, manifest)
|
|
497
|
+
return {"schema": SCHEMA, "action": "restore", "changed": changed, "mutation": bool(changed),
|
|
498
|
+
"runtimeReloadRequired": bool(changed)}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def runtime_dependencies(home, runtime):
|
|
502
|
+
"""Observe native wrappers referencing a runtime, never signal or mutate them."""
|
|
503
|
+
target = Path(runtime).resolve()
|
|
504
|
+
files, rows = _inventory(home)
|
|
505
|
+
result = [{"host": row["host"], "name": "*", "unverifiable": True}
|
|
506
|
+
for row in rows if row["status"] == "unsafe-or-invalid-config"]
|
|
507
|
+
for item in files:
|
|
508
|
+
maps = [("", item["doc"].get("mcp_servers", {}))] if item["kind"] == "toml" else _maps(item["doc"], item["kind"])
|
|
509
|
+
host = next(host for host, rel, _kind in HOSTS if rel == item["rel"])
|
|
510
|
+
for scope, servers in maps:
|
|
511
|
+
if not isinstance(servers, dict):
|
|
512
|
+
continue
|
|
513
|
+
for name, config in servers.items():
|
|
514
|
+
if isinstance(config, dict) and _is_managed(config) and Path(config["args"][0]).resolve() == target:
|
|
515
|
+
result.append({"host": host, "name": (scope + ":" if scope else "") + str(name)})
|
|
516
|
+
return result
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
_IDENTITY_READER = None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _identity(pid):
|
|
523
|
+
global _IDENTITY_READER
|
|
524
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
|
525
|
+
return None
|
|
526
|
+
try:
|
|
527
|
+
if _IDENTITY_READER is None:
|
|
528
|
+
spec = importlib.util.spec_from_file_location("crow_mcp_status_identity", _runtime())
|
|
529
|
+
module = importlib.util.module_from_spec(spec)
|
|
530
|
+
spec.loader.exec_module(module)
|
|
531
|
+
_IDENTITY_READER = module.process_identity
|
|
532
|
+
return _IDENTITY_READER(pid)
|
|
533
|
+
except (OSError, ValueError, ImportError, AttributeError):
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def status(home):
|
|
538
|
+
home = _home(home)
|
|
539
|
+
result = plan(home)
|
|
540
|
+
result.update(action="status", configuredManaged=sum(r["managed"] for r in result["servers"]))
|
|
541
|
+
snapshots = []
|
|
542
|
+
directory = _state(home) / "instances"
|
|
543
|
+
if directory.exists():
|
|
544
|
+
_safe(directory, home)
|
|
545
|
+
for path in sorted(directory.glob("runtime-*.json"))[:200]:
|
|
546
|
+
try:
|
|
547
|
+
data = json.loads(_read(path, home))
|
|
548
|
+
# Registry entries are observations, never authority to signal PIDs.
|
|
549
|
+
row = {k: data[k] for k in ("pid", "child_pid", "pending_client", "pending_server", "subscriptions", "startup_ms", "resume_ms")
|
|
550
|
+
if k in data and isinstance(data[k], (int, float, type(None))) and not isinstance(data[k], bool)}
|
|
551
|
+
if data.get("state") in ("cold", "starting", "ready", "busy", "idle", "standby", "stopped", "pinned", "failed", "active", "hibernated", "session_lost"):
|
|
552
|
+
row["state"] = data["state"]
|
|
553
|
+
if data.get("profile") in ("session", "restartable"):
|
|
554
|
+
row["profile"] = data["profile"]
|
|
555
|
+
if isinstance(data.get("retainedMemory"), bool):
|
|
556
|
+
row["retainedMemory"] = data["retainedMemory"]
|
|
557
|
+
for pid_key, identity_key, live_key, verified_key in (("pid", "pid_identity", "pidAlive", "identityVerified"),
|
|
558
|
+
("child_pid", "child_identity", "childAlive", "childIdentityVerified")):
|
|
559
|
+
current = _identity(data.get(pid_key))
|
|
560
|
+
recorded = data.get(identity_key)
|
|
561
|
+
row[live_key] = current is not None
|
|
562
|
+
row[verified_key] = current is not None and isinstance(recorded, (tuple, list)) and tuple(recorded) == tuple(current)
|
|
563
|
+
row["source"] = "runtime-reported"
|
|
564
|
+
snapshots.append(row)
|
|
565
|
+
except (OSError, ValueError):
|
|
566
|
+
continue
|
|
567
|
+
result["runtimeObservations"] = snapshots
|
|
568
|
+
result["liveSessionsReloaded"] = False
|
|
569
|
+
return result
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def activate(home, runtime, approved=False, hosts=None):
|
|
573
|
+
"""Installer hook: approved, scoped installation wraps hosts without starting servers.
|
|
574
|
+
|
|
575
|
+
Without installation approval this is observation only. An approved call must
|
|
576
|
+
name a supported host scope; it never expands to unrelated configured apps.
|
|
577
|
+
"""
|
|
578
|
+
if not approved:
|
|
579
|
+
return {"schema": SCHEMA, "active": False, "mode": "observe", "wrapped": False,
|
|
580
|
+
"plan": plan(home, runtime=runtime, hosts=hosts)}
|
|
581
|
+
_require_platform()
|
|
582
|
+
if not hosts:
|
|
583
|
+
raise MCPError("explicit supported host scope required for installation activation")
|
|
584
|
+
resolved = _runtime(runtime)
|
|
585
|
+
result = apply(home, runtime=resolved, yes=True, hosts=hosts)
|
|
586
|
+
_atomic(_state(home) / "activation.json", _encode({"schema": SCHEMA, "enabled": True,
|
|
587
|
+
"mode": "managed", "runtime": resolved, "hosts": sorted(set(hosts)),
|
|
588
|
+
"activatedAt": int(time.time())}), home, private=True)
|
|
589
|
+
return {"schema": SCHEMA, "active": True, "mode": "managed", "wrapped": True,
|
|
590
|
+
"result": result, "runtimeReloadRequired": result["runtimeReloadRequired"]}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def main(argv=None):
|
|
594
|
+
parser = argparse.ArgumentParser(prog="crow-skills mcp")
|
|
595
|
+
parser.add_argument("action", choices=("status", "plan", "apply", "restore"))
|
|
596
|
+
parser.add_argument("--home", default=str(Path.home()))
|
|
597
|
+
parser.add_argument("--json", action="store_true")
|
|
598
|
+
parser.add_argument("--yes", action="store_true")
|
|
599
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
600
|
+
parser.add_argument("--runtime")
|
|
601
|
+
parser.add_argument("--hosts", help="comma-separated explicit host scope")
|
|
602
|
+
parser.add_argument("--restartable", default="", help="comma-separated server names or host:name, explicit restartable profile")
|
|
603
|
+
args = parser.parse_args(argv)
|
|
604
|
+
try:
|
|
605
|
+
if args.action == "status":
|
|
606
|
+
result = status(args.home)
|
|
607
|
+
elif args.action == "restore":
|
|
608
|
+
result = restore(args.home, yes=args.yes, dry_run=args.dry_run, hosts=[s.strip() for s in args.hosts.split(",") if s.strip()] if args.hosts is not None else None)
|
|
609
|
+
else:
|
|
610
|
+
opts = {"runtime": args.runtime, "restartable": [s.strip() for s in args.restartable.split(",") if s.strip()],
|
|
611
|
+
"hosts": [s.strip() for s in args.hosts.split(",") if s.strip()] if args.hosts is not None else None}
|
|
612
|
+
result = apply(args.home, yes=args.yes, **opts) if args.action == "apply" and not args.dry_run else plan(args.home, **opts)
|
|
613
|
+
if args.json:
|
|
614
|
+
print(json.dumps({"schema_version": 1, "ok": True, "command": "mcp", "data": result}, ensure_ascii=False))
|
|
615
|
+
else:
|
|
616
|
+
print("MCP " + args.action + ": " + str(len(result.get("changed", []))) + " file(s) changed" if args.action in ("apply", "restore") else
|
|
617
|
+
"MCP " + args.action + ": " + str(len(result.get("servers", []))) + " configured entry/entries")
|
|
618
|
+
for row in result.get("servers", []):
|
|
619
|
+
print((row["host"] + " / " + row["name"] + ": " + row["status"]).encode("unicode_escape").decode())
|
|
620
|
+
return 0
|
|
621
|
+
except (MCPError, OSError) as error:
|
|
622
|
+
# OSError can include paths but not config bodies; keep it generic too.
|
|
623
|
+
message = str(error) if isinstance(error, MCPError) else "filesystem operation failed; no unsafe retry performed"
|
|
624
|
+
if args.json:
|
|
625
|
+
print(json.dumps({"schema_version": 1, "ok": False, "command": "mcp", "error": {"code": "mcp_adapter", "message": message}}))
|
|
626
|
+
else:
|
|
627
|
+
print("MCP: " + message, file=sys.stderr)
|
|
628
|
+
return 2
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
if __name__ == "__main__":
|
|
632
|
+
raise SystemExit(main())
|
package/docs/cli.md
CHANGED
|
@@ -69,6 +69,8 @@ Nome: `@andespindola/crow-skills`; `publishConfig.access` é `public` desde a 0.
|
|
|
69
69
|
|
|
70
70
|
A publicação é feita pelo workflow `.github/workflows/publish.yml` ao empurrar uma tag `vX.Y.Z` igual à `version` do `package.json` (ou por `workflow_dispatch` em `main`). Ele autentica por Trusted Publishing (OIDC), sem token npm em secret, e roda check, testes e o teste de instalação isolada antes de `npm publish`. O trusted publisher do pacote no npmjs.com deve apontar para `andersonflima/crow-skills` e `publish.yml`.
|
|
71
71
|
|
|
72
|
+
Depois do `npm publish`, o pipeline confirma a versão no próprio registry: consulta `https://registry.npmjs.org/@andespindola%2Fcrow-skills` sem npm e sem credencial e espera a versão do `package.json` aparecer em `.versions` e virar o `dist-tags.latest`, tentando a cada 20 s por até 15 minutos e registrando cada tentativa no log. Isso existe porque o log do `npm publish` sozinho não distingue uma publicação real de um `--dry-run` (que imprime um `+ pacote@versao` praticamente igual) nem de um passo pulado por gate, que deixa o job verde sem ter publicado nada. Propagação de alguns minutos até dezenas de minutos é normal e **não** é falha: passado o teto, o job termina com sucesso e emite um aviso com o comando `curl -s https://registry.npmjs.org/@andespindola%2Fcrow-skills | jq -r '.["dist-tags"].latest'` para conferir depois.
|
|
73
|
+
|
|
72
74
|
Depois de a versão estar publicada e a conta estar autenticada com acesso:
|
|
73
75
|
|
|
74
76
|
```sh
|
|
@@ -620,3 +622,25 @@ Cada execução:
|
|
|
620
622
|
3. Para cada agente com skills gerenciadas (claude, codex/`$CODEX_HOME/skills`, gemini, devin, uniflow), roda o equivalente a `update <skills> --agents <agente> --global --yes`: regenera guia, integrações e hooks; a [identidade do pacote](#origem-e-versões) evita `source_mismatch` depois do upgrade. Skills editadas localmente (`modified`), ausentes ou instaladas de outra origem (`other_origin`, por exemplo de um clone Git) ficam como estão e aparecem em `kept`; nada é sobrescrito. Uma reaplicação dos hooks troca as entradas do crow-skills no lugar, sem mudar a posição das suas. Uma recusa num agente (por exemplo, guia editado) aparece em `error` e não interrompe os outros.
|
|
621
623
|
|
|
622
624
|
O resultado fica em `~/.local/state/crow-skills/autoupdate/state.json` (`lastRun` e as últimas 20 execuções), aparece em `autoupdate status`, em `status`/`doctor` (doctor `healthy:false` se a última execução falhou ou o agendador foi alterado/removido) e em `events snapshot`. Duas execuções simultâneas são recusadas (`busy`).
|
|
625
|
+
|
|
626
|
+
## MCP passivo: crow-uny-mcp
|
|
627
|
+
|
|
628
|
+
`crow-uny-mcp` é uma skill com runtime stdio autocontido, não uma substituição das ferramentas por comandos de uma CLI externa. Uma instalação global explicitamente aprovada configura os adaptadores suportados do agente selecionado e o guia nativo; instalação de projeto não modifica configurações globais. Não instala dependências de terceiros nem acorda servidores para descobrir ferramentas. Recarregue o host: configuração em disco não substitui conexões já abertas.
|
|
629
|
+
|
|
630
|
+
```sh
|
|
631
|
+
crow-skills install crow-uny-mcp --agents claude --global --yes
|
|
632
|
+
crow-skills mcp status --json
|
|
633
|
+
crow-skills mcp plan --hosts claude --json
|
|
634
|
+
crow-skills mcp apply --hosts claude --yes
|
|
635
|
+
crow-skills mcp restore --hosts claude --yes
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Os adaptadores cobrem entradas stdio nos formatos conhecidos de Codex, Claude Code, Devin, Claude Desktop no macOS e catálogo JSON compartilhado de agentes. Entradas HTTP nativas, plugins internos de apps e outros formatos ficam somente observados. O runtime usa sinais e identidade de processos POSIX; isso não é validação em todos os sistemas operacionais ou versões desses hosts.
|
|
639
|
+
|
|
640
|
+
- Padrão `session`: preserva o estado suspendendo a árvore própria ociosa e retomando antes da chamada. A RAM permanece alocada; sockets remotos ainda podem expirar durante a pausa.
|
|
641
|
+
- Perfil `restartable`: encerra a árvore ociosa para liberar RAM e refaz somente o handshake na próxima chamada. Autorize os nomes verificados com `--restartable nome1,nome2`; mudar perfil já gerenciado requer restore/novo apply, não troca silenciosa.
|
|
642
|
+
- Pedidos em andamento, chamadas servidor→cliente e assinaturas impedem standby/reap. O cache privado atende descoberta quente sem iniciar o servidor; descoberta fria ainda precisa de uma inicialização. Não há repetição automática de operação de negócio após falha.
|
|
643
|
+
- O proxy tem overhead próprio. Tempos sintéticos de warm-up não garantem latência de um servidor autenticado real. Processos externos já existentes ou que se desprendem antes da captura de propriedade não são adotados pelo manager.
|
|
644
|
+
- Backup e restauração são privados e condicionais: edição externa bloqueia restore, preservando o arquivo. O plano distingue configurado, observado e não suportado; `status` sem processo observado não prova que a conexão de um app foi recarregada.
|
|
645
|
+
|
|
646
|
+
O guia torna o contrato passivo vigente quando instalado, independentemente do interruptor de coordenação `crow-behaviour`; não confunda isso com poder obrigar um app sem adaptador a obedecer. O usuário continua dono da configuração e das permissões.
|