tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged. Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals. Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""Shell side-effect snapshots and output spill helpers."""
|
|
|
|
import glob
|
|
import os
|
|
import re
|
|
import shlex
|
|
|
|
from tai_pkg.consts import Consts
|
|
from tai_pkg.text import Text
|
|
|
|
__all__ = ["ShellSnap"]
|
|
|
|
|
|
class ShellSnap:
|
|
"""ShellSnap."""
|
|
|
|
@staticmethod
|
|
def shell_target_paths(command, workdir):
|
|
spaced = re.sub(r"([;&|()<>])", r" \1 ", command)
|
|
try:
|
|
tokens = shlex.split(spaced, posix=True)
|
|
except ValueError:
|
|
return []
|
|
found = []
|
|
segment = []
|
|
|
|
def flush():
|
|
found.extend(ShellSnap.segment_targets(segment))
|
|
|
|
for token in tokens:
|
|
if token in Consts.SHELL_SEGMENT:
|
|
flush()
|
|
segment = []
|
|
else:
|
|
segment.append(token)
|
|
flush()
|
|
resolved = []
|
|
for item in found:
|
|
if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES:
|
|
break
|
|
base = item if os.path.isabs(item) else os.path.join(workdir, item)
|
|
if any(mark in item for mark in ("*", "?", "[")):
|
|
try:
|
|
hits = sorted(glob.glob(base, recursive=True))
|
|
except (OSError, ValueError):
|
|
continue
|
|
else:
|
|
hits = [base]
|
|
for hit in hits:
|
|
if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES:
|
|
break
|
|
if os.path.isfile(hit):
|
|
resolved.append(os.path.abspath(hit))
|
|
elif os.path.isdir(hit):
|
|
for root, _dirs, files in os.walk(hit):
|
|
for entry in sorted(files):
|
|
if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES:
|
|
break
|
|
full = os.path.join(root, entry)
|
|
if os.path.isfile(full):
|
|
resolved.append(os.path.abspath(full))
|
|
if len(resolved) >= Consts.SHELL_SNAPSHOT_MAX_FILES:
|
|
break
|
|
seen = []
|
|
for item in resolved:
|
|
if item not in seen:
|
|
seen.append(item)
|
|
return seen
|
|
|
|
@staticmethod
|
|
def segment_targets(tokens):
|
|
targets = []
|
|
pos = 0
|
|
while pos < len(tokens):
|
|
token = tokens[pos]
|
|
if "=" in token and not token.startswith((">", "<", "-", "/")):
|
|
pos += 1
|
|
elif token in Consts.SHELL_WRAPPERS:
|
|
pos += 1
|
|
elif token == "timeout" and pos + 1 < len(tokens) and re.fullmatch(r"[0-9.]+[smhd]?", tokens[pos + 1] or ""):
|
|
pos += 2
|
|
else:
|
|
break
|
|
rest = tokens[pos:]
|
|
for index, token in enumerate(rest):
|
|
if token in (">", ">>") and index + 1 < len(rest):
|
|
cand = rest[index + 1]
|
|
if cand and not cand.startswith("&") and not cand.startswith("/dev/"):
|
|
targets.append(cand)
|
|
if not rest:
|
|
return targets
|
|
name = os.path.basename(rest[0])
|
|
operands = [item for item in rest[1:] if not item.startswith("-") or item in ("-", "--")]
|
|
operands = [item for item in operands if item not in (">", ">>", "<", "--")]
|
|
if name == "rm":
|
|
targets.extend(operands)
|
|
elif name in ("mv", "cp") and operands:
|
|
targets.append(operands[-1])
|
|
elif name == "tee":
|
|
targets.extend(operands)
|
|
elif name == "truncate":
|
|
for item in operands:
|
|
if re.fullmatch(r"[0-9]+[KMGTPE]?[iB]?", item or ""):
|
|
continue
|
|
targets.append(item)
|
|
elif name == "dd":
|
|
for item in operands:
|
|
if item.startswith("of="):
|
|
targets.append(item[3:])
|
|
elif name == "shred":
|
|
targets.extend(operands)
|
|
return [item for item in targets if item and item != "-"]
|
|
|
|
@staticmethod
|
|
def spill_output(store, kind, title, text, tags=(), profile=None):
|
|
profile = profile if profile is not None else store.profile
|
|
record_id = store.add_record(kind, title, text, tags, profile)
|
|
store.add_chunks("record", record_id, Text.chunk_lines(text), profile)
|
|
return record_id
|
|
|
|
@staticmethod
|
|
def spilled_view(text, record_id, size, head=1500, tail=500):
|
|
return text[:head] + "\n...[%d chars spilled to %s, record_read pages the rest]...\n" % (size, record_id) + text[-tail:]
|
|
|
|
@staticmethod
|
|
def spill_or_truncate(store, kind, title, text, tags=(), profile=None):
|
|
if store is None or len(text) <= Consts.SPILL_LIMIT:
|
|
return Text.truncate(text)
|
|
record_id = ShellSnap.spill_output(store, kind, title, text, tags, profile)
|
|
return ShellSnap.spilled_view(text, record_id, len(text))
|