@andespindola/crow-skills 0.12.2 → 0.13.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.
@@ -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:]))