Verificando acesso...

MÓDULO 2.6

📦 Empacotar e operar

launchd, systemd, secrets com Fernet, logs estruturados em JSON, cron com lock e healthcheck. Como tirar o agente do "roda no meu laptop" e colocar pra trabalhar 24/7 sem te acordar de madrugada.

6
Tópicos
90
Minutos
Técnico
Nível
Ops
Tipo
1

🍎 launchd no macOS — plist + load + start

No macOS, launchd é o init system. Você descreve o serviço num plist XML, coloca em ~/Library/LaunchAgents/, carrega com launchctl load. Pronto: roda no boot, reinicia se cair, redireciona stdout pra arquivo. Sem nohup, sem screen, sem "deixa o terminal aberto".

📌 A regra do plist

Convenção: com.<empresa>.<servico>.plist. RunAtLoad=true + KeepAlive=true é o combo "sempre vivo". Logs em ~/Library/Logs/ pra Console.app indexar.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>club.inema.agentos</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/marco/.venvs/agentos/bin/python</string>
    <string>-m</string>
    <string>agentos.main</string>
  </array>

  <key>WorkingDirectory</key>
  <string>/Users/marco/code/agentos</string>

  <key>EnvironmentVariables</key>
  <dict>
    <key>AGENTOS_ENV</key>
    <string>production</string>
    <key>SECRET_KEY_FILE</key>
    <string>/Users/marco/.agentos/master.key</string>
  </dict>

  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>

  <key>StandardOutPath</key>
  <string>/Users/marco/Library/Logs/agentos.out.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/marco/Library/Logs/agentos.err.log</string>

  <key>ThrottleInterval</key><integer>10</integer>
</dict>
</plist>
# Salvar como ~/Library/LaunchAgents/club.inema.agentos.plist
$ chmod 644 ~/Library/LaunchAgents/club.inema.agentos.plist
$ launchctl load ~/Library/LaunchAgents/club.inema.agentos.plist
$ launchctl start club.inema.agentos

# Ver status
$ launchctl list | grep agentos
12345  0  club.inema.agentos   # PID, último exit code, label

# Recarregar após editar plist
$ launchctl unload ~/Library/LaunchAgents/club.inema.agentos.plist
$ launchctl load   ~/Library/LaunchAgents/club.inema.agentos.plist

# Tail dos logs
$ tail -f ~/Library/Logs/agentos.err.log

✓ Fazer

  • ThrottleInterval ≥ 10s — evita loop de crash que consome CPU.
  • Path absoluto pro Python do venv — launchd não herda PATH do shell.
  • Logs separados out e err.
  • WorkingDirectory explícito — paths relativos quebram sem ele.

✗ Evitar

  • Usar /usr/bin/python3 — em update do macOS some.
  • Esquecer KeepAlive — caiu uma vez, ficou caído.
  • Colocar segredo direto em EnvironmentVariables — plist vai pro git.
  • chmod 755 no plist — launchctl rejeita por segurança.
Escopo

LaunchAgents = usuário. LaunchDaemons = root.

Boot

Sobe no login automático.

Crash

KeepAlive=true reinicia em ~ThrottleInterval.

Debug

Console.app mostra crash + stderr.

2

🐧 systemd no Linux — unit user + enable

No Linux, systemd é o padrão. Você descreve o serviço num .service INI, coloca em ~/.config/systemd/user/ (modo user — sem sudo), habilita com systemctl --user enable. journalctl dá log estruturado de graça.

# ~/.config/systemd/user/agentos.service
[Unit]
Description=Agentic OS — main loop
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/home/marco/code/agentos
ExecStart=/home/marco/.venvs/agentos/bin/python -m agentos.main
Environment=AGENTOS_ENV=production
Environment=SECRET_KEY_FILE=/home/marco/.agentos/master.key
EnvironmentFile=-/home/marco/.agentos/runtime.env

Restart=always
RestartSec=10
StartLimitInterval=120
StartLimitBurst=5

StandardOutput=journal
StandardError=journal
SyslogIdentifier=agentos

# Hardening básico — vale o esforço
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/home/marco/.agentos /home/marco/code/agentos/data

