@andespindola/crow-skills 0.12.2 → 0.13.1
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 +1 -1
- package/catalog.json +1 -1
- package/crow_skills/__init__.py +1 -1
- package/crow_skills/guide.py +5 -3
- package/crow_skills/hook.py +5 -3
- package/docs/cli.md +3 -3
- package/package.json +1 -1
- package/release.json +33 -13
- package/skills/crow-behaviour/SKILL.md +20 -9
- package/skills/crow-behaviour/references/context-economy.md +146 -0
- package/skills/crow-behaviour/references/orchestration.md +1 -1
- package/skills/crow-behaviour/references/progress-ledger.md +4 -2
- package/skills/crow-behaviour/references/quality-patterns.md +1 -1
- package/skills/crow-behaviour/scripts/lc_fidelity.py +320 -0
- package/skills/crow-behaviour/scripts/lc_proxy.py +627 -0
- package/skills/crow-behaviour/scripts/lc_run.py +731 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lean-context proxy: detecta, configura com segurança e verifica os proxies de compressão de API
|
|
3
|
+
(caveman-proxy e rtk-proxy), apontando o agente para eles só por processo, sem editar arquivos globais.
|
|
4
|
+
|
|
5
|
+
Subcomandos:
|
|
6
|
+
detect [--json] o que está instalado, rodando e para onde o agente aponta
|
|
7
|
+
check caveman|rtk verifica identidade e postura da instância em execução
|
|
8
|
+
start caveman|rtk [--mode record|compress] [--accept-user-text-compression] [--dry-run]
|
|
9
|
+
sobe o proxy com a configuração segura (daemon, loopback)
|
|
10
|
+
stop caveman|rtk [--purge] para só a instância que este script subiu (identidade conferida)
|
|
11
|
+
launch caveman|rtk -- claude [args] roda o agente apontado para o proxy (só neste processo)
|
|
12
|
+
env caveman|rtk imprime os exports, sem expor o token
|
|
13
|
+
|
|
14
|
+
Regras:
|
|
15
|
+
- só IP literal de loopback (127.0.0.0/8, ::1) ou "localhost"; nomes DNS são recusados;
|
|
16
|
+
- a identidade da instância é provada antes de qualquer token sair: manifesto deste script, mesmo endereço,
|
|
17
|
+
pid vivo com o mesmo executável e horário de início, dono da porta (lsof) e, no caveman, o header
|
|
18
|
+
X-Caveman-Instance igual ao run-state privado; launch exige identidade provada e zero falhas;
|
|
19
|
+
- caveman: token obrigatório também em loopback; modo record por padrão; compress só com
|
|
20
|
+
--accept-user-text-compression (CAV-01 não tem configuração que o feche); warm cache, nuvem,
|
|
21
|
+
telemetria e captura desligados; upstream conferido no run-state;
|
|
22
|
+
- rtk: sem empréstimo de credencial, upstream oficial e bind conferidos em execução;
|
|
23
|
+
- ambiente do proxy por lista de permissão (nada de CAVE_UPSTREAM_PROXY, CAVEMAN_CONFIG, ALL_PROXY...);
|
|
24
|
+
- estado isolado num diretório 0700; token em arquivo 0600; nunca imprime o token;
|
|
25
|
+
- nunca edita ~/.claude/settings.json nem instala binários. Só stdlib, macOS e Linux.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import http.client as httpclient
|
|
31
|
+
import ipaddress
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import secrets
|
|
36
|
+
import shlex
|
|
37
|
+
import shutil
|
|
38
|
+
import signal
|
|
39
|
+
import stat
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
import urllib.error
|
|
44
|
+
import urllib.request
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
TAG = "[lean-context proxy]"
|
|
48
|
+
OFFICIAL_ANTHROPIC = "https://api.anthropic.com"
|
|
49
|
+
|
|
50
|
+
PROXIES = {
|
|
51
|
+
"caveman": {"binary": "caveman-proxy", "default": "127.0.0.1:8787", "listen_env": "CAVEMAN_LISTEN",
|
|
52
|
+
"health": "/health/live", "service": "caveman-proxy"},
|
|
53
|
+
"rtk": {"binary": "rtk-proxy", "default": "127.0.0.1:8080", "listen_env": "RTK_PROXY_BIND",
|
|
54
|
+
"health": "/health", "service": "rtk-proxy"},
|
|
55
|
+
}
|
|
56
|
+
# ambiente que o proxy herda (lista de permissão); o resto é descartado
|
|
57
|
+
ENV_ALLOW = {"PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "TZ", "TERM",
|
|
58
|
+
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY",
|
|
59
|
+
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION",
|
|
60
|
+
"AWS_BEARER_TOKEN_BEDROCK"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------- endereços
|
|
64
|
+
def split_hostport(hp: str):
|
|
65
|
+
m = re.fullmatch(r"\[([0-9a-fA-F:]+)\]:(\d{1,5})|([^:\[\]]+):(\d{1,5})", hp.strip())
|
|
66
|
+
if not m:
|
|
67
|
+
return None, None
|
|
68
|
+
host = m.group(1) or m.group(3)
|
|
69
|
+
port = int(m.group(2) or m.group(4))
|
|
70
|
+
return (host, port) if 0 < port < 65536 else (None, None)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def loopback_ip(hp: str) -> str | None:
|
|
74
|
+
"""IP literal de loopback para o endereço, ou None. 'localhost' vira 127.0.0.1; nomes DNS são recusados."""
|
|
75
|
+
host, port = split_hostport(hp)
|
|
76
|
+
if host is None:
|
|
77
|
+
return None
|
|
78
|
+
if host == "localhost":
|
|
79
|
+
return "127.0.0.1"
|
|
80
|
+
try:
|
|
81
|
+
ip = ipaddress.ip_address(host)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None
|
|
84
|
+
return str(ip) if ip.is_loopback else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_loopback(hp: str) -> bool:
|
|
88
|
+
return loopback_ip(hp) is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def url_for(hp: str, path: str) -> str:
|
|
92
|
+
ip = loopback_ip(hp)
|
|
93
|
+
if ip is None:
|
|
94
|
+
raise SystemExit(f"{TAG} recusado: {hp!r} não é IP de loopback; só consulto 127.0.0.0/8, ::1 ou localhost")
|
|
95
|
+
port = split_hostport(hp)[1]
|
|
96
|
+
host = f"[{ip}]" if ":" in ip else ip
|
|
97
|
+
return f"http://{host}:{port}{path}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def addr(name: str) -> str:
|
|
101
|
+
return os.environ.get(PROXIES[name]["listen_env"]) or PROXIES[name]["default"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def call(method: str, hp: str, path: str, headers: dict | None = None, body: bytes | None = None, timeout=2.0):
|
|
105
|
+
req = urllib.request.Request(url_for(hp, path), data=body, method=method, headers=headers or {})
|
|
106
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) # ignora HTTP(S)_PROXY/ALL_PROXY
|
|
107
|
+
try:
|
|
108
|
+
with opener.open(req, timeout=timeout) as r:
|
|
109
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read(65536)
|
|
110
|
+
except urllib.error.HTTPError as e:
|
|
111
|
+
try:
|
|
112
|
+
raw = e.read(65536)
|
|
113
|
+
except Exception:
|
|
114
|
+
raw = b""
|
|
115
|
+
return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, raw
|
|
116
|
+
except (urllib.error.URLError, OSError, TimeoutError, httpclient.HTTPException, ValueError):
|
|
117
|
+
return None, {}, b""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------- estado privado
|
|
121
|
+
def state_dir() -> Path:
|
|
122
|
+
base = os.environ.get("LEAN_CONTEXT_DIR")
|
|
123
|
+
if not base:
|
|
124
|
+
base = os.path.join(os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache"), "lean-context")
|
|
125
|
+
p = Path(base) / "proxy"
|
|
126
|
+
p.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
127
|
+
for d in (p.parent, p):
|
|
128
|
+
st = os.lstat(d)
|
|
129
|
+
if stat.S_ISLNK(st.st_mode) or st.st_uid != os.getuid():
|
|
130
|
+
raise SystemExit(f"{TAG} recusado: {d} não é um diretório privado deste usuário")
|
|
131
|
+
if stat.S_IMODE(st.st_mode) & 0o077:
|
|
132
|
+
os.chmod(d, 0o700)
|
|
133
|
+
return p
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def write_private(path: Path, data: str) -> None:
|
|
137
|
+
tmp = path.with_name(path.name + f".tmp{os.getpid()}")
|
|
138
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
139
|
+
with os.fdopen(fd, "w") as f:
|
|
140
|
+
f.write(data)
|
|
141
|
+
os.replace(tmp, path)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def read_private_json(path: Path) -> dict | None:
|
|
145
|
+
try:
|
|
146
|
+
st = os.lstat(path)
|
|
147
|
+
if stat.S_ISLNK(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) & 0o077:
|
|
148
|
+
return None
|
|
149
|
+
d = json.loads(path.read_text())
|
|
150
|
+
return d if isinstance(d, dict) else None
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def token_path() -> Path:
|
|
156
|
+
return state_dir() / "caveman-token"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_token(create: bool) -> str | None:
|
|
160
|
+
p = token_path()
|
|
161
|
+
if p.exists():
|
|
162
|
+
st = os.lstat(p)
|
|
163
|
+
if stat.S_ISLNK(st.st_mode) or stat.S_IMODE(st.st_mode) & 0o077 or st.st_uid != os.getuid():
|
|
164
|
+
raise SystemExit(f"{TAG} recusado: {p} precisa ser arquivo comum 0600 deste usuário")
|
|
165
|
+
return p.read_text().strip()
|
|
166
|
+
if not create:
|
|
167
|
+
return None
|
|
168
|
+
tok = secrets.token_hex(32)
|
|
169
|
+
write_private(p, tok + "\n")
|
|
170
|
+
return tok
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def manifest_path(name: str) -> Path:
|
|
174
|
+
return state_dir() / f"{name}.json"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def read_manifest(name: str) -> dict | None:
|
|
178
|
+
return read_private_json(manifest_path(name))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------- identidade de processo
|
|
182
|
+
def proc_identity(pid) -> dict | None:
|
|
183
|
+
"""Executável e horário de início do processo (ps), para não confundir pid reutilizado."""
|
|
184
|
+
try:
|
|
185
|
+
pid = int(pid)
|
|
186
|
+
out = subprocess.run(["ps", "-o", "lstart=", "-o", "comm=", "-p", str(pid)], capture_output=True,
|
|
187
|
+
text=True, timeout=5).stdout.strip()
|
|
188
|
+
except (TypeError, ValueError, OSError, subprocess.SubprocessError):
|
|
189
|
+
return None
|
|
190
|
+
if not out:
|
|
191
|
+
return None
|
|
192
|
+
parts = out.split()
|
|
193
|
+
return {"start": " ".join(parts[:5]), "comm": os.path.basename(" ".join(parts[5:]))}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def port_owners(port: int) -> set | None:
|
|
197
|
+
"""PIDs escutando na porta (lsof). None quando lsof não está disponível."""
|
|
198
|
+
lsof = shutil.which("lsof")
|
|
199
|
+
if not lsof:
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
out = subprocess.run([lsof, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], capture_output=True,
|
|
203
|
+
text=True, timeout=5).stdout
|
|
204
|
+
except (OSError, subprocess.SubprocessError):
|
|
205
|
+
return None
|
|
206
|
+
return {int(x) for x in out.split() if x.isdigit()}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def same_process(m: dict) -> bool:
|
|
210
|
+
ident = proc_identity(m.get("pid"))
|
|
211
|
+
return bool(ident and m.get("identity") and ident == m["identity"])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------- consultas aos proxies
|
|
215
|
+
def health(name: str, hp: str | None = None):
|
|
216
|
+
hp = hp or addr(name)
|
|
217
|
+
code, hdrs, body = call("GET", hp, PROXIES[name]["health"])
|
|
218
|
+
if code != 200:
|
|
219
|
+
return None, {}
|
|
220
|
+
try:
|
|
221
|
+
data = json.loads(body)
|
|
222
|
+
except ValueError:
|
|
223
|
+
return None, {}
|
|
224
|
+
if not isinstance(data, dict) or data.get("service") != PROXIES[name]["service"]:
|
|
225
|
+
return None, {} # outra coisa na porta: não é o proxy
|
|
226
|
+
return data, hdrs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def caveman_runstate(m: dict | None, hp: str) -> dict:
|
|
230
|
+
"""Run-state do home isolado da instância deste script (contém instance_token; nunca é impresso)."""
|
|
231
|
+
port = split_hostport(hp)[1]
|
|
232
|
+
home = (m or {}).get("caveman_home")
|
|
233
|
+
if not home:
|
|
234
|
+
return {}
|
|
235
|
+
return read_private_json(Path(home) / "run" / f"{port}.json") or {}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------- detect
|
|
239
|
+
def settings_base_url() -> str | None:
|
|
240
|
+
cfg = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude") / "settings.json"
|
|
241
|
+
try:
|
|
242
|
+
env = json.loads(cfg.read_text()).get("env") or {}
|
|
243
|
+
return env.get("ANTHROPIC_BASE_URL") if isinstance(env, dict) else None
|
|
244
|
+
except (OSError, ValueError, AttributeError):
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def detect() -> dict:
|
|
249
|
+
out = {"agent": {"ANTHROPIC_BASE_URL (ambiente)": os.environ.get("ANTHROPIC_BASE_URL"),
|
|
250
|
+
"ANTHROPIC_BASE_URL (~/.claude/settings.json)": settings_base_url()},
|
|
251
|
+
"proxies": {}}
|
|
252
|
+
for name, spec in PROXIES.items():
|
|
253
|
+
hp = addr(name)
|
|
254
|
+
info = {"binário": shutil.which(spec["binary"]), "endereço": hp, "rodando": False, "versão": None,
|
|
255
|
+
"iniciado por este script": bool(read_manifest(name))}
|
|
256
|
+
if not is_loopback(hp):
|
|
257
|
+
info["erro"] = "endereço configurado não é IP de loopback; não consultado"
|
|
258
|
+
else:
|
|
259
|
+
h, _ = health(name, hp)
|
|
260
|
+
info["rodando"] = bool(h)
|
|
261
|
+
info["versão"] = (h or {}).get("version")
|
|
262
|
+
out["proxies"][name] = info
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def print_detect(d: dict) -> None:
|
|
267
|
+
for k, v in d["agent"].items():
|
|
268
|
+
print(f"{TAG} agente · {k}: {v or 'não definido'}")
|
|
269
|
+
for name, i in d["proxies"].items():
|
|
270
|
+
bits = [f"binário {'em ' + i['binário'] if i['binário'] else 'não instalado'}",
|
|
271
|
+
f"{'rodando' if i['rodando'] else 'parado'} em {i['endereço']}"]
|
|
272
|
+
if i.get("versão"):
|
|
273
|
+
bits.append(f"versão {i['versão']}")
|
|
274
|
+
if i.get("erro"):
|
|
275
|
+
bits.append(i["erro"])
|
|
276
|
+
bits.append("subido por este script" if i["iniciado por este script"] else "não subido por este script")
|
|
277
|
+
print(f"{TAG} {name}: " + " · ".join(bits))
|
|
278
|
+
for name, i in d["proxies"].items():
|
|
279
|
+
host, port = split_hostport(i["endereço"])
|
|
280
|
+
for k, v in d["agent"].items():
|
|
281
|
+
if v and port and re.match(rf"https?://(127\.[\d.]+|localhost|\[::1\]):{port}(/|$)", v.strip()):
|
|
282
|
+
print(f"{TAG} o agente já aponta para a porta do {name} via {k}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------- check
|
|
286
|
+
class Report:
|
|
287
|
+
def __init__(self, quiet=False):
|
|
288
|
+
self.fails = 0
|
|
289
|
+
self.unknown = 0
|
|
290
|
+
self.quiet = quiet
|
|
291
|
+
|
|
292
|
+
def _p(self, s):
|
|
293
|
+
if not self.quiet:
|
|
294
|
+
print(s)
|
|
295
|
+
|
|
296
|
+
def ok(self, msg):
|
|
297
|
+
self._p(f" PASSA {msg}")
|
|
298
|
+
|
|
299
|
+
def fail(self, msg):
|
|
300
|
+
self.fails += 1
|
|
301
|
+
self._p(f" FALHA {msg}")
|
|
302
|
+
|
|
303
|
+
def unk(self, msg):
|
|
304
|
+
self.unknown += 1
|
|
305
|
+
self._p(f" ? {msg}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def verify_identity(name: str, hp: str, r: Report, hdrs: dict) -> tuple[bool, dict | None]:
|
|
309
|
+
"""Prova que quem responde em hp é a instância que este script subiu. Nada de token antes disso."""
|
|
310
|
+
m = read_manifest(name)
|
|
311
|
+
if not m:
|
|
312
|
+
r.unk(f"instância não foi subida por este script: identidade e configuração não confirmadas; "
|
|
313
|
+
f"para usar com segurança, pare-a e rode `lc_proxy.py start {name}`")
|
|
314
|
+
return False, None
|
|
315
|
+
if m.get("address") != hp:
|
|
316
|
+
r.fail(f"o manifesto é de {m.get('address')}, mas a consulta é em {hp}: outra instância")
|
|
317
|
+
return False, m
|
|
318
|
+
if not same_process(m):
|
|
319
|
+
r.fail("o processo do manifesto não existe mais (ou o pid foi reutilizado): quem responde na porta é outro")
|
|
320
|
+
return False, m
|
|
321
|
+
owners = port_owners(split_hostport(hp)[1])
|
|
322
|
+
if owners is None:
|
|
323
|
+
r.unk("lsof indisponível: não deu para confirmar o dono da porta")
|
|
324
|
+
elif int(m["pid"]) not in owners:
|
|
325
|
+
r.fail(f"a porta é ouvida por outro processo ({sorted(owners)}), não pelo pid {m['pid']} do manifesto")
|
|
326
|
+
return False, m
|
|
327
|
+
if name == "caveman":
|
|
328
|
+
rs = caveman_runstate(m, hp)
|
|
329
|
+
tok, live = rs.get("instance_token"), hdrs.get("x-caveman-instance")
|
|
330
|
+
if not tok or not live:
|
|
331
|
+
r.unk("caveman sem X-Caveman-Instance/run-state para conferir (versão antiga?)")
|
|
332
|
+
elif not secrets.compare_digest(str(tok), str(live)):
|
|
333
|
+
r.fail("X-Caveman-Instance não bate com o run-state privado: outra instância responde na porta")
|
|
334
|
+
return False, m
|
|
335
|
+
else:
|
|
336
|
+
r.ok("X-Caveman-Instance confere com o run-state privado da instância")
|
|
337
|
+
r.ok(f"identidade provada: pid {m['pid']} ({m['identity']['comm']}) subido por este script, dono da porta")
|
|
338
|
+
return True, m
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def check(name: str, quiet=False) -> tuple[int, bool]:
|
|
342
|
+
hp = addr(name)
|
|
343
|
+
r = Report(quiet)
|
|
344
|
+
r._p(f"{TAG} postura de {name} em {hp}")
|
|
345
|
+
if not is_loopback(hp):
|
|
346
|
+
r.fail(f"{hp!r} não é IP de loopback (só 127.0.0.0/8, ::1 ou localhost)")
|
|
347
|
+
r._p(f"{TAG} resultado: {r.fails} falha(s), {r.unknown} ponto(s) não confirmados")
|
|
348
|
+
return 1, False
|
|
349
|
+
h, hdrs = health(name, hp)
|
|
350
|
+
if not h:
|
|
351
|
+
r.fail(f"nenhum {PROXIES[name]['service']} respondendo em {hp}")
|
|
352
|
+
r._p(f"{TAG} resultado: {r.fails} falha(s), {r.unknown} ponto(s) não confirmados")
|
|
353
|
+
return 1, False
|
|
354
|
+
r.ok(f"{PROXIES[name]['service']} respondendo em loopback (versão {h.get('version', '?')})")
|
|
355
|
+
verified, m = verify_identity(name, hp, r, hdrs)
|
|
356
|
+
body, hdr = b"{}", {"content-type": "application/json"}
|
|
357
|
+
if name == "caveman":
|
|
358
|
+
code, _, _ = call("POST", hp, "/v1/messages", hdr, body)
|
|
359
|
+
(r.ok if code == 401 else r.fail)(f"requisição sem token é recusada (HTTP {code}; esperado 401)")
|
|
360
|
+
if verified:
|
|
361
|
+
tok = get_token(create=False)
|
|
362
|
+
if tok:
|
|
363
|
+
code2, _, _ = call("POST", hp, "/v1/messages", dict(hdr, **{"x-cave-api-key": tok}), body)
|
|
364
|
+
(r.ok if code2 not in (None, 401) else r.fail)(f"requisição com o token passa (HTTP {code2})")
|
|
365
|
+
else:
|
|
366
|
+
r.fail("sem token salvo por este script; o agente não terá como se autenticar")
|
|
367
|
+
rs = caveman_runstate(m, hp)
|
|
368
|
+
mode = rs.get("mode")
|
|
369
|
+
if mode == "record":
|
|
370
|
+
r.ok("modo record (lido da instância): bytes intactos, inclusive o texto do usuário; só medição")
|
|
371
|
+
elif mode == "compress":
|
|
372
|
+
if m.get("accepted_user_text_compression"):
|
|
373
|
+
r.unk("modo compress aceito explicitamente: o texto do usuário PODE ser comprimido (CAV-01)")
|
|
374
|
+
else:
|
|
375
|
+
r.fail("modo compress sem aceite explícito: o texto do usuário pode ser comprimido (CAV-01)")
|
|
376
|
+
else:
|
|
377
|
+
r.unk(f"modo não identificado no run-state ({mode})")
|
|
378
|
+
up = (rs.get("provider_upstreams") or {}).get("anthropic")
|
|
379
|
+
if up is None:
|
|
380
|
+
r.unk("run-state sem provider_upstreams.anthropic para conferir o upstream")
|
|
381
|
+
else:
|
|
382
|
+
(r.ok if str(up).rstrip("/") == OFFICIAL_ANTHROPIC else r.fail)(f"upstream Anthropic é a API oficial ({up})")
|
|
383
|
+
else:
|
|
384
|
+
r.unk("token não enviado: a instância não teve a identidade provada")
|
|
385
|
+
else:
|
|
386
|
+
code, _, raw = call("GET", hp, "/admin/settings")
|
|
387
|
+
try:
|
|
388
|
+
s = json.loads(raw) if code == 200 else {}
|
|
389
|
+
except ValueError:
|
|
390
|
+
s = {}
|
|
391
|
+
s = s if isinstance(s, dict) else {}
|
|
392
|
+
up = s.get("anthropic_upstream")
|
|
393
|
+
if up is None:
|
|
394
|
+
r.fail("não consegui ler o upstream em /admin/settings")
|
|
395
|
+
else:
|
|
396
|
+
(r.ok if str(up).rstrip("/") == OFFICIAL_ANTHROPIC else r.fail)(
|
|
397
|
+
f"upstream Anthropic é a API oficial ({up}) — um upstream trocado receberia suas chaves")
|
|
398
|
+
bind = s.get("bind")
|
|
399
|
+
if bind is None:
|
|
400
|
+
r.unk("o proxy não informou o bind")
|
|
401
|
+
else:
|
|
402
|
+
(r.ok if str(bind) == hp else r.fail)(f"bind informado pelo proxy ({bind}) é o endereço consultado ({hp})")
|
|
403
|
+
if verified and (m.get("env") or {}).get("RTK_PROXY_RELAY_BORROWING") == "false":
|
|
404
|
+
r.ok("empréstimo de credencial desligado nesta instância (RTK-10)")
|
|
405
|
+
else:
|
|
406
|
+
code3, _, raw3 = call("POST", hp, "/v1/messages", hdr, body)
|
|
407
|
+
if b"invalid_request_error" in raw3:
|
|
408
|
+
r.fail("pedido sem credencial foi aceito com credencial emprestada (RTK-10)")
|
|
409
|
+
else:
|
|
410
|
+
r.unk("empréstimo de credencial não confirmado (o rtk só empresta depois do primeiro uso)")
|
|
411
|
+
if Path("/etc/rtk-proxy/rtk-proxy.toml").exists():
|
|
412
|
+
r.unk("/etc/rtk-proxy/rtk-proxy.toml existe e pode mudar a configuração; revise-o")
|
|
413
|
+
r.unk("rtk-proxy não tem autenticação de entrada: qualquer processo local pode usá-lo (sem a sua credencial)")
|
|
414
|
+
r._p(f"{TAG} resultado: {r.fails} falha(s), {r.unknown} ponto(s) não confirmados")
|
|
415
|
+
return (1 if r.fails else 0), verified
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------- start / stop
|
|
419
|
+
def secure_env(name: str, mode: str) -> tuple[dict, dict]:
|
|
420
|
+
"""(env do processo do proxy, parte visível para o manifesto, sem segredos)."""
|
|
421
|
+
sd = state_dir()
|
|
422
|
+
env = {k: v for k, v in os.environ.items() if k in ENV_ALLOW or k.startswith("LC_")}
|
|
423
|
+
hp = addr(name)
|
|
424
|
+
if name == "caveman":
|
|
425
|
+
home = sd / "caveman-home"
|
|
426
|
+
home.mkdir(mode=0o700, exist_ok=True)
|
|
427
|
+
visible = {"CAVEMAN_LISTEN": hp, "CAVEMAN_MODE": mode, "CAVEMAN_CACHE_WARM": "off",
|
|
428
|
+
"CAVEMAN_OFFLINE": "1", "DO_NOT_TRACK": "1", "CAVEMAN_TELEMETRY": "0", "CAVE_NO_KEYCHAIN": "1",
|
|
429
|
+
"CAVEMAN_SUBSCRIPTION_COMPRESS": "off", "CAVEMAN_HOME": str(home)}
|
|
430
|
+
env.update(visible)
|
|
431
|
+
tok = get_token(create=True)
|
|
432
|
+
env["CAVEMAN_AUTH_TOKEN"] = tok
|
|
433
|
+
env["CAVEMAN_METRICS_TOKEN"] = tok
|
|
434
|
+
visible = dict(visible, CAVEMAN_AUTH_TOKEN=f"<arquivo {token_path()}>", CAVEMAN_METRICS_TOKEN="<mesmo token>")
|
|
435
|
+
else:
|
|
436
|
+
visible = {"RTK_PROXY_BIND": hp, "RTK_PROXY_RELAY_BORROWING": "false",
|
|
437
|
+
"RTK_PROXY_ANTHROPIC_UPSTREAM": OFFICIAL_ANTHROPIC, "RTK_PROXY_ICM_TTL_SECS": "86400",
|
|
438
|
+
"RTK_PROXY_DB": str(sd / "rtk-proxy.db"), "RTK_PROXY_ICM_DB": str(sd / "rtk-proxy-memory.db")}
|
|
439
|
+
env.update(visible)
|
|
440
|
+
return env, visible
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def start(name: str, mode: str, accept: bool, dry: bool) -> int:
|
|
444
|
+
spec = PROXIES[name]
|
|
445
|
+
hp = addr(name)
|
|
446
|
+
if not is_loopback(hp):
|
|
447
|
+
print(f"{TAG} recusado: {hp!r} não é IP de loopback; esta skill só sobe o proxy em 127.0.0.1")
|
|
448
|
+
return 2
|
|
449
|
+
if name == "caveman" and mode == "compress" and not accept:
|
|
450
|
+
print(f"{TAG} recusado: no modo compress o caveman-proxy comprime também o texto que o usuário digita "
|
|
451
|
+
"(CAV-01) e não há opção que evite isso. Só use com --accept-user-text-compression, depois de o usuário aceitar.")
|
|
452
|
+
return 2
|
|
453
|
+
binary = shutil.which(spec["binary"])
|
|
454
|
+
if not binary and not dry:
|
|
455
|
+
print(f"{TAG} {spec['binary']} não está instalado. Esta skill não instala binários: peça ao usuário "
|
|
456
|
+
"para instalar pela fonte oficial, numa versão fixa.")
|
|
457
|
+
return 3
|
|
458
|
+
if not dry and health(name, hp)[0]:
|
|
459
|
+
print(f"{TAG} já existe um {spec['service']} em {hp}. Variáveis novas não valem para uma instância "
|
|
460
|
+
f"rodando: rode `lc_proxy.py check {name}` e, se precisar, pare a instância atual antes.")
|
|
461
|
+
return 4
|
|
462
|
+
env, visible = secure_env(name, mode)
|
|
463
|
+
argv = [binary or spec["binary"]] + (["start"] if name == "rtk" else [])
|
|
464
|
+
print(f"{TAG} comando: {' '.join(argv)} (ambiente por lista de permissão)")
|
|
465
|
+
for k, v in visible.items():
|
|
466
|
+
print(f"{TAG} {k}={v}")
|
|
467
|
+
if dry:
|
|
468
|
+
print(f"{TAG} --dry-run: nada foi iniciado")
|
|
469
|
+
return 0
|
|
470
|
+
sd = state_dir()
|
|
471
|
+
log = sd / f"{name}.log"
|
|
472
|
+
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
473
|
+
proc = subprocess.Popen(argv, env=env, cwd=str(sd), stdin=subprocess.DEVNULL, stdout=fd, stderr=fd,
|
|
474
|
+
start_new_session=True)
|
|
475
|
+
os.close(fd)
|
|
476
|
+
ident = None
|
|
477
|
+
for _ in range(30):
|
|
478
|
+
ident = proc_identity(proc.pid)
|
|
479
|
+
if ident:
|
|
480
|
+
break
|
|
481
|
+
time.sleep(0.05)
|
|
482
|
+
write_private(manifest_path(name), json.dumps({
|
|
483
|
+
"pid": proc.pid, "identity": ident, "binary": binary, "started": int(time.time()), "address": hp,
|
|
484
|
+
"mode": mode if name == "caveman" else "tool-output", "accepted_user_text_compression": bool(accept),
|
|
485
|
+
"caveman_home": visible.get("CAVEMAN_HOME"), "env": visible, "log": str(log)}, indent=1))
|
|
486
|
+
for _ in range(50):
|
|
487
|
+
if health(name, hp)[0]:
|
|
488
|
+
break
|
|
489
|
+
if proc.poll() is not None:
|
|
490
|
+
print(f"{TAG} o proxy saiu com código {proc.returncode}; veja {log}")
|
|
491
|
+
manifest_path(name).unlink(missing_ok=True)
|
|
492
|
+
return 5
|
|
493
|
+
time.sleep(0.1)
|
|
494
|
+
# a identidade pode mudar se um wrapper fizer exec: regrava com o processo final
|
|
495
|
+
final = proc_identity(proc.pid)
|
|
496
|
+
if final and final != ident:
|
|
497
|
+
m = read_manifest(name) or {}
|
|
498
|
+
m["identity"] = final
|
|
499
|
+
write_private(manifest_path(name), json.dumps(m, indent=1))
|
|
500
|
+
print(f"{TAG} iniciado (pid {proc.pid}); log em {log}")
|
|
501
|
+
return check(name)[0]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def stop(name: str, purge: bool) -> int:
|
|
505
|
+
m = read_manifest(name)
|
|
506
|
+
if not m:
|
|
507
|
+
print(f"{TAG} nenhuma instância de {name} subida por este script; não paro processos que não iniciei")
|
|
508
|
+
return 1
|
|
509
|
+
pid = m.get("pid")
|
|
510
|
+
if proc_identity(pid) is None:
|
|
511
|
+
print(f"{TAG} o processo {pid} já não existe")
|
|
512
|
+
elif not same_process(m):
|
|
513
|
+
print(f"{TAG} recusado: o pid {pid} agora é outro processo (executável ou início diferentes); não vou pará-lo")
|
|
514
|
+
manifest_path(name).unlink(missing_ok=True)
|
|
515
|
+
return 1
|
|
516
|
+
else:
|
|
517
|
+
try:
|
|
518
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
519
|
+
except ProcessLookupError:
|
|
520
|
+
pass
|
|
521
|
+
except PermissionError:
|
|
522
|
+
print(f"{TAG} recusado: sem permissão para sinalizar o pid {pid}")
|
|
523
|
+
return 1
|
|
524
|
+
for _ in range(50):
|
|
525
|
+
if proc_identity(pid) is None:
|
|
526
|
+
break
|
|
527
|
+
time.sleep(0.1)
|
|
528
|
+
print(f"{TAG} {name} parado (pid {pid})")
|
|
529
|
+
manifest_path(name).unlink(missing_ok=True)
|
|
530
|
+
if purge:
|
|
531
|
+
sd = state_dir()
|
|
532
|
+
targets = [sd / "caveman-home"] if name == "caveman" else [sd / "rtk-proxy.db", sd / "rtk-proxy-memory.db"]
|
|
533
|
+
for t in targets:
|
|
534
|
+
if t.is_dir() and not t.is_symlink():
|
|
535
|
+
shutil.rmtree(t, ignore_errors=True)
|
|
536
|
+
else:
|
|
537
|
+
for f in t.parent.glob(t.name + "*"):
|
|
538
|
+
f.unlink(missing_ok=True)
|
|
539
|
+
print(f"{TAG} dados locais apagados (originais guardados pelo proxy e estatísticas)")
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------- launch / env
|
|
544
|
+
def agent_env(name: str) -> dict:
|
|
545
|
+
hp = addr(name)
|
|
546
|
+
base = url_for(hp, "").rstrip("/")
|
|
547
|
+
env = {"ANTHROPIC_BASE_URL": base, "ENABLE_TOOL_SEARCH": "true"}
|
|
548
|
+
no_proxy = [x for x in (os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "").split(",") if x]
|
|
549
|
+
for h in ("127.0.0.1", "localhost", "::1"):
|
|
550
|
+
if h not in no_proxy:
|
|
551
|
+
no_proxy.append(h)
|
|
552
|
+
env["NO_PROXY"] = env["no_proxy"] = ",".join(no_proxy) # nunca passar o tráfego local por proxy corporativo
|
|
553
|
+
if name == "caveman":
|
|
554
|
+
tok = get_token(create=False)
|
|
555
|
+
if not tok:
|
|
556
|
+
raise SystemExit(f"{TAG} sem token: suba o proxy com `lc_proxy.py start caveman` primeiro")
|
|
557
|
+
cur = [l for l in (os.environ.get("ANTHROPIC_CUSTOM_HEADERS") or "").splitlines()
|
|
558
|
+
if l.strip() and not l.lower().lstrip().startswith("x-cave-api-key")]
|
|
559
|
+
env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(cur + [f"x-cave-api-key: {tok}"])
|
|
560
|
+
return env
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def launch(name: str, cmd: list[str]) -> int:
|
|
564
|
+
if not cmd:
|
|
565
|
+
raise SystemExit(f"{TAG} informe o comando do agente depois de --")
|
|
566
|
+
print(f"{TAG} conferindo identidade e postura antes de apontar o agente…")
|
|
567
|
+
rc, verified = check(name)
|
|
568
|
+
if rc != 0 or not verified:
|
|
569
|
+
why = "há falhas acima" if rc != 0 else "a instância não teve a identidade provada (suba-a com `start`)"
|
|
570
|
+
print(f"{TAG} recusado: {why}. O tráfego e as credenciais da API não serão apontados para ela.")
|
|
571
|
+
return 1
|
|
572
|
+
env = dict(os.environ, **agent_env(name))
|
|
573
|
+
print(f"{TAG} iniciando {cmd[0]} com ANTHROPIC_BASE_URL={env['ANTHROPIC_BASE_URL']} (só neste processo)")
|
|
574
|
+
sys.stdout.flush()
|
|
575
|
+
os.execvpe(cmd[0], cmd, env)
|
|
576
|
+
return 0
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def print_env(name: str) -> int:
|
|
580
|
+
hp = addr(name)
|
|
581
|
+
print(f"export ANTHROPIC_BASE_URL={shlex.quote(url_for(hp, '').rstrip('/'))}")
|
|
582
|
+
print("export ENABLE_TOOL_SEARCH=true")
|
|
583
|
+
print("export NO_PROXY=\"${NO_PROXY:+$NO_PROXY,}127.0.0.1,localhost,::1\"")
|
|
584
|
+
if name == "caveman":
|
|
585
|
+
print(f'export ANTHROPIC_CUSTOM_HEADERS="x-cave-api-key: $(cat {shlex.quote(str(token_path()))})"')
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def main(argv: list[str]) -> int:
|
|
590
|
+
cmd = []
|
|
591
|
+
if "--" in argv:
|
|
592
|
+
i = argv.index("--")
|
|
593
|
+
argv, cmd = argv[:i], argv[i + 1:]
|
|
594
|
+
ap = argparse.ArgumentParser(prog="lc_proxy.py")
|
|
595
|
+
sub = ap.add_subparsers(dest="op", required=True)
|
|
596
|
+
d = sub.add_parser("detect")
|
|
597
|
+
d.add_argument("--json", action="store_true")
|
|
598
|
+
for op in ("check", "start", "stop", "launch", "env"):
|
|
599
|
+
p = sub.add_parser(op)
|
|
600
|
+
p.add_argument("name", choices=sorted(PROXIES))
|
|
601
|
+
if op == "start":
|
|
602
|
+
p.add_argument("--mode", choices=("record", "compress"), default="record")
|
|
603
|
+
p.add_argument("--accept-user-text-compression", action="store_true")
|
|
604
|
+
p.add_argument("--dry-run", action="store_true")
|
|
605
|
+
if op == "stop":
|
|
606
|
+
p.add_argument("--purge", action="store_true")
|
|
607
|
+
a = ap.parse_args(argv)
|
|
608
|
+
if a.op == "detect":
|
|
609
|
+
d = detect()
|
|
610
|
+
if a.json:
|
|
611
|
+
print(json.dumps(d, ensure_ascii=False, indent=1))
|
|
612
|
+
else:
|
|
613
|
+
print_detect(d)
|
|
614
|
+
return 0
|
|
615
|
+
if a.op == "check":
|
|
616
|
+
return check(a.name)[0]
|
|
617
|
+
if a.op == "start":
|
|
618
|
+
return start(a.name, a.mode, a.accept_user_text_compression, a.dry_run)
|
|
619
|
+
if a.op == "stop":
|
|
620
|
+
return stop(a.name, a.purge)
|
|
621
|
+
if a.op == "launch":
|
|
622
|
+
return launch(a.name, cmd)
|
|
623
|
+
return print_env(a.name)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
if __name__ == "__main__":
|
|
627
|
+
sys.exit(main(sys.argv[1:]))
|