diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 67a0f6b8..9ad0bc2d 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -121,7 +121,7 @@ Fast gate: `pnpm gate:fast`. Push: `pnpm push`. | `AUTOMERGE_PAT` | **Arm do auto-merge nativo** (`agent-pr-ready-automerge.yml`) — PAT de usuário real (fsolla) com `pull-requests: write` + `contents: write` no repo. **Sem ele o job falha fail-closed**: o `GITHUB_TOKEN` nativo armado faria o merge como `github-actions[bot]` e o `closed` do PR não criaria runs (anti-recursão do GitHub) — os flips pós-merge ficariam silenciosamente mortos (OPS71-FLIP, PR #746) | | `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje | -Local: `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection`, `pnpm issue`/`agent:*` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50). `FORGEJO_API_TOKEN` (git.solla.dev) é **legado da era pré-OPS76** — o tracker migrou para o GitHub e o cliente `forgejo-api.mjs` foi removido. +Local: `GITHUB_TOKEN` (PAT com escopo `repo` + `issues: write`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection`, `pnpm issue`/`agent:*` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. **PAT expira**: `401 Bad credentials` nos scripts (ou no `gh`) = token vencido/revogado — e um `GITHUB_TOKEN` inválido no ambiente sombreia o login do `gh`; rotacione o PAT ou derive do gh com `export GITHUB_TOKEN=$(env -u GITHUB_TOKEN gh auth token)`. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50). `FORGEJO_API_TOKEN` (git.solla.dev) é **legado da era pré-OPS76** — o tracker migrou para o GitHub e o cliente `forgejo-api.mjs` foi removido. ### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual diff --git a/docs/changelog/2026-09-18-fix-401-token-e-c201.md b/docs/changelog/2026-09-18-fix-401-token-e-c201.md new file mode 100644 index 00000000..2851190a --- /dev/null +++ b/docs/changelog/2026-09-18-fix-401-token-e-c201.md @@ -0,0 +1 @@ +**FIX — 401 `Bad credentials` do `GITHUB_TOKEN` local (PAT expirado) + C201 `main` RED (2026-09-18):** `pnpm issue next`, `agent:*`, `github-pr.mjs` e o próprio `gh` falhavam com `GitHub API GET /repos/fsolla/teqo/issues → 401 Bad credentials`. Causa-raiz: o `GITHUB_TOKEN` exportado no `~/.bashrc` desde o OPS71 era um PAT fine-grained, expirou (~30d) e, inválido, passou a sombrear a credencial válida do keyring do `gh` (que honra o env var acima do keyring) — e o bash dos agentes herda o env do `opencode serve`, não do `.bashrc`, então worktrees quebravam também. Fix: (1) **fora do repo** o `~/.bashrc` deriva `GITHUB_TOKEN` de `gh auth token` (sobrescreve valor herdado/expirado, auto-renova enquanto o login do keyring existir); (2) **repo** `scripts/lib/github-api.mjs` anexa dica acionável a todo 401 (REST e GraphQL: rode `gh auth status` e rotacione o PAT), pinada por 2 testes unitários em `tests/unit/githubApi.unit.spec.ts`, e `docs/AGENT-OPS.md` documenta a expiração/recuperação; (3) **C201/#1169** — o rename do C193 (`canReadSpeechCatalog` → `canReadCommunicationCatalog`) deixou o import órfão em `src/utilities/speech/speechPageData.ts:5`, e o `typecheck` da `main` estava RED desde `bdaf3c7c` (job `verify`/Typecheck do deploy run 35394524888, bloqueando staging/produção) — import/call site corrigidos. Sem migration/schema/Consent; post-mortem em `docs/postmortems/2026-09-18-github-token-401.md`. diff --git a/docs/postmortems/2026-09-18-github-token-401.md b/docs/postmortems/2026-09-18-github-token-401.md new file mode 100644 index 00000000..ca1c9388 --- /dev/null +++ b/docs/postmortems/2026-09-18-github-token-401.md @@ -0,0 +1,77 @@ +# Post-mortem: PAT fine-grained expirado no `GITHUB_TOKEN` do shell quebra `pnpm issue`/`agent:*`/`gh` (e drive-by: main RED por conflito semântico de rebase) + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | -------------------------------------------------------------------------------------- | +| Data do post-mortem | 2026-09-18 | +| Severidade | média (ferramental local; o drive-by, porém, bloqueava staging/produção — ver "O bug") | +| Ambiente | dev (workstation) + CI/deploy (drive-by) | +| Issue(s) | sem Issue (fluxo `/bug-fix`; o registro é o post-mortem) | +| PR do fix | # | +| Detectado por | log (sessão de agente c194, antes de 2026-09-18) e humano (2026-09-18) | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Início provável | 2026-08-20 | PAT fine-grained `github_pat_11ACYUP…` criado no cutover OPS71 e exportado em `~/.bashrc:194`, com o comentário "rotacionar após o cutover"; nunca rotacionado. Expira ~2026-09 (validade default de 30d) | +| Detecção | antes de 2026-09-18 (log) / 2026-09-18 (humano) | Log `~/.local/state/teqo/agent-sessions/c194-…log:74`: `gh issue view` → `HTTP 401: Bad credentials`. Depois o humano reproduziu `pnpm issue next` → 401 | +| Correção mergeada | 2026-09-18 | não apurado à época da escrita — merge deste PR (a confirmar) | +| Deploy | não apurado à época da escrita | o drive-by é o que desbloqueia o deploy de `main`, vermelho desde C193 no job `verify`/`Typecheck` do run 35394524888; staging/produção a confirmar após o merge | +| Verificado em prod | não se aplica (bug local de ferramental) | — | + +## O bug + +`pnpm issue next`, rodado de `~/Code/teqo`, falhava com `[agent:issue] GitHub API GET /repos/fsolla/teqo/issues → 401: {"message": "Bad credentials", ...}`. O sintoma atingia toda a ferramentaria local que fala com o GitHub — `pnpm issue`, `agent:*`, `scripts/github-pr.mjs` — além do próprio `gh`, que já havia falhado numa sessão de agente recente (log `c194-…log:74`). Não era runtime de produção; dados de produção intactos. + +No mesmo PR saiu um drive-by de outro bug: **main estava RED desde C193 (`bdaf3c7c`)** — o job `verify`/`Typecheck` do Deploy run 35394524888 falhava com TS2305, **bloqueando staging e produção**. O 401 e o main RED são independentes; estão neste post-mortem porque a correção saiu no mesmo PR. **Sintoma — não a causa.** + +## Causa-raiz + +5-whys (401): + +1. A API rejeitava o token com "Bad credentials". +2. O ambiente exportava um PAT fine-grained **expirado**; `scripts/lib/github-api.mjs:80` lê `process.env.GITHUB_TOKEN` e usava esse valor. +3. O PAT nunca foi rotacionado: o lembrete vivia só no comentário de `~/.bashrc:194` ("rotacionar após o cutover"), fora de qualquer tracker; PATs fine-grained expiram por padrão em 30d. +4. A expiração foi **silenciosa** — nenhum alerta; o primeiro sinal foi um 401 cru e enganoso. +5. O valor inválido no ambiente **sombreava** a credencial válida do keyring do `gh` (`gho_…`, conta fsolla), porque o `gh` honra `GITHUB_TOKEN` acima do keyring. + +**Evidência:** `~/.bashrc:194` (pré-fix, PAT adicionado no OPS71 em 2026-08-20); `scripts/lib/github-api.mjs:80`. Reprodução: `gh auth status` acusava o token de env inválido; `env -u GITHUB_TOKEN gh api /repos/fsolla/teqo` funcionava; `GITHUB_TOKEN=$(env -u GITHUB_TOKEN gh auth token) pnpm issue next` funcionava; `GITHUB_TOKEN=ghp_invalid_token pnpm issue next` → 401. + +Causa-raiz do drive-by (main RED): C192 e C193 passaram verdes individualmente, mas o auto-merge por rebase combinou os dois num commit que não foi re-checado; C193 renomeou `canReadSpeechCatalog` → `canReadCommunicationCatalog`, e `src/utilities/speech/speechPageData.ts` manteve o import/call site antigo. Conflito semântico que o merge textual não detecta; a primeira prova foi o `verify` do deploy — que só detecta o dano depois de `main` já estar vermelha. + +## Correção + +(1) **Ambiente (fora do repo):** `~/.bashrc` agora deriva `GITHUB_TOKEN` de `env -u GITHUB_TOKEN gh auth token` quando não setado — escolha do humano; auto-renova enquanto existir o login do `gh` no keyring. + +(2) **Repo:** `scripts/lib/github-api.mjs` passa a anexar dica acionável aos erros 401 (REST e GraphQL): `token rejeitado — GITHUB_TOKEN inválido/expirado ou revogado? Rode \`gh auth status\` e rotacione o PAT (um token inválido no ambiente também sombreia o login do \`gh\`).`Dois testes unitários fixam o comportamento (REST 401 sem retry + GraphQL 401) em`tests/unit/githubApi.unit.spec.ts`; `docs/AGENT-OPS.md:124`documenta a expiração de PAT e a recuperação via`gh`. + +(3) **Drive-by, mesmo PR:** `src/utilities/speech/speechPageData.ts` atualizado para importar/chamar `canReadCommunicationCatalog` — resolve a causa do TS2305 e desbloqueia o deploy. + +## Verificação + +- Teste de regressão: testes de 401 em `tests/unit/githubApi.unit.spec.ts` — prova RED pré-fix: o módulo (cópia de `HEAD`) emitia `GitHub API GET … → 401: {"message":"Bad credentials"}` sem dica; pós-fix inclui a dica +- Suíte: `pnpm test:unit` 364 arquivos / 3869 testes passaram (inclui os 2 novos); `pnpm test:int` 94 arquivos / 838 passaram (2 skipped), após um flake pré-existente e não relacionado de ordem/estado (`testDatabaseLease.int.spec.ts`, verde isolado e em rerun limpo); lint/format/knip/cycles 0; `pnpm typecheck` 0; build 0 +- Diff sem segredos; `GITHUB_TOKEN=ghp_invalid_token pnpm issue next` sai 1 com a dica acionável; `env -u GITHUB_TOKEN bash -ic 'pnpm issue next'` imprime a Issue #1154 (read-only, confirmada ainda `ready`/unassigned) +- CI: verde no PR # — a confirmar no merge +- Prod: não se aplica (bug local) + +## Prevenção + +| Estratégia | Custo | Estado | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------- | +| Dica acionável no 401 (REST + GraphQL) + 2 testes de regressão + nota em `docs/AGENT-OPS.md` | barata | implementada agora (commit/PR) | +| `scripts/github-doctor.mjs` espelhando `db-doctor.mjs` (`GET /user`, imprime login/método), chamado no início de `pnpm issue`/`agent:*`/sessões work-issue — pegaria ESTE bug no início do run | barata | documentada — não implementada neste fluxo | +| Ciclo de vida de credencial via GitHub App installation token + alerta de expiração em sops/age em vez de `.bashrc` — previne a classe | cara | documentada — não implementada neste fluxo (candidata a Issue futura) | +| Guard de conflito semântico de rebase para main (branch protection `strict=true` re-checando o head rebaseado, ou GitHub Merge Queue) — teria pego o main RED de C192+C193 antes do merge | cara | documentada — não implementada neste fluxo (candidata a Issue futura) | + +**Estratégia implementada:** dica acionável no erro 401 (em vez do "Bad credentials" cru) com testes unitários que a fixam, mais a nota operacional em `AGENT-OPS.md`; o drive-by restaura o typecheck de main. + +**Estratégia documentada (cara):** GitHub App installation token com expiração alertada via sops/age; e guard de re-checagem do head rebaseado (branch protection estrita ou Merge Queue) — nota-se que o `verify` pós-merge do deploy só detecta o dano **depois** de `main` vermelha. Também registrado: **não** fazer fallback silencioso para `gh auth token` dentro de `github-api.mjs` (OPS77 manteve resolução explícita de PAT; fallback silencioso mascara expiração e age como identidade errada). + +## Lições + +Expiração de token é silenciosa: o lembrete existia só como comentário de dotfile fora de qualquer tracker, e o primeiro sinal foi um 401 cru. Uma variável de ambiente inválida sombreou uma credencial válida do keyring, produzindo um erro enganoso (a culpa parecia do `gh`/API, não do shell). E dois PRs individualmente verdes podem combinar vermelho em `main` via auto-merge por rebase, sem re-check do commit rebaseado — a primeira prova é o `verify` do deploy, que bloqueia produção até o fix. Trilha de evidências: `~/.bashrc:194` (pré-fix), `scripts/lib/github-api.mjs:80`, commit C193 `bdaf3c7c`, deploy run 35394524888. diff --git a/scripts/lib/github-api.mjs b/scripts/lib/github-api.mjs index c9e7f63d..48b332ad 100644 --- a/scripts/lib/github-api.mjs +++ b/scripts/lib/github-api.mjs @@ -23,6 +23,9 @@ const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql' const DEFAULT_REPOSITORY = 'fsolla/teqo' const USER_AGENT = 'teqo-agent-scripts/1.0 (github)' const RETRYABLE_STATUSES = new Set([502, 503, 504]) +const AUTH_401_HINT = + 'token rejeitado — GITHUB_TOKEN inválido/expirado ou revogado? Rode `gh auth status` e rotacione ' + + 'o PAT (um token inválido no ambiente também sombreia o login do `gh`).' /** * @typedef {object} GithubApiOptions @@ -131,7 +134,10 @@ export const createApi = ({ } if (response.status === 404 && method === 'GET') return null if (!response.ok) { - throw new Error(`GitHub API ${method} ${path} → ${response.status}: ${text.slice(0, 400)}`) + const hint = response.status === 401 ? `${AUTH_401_HINT} ` : '' + throw new Error( + `GitHub API ${method} ${path} → ${response.status}: ${hint}${text.slice(0, 400)}`, + ) } if (!text) return null try { @@ -155,7 +161,8 @@ export const createApi = ({ } const text = await response.text() if (!response.ok) { - throw new Error(`GitHub GraphQL → ${response.status}: ${text.slice(0, 400)}`) + const hint = response.status === 401 ? `${AUTH_401_HINT} ` : '' + throw new Error(`GitHub GraphQL → ${response.status}: ${hint}${text.slice(0, 400)}`) } const parsed = JSON.parse(text) const firstError = parsed?.errors?.[0] diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts index bae9d337..972b9d2e 100644 --- a/src/utilities/speech/speechPageData.ts +++ b/src/utilities/speech/speechPageData.ts @@ -2,7 +2,7 @@ import 'server-only' import type { Payload } from 'payload' -import { canReadSpeechCatalog } from '@/lib/campaignRoles' +import { canReadCommunicationCatalog } from '@/lib/campaignRoles' import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod' import type { CampaignUser, Speech } from '@/payload-types' import { @@ -184,7 +184,7 @@ export const loadSpeechAcervoPageData = async ( const themeRequested = state.mode === 'tema' && Boolean(state.q) let themeTerms: readonly string[] = [] let themeUnavailable = false - if (themeRequested && canReadSpeechCatalog(user.role)) { + if (themeRequested && canReadCommunicationCatalog(user.role)) { const expansion = await expandTheme(state.q ?? '') if (expansion) themeTerms = expansion.terms else themeUnavailable = true diff --git a/tests/unit/githubApi.unit.spec.ts b/tests/unit/githubApi.unit.spec.ts index 51f1920b..a1de182c 100644 --- a/tests/unit/githubApi.unit.spec.ts +++ b/tests/unit/githubApi.unit.spec.ts @@ -487,6 +487,33 @@ describe('github-api (issue tracker layer)', () => { await expect(api.getPullRequestAutoMergeStatus(99)).rejects.toThrow(/não encontrado/) }) + it('surfaces an actionable hint when GITHUB_TOKEN is rejected (401, no retry)', async () => { + let calls = 0 + const api = createApi({ + token: 'tok', + fetchImpl: async () => { + calls += 1 + return ok({ message: 'Bad credentials' }, 401) + }, + }) + const error = await api.listIssues().then( + () => null, + (reason: Error) => reason, + ) + expect(error?.message).toContain('401') + expect(error?.message).toContain('GITHUB_TOKEN') + expect(error?.message).toContain('gh auth status') + expect(calls).toBe(1) + }) + + it('adds the same 401 hint to GraphQL credential failures', async () => { + const api = createApi({ + token: 'tok', + fetchImpl: async () => ok({ message: 'Bad credentials' }, 401), + }) + await expect(api.getPullRequestAutoMergeStatus(1)).rejects.toThrow(/GITHUB_TOKEN/) + }) + it('convertPullRequestToDraft sends the GraphQL draft mutation with the node id', async () => { const calls: FetchCall[] = [] const api = createApi({