[Install]
WantedBy=default.target
# Habilitar e iniciar (sem sudo)
$ systemctl --user daemon-reload
$ systemctl --user enable --now agentos.service

# Sobreviver ao logout (caso contrário cai com a sessão)
$ sudo loginctl enable-linger marco

# Status, logs, restart
$ systemctl --user status agentos
$ journalctl --user -u agentos -f
$ journalctl --user -u agentos --since "2 hours ago" -p err
$ systemctl --user restart agentos

📊 macOS vs Linux — tabela comparativa

Aspecto launchd (macOS) systemd (Linux)
Formatoplist XMLINI .service
Local (user)~/Library/LaunchAgents/~/.config/systemd/user/
Carregarlaunchctl loadsystemctl --user daemon-reload
Ligarlaunchctl start <label>systemctl --user start agentos
Auto-restartKeepAlive=trueRestart=always
ThrottleThrottleInterval=10RestartSec=10
LogsArquivo em ~/Library/Logs/journalctl (estruturado)
Sobrevive logoutSim (LaunchAgent)Só com loginctl enable-linger
HardeningLimitado (sandbox-exec)Rico (NoNewPrivileges, ProtectSystem…)
Type=simple

Default — processo não faz fork.

StartLimitBurst

5 crashes em 120s = systemd desiste.

linger

Sem ele, serviço user morre no logout.

ReadWritePaths

Whitelist explícita de escrita.

3

🔐 Secrets com Fernet — AES + chave derivada

Tokens de API, chaves do bot, credenciais de banco — nada disso em .env versionado. Use cryptography.fernet: criptografia simétrica AES-128-CBC + HMAC-SHA256, chave única em arquivo fora do repo, descriptografia em memória só quando precisa.

🚨 Alerta — secrets em git são compromisso vitalício

Um git push com chave Anthropic exposta = chave queimada. Mesmo se você forçar --force-push, o GitHub escaneia, notifica o provider e a chave é revogada automaticamente em horas. Pior: bots scrapeiam commits públicos em segundos. Trate qualquer leak como rotação obrigatória, não como susto.

# secrets/box.py
import json
import os
from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken

class SecretBox:
    """Wrapper sobre Fernet com chave em arquivo + cache em memória."""

    def __init__(self, key_path: Path, vault_path: Path):
        self.key_path = key_path
        self.vault_path = vault_path
        self._cache: dict[str, str] | None = None

    @classmethod
    def init(cls, key_path: Path, vault_path: Path) -> "SecretBox":
        """Gera chave nova se não existir. chmod 0600 obrigatório."""
        if not key_path.exists():
            key_path.parent.mkdir(parents=True, exist_ok=True)
            key_path.write_bytes(Fernet.generate_key())
            os.chmod(key_path, 0o600)
        if not vault_path.exists():
            vault_path.write_bytes(b"")
            os.chmod(vault_path, 0o600)
        return cls(key_path, vault_path)

    def _fernet(self) -> Fernet:
        # Recusa abrir se permissões frouxas
        mode = self.key_path.stat().st_mode & 0o777
        if mode != 0o600:
            raise PermissionError(
                f"{self.key_path} tem modo {oct(mode)}; deveria ser 0o600"
            )
        return Fernet(self.key_path.read_bytes())

    def _load(self) -> dict[str, str]:
        if self._cache is not None:
            return self._cache
        raw = self.vault_path.read_bytes()
        if not raw:
            self._cache = {}
            return self._cache
        try:
            plain = self._fernet().decrypt(raw)
        except InvalidToken:
            raise RuntimeError("Vault corrompido ou chave errada")
        self._cache = json.loads(plain.decode("utf-8"))
        return self._cache

    def get(self, name: str) -> str:
        data = self._load()
        if name not in data:
            raise KeyError(f"Secret '{name}' não está no vault")
        return data[name]

    def put(self, name: str, value: str) -> None:
        data = self._load()
        data[name] = value
        token = self._fernet().encrypt(json.dumps(data).encode("utf-8"))
        # Escrita atômica — tmp + rename
        tmp = self.vault_path.with_suffix(".tmp")
        tmp.write_bytes(token)
        os.chmod(tmp, 0o600)
        tmp.replace(self.vault_path)
        self._cache = data
