Skip to content
dsh.fish
Bundle

dsh-tavily-resilient-search

Pesquisa web em tempo real para o DeepSeek Harness via API Tavily, com rotação atómica e resiliente de um agrupamento de chaves (rate limit 429, cota mensal 432/433, revogação 401) e contingência keyless — sem nunca interromper o agent loop.

Source
frederico-kluser
License
MIT
Updated
Updated 14 days ago

Readme

# dsh-tavily-resilient-search

Pesquisa web em tempo real para o **DeepSeek Harness** (DSH), através da API
[Tavily](https://tavily.com), com **rotação atómica e resiliente de um agrupamento
de credenciais** e contingência *keyless* — sem quebrar o `agent loop` quando as
chaves atingem limites de taxa (429), esgotam a cota mensal (432/433) ou são
revogadas (401).

Plugin **Cordis** autónomo: instala-se por manifesto, sem *forks* do código do
DSH, e sobrevive a hot-reload/unload sem fugas nem escutas órfãs.

---

## O problema que resolve

Numa implementação ingénua, um `HTTP 429` ou `432` é propagado como rejeição de
promessa à rotina de turnos: o turno termina abruptamente — ou o modelo conclui,
erradamente, que "a internet deixou de existir". Aqui, a intenção semântica
(pesquisar) está desacoplada da camada física (qual credencial despacha agora):

- erros de **saturação/faturação** mutam o estado da chave causadora e a chamada
  é **reemitida de imediato** com o payload intacto, pela próxima credencial
  desimpedida (round-robin);
- quando todo o pool está impedido, ativa-se a contingência **keyless**
  (`X-Tavily-Access-Mode: keyless`);
- só quando **todas** as alternativas de transporte se esgotam é que a ferramenta
  devolve uma conclusão estruturada de aviso — **nunca** uma exceção que ejetasse
  o processo hospedeiro.

## Instalação

```bash
dsh plugin --profile web add dsh-tavily-resilient-search
```

A ativação é automática: o pacote declara `dsh.bundle.patch` (apenas
`dsh.bundle.patch` ativa um plugin — um `bundle: {}` vazio não ativa nada) e o
manifesto `cordis.patch.yml` entra na camada **Bundle** da composição.

Defina as credenciais por variáveis de ambiente (nunca em texto simples):

```bash
export TAVILY_API_KEY_A="tvly-..."
export TAVILY_API_KEY_B="tvly-..."   # tantas quantas as contas disponíveis
export TAVILY_API_KEY_C="tvly-..."
export TAVILY_API_KEY_D="tvly-..."
```

Sem nenhuma chave o plugin **não rebenta o boot**: degrada para o modo
*keyless-only* (limites muito mais severos) e avisa em voz alta. Estado
legítimo documentado: "ainda não configurado".

## Gestão de chaves via interface

Todo o CRUD do pool vive num **único sítio**: a secção **“Tavily Keys”** em
**Definições (Settings)** — contribuída para o slot `settings.section` pelo
bundle de cliente (`client/client.js`, formato `__ModuleLoader__` medido no
exemplo oficial), estilizada com o design system do DSH.

- **CRUD completo** num só ecrã: **adicionar** (C, com `Enter`), **listar** com
  estados em cartões (R), **editar/substituir** (U, `Enter` guarda, `Esc`
  cancela), **remover** (D) e **Testar** (valida contra a API — 1 crédito).
- **Feedback em cada ação**: spinner durante o pedido e confirmação `✓`/`⚠`
  imediata — nada de cliques sem resposta.
- **Botão “🔑 Tavily Keys”** na área lateral **só quando não existe chave
  válida** (desaparece assim que há credencial utilizável); abre diretamente as
  Definições. Não há ecrã paralelo: a antiga página autónoma responde `410`.
- **Links úteis no painel**: `app.tavily.com` para obter chaves, documentação e
  onde está o token.

**Token administrativo** (para abrir o painel): gerado por CSPRNG no primeiro
arranque, guardado em `~/.dsh/dsh-tavily-resilient-search/admin-token.txt`
(0600) e impresso uma vez no registo do DSH (`logger 'tavily-pool'`). Perdeu?
Recarregue com `DSH_TAVILY_ADMIN_RESET=1`. Também pode definir
`DSH_TAVILY_ADMIN_TOKEN`.
- **Fronteira**: origem (socket + `Origin`) → `Host` → credencial, por esta
  ordem fixa; denegações byte-idênticas (sem oráculo). Predefinição: só
  loopback (`admin.trustedRemotes` / `admin.allowedHosts`).
- **Força bruta**: orçamento NIST SP 800-63B-4 (100 falhas → lockout com o
  mesmo 401).
- **Ações destrutivas** (remoção **e substituição/edição**) exigem nonce de
  confirmação, de uso único.
- **Auditoria**: `audit.log` apensível (0600, `O_NOFOLLOW`) com todas as
  decisões mutáveis e denegações.
- Chaves removidas que vêm do **ambiente** regressam no próximo arranque (a
  remoção é volátil e o painel avisa); chaves persistidas/removidas pelo painel
  são definitivas.
- Sobre um bind público (`0.0.0.0`) o painel **recusa-se a subir** (fail-closed,
  ruidoso) até `admin.allowPublicBind: true` — a ferramenta de pesquisa continua
  a funcionar em qualquer caso.

## Configuração

Sobreponha qualquer campo na camada **Profile**
(`$DSH_HOME/profiles/<nome>/cordis.patch.yml`), **Home**
(`$DSH_HOME/cordis.patch.yml`) ou **CLI overlay** (`dsh --patch ./o.yml`). A
resolução é de **entrada inteira** (`whole-entry replace`), nunca *deep-merge*:
aponte sempre ao id do próprio plugin.

| Campo | Tipo | Predefinição | Função |
| --- | --- | --- | --- |
| `apiKeys` | `string[]` | `[]` | Agrupamento de credenciais (round-robin). Vazio = *keyless-only*. |
| `searchDepth` | `ultra-fast\|fast\|basic\|advanced` | `basic` | Profundidade predefinida quando o modelo não escolhe. |
| `maxResults` | `1..100` (clamp a 10) | `5` | Máximo de fontes qualificadas. |
| `includeAnswer` | `boolean` | `true` | Injeta a resposta preliminar sumarizada. |
| `timeoutMs` | `250..300000` | `15000` | Timeout **por tentativa** HTTP. |
| `callTimeoutMs` | `≥ timeoutMs` | `120000` | Orçamento cooperativo total da chamada (todas as rotações). |
| `projectId` | `string` | — | Segregação por projeto (`X-Project-ID`). |
| `admin.enabled` | `boolean` | `true` | Painel de gestão de chaves via interface. |
| `admin.trustedRemotes` | `string[]` | loopback | Origens de soquete admitidas no painel. |
| `admin.allowedHosts` | `string[]` | `127.0.0.1`, `localhost`, `::1` | Nomes de `Host` admitidos (anti DNS-rebinding). |
| `admin.stateDir` | `string` | `$DSH_HOME/dsh-tavily-resilient-search` | Diretório de estado (0700). |
| `admin.allowPublicBind` | `boolean` | `false` | Opt-out explícito da recusa de bind não-loopback. |
| `securityProfile` | `{sandbox, approval}` | **obrigatório** | Atestação do perfil de instalação (ver [Segurança](#segurança)). |

Exemplo (camada Bundle, já incluído no pacote):

```yaml
- insert:
    - id: dsh-tavily-resilient-search
      name: 'dsh-tavily-resilient-search'
      config:
        searchDepth: 'basic'
        maxResults: 5
        includeAnswer: true
        timeoutMs: 15000
        callTimeoutMs: 120000
        securityProfile:
          sandbox: 'workspace-write'
          approval: 'ask'
        apiKeys: !!js >
          (() => { try { return [process.env.TAVILY_API_KEY_A, process.env.TAVILY_API_KEY_B, process.env.TAVILY_API_KEY_C, process.env.TAVILY_API_KEY_D].filter(Boolean) } catch { return [] } })()
```

Regras medidas do manifesto (`@deepseek-ai/dsh` 0.1.x-rc): a ordem das linhas não
carrega semântica de carga (a ativação é dirigida por `inject`) e **um `!!js` que
lança na carga impede o boot do DSH para todos os instaladores** — a expressão
acima é defensiva e devolve `[]` em falha.

## Ferramenta exposta: `web_search`

| Parâmetro | Tipo | Obrig. | Descrição |
| --- | --- | --- | --- |
| `query` | `string` | sim | Termos objetivos (recomendado < 1500 caracteres; limite duro 4000). |
| `search_depth` | enum | não | `ultra-fast`/`fast` (< 800 ms) · `basic` · `advanced` (só documentação complexa). |
| `max_results` | `integer` | não | 1 a 10 (predefinição 5). |
| `topic` | enum | não | `general` \| `news`. |

Resultado canónico (forma total, `null` explícito quando não aplicável):
`ok`, `query`, `answer`, `sources[]` (`title`, `url`, `snippet`, `score`),
`responseTime`, `resultsTruncated`, `keylessFallbackUsed`, `error`, `detail`,
`suggestion`. A projeção para o modelo inclui sempre um **aviso de
não-confiança** sobre o conteúdo web (contenção de injeção indireta).

## Comportamento por código HTTP

| HTTP | Condição | Comportamento do plugin |
| --- | --- | --- |
| 200 | Sucesso | Descodifica, normaliza, aplica teto de 50 KB, devolve resultados. |
| 400 | Parâmetros malformados | **Não rotaciona** — devolve erro + sugestão de correção. |
| 401 | Chave inválida/revogada | Banida em definitivo (`REVOKED`) e avança já para a próxima. |
| 403 | Domínio/rota interdita | **Não rotaciona** — informa impossibilidade de acesso. |
| 429 | Limite de taxa | Lê `Retry-After` (segundos ou data HTTP), arrefece a chave e rotaciona. |
| 432 | Cota mensal esgotada | Suspende até à meia-noite UTC do mês seguinte e rotaciona. |
| 433 | Teto PAYGO atingido | Igual a 432 (suspensão temporizada). |
| 5xx | Instabilidade transiente | Nova tentativa com a alternativa; estado da chave não muda. |

### Máquina de estados do agrupamento

```
                ┌──────────────┐  401   ┌──────────┐
      ┌────────►│    ACTIVE    │───────►│ REVOKED  │ (definitivo)
      │         └──────┬───────┘        └──────────┘
      │  cooldown      │ 429
      │  expirado      ▼
┌─────┴────────┐  ┌──────────────┐
│ (round-robin)│◄─│ RATE_LIMITED │  T = Retry-After ou min(Tmax, T0·2^k) + δ
└──────────────┘  └──────────────┘
      ▲
      │ reposição mensal (1.º dia, UTC)
┌─────┴───────────┐
│ QUOTA_EXHAUSTED │  432/433 → suspensa até à meia-noite UTC do mês seguinte
└─────────────────┘
```

`T₀ = 500 ms`, `T_max = 60 s`, jitter `δ ∈ [0, 500) ms` contra ressaturação em
bloco. A recuperação é *lazy* (sem timers): a expiração é aplicada na seleção
seguinte, o que mantém leituras puras e o event loop limpo.

## Segurança

Resumo (completo em [docs/seguranca.md](docs/seguranca.md)):

- **A conjugação `sandbox: danger-full-access` + `approval: never` é recusada no
  load** (fail-loud, irrecuperável de propósito). O `securityProfile` é uma
  atestação obrigatória do operador — configuração ausente também falha alto.
- **Canário de segredos por valor**: material de credencial é redigido de todo o
  texto de saída e registos (um corpo de resposta malicioso que ecoasse a chave
  do `Authorization` nunca a propaga); identificação em logs apenas por máscara
  `…últimos4`.
- **Teto volumétrico de 50 KB** por invocação: documentos-bomba não saturam a
  janela de contexto (`include_raw_content` nunca é enviado).
- **Conteúdo web é dado não-confiável**: a projeção do modelo carrega aviso
  permanente contra injeção indireta de instruções.

| Sandbox \ Approval | `ask` | `never` |
| --- | --- | --- |
| `workspace-write` | Prudencial (padrão) | Automação controlada |
| `danger-full-access` | Alargado vigiado | **PROIBIDO — recusa de carga** |

## API validada por medição (nunca por prosa)

A superfície usada foi lida dos `.d.ts` dentro dos tarballs npm publicados
(sha256 registado em `types/`; reverificado por `pnpm run check:upstream`) e é
travada por um **contract test** que falha se a API pública divergir. Factos
medidos que corrigem documentação circulante:

| Afirmação morta | Comportamento real medido |
| --- | --- |
| `inject: ['tools', 'logger']` | `logger` **não** é Service do Cordis: a fiber ficaria `PENDING` para sempre. Usa-se `ctx.logger(nome)`; `inject` = `['tools']`. |
| `dsh.bundle: {}` ativa o plugin | Só `dsh.bundle.patch` ativa (medido em `@deepseek-ai/dsh` 0.1.0-rc.7+). |
| `required: false` nos parâmetros | O DSL marca obrigatoriedade com `required?: true` — opcional = **omitir** a chave. |
| `output: { schema: { type: 'object' } }` | Objetos exigem `additionalProperties: boolean` explícito. |
| `ctx.httpServer` / `spawn(cmd, args)` | `ctx.webServer` / `ctx.subprocess.spawn(spec)` (verificados na gama 0.1.x-rc). |

Linhas suportadas: `@deepseek-ai/dsh-tools` `^0.1.1-rc.1 || ^0.1.7-rc.1` (o
drift entre ambas é apenas aditivo) e `@deepseek-ai/cordis` `~4.0.4`. Nota: o
dist-tag `latest` dos subpacotes `dsh-*` aponta para a publicação mais antiga —
a linha viva é `next`.

## Desenvolvimento

```bash
pnpm install
pnpm run gate          # lint + typecheck + build + test + check:tarball
pnpm run check:upstream # reverifica tarballs/sha256 pela rede (Q-1)
```

Testes em quatro camadas (`node:test` + `tsx`, determinísticos — relógio e RNG
injetados, sem sleeps):

| Camada | Prova |
| --- | --- |
| `test/unit` | núcleo puro: máquina de estados, teto de bytes, validação fail-loud, token/nonce/lockout, persistência 0600 + auditoria à prova de symlink |
| `test/integration` | rotação atómica sobre um double de transporte e o router do painel sobre servidor HTTP real (porta 0) |
| `test/adversarial` | tenta brechar: canário de segredos, documento-bomba, injeção indireta, perfil YOLO, regressão P-09, boot-safety, CSRF/rebinding byte-idêntico, força bruta sem oráculo, nonce, recusa de bind público |
| `test/contract` | os espelhos `types/` correspondem verbatim aos `.d.ts` publicados |

## Empacotamento e publicação

Publica-se **pré-compilado** (`dist/`), sem scripts de build na instalação do
consumidor (risgo de cadeia de suprimentos). `files` é uma allowlist: `dist/`,
`cordis.patch.yml`, `README.md`, `LICENSE`, `CHANGELOG.md`. O gate de release é
`pnpm run gate && publint && attw --pack . && node scripts/check-tarball.mjs`
(este último inspeciona o tarball real). Notas de versão via
[changesets](https://github.com/changesets/changesets); em `0.x`, um **minor** é
breaking. Releases em CI usam *trusted publishing* (OIDC) — configurar uma vez
no console do npm para este repositório.

## Licença

[MIT](LICENSE)

Install

dsh plugin --profile web add github:frederico-kluser/dsh-tavily-resilient-search

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source