@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.
- package/crow_skills/__init__.py +1 -1
- package/package.json +1 -1
- package/release.json +25 -5
- package/skills/crow-behaviour/SKILL.md +2 -1
- package/skills/crow-behaviour/references/context-economy.md +146 -0
- 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,731 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lean-context: roda um comando e entrega ao agente uma visão enxuta e fiel da saída.
|
|
3
|
+
|
|
4
|
+
Princípios (cada um corrige um gap observado em rtk, caveman ou context-mode):
|
|
5
|
+
- Nunca resume, reescreve ou inventa: só seleciona linhas reais, com a numeração original.
|
|
6
|
+
- Nunca esconde perda: todo corte é marcado e a saída completa fica num arquivo reabrível,
|
|
7
|
+
também quando o comando dá certo.
|
|
8
|
+
- Preserva o exit code do comando (sinal -> 128+N, timeout -> 124), herda o stdin,
|
|
9
|
+
roda em grupo de processos próprio, tem timeout e mata o grupo inteiro ao estourar.
|
|
10
|
+
- Mascara padrões comuns de segredo no que mostra e no que grava (melhor esforço, não garantia);
|
|
11
|
+
nada bruto vai para o disco; arquivos 0600 num diretório 0700, com retenção curta.
|
|
12
|
+
- Métrica honesta: bytes medidos (brutos vs. exibidos), nunca tokens estimados.
|
|
13
|
+
|
|
14
|
+
Uso:
|
|
15
|
+
lc_run.py [opções] -- comando arg1 arg2 # executa direto (sem shell)
|
|
16
|
+
lc_run.py [opções] -- 'cmd | outro && mais' # um único argumento: roda em bash -c
|
|
17
|
+
lc_run.py --stats # totais medidos
|
|
18
|
+
Só usa a biblioteca padrão do Python 3.8+. macOS e Linux.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import collections
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import signal
|
|
29
|
+
import stat
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
TAG = "[lean-context]"
|
|
37
|
+
PIECE = 65536 # linhas maiores são processadas em pedaços: custo linear, memória limitada
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- redação (melhor esforço)
|
|
40
|
+
_KW = r"(?:password|passwd|passphrase|pwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key|client[_-]?secret|credentials?)"
|
|
41
|
+
_SECRET_PATTERNS = [
|
|
42
|
+
# PEM/PGP inteiro numa linha (ex.: JSON de service account com \n literal)
|
|
43
|
+
(re.compile(rb"(-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----).{0,20000}?(-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----)"), rb"\1<redigido:chave-privada>\2"),
|
|
44
|
+
(re.compile(rb"(?i)\b(authorization\s{0,5}[:=]\s{0,5}(?:bearer|basic|token|bot)\s{1,5})[A-Za-z0-9._~+/=-]{8,}"), rb"\1<redigido>"),
|
|
45
|
+
(re.compile(rb"(?i)\b(bearer\s{1,5})[A-Za-z0-9._~+/=-]{16,}"), rb"\1<redigido>"),
|
|
46
|
+
(re.compile(rb"(?i)\b(x-api-key\s{0,5}[:=]\s{0,5})\S{8,}"), rb"\1<redigido>"),
|
|
47
|
+
# VAR=valor / chave: valor / "chave": "valor", com prefixo (PGPASSWORD, AWS_SECRET_ACCESS_KEY, OPENAI_API_KEY);
|
|
48
|
+
# o valor só é mascarado se parecer segredo (ver _kv_repl): erros como token: expired_or_revoked ficam
|
|
49
|
+
(re.compile(rb"(?i)(" + _KW.encode() + rb"([A-Za-z0-9_]{0,20})[\"']?\s{0,5}[:=]\s{0,5}[\"']?)(?!<redigido)([^\s\"',;&]{4,})"), "kv"),
|
|
50
|
+
# flags: --password valor, --token=valor
|
|
51
|
+
(re.compile(rb"(?i)((?<![\w-])--?(?:password|passwd|pass|token|secret|api-key|apikey|auth-token)(?:=|\s{1,5}))(?![0-9]+\b)(?!<redigido)[^\s\"']{4,}"), rb"\1<redigido>"),
|
|
52
|
+
# curl -u usuario:senha, cookies, Azure, Grafana, Hugging Face
|
|
53
|
+
(re.compile(rb"((?<![\w-])(?:-u|--user)\s{1,5}[^\s:'\"]{1,64}:)[^\s'\"]{4,}"), rb"\1<redigido>"),
|
|
54
|
+
(re.compile(rb"(?i)\b((?:set-)?cookie:\s{0,5}[^=;\s]{1,64}=)[^;\s]{8,}"), rb"\1<redigido>"),
|
|
55
|
+
(re.compile(rb"(?i)\b(AccountKey=|SharedAccessSignature=|SharedAccessKey=|[?&]sig=)[A-Za-z0-9%+/=]{16,}"), rb"\1<redigido>"),
|
|
56
|
+
(re.compile(rb"\bglsa_[A-Za-z0-9_]{20,}"), b"<redigido:grafana>"),
|
|
57
|
+
(re.compile(rb"\bhf_[A-Za-z0-9]{30,}"), b"<redigido:huggingface>"),
|
|
58
|
+
(re.compile(rb"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}"), b"<redigido:sk>"),
|
|
59
|
+
(re.compile(rb"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}"), b"<redigido:stripe>"),
|
|
60
|
+
(re.compile(rb"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), b"<redigido:sendgrid>"),
|
|
61
|
+
(re.compile(rb"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}"), b"<redigido:github>"),
|
|
62
|
+
(re.compile(rb"\bgithub_pat_[A-Za-z0-9_]{40,}"), b"<redigido:github>"),
|
|
63
|
+
(re.compile(rb"\bglpat-[A-Za-z0-9_-]{20,}"), b"<redigido:gitlab>"),
|
|
64
|
+
(re.compile(rb"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), b"<redigido:aws>"),
|
|
65
|
+
(re.compile(rb"\bxox[abprs]-[A-Za-z0-9-]{10,}"), b"<redigido:slack>"),
|
|
66
|
+
(re.compile(rb"\bAIza[0-9A-Za-z_-]{35}\b"), b"<redigido:google>"),
|
|
67
|
+
(re.compile(rb"\bnpm_[A-Za-z0-9]{36}\b"), b"<redigido:npm>"),
|
|
68
|
+
(re.compile(rb"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), b"<redigido:jwt>"),
|
|
69
|
+
# credencial em URL: esquema://usuario:senha@ (ancorado e limitado: linear)
|
|
70
|
+
(re.compile(rb"(?<![A-Za-z0-9+.-])([A-Za-z][A-Za-z0-9+.-]{0,20}://[^\s:/@]{1,200}:)[^\s@/]{3,200}@"), rb"\1<redigido>@"),
|
|
71
|
+
]
|
|
72
|
+
# pré-filtro: só roda as regex completas quando a linha tem algum gatilho
|
|
73
|
+
_SECRET_TRIGGER = re.compile(rb"(?i)-----BEGIN|authoriz|bearer|api[_-]?key|pass|pwd|secret|token|private|access|credential|sk-|_live_|_test_|SG\.|gh[pousr]_|github_pat_|glpat-|AKIA|ASIA|xox|AIza|npm_|eyJ|://|cookie|accountkey|sharedaccess|sig=|glsa_|hf_|-u |--user")
|
|
74
|
+
_KEY_SUFFIX_NOT_SECRET = re.compile(rb"(?i)^(izer|izers|ization|_?names?|_?types?|_?ids?|_?paths?|_?files?|_?urls?|_?counts?|_?limits?|_?len(gth)?|_?expir\w*|_?ttl|s_used|_?status|_?policy|_?format)$")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _secretish(val: bytes) -> bool:
|
|
78
|
+
if len(val) < 6 or val[:1] in (b"/", b"~", b".", b"$", b"<", b"{"):
|
|
79
|
+
return False
|
|
80
|
+
if re.fullmatch(rb"[a-z_.-]+", val) or re.fullmatch(rb"[0-9]+", val):
|
|
81
|
+
return False # palavra comum (expired_or_revoked, invalid_grant) ou número (max_tokens=4096)
|
|
82
|
+
if val.lower() in (b"true", b"false", b"null", b"none", b"undefined", b"required", b"missing", b"invalid"):
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
_PEM_BEGIN = re.compile(rb"-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----")
|
|
86
|
+
_PEM_END = re.compile(rb"-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----")
|
|
87
|
+
_PEM_BODY = re.compile(rb"^\s*(?:[A-Za-z0-9+/=]{1,}|[A-Za-z][A-Za-z-]{0,30}: .{0,200}|=[A-Za-z0-9+/]{1,10})?\s*$")
|
|
88
|
+
PEM_MAX_LINES = 200
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Redactor:
|
|
92
|
+
def __init__(self, enabled: bool):
|
|
93
|
+
self.enabled = enabled
|
|
94
|
+
self.pem_lines = -1 # -1 = fora de bloco PEM
|
|
95
|
+
self.count = 0
|
|
96
|
+
|
|
97
|
+
def _kv_repl(self, m) -> bytes:
|
|
98
|
+
if _KEY_SUFFIX_NOT_SECRET.match(m.group(2)) or not _secretish(m.group(3)):
|
|
99
|
+
return m.group(0)
|
|
100
|
+
self.count += 1
|
|
101
|
+
return m.group(1) + b"<redigido>"
|
|
102
|
+
|
|
103
|
+
def piece(self, raw: bytes, line_start: bool) -> bytes:
|
|
104
|
+
if not self.enabled:
|
|
105
|
+
return raw
|
|
106
|
+
if self.pem_lines >= 0 and line_start:
|
|
107
|
+
if _PEM_END.search(raw):
|
|
108
|
+
self.pem_lines = -1
|
|
109
|
+
return raw
|
|
110
|
+
# só o miolo base64 é mascarado; qualquer outra linha encerra o modo PEM
|
|
111
|
+
if _PEM_BODY.match(raw.rstrip(b"\r\n")) and self.pem_lines < PEM_MAX_LINES:
|
|
112
|
+
self.pem_lines += 1
|
|
113
|
+
self.count += 1
|
|
114
|
+
return b"<redigido:chave-privada>" + (b"\n" if raw.endswith(b"\n") else b"")
|
|
115
|
+
self.pem_lines = -1
|
|
116
|
+
if not _SECRET_TRIGGER.search(raw):
|
|
117
|
+
return raw
|
|
118
|
+
out = raw
|
|
119
|
+
for pat, rep in _SECRET_PATTERNS:
|
|
120
|
+
if rep == "kv":
|
|
121
|
+
out = pat.sub(self._kv_repl, out)
|
|
122
|
+
else:
|
|
123
|
+
out, n = pat.subn(rep, out)
|
|
124
|
+
self.count += n
|
|
125
|
+
if _PEM_BEGIN.search(out) and not _PEM_END.search(out):
|
|
126
|
+
self.pem_lines = 0
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------- sinais (todas as regex limitadas: custo linear)
|
|
131
|
+
_ERR = re.compile(
|
|
132
|
+
r"(?i)(\b(error|errors|erro|erros|fatal|panic|panicked|exception|traceback|failed|failure|failing|fail|"
|
|
133
|
+
r"falha|falhas|falhou|falharam|falhando|denied|negado|refused|recusad[oa]|timed out|timeout|"
|
|
134
|
+
r"segmentation fault|segfault|sigsegv|sigabrt|aborted|core dumped|critical|crit|"
|
|
135
|
+
r"cannot|can't|unable to|não foi possível|nao foi possivel|not found|não encontrad[oa]|no such file|"
|
|
136
|
+
r"undefined|unresolved|assertion|assert|caused by|killed|oom|oomkilled|out of memory|evicted|crashloopbackoff|imagepullbackoff|errimagepull)\b"
|
|
137
|
+
r"|(Error|Exception|Fault)\b|ERR!|^\s{0,8}ERR(OR)?\b|\b(BUILD|TEST) FAIL"
|
|
138
|
+
r"|^\s{0,8}(E|F)\s{2,}|^\s{0,12}[✗✘×✕✖●]\s"
|
|
139
|
+
r"|\bHTTP/\d(\.\d)?\s{1,5}[45]\d\d\b|\bstatus(code|_code)?[\"']?\s{0,3}[=:]\s{0,3}[\"']?5\d\d\b"
|
|
140
|
+
r"|\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b.{0,300}[\s\"]5\d\d\b"
|
|
141
|
+
r"|\blevel[\"']?\s{0,3}[=:]\s{0,3}[\"']?(crit|critical|error|err|fatal|panic|alert|emerg)\b"
|
|
142
|
+
r"|\bexit(ed)?( with)?( code| status)?[=: ]{1,3}[1-9]\d{0,2}\b)"
|
|
143
|
+
)
|
|
144
|
+
_FRAME = re.compile(r"^\s{1,40}(at\s\S|File \"[^\"\n]{0,500}\", line \d+|\.\.\. \d+ more|in <module>|#\d+\s+0x)"
|
|
145
|
+
r"|^\s{4,40}\S[^()\n]{0,300}\([^()\n]{0,300}:\d+(:\d+)?\)\s*$")
|
|
146
|
+
_WARN = re.compile(r"(?i)\b(warn|warning|warnings|deprecat\w{0,10}|aviso|avisos)\b")
|
|
147
|
+
_ZERO = re.compile(r"(?i)\b(0|no|zero|sem|nenhum[a]?)\s{1,3}(failed|failures?|errors?|erros?|falhas?)\b|\b(failed|errors?|failures?|erros?|falhas?)[\"']?\s{0,3}[=:]\s{0,3}0\b")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_FRAME_M = re.compile(_FRAME.pattern, re.M)
|
|
151
|
+
_PYTEST_M = re.compile(r"(?m)^\s{0,8}[EF]\s\s")
|
|
152
|
+
# palavras que qualquer linha de falha ou aviso contém (em minúsculas); bloco sem nenhuma vai pelo caminho rápido
|
|
153
|
+
_SIG_KEYS = ("err", "fatal", "fail", "falh", "panic", "xception", "traceback", "denied", "negad", "refus", "recusad", "timed out",
|
|
154
|
+
"timeout", "fault", "sigsegv", "sigabrt", "abort", "core dump", "crit", "cannot", "can't", "unable",
|
|
155
|
+
"foi poss", "not found", "encontrad", "no such", "undefined", "unresolved", "assert", "caused by", "killed",
|
|
156
|
+
"oom", "out of memory", "evicted", "backoff", "warn", "deprecat", "aviso",
|
|
157
|
+
"✗", "✘", "×", "✕", "✖", "●")
|
|
158
|
+
# formas específicas de falha que contêm palavras comuns (status=200, GET, level=info, exit 0 não disparam)
|
|
159
|
+
_BLOCK_SIG = re.compile(r"status(code|_code)?[\"']?\s{0,3}[=:]\s{0,3}[\"']?5\d\d|\b(get|post|put|patch|delete|head|options)\b[^\n]{0,300}[\s\"]5\d\d\b"
|
|
160
|
+
r"|level[\"']?\s{0,3}[=:]\s{0,3}[\"']?(crit|critical|error|err|fatal|panic|alert|emerg)\b"
|
|
161
|
+
r"|\bexit(ed)?( with)?( code| status)?[=: ]{1,3}[1-9]|http/\d(\.\d)?\s{1,5}[45]\d\d")
|
|
162
|
+
_SECRET_TRIGGER_L = re.compile(_SECRET_TRIGGER.pattern.replace(b"(?i)", b"").lower())
|
|
163
|
+
_CTRL_B = re.compile(rb"[\x00-\x08\x0b\x0e-\x1f\x7f]")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def match_err(text: str):
|
|
167
|
+
m = _FRAME.search(text)
|
|
168
|
+
if m:
|
|
169
|
+
return m
|
|
170
|
+
m = _ERR.search(text)
|
|
171
|
+
if m and _ZERO.search(text):
|
|
172
|
+
m = _ERR.search(_ZERO.sub(lambda z: " " * len(z.group(0)), text))
|
|
173
|
+
return m
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def classify(text: str) -> int:
|
|
177
|
+
if match_err(text):
|
|
178
|
+
return 1
|
|
179
|
+
if _WARN.search(text):
|
|
180
|
+
return 2
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_CTRL = re.compile(r"[\x00-\x08\x0b\x0e-\x1f\x7f]+|\ufffd+")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_binary(b: bytes) -> bool:
|
|
188
|
+
if not b:
|
|
189
|
+
return False
|
|
190
|
+
if b"\x00" in b:
|
|
191
|
+
return True
|
|
192
|
+
ctrl = sum(1 for c in b[:4096] if c < 32 and c not in (9, 10, 12, 13, 27))
|
|
193
|
+
return ctrl / min(len(b), 4096) > 0.3
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------- linha lógica
|
|
197
|
+
class Line:
|
|
198
|
+
"""Visão de exibição de uma linha (possivelmente enorme, com \\r de barra de progresso)."""
|
|
199
|
+
__slots__ = ("n", "length", "kind", "text", "binary", "cr")
|
|
200
|
+
|
|
201
|
+
def __init__(self, n):
|
|
202
|
+
self.n, self.length, self.kind, self.text, self.binary, self.cr = n, 0, 0, None, False, False
|
|
203
|
+
|
|
204
|
+
def feed(self, red: bytes, width: int):
|
|
205
|
+
self.length += len(red)
|
|
206
|
+
txt = red.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
207
|
+
if is_binary(red) or (len(txt) > 8 and txt.count("\ufffd") > 0.3 * len(txt)):
|
|
208
|
+
self.binary = True
|
|
209
|
+
txt = _CTRL.sub("", txt)
|
|
210
|
+
m = match_err(txt)
|
|
211
|
+
if m:
|
|
212
|
+
self.kind, self.text = 1, window(txt, m.start(), width)
|
|
213
|
+
return
|
|
214
|
+
segs = txt.split("\r") if "\r" in txt else [txt]
|
|
215
|
+
if len(segs) > 1:
|
|
216
|
+
self.cr = True
|
|
217
|
+
for seg in segs:
|
|
218
|
+
m = match_err(seg)
|
|
219
|
+
k = 1 if m else (2 if _WARN.search(seg) else 0)
|
|
220
|
+
if k and (self.kind == 0 or k < self.kind):
|
|
221
|
+
self.kind = k
|
|
222
|
+
self.text = window(seg, m.start() if m else 0, width)
|
|
223
|
+
elif self.kind == 0 and seg.strip():
|
|
224
|
+
self.text = window(seg, 0, width) # último segmento visível de uma barra de progresso
|
|
225
|
+
if self.text is None:
|
|
226
|
+
self.text = ""
|
|
227
|
+
|
|
228
|
+
def show(self) -> str:
|
|
229
|
+
if self.binary and not self.text:
|
|
230
|
+
return f"<conteúdo binário, {self.length} bytes>"
|
|
231
|
+
extra = []
|
|
232
|
+
if self.cr:
|
|
233
|
+
extra.append("atualizações com \\r omitidas")
|
|
234
|
+
if self.length > len(self.text.encode("utf-8", errors="replace")) + 4 and not self.cr:
|
|
235
|
+
extra.append(f"linha com {self.length} bytes")
|
|
236
|
+
return self.text + (f" …({'; '.join(extra)}; ver arquivo)" if extra else "")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def window(s: str, pos: int, width: int) -> str:
|
|
240
|
+
if len(s) <= width:
|
|
241
|
+
return s
|
|
242
|
+
start = max(0, min(pos - width // 4, len(s) - width))
|
|
243
|
+
out = s[start:start + width]
|
|
244
|
+
return ("…" if start else "") + out + ("…" if start + width < len(s) else "")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------- estado em disco
|
|
248
|
+
def state_dir(arg: str | None) -> Path:
|
|
249
|
+
base = arg or os.environ.get("LEAN_CONTEXT_DIR")
|
|
250
|
+
if not base:
|
|
251
|
+
xdg = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
252
|
+
base = os.path.join(xdg, "lean-context")
|
|
253
|
+
p = Path(base)
|
|
254
|
+
p.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
255
|
+
st = os.lstat(p)
|
|
256
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
257
|
+
raise SystemExit(f"{TAG} recusado: {p} não é um diretório comum")
|
|
258
|
+
if st.st_uid != os.getuid():
|
|
259
|
+
raise SystemExit(f"{TAG} recusado: {p} pertence a outro usuário")
|
|
260
|
+
if stat.S_IMODE(st.st_mode) & 0o077:
|
|
261
|
+
os.chmod(p, 0o700)
|
|
262
|
+
if stat.S_IMODE(os.lstat(p).st_mode) & 0o077:
|
|
263
|
+
raise SystemExit(f"{TAG} recusado: não consegui deixar {p} privado (0700)")
|
|
264
|
+
return p
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def purge_old(d: Path, keep_days: float) -> None:
|
|
268
|
+
limit = time.time() - keep_days * 86400
|
|
269
|
+
for pat in ("run-*.log", ".tmp-*"):
|
|
270
|
+
for f in d.glob(pat):
|
|
271
|
+
try:
|
|
272
|
+
if os.lstat(f).st_mtime < limit:
|
|
273
|
+
f.unlink()
|
|
274
|
+
except OSError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def private_open(path: Path, append=False):
|
|
279
|
+
flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
|
280
|
+
flags |= os.O_APPEND if append else os.O_EXCL
|
|
281
|
+
fd = os.open(path, flags, 0o600)
|
|
282
|
+
return os.fdopen(fd, "ab" if append else "wb")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def human(n: float) -> str:
|
|
286
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
287
|
+
if n < 1024 or unit == "GB":
|
|
288
|
+
return f"{int(n)} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
289
|
+
n /= 1024
|
|
290
|
+
return str(n)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def emit(s: str = "") -> None:
|
|
294
|
+
try:
|
|
295
|
+
sys.stdout.write(s + "\n")
|
|
296
|
+
except (BrokenPipeError, OSError):
|
|
297
|
+
pass # erro de escrita nunca troca o exit code do comando
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def safe_cut(data: bytes, limit: int) -> int:
|
|
301
|
+
"""Ponto de corte de uma linha enorme: o último espaço antes do limite cujos 96 bytes anteriores não têm
|
|
302
|
+
gatilho de segredo, para que nem segredo nem palavra de erro fiquem partidos entre dois pedaços."""
|
|
303
|
+
lo = max(1, limit - 8192)
|
|
304
|
+
i = limit
|
|
305
|
+
while True:
|
|
306
|
+
i = max(data.rfind(b" ", lo, i), data.rfind(b"\t", lo, i))
|
|
307
|
+
if i < lo:
|
|
308
|
+
return limit # sem espaço (ex.: minificado): corte fixo; risco residual documentado
|
|
309
|
+
if not _SECRET_TRIGGER_L.search(data[max(0, i - 96):i].lower()):
|
|
310
|
+
return i + 1
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------- coleta em streaming
|
|
314
|
+
class Collector:
|
|
315
|
+
def __init__(self, args, redactor: Redactor, out):
|
|
316
|
+
self.a, self.red, self.out = args, redactor, out
|
|
317
|
+
self.lines = 0
|
|
318
|
+
self.bytes = 0
|
|
319
|
+
self.head: list[Line] = []
|
|
320
|
+
self.tail: collections.deque = collections.deque(maxlen=args.tail)
|
|
321
|
+
k = max(1, args.max_signals)
|
|
322
|
+
self.err_first: list[Line] = []
|
|
323
|
+
self.err_last: collections.deque = collections.deque(maxlen=k // 2 or 1)
|
|
324
|
+
self.err_total = 0
|
|
325
|
+
self.warn: list[Line] = []
|
|
326
|
+
self.warn_total = 0
|
|
327
|
+
self.ctx_after = 0
|
|
328
|
+
self.ctx_buf: collections.deque = collections.deque(maxlen=max(1, args.context))
|
|
329
|
+
self.pending_ctx: list = []
|
|
330
|
+
self.small_raw = bytearray() # cópia redigida enquanto a saída ainda é pequena
|
|
331
|
+
self.small_ok = True
|
|
332
|
+
self.altered = False
|
|
333
|
+
self.binary_lines = 0
|
|
334
|
+
self.cur: Line | None = None
|
|
335
|
+
self.buf = bytearray()
|
|
336
|
+
self.sec_any = True
|
|
337
|
+
self.sig_any = True
|
|
338
|
+
self.must_keep = False
|
|
339
|
+
|
|
340
|
+
def write(self, chunk: bytes):
|
|
341
|
+
data = bytes(self.buf) + chunk if self.buf else chunk
|
|
342
|
+
# testes de bloco: um bloco sem nenhum gatilho dispensa as regex por linha
|
|
343
|
+
self.sec_any = self.red.enabled and (self.red.pem_lines >= 0 or bool(_SECRET_TRIGGER_L.search(data.lower())))
|
|
344
|
+
if _CTRL_B.search(data):
|
|
345
|
+
self.sig_any = True
|
|
346
|
+
else:
|
|
347
|
+
t = data.decode("utf-8", errors="replace")
|
|
348
|
+
low = t.lower()
|
|
349
|
+
self.sig_any = ("\ufffd" in t or "\r" in t or any(k in low for k in _SIG_KEYS)
|
|
350
|
+
or bool(_BLOCK_SIG.search(low) or _FRAME_M.search(t) or _PYTEST_M.search(t)))
|
|
351
|
+
parts = data.split(b"\n")
|
|
352
|
+
rest = parts.pop()
|
|
353
|
+
for part in parts:
|
|
354
|
+
while len(part) >= PIECE:
|
|
355
|
+
cut = safe_cut(part, PIECE)
|
|
356
|
+
self._piece(part[:cut], end=False)
|
|
357
|
+
part = part[cut:]
|
|
358
|
+
self._piece(part + b"\n", end=True)
|
|
359
|
+
while len(rest) >= PIECE:
|
|
360
|
+
cut = safe_cut(rest, PIECE)
|
|
361
|
+
self._piece(rest[:cut], end=False)
|
|
362
|
+
rest = rest[cut:]
|
|
363
|
+
self.buf = bytearray(rest)
|
|
364
|
+
|
|
365
|
+
def close(self):
|
|
366
|
+
self.sec_any = self.sig_any = True
|
|
367
|
+
if self.buf:
|
|
368
|
+
self._piece(bytes(self.buf), end=True)
|
|
369
|
+
self.buf.clear()
|
|
370
|
+
elif self.cur is not None:
|
|
371
|
+
self._finish()
|
|
372
|
+
|
|
373
|
+
def _piece(self, raw: bytes, end: bool):
|
|
374
|
+
start = self.cur is None
|
|
375
|
+
if start:
|
|
376
|
+
self.cur = Line(self.lines + 1)
|
|
377
|
+
red = self.red.piece(raw, start) if self.sec_any else raw
|
|
378
|
+
if red != raw:
|
|
379
|
+
self.altered = True
|
|
380
|
+
self.out.write(red)
|
|
381
|
+
self.bytes += len(raw)
|
|
382
|
+
if self.small_ok:
|
|
383
|
+
if len(self.small_raw) + len(red) <= self.a.inline_max_bytes:
|
|
384
|
+
self.small_raw += red
|
|
385
|
+
else:
|
|
386
|
+
self.small_ok = False
|
|
387
|
+
self.small_raw = bytearray()
|
|
388
|
+
if not self.sig_any:
|
|
389
|
+
ln = self.cur
|
|
390
|
+
ln.length += len(red)
|
|
391
|
+
if ln.text is None or (ln.kind == 0 and len(ln.text) < self.a.width):
|
|
392
|
+
t = red[: self.a.width * 4].decode("utf-8", errors="replace").rstrip("\r\n")
|
|
393
|
+
ln.text = window(((ln.text or "") + t), 0, self.a.width) if ln.text else window(t, 0, self.a.width)
|
|
394
|
+
elif len(self.cur.text or "") < self.a.width or self.cur.kind == 0:
|
|
395
|
+
self.cur.feed(red, self.a.width)
|
|
396
|
+
else:
|
|
397
|
+
self.cur.length += len(red)
|
|
398
|
+
if end:
|
|
399
|
+
self._finish()
|
|
400
|
+
|
|
401
|
+
def _finish(self):
|
|
402
|
+
ln, self.cur = self.cur, None
|
|
403
|
+
self.lines += 1
|
|
404
|
+
if ln.binary:
|
|
405
|
+
self.binary_lines += 1
|
|
406
|
+
if len(self.head) < self.a.head:
|
|
407
|
+
self.head.append(ln)
|
|
408
|
+
self.tail.append(ln)
|
|
409
|
+
if ln.kind == 1:
|
|
410
|
+
self.err_total += 1
|
|
411
|
+
group = list(self.ctx_buf)[-1:] + [ln] # uma linha antes (ex.: título do teste)
|
|
412
|
+
self._add_err(group)
|
|
413
|
+
self.ctx_after = self.a.context
|
|
414
|
+
elif self.ctx_after > 0:
|
|
415
|
+
self.ctx_after -= 1
|
|
416
|
+
self._add_err([ln], context=True)
|
|
417
|
+
elif ln.kind == 2:
|
|
418
|
+
self.warn_total += 1
|
|
419
|
+
if len(self.warn) < self.a.max_signals:
|
|
420
|
+
self.warn.append(ln)
|
|
421
|
+
self.ctx_buf.append(ln)
|
|
422
|
+
|
|
423
|
+
def _add_err(self, group, context=False):
|
|
424
|
+
k = max(1, self.a.max_signals)
|
|
425
|
+
if len(self.err_first) < k:
|
|
426
|
+
self.err_first.extend(group)
|
|
427
|
+
elif context:
|
|
428
|
+
if self.err_last:
|
|
429
|
+
self.err_last[-1].extend(group)
|
|
430
|
+
else:
|
|
431
|
+
self.err_last.append(list(group))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------- execução
|
|
435
|
+
def run(cmd: list[str], timeout: float, coll: Collector) -> tuple[int, float, list[str]]:
|
|
436
|
+
notes: list[str] = []
|
|
437
|
+
if len(cmd) == 1 and re.search(r"[\s|&;<>()$`*?~{}\[\]'\"\\]", cmd[0]):
|
|
438
|
+
sh = shutil.which("bash") or "/bin/sh"
|
|
439
|
+
argv = [sh, "-c", cmd[0]]
|
|
440
|
+
else:
|
|
441
|
+
argv = cmd
|
|
442
|
+
start = time.monotonic()
|
|
443
|
+
try:
|
|
444
|
+
# grupo de processos próprio na mesma sessão (o vigia precisa poder entrar nele)
|
|
445
|
+
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=None,
|
|
446
|
+
preexec_fn=lambda: os.setpgid(0, 0))
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
coll.write(f"lean-context: comando não encontrado: {argv[0]}\n".encode())
|
|
449
|
+
coll.close()
|
|
450
|
+
return 127, 0.0, notes
|
|
451
|
+
except PermissionError:
|
|
452
|
+
coll.write(f"lean-context: sem permissão para executar: {argv[0]}\n".encode())
|
|
453
|
+
coll.close()
|
|
454
|
+
return 126, 0.0, notes
|
|
455
|
+
pgid = proc.pid
|
|
456
|
+
watchdog = start_watchdog(pgid)
|
|
457
|
+
|
|
458
|
+
def forward(signum, _frame):
|
|
459
|
+
try:
|
|
460
|
+
os.killpg(pgid, signum)
|
|
461
|
+
except (ProcessLookupError, PermissionError):
|
|
462
|
+
pass
|
|
463
|
+
|
|
464
|
+
old = {s: signal.signal(s, forward) for s in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
|
465
|
+
lock = threading.Lock()
|
|
466
|
+
|
|
467
|
+
def pump():
|
|
468
|
+
assert proc.stdout is not None
|
|
469
|
+
for chunk in iter(lambda: proc.stdout.read1(65536), b""):
|
|
470
|
+
with lock:
|
|
471
|
+
coll.write(chunk)
|
|
472
|
+
|
|
473
|
+
reader = threading.Thread(target=pump, daemon=True)
|
|
474
|
+
reader.start()
|
|
475
|
+
timed_out = False
|
|
476
|
+
try:
|
|
477
|
+
proc.wait(timeout=timeout if timeout > 0 else None)
|
|
478
|
+
except subprocess.TimeoutExpired:
|
|
479
|
+
timed_out = True
|
|
480
|
+
kill_group(pgid, proc)
|
|
481
|
+
notes.append(f"timeout de {timeout:g}s atingido; SIGTERM e depois SIGKILL enviados ao grupo do comando "
|
|
482
|
+
"(processos que criaram sessão própria com setsid ficam fora do alcance)")
|
|
483
|
+
reader.join(timeout=5)
|
|
484
|
+
if reader.is_alive():
|
|
485
|
+
if timed_out:
|
|
486
|
+
kill_group(pgid, proc)
|
|
487
|
+
notes.append("um processo filho em segundo plano manteve a saída aberta; a leitura parou sem matá-lo")
|
|
488
|
+
for s, h in old.items():
|
|
489
|
+
signal.signal(s, h)
|
|
490
|
+
if watchdog is not None:
|
|
491
|
+
try:
|
|
492
|
+
watchdog.kill()
|
|
493
|
+
watchdog.wait(timeout=2)
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|
|
496
|
+
with lock:
|
|
497
|
+
coll.close()
|
|
498
|
+
elapsed = time.monotonic() - start
|
|
499
|
+
rc = proc.returncode
|
|
500
|
+
if timed_out:
|
|
501
|
+
rc = 124
|
|
502
|
+
elif rc is None:
|
|
503
|
+
rc = 1
|
|
504
|
+
elif rc < 0:
|
|
505
|
+
rc = 128 + (-rc)
|
|
506
|
+
return rc, elapsed, notes
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
_WATCHDOG = r"""
|
|
510
|
+
import os, signal, sys, time
|
|
511
|
+
parent, pgid = int(sys.argv[1]), int(sys.argv[2])
|
|
512
|
+
while os.getppid() == parent:
|
|
513
|
+
time.sleep(0.5)
|
|
514
|
+
for sig, wait in ((signal.SIGTERM, 2.0), (signal.SIGKILL, 0)):
|
|
515
|
+
try:
|
|
516
|
+
os.killpg(pgid, sig)
|
|
517
|
+
except ProcessLookupError:
|
|
518
|
+
break
|
|
519
|
+
time.sleep(wait)
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
_REAP: list = []
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def start_watchdog(pgid: int):
|
|
527
|
+
"""Processo vigia dentro do grupo do comando: se o lc_run for morto (ex.: SIGKILL pelo timeout do host),
|
|
528
|
+
o vigia percebe a orfandade e encerra o grupo, para não sobrar comando órfão."""
|
|
529
|
+
try:
|
|
530
|
+
w = subprocess.Popen([sys.executable, "-I", "-c", _WATCHDOG, str(os.getpid()), str(pgid)],
|
|
531
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
532
|
+
preexec_fn=lambda: os.setpgid(0, pgid))
|
|
533
|
+
_REAP.append(w)
|
|
534
|
+
return w
|
|
535
|
+
except Exception:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def group_alive(pgid: int) -> bool:
|
|
540
|
+
try:
|
|
541
|
+
os.killpg(pgid, 0)
|
|
542
|
+
return True
|
|
543
|
+
except (ProcessLookupError, PermissionError):
|
|
544
|
+
return False # macOS: EPERM quando só restam zombies no grupo
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def kill_group(pgid: int, proc) -> None:
|
|
548
|
+
for sig, grace in ((signal.SIGTERM, 3.0), (signal.SIGKILL, 2.0)):
|
|
549
|
+
try:
|
|
550
|
+
os.killpg(pgid, sig)
|
|
551
|
+
except (ProcessLookupError, PermissionError):
|
|
552
|
+
return
|
|
553
|
+
deadline = time.monotonic() + grace
|
|
554
|
+
while time.monotonic() < deadline:
|
|
555
|
+
proc.poll()
|
|
556
|
+
for w in _REAP:
|
|
557
|
+
w.poll()
|
|
558
|
+
if not group_alive(pgid):
|
|
559
|
+
return
|
|
560
|
+
time.sleep(0.05)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# ---------------------------------------------------------------- relatório
|
|
564
|
+
def render(rc, elapsed, c: Collector, path: Path, args, notes, shell_cmd: bool):
|
|
565
|
+
small = (c.small_ok and c.lines <= args.inline_max_lines and c.binary_lines == 0)
|
|
566
|
+
shown = 0
|
|
567
|
+
|
|
568
|
+
def out(s: str = ""):
|
|
569
|
+
nonlocal shown
|
|
570
|
+
shown += len(s.encode("utf-8", errors="replace")) + 1
|
|
571
|
+
emit(s)
|
|
572
|
+
|
|
573
|
+
status = f"exit={rc} · {c.lines} linhas · {human(c.bytes)} · {elapsed:.1f}s"
|
|
574
|
+
if c.bytes == 0:
|
|
575
|
+
out(f"{TAG} {status} · o comando não escreveu nada")
|
|
576
|
+
elif small:
|
|
577
|
+
text = bytes(c.small_raw).decode("utf-8", errors="replace")
|
|
578
|
+
for line in text.split("\n")[:-1] if text.endswith("\n") else text.split("\n"):
|
|
579
|
+
out(line)
|
|
580
|
+
note = "saída completa acima, sem cortes"
|
|
581
|
+
if c.altered or "\ufffd" in text or "\r" in text:
|
|
582
|
+
c.must_keep = True
|
|
583
|
+
note += f" (exibição pode diferir em bytes: \\r, UTF-8 inválido ou segredos; original redigido em {path})"
|
|
584
|
+
out(f"{TAG} {status} · {note}")
|
|
585
|
+
else:
|
|
586
|
+
red = " (padrões comuns de segredo mascarados; melhor esforço)" if c.red.enabled else ""
|
|
587
|
+
out(f"{TAG} {status}")
|
|
588
|
+
out(f"{TAG} saída completa{red}: {path}")
|
|
589
|
+
out(f"{TAG} abaixo só há linhas reais, com a numeração original; nada foi resumido nem reescrito")
|
|
590
|
+
seen: set[int] = set()
|
|
591
|
+
|
|
592
|
+
def block(title: str, items):
|
|
593
|
+
fresh = [l for l in items if l.n not in seen]
|
|
594
|
+
if not fresh:
|
|
595
|
+
return
|
|
596
|
+
out(f"--- {title} ---")
|
|
597
|
+
prev = None
|
|
598
|
+
run_start = None
|
|
599
|
+
for l in sorted(fresh, key=lambda x: x.n):
|
|
600
|
+
if l.n in seen:
|
|
601
|
+
continue
|
|
602
|
+
if prev is not None and l.n > prev + 1:
|
|
603
|
+
if run_start is not None:
|
|
604
|
+
out(f"L{run_start}–L{prev}: <conteúdo binário>")
|
|
605
|
+
run_start = None
|
|
606
|
+
out(f" … L{prev + 1}–L{l.n - 1} omitidas")
|
|
607
|
+
if l.binary and not l.text:
|
|
608
|
+
if run_start is None:
|
|
609
|
+
run_start = l.n
|
|
610
|
+
else:
|
|
611
|
+
if run_start is not None:
|
|
612
|
+
out(f"L{run_start}–L{prev}: <conteúdo binário>")
|
|
613
|
+
run_start = None
|
|
614
|
+
out(f"L{l.n}: {l.show()}")
|
|
615
|
+
seen.add(l.n)
|
|
616
|
+
prev = l.n
|
|
617
|
+
if run_start is not None:
|
|
618
|
+
out(f"L{run_start}–L{prev}: <conteúdo binário>")
|
|
619
|
+
|
|
620
|
+
if c.head:
|
|
621
|
+
block(f"início (L1–L{c.head[-1].n})", c.head)
|
|
622
|
+
errs = list(c.err_first) + [l for g in c.err_last for l in g]
|
|
623
|
+
if c.err_total:
|
|
624
|
+
shown_err = len({l.n for l in errs if l.kind == 1})
|
|
625
|
+
title = f"falhas e contexto ({c.err_total} linha(s) de falha"
|
|
626
|
+
if c.err_total > shown_err:
|
|
627
|
+
title += f"; exibidas as primeiras e as últimas, {c.err_total - shown_err} omitidas: use Grep no arquivo"
|
|
628
|
+
block(title + ")", errs)
|
|
629
|
+
budget = max(0, args.max_signals - len(errs))
|
|
630
|
+
if c.warn_total and budget:
|
|
631
|
+
ws = c.warn[:budget]
|
|
632
|
+
title = f"avisos ({c.warn_total} no total"
|
|
633
|
+
title += f"; {c.warn_total - len(ws)} não exibidos)" if c.warn_total > len(ws) else ")"
|
|
634
|
+
block(title, ws)
|
|
635
|
+
elif c.warn_total:
|
|
636
|
+
out(f"{TAG} {c.warn_total} linha(s) de aviso não exibidas (prioridade para falhas)")
|
|
637
|
+
if c.tail:
|
|
638
|
+
block(f"fim (L{c.tail[0].n}–L{c.tail[-1].n})", list(c.tail))
|
|
639
|
+
out(f"{TAG} {len(seen)} de {c.lines} linhas exibidas; {c.lines - len(seen)} omitidas. Para ver mais: Read no arquivo com offset/limit, ou Grep nele")
|
|
640
|
+
if rc != 0 and not c.err_total and c.bytes:
|
|
641
|
+
out(f"{TAG} atenção: exit={rc} sem linha de falha reconhecida; leia o fim do arquivo antes de concluir")
|
|
642
|
+
if rc == 0 and c.err_total:
|
|
643
|
+
hint = " (num pipeline vale o exit do último estágio; use set -o pipefail)" if shell_cmd else ""
|
|
644
|
+
out(f"{TAG} atenção: exit=0, mas há {c.err_total} linha(s) com cara de falha{hint}")
|
|
645
|
+
if c.red.count:
|
|
646
|
+
out(f"{TAG} {c.red.count} trecho(s) com cara de segredo mascarado(s)")
|
|
647
|
+
for n in notes:
|
|
648
|
+
out(f"{TAG} {n}")
|
|
649
|
+
return small, shown
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def record_stats(d: Path, raw_bytes: int, shown: int, rc: int, kept: bool) -> None:
|
|
653
|
+
try:
|
|
654
|
+
with private_open(d / "stats.jsonl", append=True) as f:
|
|
655
|
+
f.write((json.dumps({"ts": int(time.time()), "raw_bytes": raw_bytes, "shown_bytes": shown,
|
|
656
|
+
"exit": rc, "file_kept": kept}) + "\n").encode())
|
|
657
|
+
except OSError:
|
|
658
|
+
pass
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def print_stats(d: Path) -> int:
|
|
662
|
+
runs = raw = shown = 0
|
|
663
|
+
p = d / "stats.jsonl"
|
|
664
|
+
if p.exists() and not p.is_symlink():
|
|
665
|
+
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
666
|
+
try:
|
|
667
|
+
r = json.loads(line)
|
|
668
|
+
except ValueError:
|
|
669
|
+
continue
|
|
670
|
+
runs += 1
|
|
671
|
+
raw += r.get("raw_bytes", 0)
|
|
672
|
+
shown += r.get("shown_bytes", 0)
|
|
673
|
+
emit(f"{TAG} execuções: {runs}")
|
|
674
|
+
emit(f"{TAG} bytes produzidos pelos comandos: {human(raw)}")
|
|
675
|
+
emit(f"{TAG} bytes que entraram no contexto: {human(shown)}")
|
|
676
|
+
if raw:
|
|
677
|
+
emit(f"{TAG} redução medida em bytes: {100 * (1 - shown / raw):.1f}% (bytes, não tokens; não diz nada sobre a qualidade da tarefa)")
|
|
678
|
+
return 0
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def main(argv: list[str]) -> int:
|
|
682
|
+
if "--" in argv:
|
|
683
|
+
i = argv.index("--")
|
|
684
|
+
opts, cmd = argv[:i], argv[i + 1:]
|
|
685
|
+
else:
|
|
686
|
+
opts, cmd = argv, []
|
|
687
|
+
ap = argparse.ArgumentParser(prog="lc_run.py", description="Saída enxuta e fiel de comandos para agentes.")
|
|
688
|
+
ap.add_argument("--timeout", type=float, default=float(os.environ.get("LEAN_CONTEXT_TIMEOUT", 110)),
|
|
689
|
+
help="segundos (padrão 110, abaixo do timeout padrão de 120 s do Bash do host)")
|
|
690
|
+
ap.add_argument("--inline-max-lines", type=int, default=150)
|
|
691
|
+
ap.add_argument("--inline-max-bytes", type=int, default=12000)
|
|
692
|
+
ap.add_argument("--head", type=int, default=30)
|
|
693
|
+
ap.add_argument("--tail", type=int, default=50)
|
|
694
|
+
ap.add_argument("--max-signals", type=int, default=40)
|
|
695
|
+
ap.add_argument("--context", type=int, default=3, help="linhas exibidas depois de cada falha")
|
|
696
|
+
ap.add_argument("--width", type=int, default=400, help="largura (marcada) de linhas muito longas")
|
|
697
|
+
ap.add_argument("--keep-days", type=float, default=3)
|
|
698
|
+
ap.add_argument("--dir", default=None)
|
|
699
|
+
ap.add_argument("--no-redact", action="store_true")
|
|
700
|
+
ap.add_argument("--stats", action="store_true")
|
|
701
|
+
args = ap.parse_args(opts)
|
|
702
|
+
d = state_dir(args.dir)
|
|
703
|
+
if args.stats:
|
|
704
|
+
return print_stats(d)
|
|
705
|
+
if not cmd:
|
|
706
|
+
ap.error("informe o comando depois de --")
|
|
707
|
+
purge_old(d, args.keep_days)
|
|
708
|
+
stamp = time.strftime("%Y%m%d-%H%M%S") + f"-{os.getpid()}"
|
|
709
|
+
final_path = d / f"run-{stamp}.log"
|
|
710
|
+
redactor = Redactor(not args.no_redact)
|
|
711
|
+
with private_open(final_path) as out:
|
|
712
|
+
coll = Collector(args, redactor, out)
|
|
713
|
+
rc, elapsed, notes = run(cmd, args.timeout, coll)
|
|
714
|
+
shell_cmd = len(cmd) == 1 and "|" in cmd[0]
|
|
715
|
+
small, shown = render(rc, elapsed, coll, final_path, args, notes, shell_cmd)
|
|
716
|
+
keep = coll.bytes > 0 and (not small or coll.altered or coll.must_keep)
|
|
717
|
+
if not keep:
|
|
718
|
+
try:
|
|
719
|
+
final_path.unlink() # coube inteiro e intacto no contexto: não há o que recuperar
|
|
720
|
+
except OSError:
|
|
721
|
+
pass
|
|
722
|
+
record_stats(d, coll.bytes, shown, rc, keep)
|
|
723
|
+
try:
|
|
724
|
+
sys.stdout.flush()
|
|
725
|
+
except (BrokenPipeError, OSError):
|
|
726
|
+
pass
|
|
727
|
+
return rc
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
if __name__ == "__main__":
|
|
731
|
+
sys.exit(main(sys.argv[1:]))
|