# Uso na boot do agente
from secrets.box import SecretBox
from pathlib import Path

box = SecretBox.init(
    key_path=Path.home() / ".agentos" / "master.key",
    vault_path=Path.home() / ".agentos" / "vault.fernet",
)

# Primeira vez — popula
box.put("ANTHROPIC_API_KEY", "sk-ant-...")
box.put("TELEGRAM_BOT_TOKEN", "1234:ABC...")

# No app
api_key = box.get("ANTHROPIC_API_KEY")

✓ chmod 0600 sempre

Só o dono lê e escreve. Sem isso, qualquer processo do user pode ler — incluindo malware que rodou com seus privilégios. SecretBox recusa abrir se modo está errado.

✗ World-readable é jogar fora

chmod 0644 deixa qualquer usuário do sistema ler. Em multi-tenant, nobody e www-data herdam acesso. É como deixar a senha do cofre num post-it no balcão.

Backup da chave

Off-site. Sem ela, vault vira lixo.

Rotação

MultiFernet aceita chave nova + velha.

Escrita atômica

tmp + rename — crash não corrompe.

Escala

>10 secrets ou multi-host? Vá pra Vault/SOPS.

4

📜 Logs estruturados — JSON lines + rotação

Log de string concatenada ("user 42 sent: hello") é hieróglifo pra máquina. JSON por linha com structlog + TimedRotatingFileHandler = busca, filtro, agregação em Grafana/Loki sem regex. Custa 5 linhas a mais no setup, paga em todo bug de produção.

💡 Dica — JSON line é o formato pra Grafana/Loki ler

Loki indexa labels (request_id, user_id, level), grep no resto. Datadog, Honeycomb, Elastic — todos consomem JSONL nativo. Texto plano vira string opaca e a busca custa 10x.

# observability/log.py
import logging
import logging.handlers
from pathlib import Path
import structlog

LOG_DIR = Path.home() / ".agentos" / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)

def configure_logging(level: str = "INFO") -> None:
    # Handler: 1 arquivo por dia, mantém 14
    handler = logging.handlers.TimedRotatingFileHandler(
        filename=LOG_DIR / "agentos.jsonl",
        when="midnight",
        interval=1,
        backupCount=14,
        encoding="utf-8",
        utc=True,
    )
    handler.suffix = "%Y-%m-%d"

    logging.basicConfig(
        format="%(message)s",   # structlog produz a linha já formatada
        level=level,
        handlers=[handler, logging.StreamHandler()],
    )

    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,   # request_id, user_id
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt="iso", utc=True),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            structlog.processors.JSONRenderer(),       # <-- saída JSON
        ],
        wrapper_class=structlog.make_filtering_bound_logger(
            logging.getLevelName(level)
        ),
        logger_factory=structlog.stdlib.LoggerFactory(),
        cache_logger_on_first_use=True,
    )

log = structlog.get_logger()
# Uso
from observability.log import log, configure_logging
from structlog.contextvars import bind_contextvars, clear_contextvars

configure_logging("INFO")

async def handle_message(msg):
    clear_contextvars()
    bind_contextvars(request_id=msg.id, user_id=msg.user_id, channel="telegram")
    log.info("turn_start", text_preview=msg.text[:60])
    try:
        result = await run_agent(msg)
        log.info("turn_end", tokens_in=result.tin, tokens_out=result.tout,
                 latency_ms=result.dt_ms)
    except Exception:
        log.exception("turn_failed")   # stack trace serializado em JSON
        raise

# Linha resultante:
# {"event":"turn_start","level":"info","timestamp":"2026-05-18T23:43:01Z",
#  "request_id":"r-abc","user_id":"u-42","channel":"telegram",
#  "text_preview":"oi, lembra do projeto X?"}
JSONL

1 evento por linha. jq ama.

Rotação diária

Disk não estoura, retenção previsível.

contextvars

request_id viaja sem passar parâmetro.

PII

Trunca payload — log não é warehouse.

5

⏰ Cron — dream cycle de 3h com lock

Agente bom dorme: a cada 3h, roda um "dream cycle" que consolida memória, gera resumos, sincroniza com fontes externas. Cron é a forma mais barata de agendar. Mas cron não sabe que a execução anterior ainda está rodando — sem lock, dois processos pisam um no outro.

# crontab -e  (user crontab)
# Min  Hora       Dia Mês DiaSem  Comando
0      */3        *   *   *       /home/marco/.venvs/agentos/bin/python -m agentos.dream >> /home/marco/.agentos/logs/dream.log 2>&1
30     4          *   *   *       /home/marco/.venvs/agentos/bin/python -m agentos.tasks.daily_digest
0      *          *   *   *       /home/marco/code/agentos/scripts/health_ping.sh
# agentos/dream.py
import asyncio
import fcntl
import os
import sys
from pathlib import Path
from contextlib import contextmanager
from observability.log import log, configure_logging

LOCK_PATH = Path.home() / ".agentos" / "dream.lock"

@contextmanager
def exclusive_lock(path: Path):
    """flock não-bloqueante. Se outro processo segura, levantamos."""
    path.parent.mkdir(parents=True, exist_ok=True)
    fp = path.open("w")
    try:
        fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        fp.close()
        raise RuntimeError(f"dream já em execução (lock: {path})")
    try:
        fp.write(str(os.getpid()))
        fp.flush()
        yield
    finally:
        fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
        fp.close()

async def dream_cycle():
    log.info("dream_start")
    await consolidate_memories()
    await refresh_external_sources()
    await summarize_recent_interactions()
    log.info("dream_end")

def main():
    configure_logging("INFO")
    try:
        with exclusive_lock(LOCK_PATH):
            asyncio.run(dream_cycle())
    except RuntimeError as e:
        log.warning("dream_skipped", reason=str(e))
        sys.exit(0)   # exit 0 — cron não considera erro

if __name__ == "__main__":
    main()

🧠 Por que fcntl.flock e não arquivo "lockfile" manual

  • Kernel libera no crash: mesmo se o processo morrer com kill -9, o lock cai junto. Lockfile manual ficaria órfão.
  • Atômico: dois processos chamando flock ao mesmo tempo — só um ganha. Sem race.
  • LOCK_NB: não-bloqueante. Se preso, falha rápido em vez de empilhar processos esperando.
  • Cross-platform-ish: macOS e Linux compartilham; no Windows use msvcrt.locking.

✓ Padrão dream cycle

  • • A cada 3h consolida memória
  • • Resume últimas 24h em 1 entrada
  • • Reindex FTS5
  • • Limpa interações >90 dias se houver fact extraído

✗ Erros típicos

  • * * * * * sem lock — concorrência
  • • Job dura mais que o intervalo
  • • Exit code ≠ 0 em "skip" → cron alerta
  • • Sem log → "não rodou" vs "rodou e deu silêncio"
Cadência

3h equilibra freshness e custo de LLM.

PATH no cron

Vazio — use sempre paths absolutos.

stderr no log

2>&1 ou o cron te manda mail.

Alternativas

systemd timers (linux) / launchd StartCalendar (mac).

6

🚦 Healthcheck e kill switch — /status + /killall

Duas peças que o módulo 1.1 chamou de "termômetro de maturidade". /status responde "tô vivo, eis meu estado". /killall é o botão vermelho — uma flag no banco que o loop principal lê a cada turno e respeita. Sem isso, "desligar tudo" vira kill -9 torcendo.

-- schema.sql — flag central
CREATE TABLE IF NOT EXISTS system_flags (
  name        TEXT PRIMARY KEY,
  value       TEXT NOT NULL,
  set_by      TEXT NOT NULL,
  set_at      REAL NOT NULL,
  reason      TEXT
);

-- Estado inicial
INSERT OR IGNORE INTO system_flags(name, value, set_by, set_at, reason)
VALUES ('agent.enabled', '1', 'system', strftime('%s','now'), 'boot');
# ops/control.py
import time
import sqlite3
from observability.log import log

DB = "/home/marco/.agentos/data/agentos.db"

def is_enabled() -> bool:
    with sqlite3.connect(DB) as cx:
        row = cx.execute(
            "SELECT value FROM system_flags WHERE name='agent.enabled'"
        ).fetchone()
        return bool(row and row[0] == "1")

def killall(actor: str, reason: str) -> None:
    with sqlite3.connect(DB) as cx:
        cx.execute("""
            INSERT INTO system_flags(name, value, set_by, set_at, reason)
            VALUES ('agent.enabled', '0', ?, ?, ?)
            ON CONFLICT(name) DO UPDATE SET
                value='0', set_by=excluded.set_by,
                set_at=excluded.set_at, reason=excluded.reason
        """, (actor, time.time(), reason))
    log.warning("kill_switch_engaged", actor=actor, reason=reason)

def resume(actor: str) -> None:
    with sqlite3.connect(DB) as cx:
        cx.execute("""
            UPDATE system_flags SET value='1', set_by=?, set_at=?, reason='resume'
            WHERE name='agent.enabled'
        """, (actor, time.time()))
    log.info("kill_switch_released", actor=actor)
# No loop principal do agente
from ops.control import is_enabled

async def handle(self, msg):
    if not is_enabled():
        await self.channel.send(OutgoingMessage(
            user_id=msg.user_id,
            text="🛑 Sistema em manutenção. Volto em breve."
        ))
        return
    # ... resto do turno

# Comandos administrativos expostos como tools (só admin)
class StatusTool(Tool):
    name = "status"
    description = "Diagnóstico: uptime, fila, último erro, flags."
    parameters = {"type": "object", "properties": {}, "required": []}

    async def execute(self, session, **kwargs):
        uptime = time.time() - BOOT_TIME
        return ToolResult(output=json.dumps({
            "enabled": is_enabled(),
            "uptime_s": round(uptime),
            "pending_turns": queue.qsize(),
            "last_error": LAST_ERROR_AT,
            "version": __version__,
        }, indent=2))

class KillallTool(Tool):
    name = "killall"
    description = "EMERGÊNCIA: para todos os turnos novos. Não mata em-andamento."
    parameters = {
        "type": "object",
        "properties": {"reason": {"type": "string"}},
        "required": ["reason"]
    }
    requires_approval = True   # T3.4 — human-in-the-loop

    async def execute(self, session, reason: str, **_):
        if not session.user.is_admin:
            return ToolResult(error="Apenas admins podem acionar kill switch")
        killall(actor=session.user.id, reason=reason)
        return ToolResult(output=f"🛑 Kill switch engajado. Motivo: {reason}")

💡 Healthcheck também vira endpoint externo

Exponha /health num HTTP server pequeno (aiohttp). UptimeRobot/Healthchecks.io faz ping a cada 1 min — se 2 falhas seguidas, te manda push. Custo: 5 linhas, 0 dólares.

📊 Conceitos-chave do kill switch

Pull, não push: agente lê a flag, sistema externo não precisa "alcançar" cada processo.
Não mata em-andamento: turnos atuais terminam (transação não fica órfã). Novos param.
Auditável: set_by + reason + set_at = quem desligou e por quê.
Idempotente: chamar 2x não causa estrago. Resume requer ação explícita.
/status

Diagnóstico em JSON. Admin lê do chat.

/killall

Flag no banco. Loop respeita.

/health

Endpoint HTTP. Monitor externo.

requires_approval

Killall pede confirmação humana.

🏁 Trilha 2 concluída — resumo do módulo

launchd e systemd resolvem 95% do "rodar 24/7" — plist no macOS, .service no Linux, ambos com auto-restart e log nativo.
Secrets nunca em git — Fernet + chave 0600 + escrita atômica. SecretBox recusa abrir com permissão errada.
Log = JSONL com structlog — rotação diária, contextvars carregam request_id, Grafana/Loki consome direto.
Cron de dream cycle com fcntl.flock — kernel libera no crash, LOCK_NB falha rápido, sem processos órfãos.
/status + /killall + /health — flag em system_flags, agente lê, kill switch auditável e idempotente.
Trilha 2 concluída: estrutura, contratos, canais, memória, ferramentas e operação. Você tem um agente Builder-grade aguentando produção.

Próxima trilha:

Trilha 3 — Multi-user OS. Tirar o agente de single-tenant e abrir pra equipe/clientes: identidade, isolamento, rate limit, audit log, billing por user.