diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7e7712b..34cf48a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,32 +1,62 @@ --- name: Bug report -about: Report a bug with bird CLI +about: Report a reproducible Codex Reset Request or retained Bird CLI problem title: '[BUG] ' labels: bug assignees: '' --- -**Describe the bug** -A clear description of what the bug is. +> For a vulnerability or sensitive conduct report, stop and use the private +> route in [SECURITY.md](../../SECURITY.md). Never put credentials or private +> data in a public issue. -**Commands run** -```bash -# What command did you run? -``` +**Safety check** + +- [ ] I removed cookies, tokens, authorization headers, browser databases/profile + paths, `~/.codex/auth.json`, prompt/source/tool text, raw rollout records, raw + network bodies, home-directory paths, and full unredacted output. I did not + attach `config.json`, `state.json`, `cursors.json`, `audit.jsonl`, watcher + locks, or service logs. + +**Component** + +`codex-reset-request` / retained `bird` CLI / documentation / other + +**Safe summary** + +A concise description without account data or private content. + +**Safe codes** + +Transcribe only the relevant safe codes from `doctor`, `status`, or service +status. Do not paste complete output. + +**Minimal synthetic reproduction** + +List commands and synthetic inputs only. Do not attach real rollout files, +browser data, or raw responses. **Expected behavior** + What you expected to happen. **Actual behavior** -What actually happened. Include full error output. + +What happened? Use safe codes; do not include full error output. **Environment** -- OS: [e.g. Ubuntu 24.04] -- Node version: [e.g. v22.22.3] -- bird version: [e.g. v3.0.0] - -**Env file check** -```bash -# Run this and confirm auth_token is 40 chars: -python3 -c "print(len(open(os.path.expanduser('~/.config/bird/env')).read().split('\"')[1]))" -``` + +- OS and version: +- Node version: +- pnpm version: +- `codex-reset-request` version: +- Codex CLI version: +- retained `bird` version, if relevant: +- run mode (`dry-run` or `auto`): +- foreground / launchd / systemd: + +**Live activity** + +State whether any live X read or write occurred. If a write occurred, state +only whether it targeted your own post and the final safe status; do not attach +credentials or raw responses. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3738503 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private security or conduct report + url: https://github.com/ncihxaonn/codex-reset-request/security/advisories/new + about: Use the private reporting route; never disclose credentials or sensitive evidence publicly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 36cb5e8..6e532c7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,13 +1,28 @@ --- name: Feature request -about: Suggest a new feature +about: Suggest a focused Codex Reset Request feature title: '[FEATURE] ' labels: enhancement assignees: '' --- -**Describe the feature** -A clear description of what you'd like to add. +> For a vulnerability or sensitive conduct report, stop and use the private +> route in [SECURITY.md](../../SECURITY.md). Never put credentials or private +> data in a public issue. -**Use case** -What problem does this solve? +**Problem and user value** + +What local workflow problem would this solve? + +**Proposed behavior** + +Describe the smallest useful behavior without private account data. + +**Threat-model and privacy impact** + +Explain any effect on Codex reads, X reads/writes, local state, logs, services, +or credentials. + +Requests for polling, retries after ambiguous writes, bulk/multi-account +posting, CAPTCHA/stealth/proxy bypass, telemetry, or runtime LLM calls are out +of scope. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fb2fd91 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '09:00' + timezone: Australia/Melbourne + open-pull-requests-limit: 5 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '09:30' + timezone: Australia/Melbourne + open-pull-requests-limit: 5 + commit-message: + prefix: ci diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9933352 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,54 @@ +## Summary + +What changed, and why? + +## Threat-model impact + +Describe any effect on rollout classification, App Server schemas, X +reads/writes, target selection, consent, deduplication, rate guards, state +recovery, logs, subprocesses, filesystem paths, or services. +Write `None` only after checking. + +## Verification + +- [ ] `pnpm run typecheck` +- [ ] `pnpm run lint` +- [ ] `pnpm run test` +- [ ] `pnpm run build:dist` +- [ ] `pnpm run verify:no-polling` +- [ ] `pnpm run verify:no-secrets` +- [ ] `pnpm run verify:attribution` +- [ ] Native watcher tests ran without a skip, or CI is expected to provide the + required host evidence. + +List any additional focused tests. + +## Safety invariants + +- [ ] Fresh configuration and `setup` remain dry-run by default; `install` + selects automatic posting only after current explicit consent and a running + service check. +- [ ] No local OS-notification feature was added. +- [ ] One logical action can perform at most one X mutation attempt; ambiguous + results never retry. +- [ ] Account, target, consent, configuration, deduplication, lock, and hard rate + guards remain fail closed. +- [ ] No periodic quota/X polling, cron/timer, telemetry, runtime LLM, + CAPTCHA/stealth/proxy bypass, or bulk/multi-account behavior was added. +- [ ] Subprocesses use argv arrays without a shell. +- [ ] No credentials, private rollout data, prompt/source/tool text, raw response + body, browser data, home path, or unredacted output is included. + +## Live X activity + +- [ ] No live X operation occurred. +- [ ] A read-only live test occurred; describe only safe metadata below. +- [ ] One explicitly authorized write to my own test post occurred; record only + the final safe status below. + +## Documentation and provenance + +- [ ] User-facing behavior, privacy/security implications, and compatibility + notes are updated where applicable. +- [ ] Bird syncs update `UPSTREAM.md` and `docs/compatibility.md` and preserve MIT + attribution, or this is not a Bird sync. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db5ff7f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CI: 'true' + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + COREPACK_DEFAULT_TO_LATEST: '0' + +jobs: + verify: + name: Node 22 / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node: ['22'] + steps: + - name: Check out full history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + package-manager-cache: false + - name: Enable pinned pnpm + run: corepack enable pnpm + - name: Show tool versions + run: | + node --version + pnpm --version + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm run typecheck + - name: Lint + run: pnpm run lint + - name: Test (native watchers required) + env: + CRR_REQUIRE_NATIVE_WATCH: '1' + run: pnpm run test + - name: Build distribution + run: pnpm run build:dist + - name: Verify no polling + run: pnpm run verify:no-polling + - name: Verify no secrets + run: pnpm run verify:no-secrets + - name: Verify attribution + run: pnpm run verify:attribution diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..2dbe1fa --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: CodeQL / ${{ matrix.language }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [javascript-typescript, actions] + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Initialize CodeQL + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + build-mode: none + languages: ${{ matrix.language }} + queries: security-extended + - name: Analyze + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..13e3634 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,44 @@ +name: Secret scan + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: secret-scan-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CI: 'true' + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + COREPACK_DEFAULT_TO_LATEST: '0' + +jobs: + scan: + name: Repository and derivative history + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out full history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + package-manager-cache: false + - name: Enable pinned pnpm + run: corepack enable pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Scan current tree and derivative history + run: pnpm run verify:no-secrets diff --git a/.gitignore b/.gitignore index 81c765c..6223e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ dist/ *.log .pnpm-debug.log* @@ -16,6 +17,7 @@ twitter-cli.bin bird .env .*.bun-build +.tmp/ *.zip *.tar *.tar.gz diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33e706b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# AGENTS.md + +- Never commit real Codex rollout files. +- Never commit browser cookies, auth_token, ct0, or Codex credentials. +- Never print prompts, source code, tool output, cookies, or auth tokens. +- Never add periodic quota polling. +- Never retry an ambiguous X write. +- One logical action may perform at most one X mutation attempt. +- Never add CAPTCHA bypass, stealth, fingerprint spoofing, or proxy rotation. +- Keep all subprocess arguments as arrays; do not invoke through a shell. +- Preserve all upstream MIT attribution. +- Update UPSTREAM.md and docs/compatibility.md when syncing Bird. +- Add tests for every Codex schema or X GraphQL parser change. +- Run typecheck, lint, tests, build, and security checks before committing. +- Do not publish an npm package without an explicit instruction. +- Do not create a private repository. +- Do not describe the project as official, compliant, safe, or guaranteed. diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d38b1..554f4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,53 @@ # Changelog -## v3.0.0 (2026-06-12) +## 0.1.0-alpha.0 — Unreleased -### Fixed -- **Transaction ID double-generation bug** — Removed broken `prepareTransactionId()` call from `twitter-client-posting.ts`. The function was generating a transaction ID before the URL was known, causing `getBaseHeaders()` to consume it, then `fetchWithTimeout()` to generate a different one for the actual URL. The mismatch caused HTTP 401 errors on all write operations (tweet, reply). -- **`fetchWithTimeout` now always overwrites** `x-client-transaction-id` with a properly generated value bound to the actual request URL path. The random value from `getBaseHeaders()` is irrelevant because it gets replaced. +### Added -### Documentation -- Added Python-based env file writing instructions (bash heredoc/cat corrupts tokens with special characters) -- Added env file verification step (auth_token must be exactly 40 chars) -- Added troubleshooting guide for 401 errors -- Documented the root cause: corrupted env file, not IP binding or code bugs +- event-driven native Codex rollout watcher with incremental cursors and no + polling fallback; +- strict `UsageLimitExceeded` classifier and local Codex App Server + `account/rateLimits/read` confirmation; +- guarded Bird target selection, active-account verification, one-shot reply, + read-after-write verification, persistent deduplication, and rolling guards; +- dry-run, explicit auto-consent, doctor, status, redacted logs, service, + and gated diagnostic commands; +- launchd and systemd user-service management, with Windows foreground support; +- private atomic state, provenance, security, privacy, + threat-model, compatibility, and bilingual documentation; +- cross-platform CI definitions, CodeQL, dependency updates, schema inspection, + release-policy verifiers, and privacy-safe support templates. -### Files Changed -- `src/lib/twitter-client-base.ts` — `fetchWithTimeout` always overwrites txn ID -- `src/lib/twitter-client-posting.ts` — Removed `prepareTransactionId()` call +### Security -## v0.9.0 (zaydiscold/bird — 2026-06) -- Added `x-client-transaction-id` header support -- Added proper GET vs POST header separation -- Updated GraphQL query IDs for June 2026 +- fresh configuration and standalone setup default automatic posting off; the + one-command installer selects auto only after exact risk confirmation; +- no mutation retries, redirect replay, token logging, telemetry, or runtime LLM + calls; +- the immutable rolling 24-hour maximum is three attempts; +- an ambiguous write is persisted as `unknown` and is never retried; +- the retained Bird credential check reports presence only and no longer prints + token prefixes. +- JSON and release-scan reads use opened file handles with identity checks to + prevent path-swap races. +- raw X news responses are no longer written to a debug file. -## v0.1.0 (jawond/bird — 2025-12) -- Initial release +## Upstream Bird history (retained for provenance) + +The entries below describe upstream Bird releases; they are not Codex Reset +Request version numbers. See `UPSTREAM.md` for the exact fork base. + +### Bird CLI v3.0.0 (2026-06-12) + +- Fixed transaction-ID double generation in posting requests. +- Bound `x-client-transaction-id` to the actual request URL path. +- Documented environment-file and HTTP 401 troubleshooting. + +### zaydiscold/bird v0.9.0 (2026-06) + +- Added `x-client-transaction-id` support, GET/POST header separation, and June + 2026 GraphQL query IDs. + +### jawond/bird v0.1.0 (2025-12) + +- Initial Bird implementation. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..60bc221 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,39 @@ +# Code of conduct + +## Our standard + +Contributors and maintainers commit to a respectful, harassment-free community. +Be constructive, assume good faith while examining evidence, respect privacy, +and make room for people with different backgrounds and levels of experience. + +Acceptable participation includes: + +- giving specific, actionable technical feedback; +- acknowledging mistakes and correcting them; +- discussing safety, platform rules, and trade-offs without personal attacks; +- protecting credentials and personal data encountered during support. + +Unacceptable behavior includes harassment, discrimination, threats, sexualized +conduct, doxxing, publishing private communications, deliberately soliciting or +sharing credentials, and using project spaces to coordinate spam, evasion, +abuse, or unauthorized account activity. + +## Scope and enforcement + +This code applies in repository issues, pull requests, reviews, discussions, +and other spaces where someone represents the project. Maintainers may edit or +remove content, reject contributions, temporarily restrict participation, or +ban participants when needed to protect the community. + +Once enabled for the public fork, report conduct concerns through the +repository's private GitHub Security Advisory flow with a title beginning +`Conduct report`. For security matters, follow [SECURITY.md](SECURITY.md). Do +not include unnecessary personal information or credentials. Enabling that +private route is a release gate; until then, do not disclose a sensitive report +in a public issue. Maintainers will handle reports as privately and impartially +as practical; retaliation is prohibited. + +## Attribution + +This policy is informed by the Contributor Covenant and common open-source +community standards, adapted for this project's security and privacy needs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7900063..55b0795 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,48 +1,106 @@ -# Contributing to Bird CLI +# Contributing -## Setup +Codex Reset Request welcomes focused contributions that preserve its +event-driven, local-first, fail-closed design. + +## Development setup ```bash -git clone https://github.com/0xEnc0der/bird-x-cli.git -cd bird-x-cli -pnpm install +git clone https://github.com/ncihxaonn/codex-reset-request.git +cd codex-reset-request +corepack enable +pnpm install --frozen-lockfile +pnpm build +pnpm test ``` -## Building +Use Node.js 22 or newer. Bun is optional and is not part of the ordinary test +or runtime path. -```bash -rm -rf dist && npx tsc -``` +Before changing files, inspect `git status --short --branch` and avoid +overwriting unrelated work. Create a focused branch, keep commits reviewable, +and do not run repository-wide rewrites in a dirty checkout. -## Testing +## Required checks ```bash -# Set up your env file first (see README.md) -bird check -bird tweet "test from bird CLI" -bird search "hello" -n 3 -bird read +pnpm typecheck +pnpm lint +pnpm test +pnpm build:dist +pnpm verify:no-polling +pnpm verify:no-secrets +pnpm verify:attribution ``` -## Updating Query IDs +Native watcher integration tests must use actual filesystem events. They may +skip only when the local sandbox does not permit native watchers; CI requires +them. Do not add polling as a test or runtime fallback. + +When the Codex App Server protocol changes, run `pnpm codex:schemas`. This +generates into ignored `.tmp/` directories, validates only the protocol fields +used by the project, and never reads a rollout or login file. Review schema +changes locally; CI intentionally does not require a Codex binary or login. + +## Safety invariants + +A contribution must not weaken these properties: -When X rotates GraphQL query IDs: +- fresh configuration and standalone setup default automatic posting off; the + installer may select auto only after current explicit consent; +- only allowlisted Codex server-error shapes can become candidates; +- App Server confirmation is required before an automatic action; +- the active X account is verified before every write; +- mutation intent is persisted immediately before the one POST; +- write transport has no retry, redirect replay, alternate endpoint, or + fallback mutation; +- `unknown` remains guarded and is never automatically retried; +- the rolling 24-hour hard maximum remains three; +- no cron, timer, interval, or polling loop is introduced; +- cookies, tokens, prompts, raw rollout records, and raw error bodies never + enter logs, fixtures, issues, or snapshots; +- no local OS-notification feature is added; +- Codex session files remain read-only; +- no telemetry, runtime LLM call, CAPTCHA bypass, stealth, proxy rotation, + multi-account automation, or bulk-reply feature is added. + +Changes to X mutations require failure-path tests, including timeouts and +ambiguous responses. Changes to rollout parsing require false-positive tests. +Changes to App Server schemas require captured fixtures that contain no user +content or credentials. + +## Live tests + +Ordinary tests use fake App Server and Bird network layers. Never run live tests +in CI. A local read requires `CRR_LIVE_X=1`. A live write additionally requires +`--live`, the singleton lock, and a test post owned by the current user: ```bash -pnpm run graphql:update -rm -rf dist && npx tsc +CRR_LIVE_X=1 codex-reset-request test x-reply \ + --url https://x.com//status/ \ + --live ``` -## Code Style +Do not use Tibo's posts or any third-party post for a live write test. Do not +attach the resulting raw network response to a pull request. + +## Bird upstream updates + +The repository retains Bird's history and `bird` binary. When syncing upstream: + +1. keep `upstream` pointed at `0xEnc0der/bird-x-cli`; +2. retain the MIT license and third-party notices; +3. update `UPSTREAM.md` and `docs/compatibility.md`; +4. run both Bird baseline and Codex Reset Request regression tests; +5. review changed GraphQL requests for accidental write retries or logging. -- TypeScript strict mode -- Use the existing mixin pattern for new features -- Run `pnpm run lint` before submitting PRs +## Issues and pull requests -## Pull Requests +Describe behavior with safe codes, operating system, Node/Codex versions, and a +minimal synthetic reproduction. Never paste cookie values, authorization +headers, browser databases, `~/.codex/auth.json`, prompt text, raw rollout +files, home-directory paths, or full unredacted output. -1. Fork the repo -2. Create a feature branch -3. Make your changes -4. Test all operations (tweet, reply, read, search) -5. Submit PR with description of changes +Pull requests should explain the threat-model impact, list tests run, and state +whether any live X action occurred. Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +and report vulnerabilities through [SECURITY.md](SECURITY.md), not a public issue. diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..2ffb5fe --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,47 @@ +# Disclaimer + +Codex Reset Request is an independent, unofficial open-source project. + +It is not affiliated with, endorsed by, sponsored by, or operated by OpenAI, +ChatGPT, Codex, X Corp., Tibo, or the maintainers of Bird. + +This software does not reset a Codex or ChatGPT account, grant additional +usage, or guarantee that a reset will be provided. It only detects a local +Codex usage-limit event and, when explicitly enabled by the user, attempts to +submit a configured reply through the user's existing authenticated X browser +session. + +The software does not require an OpenAI API key or X API key and does not make +LLM inference calls at runtime. Existing Codex authentication and an +authenticated X browser session are still required. + +Bird and this project use undocumented X web GraphQL endpoints. These +endpoints may change or stop working without notice. X's current +[Automation Rules](https://help.x.com/en/rules-and-policies/x-automation) +prohibit non-API scripting of the X website and impose express-consent and +opt-out requirements on automated replies; the +[Terms of Service](https://x.com/en/tos) also require access through X's +currently available published interfaces. This implementation therefore must +not be treated as eligible for public release or live automatic use as written. +Use may result in failed posts, duplicated or unintended activity, content +removal, reduced visibility, rate limiting, account restrictions, or account +suspension. + +A disclaimer does not override or provide an exemption from any platform +rule. + +Users are solely responsible for reviewing and complying with all applicable +platform terms, laws, workplace policies, account-security obligations, and +other requirements. Users are responsible for every action performed through +their accounts. + +Do not use this software for bulk replies, coordinated campaigns, harassment, +spam, deceptive engagement, multiple-account automation, CAPTCHA bypass, +anti-bot bypass, rate-limit circumvention, or evasion of platform safeguards. + +The software is provided "AS IS", without warranty of any kind. Use it +entirely at your own risk. + +An accurate Chinese translation is included in +[`README.zh-CN.md`](README.zh-CN.md#免责声明准确中文翻译). The English text above +is the project's primary disclaimer. diff --git a/LICENSE b/LICENSE index c370933..6c43d8f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,9 @@ MIT License +Copyright (c) 2024 Peter Steinberger (steipete) +Copyright (c) 2025 Peter Steinberger Copyright (c) 2026 0xEnc0der +Copyright (c) 2026 Codex Reset Request contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ad6415e..05c6f9b 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,396 @@ -# Bird CLI v3.0.0 — Free X/Twitter CLI +# Codex Reset Request — Codex Usage Limits Monitor -A fast, free X/Twitter CLI that uses browser cookies (GraphQL) instead of the paid X API. Post tweets, reply, read threads, search — no API key needed. +An event-driven local Codex usage limits monitor. It detects a confirmed Codex +usage-limit or rate-limit event and can optionally submit one customizable +reset request through Bird using the user's existing authenticated X browser +session. -**Fork of [zaydiscold/bird](https://github.com/zaydiscold/bird) v0.9.0** with critical fixes for June 2026 X API changes. +- No polling +- No X API key +- No OpenAI API key +- No runtime LLM calls +- Explicit opt-in required for automatic posting -## What's Fixed in v3.0.0 +This tool does not reset your Codex account and does not guarantee that anyone +will provide a reset. -1. **Transaction ID double-generation bug** — `prepareTransactionId()` was generating a transaction ID before the URL was known, causing a mismatch. Fixed by removing it and letting `fetchWithTimeout()` handle all transaction ID generation from the actual request URL path. +> **Public development alpha — source available for review, not an endorsement +> of live automatic posting.** X's current Automation Rules prohibit scripting +> the X website; this Bird-derived build uses undocumented web GraphQL rather +> than the official X API. Keep `dry-run` mode unless you have independently +> resolved the live-operation requirements in [DISCLAIMER.md](DISCLAIMER.md) +> and the [release checklist](docs/public-release-checklist.md#live-operation-policy-blockers). -2. **Documented env file corruption issue** — Writing credential env files with bash heredoc/cat corrupts tokens containing special characters. The README now includes Python-based env file writing instructions. +[简体中文](README.zh-CN.md) + +## How it works + +```text +Codex rollout append + ↓ +Native filesystem event + ↓ +Strict UsageLimit classifier + ↓ +Codex App Server confirmation + ↓ +Deduplication + rate guard + ↓ +Bird target selection + ↓ +Expected X account verification + ↓ +One mutation attempt + ↓ +sent / definitive failure / unknown +``` + +The watcher sleeps until the operating system reports a file change. It tails +only newly appended rollout JSONL bytes, accepts narrowly structured Codex +usage-limit errors, then starts a short-lived local Codex App Server process to +confirm `account/rateLimits/read`. Only a confirmed event can proceed from the +pipeline to X selection/action stages. Idle watching performs no X requests, +App Server calls, or LLM calls. + +On first start, existing rollout files are bookmarked at EOF, so historical +errors do not trigger an action. New rollout files begin at byte zero. Partial +lines, truncation, replacement, new date directories, restarts, and duplicate +limit windows are handled conservatively. + +See [architecture](docs/architecture.md) and the five +[architecture decisions](docs/adr/0001-public-bird-fork.md). ## Requirements -- Node.js >= 22 -- pnpm -- X.com account (for browser cookies) +- Node.js 22 or newer +- pnpm (Corepack is suitable) +- a working Codex CLI login; `0.140.0` is the version tested for this alpha +- an X login in Safari, Chrome, or Firefox readable by Bird +- macOS or Linux for managed background service installation +- Windows is supported in foreground with `watch`; auto-start is not supported + in v0.1 + +No developer API key is required. Browser cookies and the Codex login are local +authentication credentials; this project does not claim to work without +authentication. Bun is optional only for the inherited standalone Bird build +and is not needed for normal installation or runtime. + +## Acknowledgements and license + +Small but important attribution: this repository is derived from the public +[`0xEnc0der/bird-x-cli`](https://github.com/0xEnc0der/bird-x-cli) repository, +with MIT attribution retained. Its secondary upstream is +[`zaydiscold/bird`](https://github.com/zaydiscold/bird), derived from the +original `jawond/bird` implementation by Peter Steinberger. + +The exact base is `a16f9901717008bf1ab3ea0b715dfd95dedc95b0`. See +[UPSTREAM.md](UPSTREAM.md), [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), +and [LICENSE](LICENSE). The inherited `bird` binary remains available for +diagnostics; `codex-reset-request` is the separate guarded workflow. + +## Installation from source + +The alpha is intentionally not published to npm. + +```bash +git clone https://github.com/ncihxaonn/codex-reset-request.git +cd codex-reset-request +corepack enable +pnpm install --frozen-lockfile +pnpm build +pnpm link --global +``` + +Confirm both binaries: + +```bash +codex-reset-request --help +bird --help +``` + +Re-run `pnpm build` after pulling source changes. Service definitions point to +the absolute built CLI and Node binary present at installation time, so run +`codex-reset-request service install` again after moving the checkout or +changing Node installations. + +## Authentication requirements + +### Codex + +Run `codex` normally and complete its login before setup. The tool reads local +rollout files below `$CODEX_HOME/sessions` and talks to a temporary local Codex +App Server subprocess. It never opens `~/.codex/auth.json`, modifies a Codex +session, or requests an OpenAI API key. + +The confirmation client calls initialize and `account/rateLimits/read`; it does +not call `thread/start` or `turn/start` and therefore does not create a Codex +conversation or inference turn. + +Codex home resolution is: + +```text +CRR_CODEX_HOME → config.codexHome → CODEX_HOME → ~/.codex +``` + +Setup pins the effective path into config so a background service watches the +same home that passed preflight. + +### X browser session + +Log in to `x.com` in the configured Safari, Chrome, or Firefox profile. Bird +reads `auth_token` and `ct0` through its local browser-cookie provider. Values +are used in memory and are not written to Codex Reset Request config, state, or +logs. + +Foreground Bird compatibility can resolve environment credentials, but the +managed background service deliberately rejects an environment-only X +credential pair: launchd/systemd should not embed secrets, and service managers +do not reliably inherit the installer's shell environment. + +## One-command installation (macOS and Linux) + +After installing the CLI from source, this one command runs the preflight, +records consent, and installs and starts the event-driven user service with +automatic X replies enabled: + +```bash +codex-reset-request install +``` + +Choose your own reply content with `--reply-text`. Automatic replies still +require the explicit risk confirmation; disable them any time with +`codex-reset-request disable-auto`. The project has no local OS-notification +feature. + +```bash +codex-reset-request install --reply-text "Please reset my Codex limit" +``` + +On Windows, use `setup` then run `watch` in a foreground terminal. + +## Setup + +Start with the non-writing default: + +```bash +codex-reset-request setup +``` + +Setup checks Node, Codex App Server rate-limit access, the active X account, and +the readable target post; displays the disclaimer; records the expected X +handle; and saves `dry-run` unless another mode was explicitly requested. + +Modes: + +- `dry-run`: detects and confirms events, but never posts. +- `auto`: permits the guarded reply only after the exact risk confirmation. + +To enable automatic posting, use the dedicated flow: + +```bash +codex-reset-request enable-auto +``` + +It repeats the App Server, account, and target reads and requires the exact +confirmation text `I UNDERSTAND THE X ACCOUNT RISK`. A changed disclaimer +version, wrong account, revoked consent, config change at the mutation boundary, +deduplication match, or rate guard prevents the write. + +Disable immediately without deleting other configuration: + +```bash +codex-reset-request disable-auto +``` + +Run in the foreground before installing a service: + +```bash +codex-reset-request watch +``` + +This is the normal way to observe dry-run behavior in a terminal. +Stop it with `Ctrl-C`; shutdown flushes cursors/audit state and releases the +singleton lock. + +## Diagnostics + +```bash +codex-reset-request doctor +codex-reset-request status +codex-reset-request status --json +codex-reset-request logs --tail 100 +``` + +`doctor` reports `PASS`, `WARN`, or `FAIL` for runtime, Codex compatibility, +sessions, App Server confirmation, X reads, native file watching, event-driven +operation, config/state, service state, single-instance lock, and consent. +Output uses safe codes and does not print cookie values, tokens, prompts, raw +rollout records, or raw GraphQL bodies. + +The local synthetic trigger performs no network or state writes: -## Installation +```bash +codex-reset-request test trigger +``` + +Live X diagnostics are never used by CI. A read requires an explicit process +gate: ```bash -git clone https://github.com/0xEnc0der/bird-x-cli.git -cd bird-x-cli -pnpm install -rm -rf dist && npx tsc -chmod +x dist/cli.js -ln -sf "$(pwd)/dist/cli.js" ~/.local/bin/bird +CRR_LIVE_X=1 codex-reset-request test x-read ``` -## Authentication +This read-only check reads the active X account plus public account and post +metadata for the hard-coded safe target `@thsottiaux`. It performs no write. + +A write is restricted to a test post owned by the current X account. Stop the +watcher first so the test can acquire its singleton lock: + +```bash +CRR_LIVE_X=1 codex-reset-request test x-reply \ + --url https://x.com//status/ \ + --live +``` -Bird uses X.com browser cookies. You need `auth_token` and `ct0`. +The command fetches the post, checks author ID and handle against the current +and setup-pinned account, explicitly refuses `@thsottiaux`, applies the same +rolling guard, persists the mutation marker, and permits one POST. An ambiguous +result remains `unknown` after at most one read-only verification and is never +retried. -**Get your cookies:** -1. Log into x.com in your browser -2. Open DevTools → Application → Cookies → x.com -3. Copy `auth_token` (40-char hex) and `ct0` values +## Background service -**Create the env file (IMPORTANT: Use Python, NOT bash):** +Build, complete setup, and make sure browser cookies—not environment-only +credentials—are readable. Then install and start the event-driven user service: -```python -import os -env_path = os.path.expanduser('~/.config/bird/env') -os.makedirs(os.path.dirname(env_path), exist_ok=True) +```bash +codex-reset-request service install +codex-reset-request service status +``` -auth_token = "your_40_char_auth_token" -ct0 = "your_ct0_token" +Lifecycle commands: -with open(env_path, 'w') as f: - f.write(f'export AUTH_TOKEN="{auth_token}"\n') - f.write(f'export CT0="{ct0}"\n') - f.write('export GUEST_ID="v1%3A..."\n') - f.write('export LANG="en"\n') +```bash +codex-reset-request service start +codex-reset-request service stop +codex-reset-request service restart +codex-reset-request service uninstall ``` -**Verify:** `python3 -c "print(len(open(os.path.expanduser('~/.config/bird/env')).read().split('\"')[1]))"` — must be exactly 40. +On macOS this manages +`~/Library/LaunchAgents/io.github.ncihxaonn.codex-reset-request.plist`. On Linux +it manages the XDG user unit +`~/.config/systemd/user/codex-reset-request.service`. Neither implementation +creates a scheduled `StartInterval`, `CalendarInterval`, systemd timer, or cron +entry. launchd's `ThrottleInterval=5` is restart backoff, not quota polling. +`stop` suppresses the current managed process; `uninstall` unloads/disables it +before removing the exact definition. Manager/file drift is reported rather +than silently ignored. + +Windows service subcommands return a clear unsupported result. Use: + +```powershell +codex-reset-request watch +``` + +## Configuration + +```bash +codex-reset-request config show +codex-reset-request config set maxAttemptsPer24Hours 1 +codex-reset-request config reset +``` + +Use `enable-auto` rather than setting `mode=auto` directly. The configured +write ceiling can be 0–3; the immutable hard maximum is three attempts in any +rolling 24 hours. The default is one. Records with `attempting`, `unknown`, or a +mutation marker continue to occupy guards across restarts. + +## Data locations + +| Platform | Config | State | Logs | +| --- | --- | --- | --- | +| macOS | `~/Library/Application Support/codex-reset-request/config.json` | `~/Library/Application Support/codex-reset-request/state/` | `~/Library/Logs/codex-reset-request/` | +| Linux | `${XDG_CONFIG_HOME:-~/.config}/codex-reset-request/config.json` | `${XDG_STATE_HOME:-~/.local/state}/codex-reset-request/` | state directory + `/logs/` | +| Windows | `%APPDATA%\codex-reset-request\config.json` | `%LOCALAPPDATA%\codex-reset-request\` | state directory + `\logs\` | + +`CRR_CONFIG_DIR`, `CRR_STATE_DIR`, and `CRR_LOG_DIR` override these locations. +Service installation requires every override to be absolute. Unix application +directories use mode `0700`; config, state, cursor, audit, lock, and service +definition files use mode `0600` where supported. + +State and cursor JSON files have a 4 MiB safety ceiling. Guard history is never +silently evicted because removing it could permit a duplicate write. If the +ceiling is reached, stop the watcher, archive the complete state directory, and +only then make an explicit operator decision; see +[troubleshooting](docs/troubleshooting.md). + +The redacted audit log is capped at 16 MiB. Service stdout/stderr logs have no +application-managed rotation. Review, rotate, or remove exact log files while +the watcher/service is stopped according to local policy. -**⚠️ NEVER use bash heredoc/cat to write the env file** — special characters in tokens get mangled. +## Privacy and security -## Quick Reference +The project has no telemetry, analytics, crash-report service, remote storage, +proxy rotation, or runtime LLM call. It reads only newly appended Codex rollout +bytes and local browser cookies needed for X authentication. Redacted audit +events remain local. -| Action | Command | -|---|---| -| Post tweet | `bird tweet "Hello world!"` | -| Reply | `bird reply "Reply text"` | -| Read tweet | `bird read ` | -| Thread | `bird thread ` | -| Search | `bird search "query" -n 10` | -| Mentions | `bird mentions -n 10` | -| Replies to post | `bird replies ` | -| Like | `bird like ` | -| Repost | `bird repost ` | -| Bookmark | `bird bookmark ` | -| Check auth | `bird check` | -| JSON output | Add `--json` to any command | +Data is not entirely offline: setup, doctor, explicit live tests, and a +confirmed action can make X web requests; App Server confirmation uses a local +Codex subprocess that relies on the existing Codex login. See +[privacy](docs/privacy.md), [threat model](docs/threat-model.md), and +[security policy](SECURITY.md). -## Troubleshooting +No local OS notifications are sent; status stays in the CLI, state, and +redacted local logs. -| Symptom | Fix | -|---|---| -| `HTTP 401` on tweet/reply | Verify auth_token is 40 chars using Python. If wrong, rewrite env with Python. | -| `HTTP 401` on read | Same — verify env file first | -| `HTTP 404` on search | Query ID stale — run `pnpm run graphql:update` and rebuild | -| `HTTP 422` GraphQL validation | Query ID wrong — update `src/lib/query-ids.json` and rebuild | +Bird uses undocumented X web GraphQL endpoints. Publishing the source does not +make live automation permissible. X's current +[Automation Rules](https://help.x.com/en/rules-and-policies/x-automation) +prohibit non-API scripting of the X website, so this implementation is not +suitable for live automatic use as written. A disclaimer does not cure that +conflict. Read the complete [disclaimer](DISCLAIMER.md). -## Updating Query IDs +## Uninstall and complete data removal -X rotates GraphQL query IDs periodically: +First disable auto mode and unload the service: ```bash -cd bird-x-cli -pnpm run graphql:update -rm -rf dist && npx tsc +codex-reset-request disable-auto +codex-reset-request service uninstall +pnpm remove --global codex-reset-request ``` -## Dependencies +The source checkout and application data are intentionally not deleted by the +uninstaller. After confirming the service is stopped, remove only the exact +config, state, and log paths listed above using your operating system's file +manager. Remove the source checkout separately if it is no longer needed. Do +not delete `$CODEX_HOME`: it belongs to Codex and is never owned by this tool. -- `x-client-transaction-id` (^0.2.0) — Generates `x-client-transaction-id` header -- `commander` (^14.0.2) — CLI framework -- `json5` (^2.2.3) — Config file parsing -- `kleur` (^4.1.5) — Terminal colors +## Known limitations and compatibility -## License +- The tool submits a request; it cannot reset or grant Codex usage. +- It never calls `account/rateLimitResetCredit/consume`; official reset-credit + redemption is outside v0.1. +- The v0.1 action targets one configured account and one reply text. +- Undocumented X endpoints and response shapes can change without notice. +- Codex `0.140.0` is tested; other versions are reported as untested and fail + closed on incompatible App Server schemas. +- Native watcher availability depends on the host filesystem. There is no + polling fallback. +- macOS and Linux have managed user services; Windows is foreground-only. +- Sleep/wake and rapid file changes are caught up from saved byte offsets, but + no latency guarantee is made for every filesystem. +- A disclaimer does not override X rules, workplace policy, or applicable law. -MIT +See the detailed [compatibility matrix](docs/compatibility.md) and +[troubleshooting guide](docs/troubleshooting.md). -## Attribution +## Contributing -- Fork: [zaydiscold/bird](https://github.com/zaydiscold/bird) v0.9.0 (June 2026) -- Original: [jawond/bird](https://github.com/jawond/bird) (steipete) -- Transaction ID: `x-client-transaction-id` npm package -- v3.0.0 patches: 0xEnc0der (June 2026) +Contributions are welcome within the deliberately narrow, local-first scope. +Read [CONTRIBUTING.md](CONTRIBUTING.md) and +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Do not attach credentials, browser +databases, raw rollout files, prompt text, or unredacted error bodies to an +issue. Live write tests must target a post owned by the tester and remain opt-in. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..72254f4 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,371 @@ +# Codex Reset Request — Codex 用量限制监控工具 + +一个事件驱动的本地 Codex usage limits monitor:检测并确认 Codex 用量限制或 +rate limit 事件,并可选择通过 Bird,使用用户现有的 X 浏览器登录会话发送一条 +可自定义的 reset request。 + +- 不轮询 +- 不需要 X Developer API key +- 不需要 OpenAI API key +- 运行时不产生 LLM inference calls +- 自动发帖必须由用户明确开启 + +本工具不会重置你的 Codex 账号,也不保证任何人会提供 reset。 + +> **公开开发中的 alpha——源码可供审查,但不代表认可真实自动发帖。**X 目前的 +> Automation Rules 禁止脚本化 X 网站;这个 Bird 衍生版本使用未公开的 web +> GraphQL,而不是官方 X API。在独立解决 [DISCLAIMER.md](DISCLAIMER.md) 和 +> [release checklist](docs/public-release-checklist.md#live-operation-policy-blockers) +> 中的真实运行要求前,请保持 `dry-run` 模式。 + +[English](README.md) + +## 工作方式 + +```text +Codex rollout append + ↓ +原生文件系统事件 + ↓ +严格 UsageLimit 分类器 + ↓ +Codex App Server 确认 + ↓ +去重 + rate guard + ↓ +Bird 目标帖子选择 + ↓ +预期 X 账号验证 + ↓ +一次 mutation attempt + ↓ +sent / definitive failure / unknown +``` + +watcher 只在操作系统报告文件变化时被唤醒,只增量读取 rollout JSONL 新增的 +字节。它仅接受限定结构中的 Codex usage-limit error,然后临时启动本地 Codex +App Server,调用 `account/rateLimits/read` 进行二次确认。空闲时不会进行 X +请求、App Server 调用或 LLM 调用。 + +第一次启动时,已有 rollout 文件的 cursor 会定位在 EOF,因此历史错误不会触发 +动作;之后新建的 rollout 文件从 byte 0 读取。partial line、truncate、replace、 +新日期目录、restart 和重复 limit window 都采用保守处理。 + +更多细节见[架构](docs/architecture.md)和 +[ADR](docs/adr/0001-public-bird-fork.md)。 + +## 要求 + +- Node.js 22 或以上 +- pnpm(可通过 Corepack 使用) +- 已正常登录的 Codex CLI;本 alpha 实测版本为 `0.140.0` +- Bird 可读取的 Safari、Chrome 或 Firefox X 登录会话 +- macOS 或 Linux 才支持托管后台服务 +- Windows v0.1 支持前台 `watch`,不支持自动启动服务 + +Bun 仅是上游 Bird standalone binary 的可选构建工具,不是普通安装或运行依赖。 + +## 致谢与许可证 + +简短但必要的署名:本仓库衍生自公开项目 +[`0xEnc0der/bird-x-cli`](https://github.com/0xEnc0der/bird-x-cli),并保留 Git +上游历史和 MIT 署名。secondary upstream 是 +[`zaydiscold/bird`](https://github.com/zaydiscold/bird),原始 `jawond/bird` +由 Peter Steinberger 实现。 + +基线 commit 是 `a16f9901717008bf1ab3ea0b715dfd95dedc95b0`。见 +[UPSTREAM.md](UPSTREAM.md)、[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) +和 [LICENSE](LICENSE)。上游 `bird` binary 继续保留用于诊断; +`codex-reset-request` 是独立的 guarded workflow。 + +## 从源码安装 + +本 alpha 暂不发布 npm package。 + +```bash +git clone https://github.com/ncihxaonn/codex-reset-request.git +cd codex-reset-request +corepack enable +pnpm install --frozen-lockfile +pnpm build +pnpm link --global +``` + +确认两个 CLI: + +```bash +codex-reset-request --help +bird --help +``` + +拉取源码更新后重新运行 `pnpm build`。service definition 会固定安装时的 Node +binary 和已构建 CLI 绝对路径;如果移动 checkout 或更换 Node,请重新运行 +`codex-reset-request service install`。 + +## 登录要求 + +### Codex + +先正常运行 `codex` 并完成登录。工具只读 `$CODEX_HOME/sessions` 下的 rollout, +并与临时本地 Codex App Server subprocess 通信。它不会打开 +`~/.codex/auth.json`、不会修改 Codex session,也不需要 OpenAI API key。 + +确认 client 只调用 initialize 和 `account/rateLimits/read`,不会调用 +`thread/start` 或 `turn/start`,因此不会创建 Codex conversation 或 inference +turn。 + +Codex home 优先级: + +```text +CRR_CODEX_HOME → config.codexHome → CODEX_HOME → ~/.codex +``` + +setup 会保存实际通过 preflight 的路径,保证后台服务监听同一个 Codex home。 + +### X 浏览器会话 + +在配置的 Safari、Chrome 或 Firefox profile 中登录 `x.com`。Bird 通过本地 +browser-cookie provider 读取 `auth_token` 和 `ct0`,仅在内存中使用;不会把 +它们写入本项目的 config、state 或 logs。 + +上游 Bird 的前台兼容模式可以使用环境变量凭据,但本项目的后台服务会拒绝 +“仅环境变量”凭据:launchd/systemd 不应保存 secrets,也不会可靠继承安装命令 +所在 shell 的变量。 + +## 一键安装并部署(macOS 与 Linux) + +从源码安装 CLI 后,执行这一条命令即可完成 preflight、记录必要同意,并安装和启动 +已开启自动 X 回复的事件驱动用户后台服务: + +```bash +codex-reset-request install +``` + +用 `--reply-text` 自定义回复内容。自动回复仍须完成明确的风险确认;随时可用 +`codex-reset-request disable-auto` 关闭。本项目不包含本地 OS 通知功能: + +```bash +codex-reset-request install --reply-text "Please reset my Codex limit" +``` + +Windows 不支持后台 service,请先用 `setup`,再以前台方式运行 `watch`。 + +## Setup 与模式 + +默认从不写入的模式开始: + +```bash +codex-reset-request setup +``` + +setup 会检查 Node、Codex App Server rate-limit 读取、当前 X 账号和目标帖子; +显示 disclaimer;保存预期 X handle;默认写入 `dry-run`。 + +- `dry-run`:检测并确认事件,但不发帖。 +- `auto`:只有完成精确风险确认后,才允许 guarded reply。 + +```bash +codex-reset-request enable-auto +codex-reset-request disable-auto +``` + +`enable-auto` 会重新检查 App Server、当前账号和目标读取,并要求逐字输入: + +```text +I UNDERSTAND THE X ACCOUNT RISK +``` + +disclaimer 版本改变、账号不匹配、consent 被撤销、mutation 边界前配置改变、 +dedup 命中或 rate guard 命中,都会阻止写入。 + +安装 service 之前,可在前台运行: + +```bash +codex-reset-request watch +``` + +这是在 terminal 中观察 dry-run 的正常方式。使用 `Ctrl-C` 停止;shutdown +会保存 cursor/audit state 并释放 single-instance lock。 + +## 诊断、状态与日志 + +```bash +codex-reset-request doctor +codex-reset-request status +codex-reset-request status --json +codex-reset-request logs --tail 100 +``` + +`doctor` 使用 `PASS / WARN / FAIL` 检查 runtime、Codex 兼容性、sessions、 +App Server、X read、原生 watcher、无轮询设计、config/state、service、 +single-instance lock 和 consent。输出只包含 safe code,不打印 cookie、token、 +prompt、完整 rollout record 或原始 GraphQL body。 + +不访问网络、不写 state 的 synthetic test: + +```bash +codex-reset-request test trigger +``` + +CI 永远不运行 live X test。只读测试必须明确设置 gate: + +```bash +CRR_LIVE_X=1 codex-reset-request test x-read +``` + +这项只读检查会读取当前 X 账号,以及硬编码安全目标 `@thsottiaux` 的公开账号与帖子 +元数据;它不会执行写操作。 + +真实写测试只能回复当前账号自己拥有的测试帖子。先停止 watcher,以便测试命令 +取得 single-instance lock: + +```bash +CRR_LIVE_X=1 codex-reset-request test x-reply \ + --url https://x.com//status/ \ + --live +``` + +命令会从服务器读取帖子,同时核对 author ID、handle、当前账号和 setup 保存的 +账号;明确拒绝 `@thsottiaux`;应用 rolling guard;先原子保存 mutation marker; +只允许一次 POST。结果不明确时只允许一次只读验证,之后保持 `unknown`,不重试。 + +## 后台服务 + +先 build、完成 setup,并确认后台可读取浏览器 cookie: + +```bash +codex-reset-request service install +codex-reset-request service status +codex-reset-request service start +codex-reset-request service stop +codex-reset-request service restart +codex-reset-request service uninstall +``` + +macOS 使用 +`~/Library/LaunchAgents/io.github.ncihxaonn.codex-reset-request.plist`;Linux 使用 +XDG user unit `~/.config/systemd/user/codex-reset-request.service`。两者都没有 +scheduled `StartInterval`、`CalendarInterval`、systemd timer 或 cron。launchd +的 `ThrottleInterval=5` 只是 restart backoff,不是 quota polling。stop 会抑制 +当前托管进程;uninstall 会先 unload/disable,再删除精确的 definition。即使 +definition 被手工删除,仍会查询 manager 状态并报告 drift。 + +Windows service 命令会明确返回 unsupported。请使用: + +```powershell +codex-reset-request watch +``` + +## 配置与 rate guard + +```bash +codex-reset-request config show +codex-reset-request config set maxAttemptsPer24Hours 1 +codex-reset-request config reset +``` + +不要直接设置 `mode=auto`,请使用 `enable-auto`。配置上限可设为 0–3,任何 +rolling 24 hours 的硬上限始终是 3,默认是 1。`attempting`、`unknown` 或带有 +mutation marker 的记录在重启后仍然占用 guard。 + +## 数据位置 + +| 平台 | Config | State | Logs | +| --- | --- | --- | --- | +| macOS | `~/Library/Application Support/codex-reset-request/config.json` | `~/Library/Application Support/codex-reset-request/state/` | `~/Library/Logs/codex-reset-request/` | +| Linux | `${XDG_CONFIG_HOME:-~/.config}/codex-reset-request/config.json` | `${XDG_STATE_HOME:-~/.local/state}/codex-reset-request/` | state 目录下的 `logs/` | +| Windows | `%APPDATA%\codex-reset-request\config.json` | `%LOCALAPPDATA%\codex-reset-request\` | state 目录下的 `logs\` | + +可用 `CRR_CONFIG_DIR`、`CRR_STATE_DIR`、`CRR_LOG_DIR` 覆盖。后台 service 要求 +所有 override 都是绝对路径。Unix 下应用目录使用 `0700`,config、state、 +cursor、audit、lock 和 service definition 在支持时使用 `0600`。 + +state/cursor JSON 有 4 MiB 安全上限。guard 历史不会被静默淘汰,因为删除记录 +可能造成重复写入。达到上限时必须先停止 watcher、完整归档 state 目录,再由 +operator 明确决定如何处理。见[故障排查](docs/troubleshooting.md)。 + +redacted audit log 的上限为 16 MiB;service stdout/stderr logs 没有应用内 +rotation。请按本地政策,在 watcher/service 停止时检查、轮换或删除精确 log 文件。 + +## 隐私与安全 + +项目不包含 telemetry、analytics、crash-report service、remote storage、proxy +rotation 或 runtime LLM calls。它只读取新追加的 Codex rollout bytes,以及 X +认证所需的本地浏览器 cookies;redacted audit events 保留在本机。 + +这不等于“完全离线”:setup、doctor、显式 live test 和确认后的 action 可以发起 +X web requests;App Server 确认使用依赖现有 Codex 登录的本地 subprocess。见 +[隐私说明](docs/privacy.md)、[threat model](docs/threat-model.md)和 +[安全政策](SECURITY.md)。 + +本项目不会发送本地 OS notification;状态只保存在 CLI、state 和本地 redacted +logs 中。 + +Bird 使用未公开的 X web GraphQL endpoints。公开源码并不代表真实自动化获准。 +X 目前的 +[Automation Rules](https://help.x.com/en/rules-and-policies/x-automation) 禁止非 API +方式脚本化 X 网站,因此当前实现不适合按原样真实自动运行;免责声明不能消除这项 +冲突。 + +## 免责声明(准确中文翻译) + +Codex Reset Request 是独立、非官方的开源项目。它不隶属于 OpenAI、ChatGPT、 +Codex、X Corp.、Tibo 或 Bird 维护者,也未获得这些主体的认可、赞助或运营。 + +本软件不会重置 Codex 或 ChatGPT 账号、不会授予额外用量,也不保证有人提供 +reset。它只检测本地 Codex 用量限制事件,并在用户明确启用后,尝试通过用户 +现有的、已认证的 X 浏览器会话发送配置的回复。 + +软件不需要 OpenAI API key 或 X API key,运行时不进行 LLM inference calls; +但仍然需要现有 Codex 认证和已认证的 X 浏览器会话。Bird 和本项目使用未公开的 +X web GraphQL endpoints,它们可能随时变化或停止工作。 + +非 API 浏览器/网站自动化以及未经请求的自动回复,可能违反 X 的条款、自动化 +规则、spam policy 或其他适用规则。使用本软件可能导致发帖失败、重复或意外 +活动、内容移除、可见度降低、rate limit、账号限制或封禁。免责声明不能覆盖、 +豁免任何平台规则。 + +用户必须自行审查并遵守适用的平台条款、法律、工作场所政策、账号安全义务及 +其他要求,并对其账号执行的每个动作负责。禁止将本软件用于批量回复、协同活动、 +骚扰、spam、欺骗性互动、多账号自动化、CAPTCHA bypass、anti-bot bypass、 +规避 rate limit 或逃避平台保护措施。 + +本软件按“原样”提供,不附带任何形式的保证;使用风险完全由用户承担。英文完整 +版本以 [DISCLAIMER.md](DISCLAIMER.md) 为准。 + +## 卸载与完整数据删除 + +先撤销 auto 并卸载 service: + +```bash +codex-reset-request disable-auto +codex-reset-request service uninstall +pnpm remove --global codex-reset-request +``` + +uninstaller 不会自动删除 source checkout 或应用数据。确认 service 已停止后, +使用操作系统文件管理器,只删除上表列出的精确 config、state、log 路径;源码 +checkout 另行删除。不要删除 `$CODEX_HOME`:它属于 Codex,不属于本工具。 + +## 已知限制与兼容性 + +- 工具只能发送 request,不能执行 reset 或增加用量。 +- 工具不会调用 `account/rateLimitResetCredit/consume`;官方 reset-credit + redemption 不属于 v0.1。 +- v0.1 只有一个配置目标和一段回复文本。 +- X 未公开 endpoint 和 response shape 可随时变化。 +- 实测 Codex `0.140.0`;其他版本标记为 untested,schema 不兼容时 fail closed。 +- 原生 watcher 是否可用取决于文件系统;没有 polling fallback。 +- macOS/Linux 支持 user service;Windows 仅前台运行。 +- 无法保证所有文件系统上的检测延迟。 +- disclaimer 不会覆盖 X 规则、公司政策或法律。 + +详见[兼容性矩阵](docs/compatibility.md)和[故障排查](docs/troubleshooting.md)。 + +## 贡献 + +欢迎在本项目刻意收窄、local-first 的范围内贡献。请阅读 +[CONTRIBUTING.md](CONTRIBUTING.md) 和 +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。issue 中不得附上 credentials、 +browser DB、原始 rollout、prompt 或未脱敏错误 body。live write test 只能针对 +测试者自己的帖子,而且必须显式开启。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..92189ee --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,57 @@ +# Security policy + +## Supported version + +The current `0.1.0-alpha.0` branch receives security fixes. This alpha has no +stability or backward-compatibility guarantee. + +## Reporting a vulnerability + +After the public fork exists, use the repository's private GitHub Security +Advisory flow. Do not open a public issue for a vulnerability and do not send +credentials as proof. + +Include only: + +- a concise impact statement; +- affected commit and operating system; +- safe reproduction steps using synthetic fixtures; +- relevant safe codes; +- a proposed mitigation, if known. + +Never include X cookies, Codex tokens, authorization headers, browser databases, +`~/.codex/auth.json`, raw rollout files, prompts, home paths, JWT-like strings, +or full response bodies. Maintainers may ask for a minimized synthetic test. + +## Security boundaries + +The project reads local Codex rollout append data and browser cookies, starts a +bounded Codex App Server subprocess, and may send one X web mutation after +explicit opt-in. It does not sandbox Node, Codex, the browser-cookie provider, +or the operating-system service manager. Users must secure the account and +host on which it runs. + +The following are treated as security-sensitive regressions: + +- a false-positive path that can reach an X write; +- any write retry, fallback mutation, or ambiguous-result replay; +- bypass of consent, expected-account checks, deduplication, the singleton lock, + or the hard rate ceiling; +- credentials, prompts, source, raw response bodies, or raw rollout records + entering action state, logs, fixtures, command arguments, or errors. The + private cursor may hold one bounded unfinished rollout fragment as disclosed + in `docs/privacy.md`; +- following symlinks outside owned data paths or writing Codex session files; +- passing unrelated X, GitHub, or API-key environment variables into the Codex + App Server subprocess; +- polling, timer, telemetry, remote configuration, or runtime LLM additions. + +Undocumented X endpoints can change independently of this project. A broken +endpoint is normally a compatibility issue; a change that causes an unintended +write, credential disclosure, or replay is a security issue. + +## Disclosure + +Please allow maintainers a reasonable period to reproduce and prepare a fix +before public disclosure. No bug-bounty program is promised. The software is +provided under the MIT License and the separate disclaimer applies. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..396e6e1 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,23 @@ +# Third-party notices + +This project is derived from Bird. + +Original Bird implementation: +Peter Steinberger + +Accessible mirrors and derivative work: +zaydiscold/bird +0xEnc0der/bird-x-cli + +Bird and its derivatives are distributed under the MIT License. +Codex Reset Request is an independent project and is not endorsed by the +upstream authors. + +The production dependency inventory was checked with +`pnpm licenses list --prod`: packages use MIT, ISC, or BSD-2-Clause licenses. +Their package-level copyright and license notices must remain available when +redistributing dependencies or a bundled binary. See the dated +[`dependency audit`](docs/dependency-audit.md). + +The complete license text and retained copyright statements are in +[`LICENSE`](LICENSE). diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 0000000..5b8680e --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,28 @@ +# Upstream provenance + +- Primary upstream: [`0xEnc0der/bird-x-cli`](https://github.com/0xEnc0der/bird-x-cli) +- Secondary upstream: [`zaydiscold/bird`](https://github.com/zaydiscold/bird) +- Original project: [`jawond/bird`](https://github.com/jawond/bird), originally implemented by Peter Steinberger +- Base commit SHA: `a16f9901717008bf1ab3ea0b715dfd95dedc95b0` +- Local base tag: `upstream-bird-v3-base` +- Base package version: `0.9.0` +- Base README version: `Bird CLI v3.0.0` +- Date forked: `2026-08-28` + +The retained copyright lines were cross-checked against each upstream's public +license file: [`jawond/bird`](https://github.com/jawond/bird/blob/main/LICENSE), +[`zaydiscold/bird`](https://github.com/zaydiscold/bird/blob/main/LICENSE), and +[`0xEnc0der/bird-x-cli`](https://github.com/0xEnc0der/bird-x-cli/blob/main/LICENSE). + +## Local changes summary + +This project retains Bird's browser-cookie credential resolution, read +operations, search, and posting implementation while adding an independent +event-driven Codex usage-limit detector and a deliberately constrained reset +request action. The new action defaults to dry-run, confirms the limit through +the local Codex App Server, verifies the active X account, selects one eligible +target post, and permits at most one mutation attempt per logical action. + +The repository version is `0.1.0-alpha.0`; it is not represented as Bird v3. +When syncing Bird, update this file and `docs/compatibility.md`, retain all MIT +notices, and add regression tests for changed GraphQL parsing or mutations. diff --git a/docs/adr/0001-public-bird-fork.md b/docs/adr/0001-public-bird-fork.md new file mode 100644 index 0000000..a900bf0 --- /dev/null +++ b/docs/adr/0001-public-bird-fork.md @@ -0,0 +1,19 @@ +# ADR 0001: Public Bird fork + +- Status: accepted +- Date: 2026-08-28 + +## Decision + +Publish Codex Reset Request as a public fork of `0xEnc0der/bird-x-cli`; retain +Git history, the `bird` binary, MIT license, upstream notices, and a precise base +SHA. Until authentication permits fork creation, complete the implementation +locally without claiming a public repository exists. Use a separate package +identity/version and `codex-reset-request` binary. + +## Rationale and consequences + +The action depends on Bird's browser-cookie and X web client. A real fork makes +the derivation and changes auditable and avoids copying code without history. +Upstream changes must be reviewed for mutation/retry and privacy regressions. +The project is not Bird v3, an official Bird release, or endorsed by upstream. diff --git a/docs/adr/0002-native-file-events.md b/docs/adr/0002-native-file-events.md new file mode 100644 index 0000000..a2fa2a7 --- /dev/null +++ b/docs/adr/0002-native-file-events.md @@ -0,0 +1,18 @@ +# ADR 0002: Native file events + +- Status: accepted +- Date: 2026-08-28 + +## Decision + +Use operating-system file events to wake an incremental cursor-based tailer. +Do not add interval polling, cron, launchd `StartInterval`/`CalendarInterval`, +or systemd timers. If native watching is unavailable, fail with a clear +diagnostic. A launchd restart throttle is process supervision, not scheduling. + +## Rationale and consequences + +The tool should be idle until a local Codex append occurs. File events can be +coalesced, so correctness must come from offsets, identities, and EOF catch-up, +not from one-event-per-write assumptions. Some sandboxes and network filesystems +are unsupported rather than receiving a silent polling fallback. diff --git a/docs/adr/0003-direct-bird-library-integration.md b/docs/adr/0003-direct-bird-library-integration.md new file mode 100644 index 0000000..1f5bcc9 --- /dev/null +++ b/docs/adr/0003-direct-bird-library-integration.md @@ -0,0 +1,18 @@ +# ADR 0003: Direct Bird library integration + +- Status: accepted +- Date: 2026-08-28 + +## Decision + +Import the retained Bird client as an internal TypeScript library. Do not spawn +the `bird` CLI from the core pipeline, add an X API-key client, or introduce a +second posting implementation. + +## Rationale and consequences + +Direct integration permits typed safe results, current-account checks, +mutation-boundary persistence, and exact one-attempt control without parsing +shell output. The inherited `bird` binary remains for diagnostics. X browser +cookies are still authentication credentials and undocumented GraphQL remains +a compatibility and policy risk. diff --git a/docs/adr/0004-no-write-retry.md b/docs/adr/0004-no-write-retry.md new file mode 100644 index 0000000..655324d --- /dev/null +++ b/docs/adr/0004-no-write-retry.md @@ -0,0 +1,18 @@ +# ADR 0004: No write retry + +- Status: accepted +- Date: 2026-08-28 + +## Decision + +Every logical action can start at most one X mutation transport attempt. Do not +retry timeouts, 5xx responses, malformed responses, redirects, thrown provider +errors, or alternate endpoints. Permit at most one read-only verification after +an ambiguous outcome. + +## Rationale and consequences + +A timeout does not prove the server rejected a POST. Retrying can duplicate a +public reply. Persisting `attempting` and `mutationStartedAt` before transport, +then retaining `unknown`, favors missed requests over unintended duplicates. +Operators must not clear unknown state to force another automatic write. diff --git a/docs/adr/0005-app-server-confirmation.md b/docs/adr/0005-app-server-confirmation.md new file mode 100644 index 0000000..3b03c9a --- /dev/null +++ b/docs/adr/0005-app-server-confirmation.md @@ -0,0 +1,19 @@ +# ADR 0005: App Server confirmation + +- Status: accepted +- Date: 2026-08-28 + +## Decision + +A rollout candidate alone cannot authorize an automatic action. Start a +short-lived bounded local Codex App Server, initialize it, call +`account/rateLimits/read`, validate the allowlisted schema and Codex home, and +require a confirmed exhausted bucket. + +## Rationale and consequences + +Rollout formats can change and text can be misleading. App Server confirmation +adds an independent structured check using the existing Codex login without an +API key or inference call. Spawning it only after an event preserves idle +zero-network behavior. Timeout, version/schema ambiguity, process failure, or +home mismatch prevents the action. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e76b1d2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,123 @@ +# Architecture + +Codex Reset Request is a single Node.js process around the retained Bird +library. It is not a Codex plugin, hosted service, cron job, or runtime agent. + +## Event and action flow + +```text +$CODEX_HOME/sessions/**/*.jsonl (read only) + │ native fs.watch events + ▼ + incremental tailer + cursor + │ complete new JSONL records + ▼ + strict rollout classifier + │ UsageLimit candidate + ▼ + temporary local Codex App Server + │ account/rateLimits/read + ▼ + deduplication + rolling rate guard + │ allowed confirmed action + ▼ + retained Bird X provider + ┌──────────┼───────────┐ + │ │ │ + current user target read one reply POST + │ │ │ + └──── account/author ───┘ + verification + │ + ▼ + atomic state + redacted audit +``` + +The OS file event is only a wake-up signal. Correctness comes from comparing +saved byte offsets with current file metadata and reading the missing range. +There is no scheduled polling interval or timeout loop. A wake-up can cover +multiple appended records; coalesced events are safe because the tailer catches +up to EOF. + +## Components + +- `watcher/`: native recursive directory watching, catch-up discovery, file + identity, byte cursors, partial-line buffering, and single-instance lock. +- `codex/`: strict rollout classification, Codex home resolution, CLI + compatibility, bounded App Server JSON-RPC, and rate-limit confirmation. +- `pipeline/`: stable event/window/action fingerprints, persistent guards, + mode handling, mutation-boundary authorization, and one-shot state machine. +- `x/`: narrow provider interface, eligible target selection, current-account + check, one mutation attempt, and read-only verification. +- `state/`: validated atomic JSON, migrations, lock, and redacted JSONL audit. +- `service/`: launchd/systemd user-service definitions and lifecycle queries. + +The inherited Bird commands and library remain under `src/commands` and +`src/lib`. The new CLI is isolated under `src/reset` but imports Bird as a +library, avoiding runtime shell calls to `bird`. + +## Startup and cursors + +On first startup, the watcher enumerates existing session JSONL metadata and +stores each current EOF without reading history. A file created afterward is +read from byte zero. A saved cursor contains only a path hash, safe basename, +file identity, byte offset, size, trailing partial line, and observation time. + +Truncation or file-identity replacement resets parsing conservatively. Deleted +files are pruned from active cursor state. New date directories and sessions +are discovered through native events plus event-driven catch-up scans. Codex +files are never written. + +## Candidate and confirmation boundary + +The classifier accepts only `event_msg` records whose payload is `error` or +`stream_error`, with an allowlisted structured usage-limit value or a narrow +fallback phrase at the expected message field. It does not recursively search +user, assistant, tool, command, log, or arbitrary nested content. HTTP 429 alone +is not a quota signal. + +For every accepted candidate, a bounded short-lived `codex app-server` +subprocess must initialize successfully and return a compatible +`account/rateLimits/read` response for the same resolved Codex home. Missing or +ambiguous buckets, timeouts, schema changes, and home mismatch fail closed. +The confirmation client does not call `thread/start` or `turn/start`; it creates +no Codex conversation or inference turn. The subprocess receives a minimal +runtime environment allowlist plus the resolved `CODEX_HOME`; unrelated X, +GitHub, and API-key variables are not inherited. + +## Durable one-write state machine + +```text +candidate → confirmed → target-resolved → attempting + │ + persist mutationStartedAt + │ + exactly one transport POST + ┌──────────┼──────────┐ + ▼ ▼ ▼ + sent definitive unknown + failure │ + one read-only check +``` + +`attempting` is stored before entering the provider. `mutationStartedAt` is +stored immediately before transport. Startup converts a stale `attempting` +record to `unknown`; neither status can be automatically retried. Redirects are +not followed for writes, and the Bird mutation method has no retry or fallback. + +Event, limit-window, action, and rolling-24-hour guards are persisted. Guard +history is not silently evicted. The configured limit defaults to one and the +hard maximum is three attempts in any rolling 24 hours. + +## Runtime and service boundaries + +The service definition contains absolute Node, built CLI, Codex home, config, +state, and log paths plus a captured non-secret PATH. It never contains X +cookies. Environment-only X credentials are refused for background install. +launchd uses RunAtLoad plus restart-on-failure and systemd uses +`Restart=on-failure`; neither uses a timer. + +All subprocesses use argv arrays without a shell, bounded output, timeouts, and +termination escalation. Logs accept only safe structured fields and the +project sends no local OS notifications. See [threat-model.md](threat-model.md) +for residual risks. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..ced9a78 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,64 @@ +# Compatibility + +This document distinguishes code support from environments actually verified +for the public alpha. + +## Runtime matrix + +| Component | Support | Alpha evidence | +| --- | --- | --- | +| Node.js | `>=22` | `22.18.0` used for local verification | +| pnpm | development/install | `11.23.0` used locally | +| Codex CLI | schema-checked, fail closed | `0.140.0` tested locally | +| macOS | foreground + launchd user service | native watcher/App Server/status exercised locally; lifecycle logic unit-tested | +| Linux | foreground + systemd user service | unit-tested; CI matrix required before release | +| Windows | foreground watcher | code paths unit-tested; Windows-host CI required before release | +| Windows auto-start | unsupported in v0.1 | returns an explicit warning | +| Bun | optional inherited binary build only | not required or installed locally | + +The Codex App Server protocol is documented by OpenAI at +. This project validates only the +small initialize and `account/rateLimits/read` response surface it uses. A Codex +version other than `0.140.0` is reported as untested; incompatible or ambiguous +responses stop the action rather than being guessed. + +Maintainers can run `pnpm codex:schemas` to generate both TypeScript and JSON +protocol trees under ignored `.tmp/` paths. The script validates the exact +initialize and rate-limit fields consumed by this project, canonicalizes JSON +before drift comparison, and does not read Codex sessions or authentication. + +## Browser credentials + +Bird attempts Safari, Chrome, and Firefox browser-cookie sources (or one +explicitly configured source). Platform/keychain restrictions can prevent a +background process from reading a session that is readable in an interactive +shell. Complete setup and `doctor`, then confirm service status on the same user +account. Managed services reject credentials available only through shell +environment variables. + +## Filesystems + +The watcher requires native filesystem notifications. Local APFS, common Linux +filesystems, and Windows foreground semantics are the intended targets. Some +network mounts, container bind mounts, sandboxed hosts, or file-provider layers +may not expose usable events. Startup fails clearly; it never falls back to +polling. + +## X web compatibility + +The retained Bird client uses undocumented X web GraphQL operations and browser +cookies. Endpoint identifiers, response shapes, and automation policy can +change without notice. Read compatibility does not guarantee write +compatibility. Every update to GraphQL parsing or mutations requires baseline, +one-shot, timeout, and malformed-response tests. + +## Service definitions + +- macOS: launchd user agent, RunAtLoad, restart-on-failure, no scheduled + StartInterval/CalendarInterval (`ThrottleInterval` is restart backoff). +- Linux: systemd user unit, `Restart=on-failure`, no timer. +- Windows: run `codex-reset-request watch` in a foreground terminal. + +Moving the source checkout, Node binary, config paths, or Codex home requires a +fresh `service install` so the absolute definition is replaced and the active +service restarted. diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md new file mode 100644 index 0000000..032c0c3 --- /dev/null +++ b/docs/dependency-audit.md @@ -0,0 +1,33 @@ +# Production dependency audit + +This snapshot records the checks run against `pnpm-lock.yaml` on 2026-09-03. +It is evidence for this source tree, not a guarantee about future advisories. +The recorded tool version is pnpm `11.23.0`. + +## Known-vulnerability check + +```bash +pnpm audit --prod --audit-level high +``` + +Result: no known vulnerabilities were reported. + +## License inventory + +```bash +pnpm licenses list --prod --json +``` + +The production dependency closure reported only permissive licenses: + +- MIT: `@steipete/sweet-cookie`, `commander`, `cssom`, `dom-serializer`, + `html-escaper`, `htmlparser2`, `json5`, `kleur`, + `x-client-transaction-id`, and `zod`. +- ISC: `boolbase`, `linkedom`, and `uhyphen`. +- BSD-2-Clause: `css-select`, `css-what`, `domelementtype`, `domhandler`, + `domutils`, `entities`, and `nth-check`. + +Package-level copyright and license files remain authoritative. Anyone shipping +a bundled executable or vendored dependency tree must retain the notices +required by those licenses. Re-run both commands against the exact release +lockfile immediately before publishing. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..4ac9026 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,76 @@ +# Privacy + +Codex Reset Request is local-first, not entirely offline. It has no telemetry, +analytics, crash-report service, remote configuration, advertising identifier, +or runtime LLM inference call. + +## Local data read + +The process can read: + +- metadata and newly appended bytes in `$CODEX_HOME/sessions/**/*.jsonl`; +- the configured browser's X cookies through Bird's cookie provider; +- its own config, state, cursor, lock, and audit files; +- current process/service metadata needed for `doctor` and lifecycle commands. + +It does not open `~/.codex/auth.json`, scan `archived_sessions`, copy a browser +cookie database into the project, or read complete rollout history on first +start. Codex rollout files are never modified. + +## Local data written + +- `config.json`: validated settings, expected handle, and consent metadata; no + cookies or tokens. +- `state.json`: hashes, safe IDs/URLs, state-machine status, guard timestamps, + and safe codes; no reply plaintext. +- `cursors.json`: path hashes, safe basenames, file identity, offsets, and the + literal unfinished JSONL fragment needed to complete a partial line after + restart. That fragment is bounded to 2 MiB but can temporarily contain Codex + user/prompt/source content; protect the cursor file as sensitive local data. +- `audit.jsonl`: redacted event type, status, safe codes, and safe URLs/IDs. +- `watcher.lock`: PID, start time, and a random ownership token. +- service stdout/stderr logs: CLI diagnostic messages with home paths redacted. + +Unix application data uses private permissions where supported. JSON input is +size-bounded and symlinks at owned sensitive paths are rejected. State guard +history is retained until the operator explicitly archives/removes the whole +state while the watcher is stopped. + +## Network and subprocess activity + +Idle watching performs no network request. Activity can occur during setup, +doctor, explicit live diagnostics, or after a confirmed rollout event: + +1. a local bounded Codex App Server subprocess uses the existing Codex login to + read account rate limits; +2. Bird sends X web requests to read the active account, target metadata, and, + only when explicitly authorized, one reply mutation; +3. an unknown mutation result permits one read-only verification request. + +The project does not send data to its own server because no such server exists. +OpenAI/Codex and X remain separate trust boundaries governed by their own +services and policies. + +## Logs + +Central redaction removes cookie/token labels, authorization values, JWT-like +strings, long credential-like hex strings, and user home paths. Prompt text, +source, shell output, raw response bodies, reply plaintext, and credentials must +never be included. The project has no local OS-notification feature. + +Redaction is defense in depth, not permission to log secrets. New code should +pass only allowlisted structured values to logging APIs. + +The audit log is append-only and capped at 16 MiB. Service stdout/stderr logs +have no application-managed rotation. Operators control retention and should +rotate/remove exact log files only while the watcher/service is stopped. + +App Server confirmation calls initialize and `account/rateLimits/read`; it does +not call `thread/start` or `turn/start` and does not create an inference turn. + +## Data control + +Use `status` and `logs` to inspect safe local records. Use `disable-auto` before +maintenance. To remove project data, uninstall the user service and delete only +the exact platform paths documented in the README. Never delete `$CODEX_HOME` +as part of project cleanup. diff --git a/docs/public-release-checklist.md b/docs/public-release-checklist.md new file mode 100644 index 0000000..3792844 --- /dev/null +++ b/docs/public-release-checklist.md @@ -0,0 +1,105 @@ +# Public source alpha release checklist + +This checklist records evidence rather than aspirations. Do not mark remote +items complete from local results alone. + +## Live-operation policy blockers + +- [ ] Replace Bird's non-API X website scripting with an officially supported + X API/OAuth integration. Current + [X Automation Rules](https://help.x.com/en/rules-and-policies/x-automation) + prohibit scripting the X website, and the + [Terms of Service](https://x.com/en/tos) require X's published interfaces, + so disclaimers alone are insufficient. +- [ ] Confirm the target account has expressly opted in to automated replies + and provide the required easy opt-out path before any live third-party + reply is enabled. +These unchecked items block live third-party automatic replies, not publication +of source code for review. Public availability does not assert that live use is +permitted by X. + +## Repository and attribution + +- [ ] Public GitHub fork exists and GitHub reports parent + `0xEnc0der/bird-x-cli`. +- [ ] `origin` points to the public fork; `upstream` points to the primary + upstream. +- [x] Published history contains the upstream Bird history plus only + personal-author derivative commits; earlier company-identity commits are + excluded. +- [x] Exact upstream base SHA/tag/version/date are recorded. +- [x] MIT license, Bird history, third-party notices, and both binaries remain. +- [x] Production dependency licenses are inventoried as MIT, ISC, or + BSD-2-Clause; bundled redistributions must retain package notices. See + the dated [dependency audit](dependency-audit.md). +- [x] Package identity is `codex-reset-request@0.1.0-alpha.0`; `private: true` + prevents accidental npm publication and does not make the GitHub source + repository private. + +## Product safety + +- [x] Default mode cannot write. +- [x] Strict classifier and App Server confirmation are required. +- [x] Existing files initialize at EOF; Codex files remain read-only. +- [x] Event/window/action/24-hour guards persist across restarts. +- [x] Mutation intent is persisted before the sole POST. +- [x] Timeout/ambiguous result becomes guarded `unknown`; no write retry exists. +- [x] Current account, target author identity, local consent, and config are + rechecked. Recipient opt-in remains a separate blocker above. +- [x] Hard maximum is three attempts per rolling 24 hours. +- [x] No polling, cron, timer, telemetry, runtime LLM, or bypass feature exists. +- [x] Logs are redacted and size-bounded; no local OS-notification feature + exists; prompts and credentials are never included. + +## Local verification + +- [x] Typecheck, lint, unit/integration tests, and build pass on the final tree. +- [x] Native watcher suite passes when run outside the restricted sandbox. +- [x] Real local Codex App Server rate-limit read has been exercised. +- [x] `bird --help` and `codex-reset-request --help` build and run. +- [x] launchd status query handles the real not-loaded exit code. +- [x] The local secret scan covers full history; no-polling and attribution + verifiers also pass. +- [x] The production dependency audit reports no known vulnerabilities. +- [ ] One manually authorized reply to a post owned by the user succeeds and is + recorded as `sent`. Never use Tibo's post for this check. + +## Hosted checks + +- [ ] CI passes on macOS, Ubuntu, and Windows with Node 22 and required native + watcher tests. +- [ ] CodeQL passes. +- [ ] secret/polling/attribution verifiers pass in GitHub Actions. +- [ ] Dependabot configuration is active. +- [ ] Branch protection/review settings are considered after the fork exists. +- [ ] GitHub private vulnerability reporting is enabled and available as the + documented private security/conduct route. + +## Documentation and operations + +- [x] English and Chinese READMEs explain authentication and no-reset guarantee. +- [x] Disclaimer, privacy, threat model, security, troubleshooting, provenance, + contributing, code of conduct, and ADRs are present. +- [x] macOS launchd and Linux systemd user services have no timers. +- [x] Windows foreground mode is documented. +- [x] Environment-only X credentials are rejected for managed services. +- [x] Exact data locations, uninstall, and complete removal are documented. + +## Deliberate v0.1 backlog + +- Windows auto-start installer. +- native Codex UsageLimit hook adapter, if an official stable hook becomes + available. +- managed/persistent App Server session mode. +- Codex Plugin packaging. +- tray/menu-bar application. +- official earned-reset redemption. This alpha never calls + `account/rateLimitResetCredit/consume`. +- Homebrew distribution and signed standalone binaries. +- additional X providers. +- web dashboard and remote webhooks. +- npm publication and upgrade channel. +- Support claims for Codex versions other than the tested version. +- Any additional automatic action or target strategy. + +These backlog items are not release blockers when clearly documented. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..cc84d72 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,72 @@ +# Threat model + +## Security goals + +The primary safety goal is preventing an unintended or repeated X write from a +false Codex signal, ambiguous transport result, wrong account, race, restart, +or configuration change. Secondary goals are keeping credentials and raw user +content out of action state, logs, and unsafe output, and avoiding writes +outside owned paths. The private cursor exception for an unfinished rollout +fragment is disclosed in `privacy.md`. + +## Assets + +- the user's X and Codex authenticated sessions; +- the authority to post from the current X account; +- Codex rollout and auth data; +- local config, guard state, cursor integrity, and audit history; +- filesystem and service-manager integrity. + +## Trust boundaries + +- Codex CLI/App Server and its evolving JSON-RPC schema; +- X web GraphQL endpoints and responses; +- Bird and the browser-cookie provider; +- local filesystem events, metadata, and permissions; +- launchd/systemd and the host user account; +- operator-supplied config and live-test URL. + +## Threats and mitigations + +| Threat | Mitigation | +| --- | --- | +| Prompt/tool text imitates a quota error | Classify only allowlisted server-error record paths; no recursive scanning | +| Generic 429 becomes an account-quota action | 429 alone is rejected; App Server bucket confirmation is mandatory | +| Old rollout triggers immediately | First-run cursors start existing files at EOF | +| Coalesced events lose data | Event wakes an offset-to-EOF incremental catch-up | +| Wrong X account posts | Current ID/handle checked during preflight and again immediately before mutation | +| Target spoofing | Target author ID, handle, post shape, recency, and timeline/search evidence validated | +| Live-test URL spoofing | Strict HTTPS host/path plus fetched author ID and current-account ownership check | +| Duplicate event/window/action | Persistent stable hashes and fail-closed state migration | +| Concurrent daemons or CLI write | Exclusive `wx` singleton lock and serialized state machine | +| Crash around POST | `attempting` and `mutationStartedAt` persisted first; restart becomes `unknown` | +| Timeout/redirect/5xx causes replay | One POST, manual redirect handling, no retry/fallback; one read-only verification only | +| Config/consent changes before write | Configuration and authorization reloaded at mutation boundary | +| Excess automated activity | Configured rolling guard and immutable hard maximum of three per 24 hours | +| Cookie/token disclosure | Browser-first in-memory resolution, no secret persistence, allowlisted output, redaction | +| Symlink/path attack | Absolute service paths, regular-file checks, sensitive symlink rejection, atomic rename | +| Runaway subprocess | argv spawn without shell, timeout, output cap, graceful then forced termination | +| Manager/file drift leaves daemon alive | Query manager independently and unload/disable known jobs even if definition is missing | + +## Explicitly out of scope + +The project does not attempt CAPTCHA or anti-bot bypass, stealth/fingerprint +spoofing, proxy rotation, multi-account operation, bulk replies, randomized +spam text, automatic reply deletion, unlimited retry, or platform-safeguard +evasion. It does not protect a fully compromised host or account. + +## Residual risks + +- X can change endpoints, response shapes, terms, or enforcement. +- A valid automatic reply may still be unwanted, duplicated outside this + project's control, removed, rate-limited, or lead to account restriction. +- A compromised browser session, Node dependency, Codex binary, or user account + is outside the process boundary. +- Native filesystem notifications can be unavailable or behave differently on + unusual mounts; the process then stops instead of polling. +- Local users/processes with equivalent account privileges may read or alter + files despite application-level permissions. +- The requested reset may never be acted upon. This tool has no reset authority. + +Review [DISCLAIMER.md](../DISCLAIMER.md) and [SECURITY.md](../SECURITY.md) before +enabling automatic posting. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..74314ab --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,91 @@ +# Troubleshooting + +Start with safe diagnostics: + +```bash +codex-reset-request doctor +codex-reset-request status --json +codex-reset-request service status --json +codex-reset-request logs --tail 100 +``` + +Share only safe codes. Do not paste full rollout lines, prompts, cookies, +authorization headers, browser databases, home paths, or raw GraphQL/App Server +responses into an issue. + +## App Server or Codex failures + +- `binary-not-found`: ensure `codex --version` works for the same user and PATH. +- `codex-version-untested`: the binary parsed, but is not the alpha's tested + `0.140.0`. +- `codex-home-mismatch`: `CRR_CODEX_HOME`, config, and the service definition do + not resolve to the same home; run setup and reinstall the service. +- `initialize-*` or `rate-limits-schema`: the App Server protocol changed or the + response is ambiguous. Upgrade only after compatibility is reviewed; do not + bypass confirmation. +- `timeout` or `process-exited`: run Codex interactively, confirm login, then + retry the read-only doctor check. + +## Native watcher unavailable + +Confirm the resolved sessions directory exists, is a real directory rather +than a symlink, and is readable. Sandboxes, network mounts, file-provider +folders, and containers may not expose native events. Move Codex sessions to a +supported local filesystem or use a compatible host. There is deliberately no +polling fallback. + +## X browser session unavailable + +Log in to `https://x.com` in the configured browser/profile and run `doctor` +from the same OS user. macOS may request keychain/browser access. A managed +service requires browser-readable cookies and rejects an environment-only +credential pair. Never put cookie values in the plist/unit, config, an issue, +or a shell command argument. + +If foreground reads pass but the service fails, stop the service, check OS +permissions for the service context, repeat setup, and reinstall. The service +definition pins absolute paths and does not inherit shell aliases. + +## Service lifecycle + +- `service-status-unavailable`: launchctl/systemd could not be queried; this is + different from an installed-but-stopped service. +- `service-running-definition-missing`: the manager still has a live job but + its definition disappeared. `service stop` or `service uninstall` will target + the known job without deleting unrelated files. +- `service-installed-stopped`: use `service start` and inspect safe service logs. +- after moving Node or the checkout: rebuild and run `service install` again. +- Linux user services require a working user systemd manager/session. +- Windows v0.1 must use foreground `watch`. + +No service command creates a timer or cron entry. + +## Action did not post + +This can be correct. Check the latest safe code for dry-run mode, confirmation +failure, target ambiguity, account mismatch, revoked consent, +same event/window/action, rolling limit, or hard limit. Do not clear state to +force a post. The project cannot guarantee a target post or a reset response. + +## Action is `unknown` + +Do not retry. `unknown` means transport may have reached X but a unique result +could not be proven. The state permanently occupies the relevant guards. Check +the current account manually and leave the record intact. + +## State file too large + +JSON data is capped at 4 MiB before atomic replacement, so the last valid file +remains in place when a write would exceed the ceiling. Stop the watcher and +uninstall/stop the service, make a complete private archive of the state +directory, and inspect safe metadata. Guard records are intentionally never +auto-evicted. Removing or editing them can enable duplicate writes; do so only +as an explicit operator decision with auto mode disabled. + +## Live test refused + +`x-read` requires `CRR_LIVE_X=1`. `x-reply` also requires `--live`, a strict +user-owned status URL, a setup-pinned account match, fetched author ownership, +the singleton lock, and available rolling capacity. CI and `@thsottiaux` are +always rejected. Stop the watcher before a live write test and use a post owned +by the current account. diff --git a/env.template b/env.template index d94d21b..0ad8647 100644 --- a/env.template +++ b/env.template @@ -1,5 +1,8 @@ -export AUTH_TOKEN="YOUR_AUTH_TOKEN_HERE" -export CT0="YOUR_CT0_TOKEN_HERE" -export GUEST_ID="YOUR_GUEST_ID_HERE" -export TWID="YOUR_TWID_HERE" +# Optional foreground Bird compatibility only. Prefer an existing browser +# session. Codex Reset Request managed services reject environment-only X +# credentials and never embed these values in service definitions. +export AUTH_TOKEN="" +export CT0="" +export GUEST_ID="" +export TWID="" export LANG="en" diff --git a/package.json b/package.json index 4d737bc..bd1958f 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,69 @@ { - "name": "@steipete/bird", - "version": "0.9.0", - "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", + "name": "codex-reset-request", + "version": "0.1.0-alpha.0", + "description": "Event-driven Codex usage limits and rate-limit monitor with an optional guarded Bird-powered X reset request.", + "private": true, "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "license": "MIT", + "keywords": [ + "codex", + "codex-cli", + "openai-codex", + "codex-usage-limits", + "codex-rate-limit", + "usage-limit-monitor", + "rate-limit-monitor", + "event-driven", + "bird", + "x-automation" + ], + "homepage": "https://github.com/ncihxaonn/codex-reset-request#readme", + "bugs": { + "url": "https://github.com/ncihxaonn/codex-reset-request/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ncihxaonn/codex-reset-request.git" + }, + "main": "dist/lib/index.js", + "types": "dist/lib/index.d.ts", "bin": { - "bird": "dist/cli.js" + "bird": "dist/cli.js", + "codex-reset-request": "dist/reset/cli.js" }, "files": [ "dist", "README.md", "CHANGELOG.md", - "LICENSE" + "LICENSE", + "DISCLAIMER.md", + "UPSTREAM.md", + "THIRD_PARTY_NOTICES.md" ], "scripts": { - "build": "pnpm run build:dist && pnpm run build:binary", - "build:dist": "tsc && node scripts/copy-dist-assets.js", - "build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/cli.ts --outfile bird", - "dev": "tsx src/index.ts", + "build": "pnpm run build:dist", + "build:dist": "node scripts/build-dist.js", + "build:binary": "node --import tsx scripts/build-binary.ts", + "dev": "tsx src/cli.ts", "bird": "pnpm run build:dist && node dist/cli.js", "test": "vitest run", "test:watch": "vitest", - "test:live": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live.test.ts", - "test:live:all": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live-all.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.oxlint.json", + "test:x-read": "pnpm run build:dist && node dist/reset/cli.js test x-read", "lint": "pnpm run lint:biome && pnpm run lint:oxlint", - "lint:biome": "biome check .", - "lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts", + "lint:biome": "biome lint src tests scripts", + "lint:oxlint": "oxlint --import-plugin --node-plugin --vitest-plugin -A vitest/no-conditional-expect -A vitest/require-mock-type-parameters -A vitest/require-to-throw-message --deny-warnings src tests scripts", "lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix", - "lint:biome:fix": "biome check --write .", - "lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts", - "format": "biome format --write .", + "lint:biome:fix": "biome lint --write src tests scripts", + "lint:oxlint:fix": "oxlint --import-plugin --node-plugin --vitest-plugin -A vitest/no-conditional-expect -A vitest/require-mock-type-parameters -A vitest/require-to-throw-message --deny-warnings --fix src tests scripts", + "format": "biome format --write src tests scripts", "binary": "pnpm run build:binary", + "codex:schemas": "node --import tsx scripts/generate-codex-schemas.ts", + "codex:schemas:check": "node --import tsx scripts/generate-codex-schemas.ts --check", + "verify:no-polling": "node --import tsx scripts/verify-no-polling.ts --check", + "verify:no-secrets": "node --import tsx scripts/verify-no-secrets.ts --check", + "verify:attribution": "node --import tsx scripts/verify-attribution.ts --check", + "verify:release": "pnpm run verify:no-polling && pnpm run verify:no-secrets && pnpm run verify:attribution", "graphql:update": "tsx scripts/update-query-ids.ts" }, "dependencies": { @@ -39,13 +71,10 @@ "commander": "^14.0.2", "json5": "^2.2.3", "kleur": "^4.1.5", - "x-client-transaction-id": "^0.3.1" - }, - "pnpm": { - "patchedDependencies": { - "@steipete/sweet-cookie": "patches/@steipete__sweet-cookie.patch" - } + "x-client-transaction-id": "^0.3.1", + "zod": "^4.4.3" }, + "packageManager": "pnpm@11.23.0", "devDependencies": { "@biomejs/biome": "^2.3.10", "@types/node": "^25.0.3", @@ -59,4 +88,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df5f78a..03ff208 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: x-client-transaction-id: specifier: ^0.3.1 version: 0.3.1 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@biomejs/biome': specifier: ^2.3.10 @@ -1082,6 +1085,9 @@ packages: x-client-transaction-id@0.3.1: resolution: {integrity: sha512-fD9YtDswTL3VyG/z7FbZUfMu5CUsvGmKk33fM3b9eOXe0Wnmj8W55UKLPA5Z/cuq6WrWTxXQhLcV3GGCD6mKaQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@babel/helper-string-parser@7.29.7': {} @@ -1862,3 +1868,5 @@ snapshots: linkedom: 0.18.13 transitivePeerDependencies: - canvas + + zod@4.4.3: {} diff --git a/scripts/build-binary.ts b/scripts/build-binary.ts new file mode 100644 index 0000000..33ed7ed --- /dev/null +++ b/scripts/build-binary.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import { runBoundedCommand } from '../src/reset/utils/process.js'; + +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { + version?: unknown; +}; +const version = typeof packageJson.version === 'string' ? packageJson.version : 'unknown'; +const git = await runBoundedCommand('git', ['rev-parse', '--short=8', 'HEAD'], { timeoutMs: 5_000 }); +const gitSha = git.ok && /^[0-9a-f]{8}$/i.test(git.stdout) ? git.stdout : 'unknown'; +const result = await runBoundedCommand( + 'bun', + ['build', '--compile', '--minify', '--env=BIRD_*', 'src/cli.ts', '--outfile', 'bird'], + { + timeoutMs: 120_000, + environment: { ...process.env, BIRD_VERSION: version, BIRD_GIT_SHA: gitSha }, + }, +); + +if (!result.ok) { + console.error(`build-binary:${result.safeCode ?? 'bun-build-failed'}`); + process.exitCode = 1; +} else { + console.log(JSON.stringify({ ok: true, code: 'bird-binary-built', output: 'bird' })); +} diff --git a/scripts/build-dist.js b/scripts/build-dist.js new file mode 100644 index 0000000..cf4249a --- /dev/null +++ b/scripts/build-dist.js @@ -0,0 +1,39 @@ +import { lstat, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const distDirectory = path.join(projectRoot, 'dist'); + +const distState = await lstat(distDirectory).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; +}); +if (distState && (!distState.isDirectory() || distState.isSymbolicLink())) { + throw new Error('Refusing to replace an unsafe dist path'); +} +if (distState) { + await rm(distDirectory, { recursive: true }); +} + +async function run(command, args) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: projectRoot, + stdio: 'inherit', + shell: false, + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Build subprocess failed (${signal ?? code ?? 'unknown'})`)); + } + }); + }); +} + +await run(process.execPath, [path.join(projectRoot, 'node_modules', 'typescript', 'bin', 'tsc')]); +await run(process.execPath, [path.join(projectRoot, 'scripts', 'copy-dist-assets.js')]); diff --git a/scripts/copy-dist-assets.js b/scripts/copy-dist-assets.js new file mode 100644 index 0000000..ddf522d --- /dev/null +++ b/scripts/copy-dist-assets.js @@ -0,0 +1,23 @@ +import { access, chmod, copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const sourceLib = path.join(projectRoot, 'src', 'lib'); +const distLib = path.join(projectRoot, 'dist', 'lib'); + +await mkdir(distLib, { recursive: true }); + +for (const asset of ['features.json', 'query-ids.json']) { + await copyFile(path.join(sourceLib, asset), path.join(distLib, asset)); +} + +for (const relativeCli of ['cli.js', path.join('reset', 'cli.js')]) { + const cliPath = path.join(projectRoot, 'dist', relativeCli); + try { + await access(cliPath); + await chmod(cliPath, 0o755); + } catch { + // The reset CLI is added in a later implementation commit. + } +} diff --git a/scripts/generate-codex-schemas.ts b/scripts/generate-codex-schemas.ts new file mode 100644 index 0000000..c648028 --- /dev/null +++ b/scripts/generate-codex-schemas.ts @@ -0,0 +1,356 @@ +import { randomUUID } from 'node:crypto'; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCodexVersion } from '../src/reset/codex/compatibility.js'; +import { runBoundedCommand } from '../src/reset/utils/process.js'; + +type JsonObject = Record; + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); +const generatedRoot = path.join(repositoryRoot, '.tmp'); +const typescriptTarget = path.join(generatedRoot, 'codex-schema'); +const jsonTarget = path.join(generatedRoot, 'codex-json-schema'); +const markerName = '.codex-reset-request-generated'; +const markerValue = 'codex-reset-request-schema-v1\n'; + +class SafeSchemaError extends Error { + constructor(readonly code: string) { + super(code); + } +} + +function object(value: unknown): JsonObject | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : null; +} + +function array(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function includesStrings(value: unknown, required: string[]): boolean { + const strings = array(value).filter((item): item is string => typeof item === 'string'); + return required.every((item) => strings.includes(item)); +} + +async function json(filePath: string): Promise { + try { + const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown; + const record = object(parsed); + if (!record) throw new SafeSchemaError('schema-json-shape'); + return record; + } catch (error) { + if (error instanceof SafeSchemaError) throw error; + throw new SafeSchemaError('schema-json-unreadable'); + } +} + +async function validateGeneratedSchemas(typescriptDirectory: string, jsonDirectory: string): Promise { + const clientRequest = await json(path.join(jsonDirectory, 'ClientRequest.json')); + const hasRateLimitRequest = array(clientRequest.oneOf).some((candidate) => { + const branch = object(candidate); + const properties = object(branch?.properties); + const method = object(properties?.method); + const params = object(properties?.params); + return ( + includesStrings(branch?.required, ['id', 'method']) && + method?.type === 'string' && + includesStrings(method.enum, ['account/rateLimits/read']) && + params?.type === 'null' + ); + }); + if (!hasRateLimitRequest) throw new SafeSchemaError('client-request-schema-incompatible'); + + const initialize = await json(path.join(jsonDirectory, 'v1', 'InitializeResponse.json')); + const initializeProperties = object(initialize.properties); + const initializeDefinitions = object(initialize.definitions); + const absolutePath = object(initializeDefinitions?.AbsolutePathBuf); + const codexHome = object(initializeProperties?.codexHome); + const codexHomeRef = object(array(codexHome?.allOf)[0]); + if ( + !includesStrings(initialize.required, ['codexHome', 'platformFamily', 'platformOs', 'userAgent']) || + object(initializeProperties?.userAgent)?.type !== 'string' || + object(initializeProperties?.platformFamily)?.type !== 'string' || + object(initializeProperties?.platformOs)?.type !== 'string' || + codexHomeRef?.$ref !== '#/definitions/AbsolutePathBuf' || + absolutePath?.type !== 'string' + ) { + throw new SafeSchemaError('initialize-schema-incompatible'); + } + + const rateLimits = await json(path.join(jsonDirectory, 'v2', 'GetAccountRateLimitsResponse.json')); + const rateProperties = object(rateLimits.properties); + const rateDefinitions = object(rateLimits.definitions); + const primaryRateLimits = object(rateProperties?.rateLimits); + const primaryRateLimitRef = object(array(primaryRateLimits?.allOf)[0]); + const byLimitId = object(rateProperties?.rateLimitsByLimitId); + const additionalProperties = object(byLimitId?.additionalProperties); + const snapshot = object(rateDefinitions?.RateLimitSnapshot); + const snapshotProperties = object(snapshot?.properties); + const window = object(rateDefinitions?.RateLimitWindow); + const windowProperties = object(window?.properties); + const expectedSnapshotProperties = [ + 'limitId', + 'limitName', + 'primary', + 'secondary', + 'credits', + 'individualLimit', + 'planType', + 'rateLimitReachedType', + ]; + if ( + !includesStrings(rateLimits.required, ['rateLimits']) || + primaryRateLimitRef?.$ref !== '#/definitions/RateLimitSnapshot' || + !includesStrings(byLimitId?.type, ['object', 'null']) || + additionalProperties?.$ref !== '#/definitions/RateLimitSnapshot' || + !expectedSnapshotProperties.every((property) => Object.hasOwn(snapshotProperties ?? {}, property)) || + !includesStrings(window?.required, ['usedPercent']) || + !['usedPercent', 'windowDurationMins', 'resetsAt'].every((property) => + Object.hasOwn(windowProperties ?? {}, property), + ) || + object(windowProperties?.usedPercent)?.type !== 'integer' || + !includesStrings(object(windowProperties?.resetsAt)?.type, ['integer', 'null']) + ) { + throw new SafeSchemaError('rate-limit-schema-incompatible'); + } + + const [clientRequestType, initializeType, rateLimitsType] = await Promise.all([ + readFile(path.join(typescriptDirectory, 'ClientRequest.ts'), 'utf8'), + readFile(path.join(typescriptDirectory, 'InitializeResponse.ts'), 'utf8'), + readFile(path.join(typescriptDirectory, 'v2', 'GetAccountRateLimitsResponse.ts'), 'utf8'), + ]).catch(() => { + throw new SafeSchemaError('schema-typescript-unreadable'); + }); + const generatedHeader = 'GENERATED CODE! DO NOT MODIFY BY HAND!'; + if ( + !clientRequestType.includes(generatedHeader) || + !clientRequestType.includes('"method": "account/rateLimits/read"') || + !initializeType.includes(generatedHeader) || + !/userAgent:\s*string/.test(initializeType) || + !/codexHome:\s*AbsolutePathBuf/.test(initializeType) || + !/platformFamily:\s*string/.test(initializeType) || + !/platformOs:\s*string/.test(initializeType) || + !rateLimitsType.includes(generatedHeader) || + !/rateLimits:\s*RateLimitSnapshot/.test(rateLimitsType) || + !/rateLimitsByLimitId:\s*\{\s*\[key in string\]\?:\s*RateLimitSnapshot\s*\}\s*\|\s*null/.test(rateLimitsType) + ) { + throw new SafeSchemaError('schema-typescript-incompatible'); + } +} + +async function generatedFiles(directory: string, prefix = ''): Promise { + const output: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === markerName) continue; + const relative = prefix ? path.posix.join(prefix, entry.name) : entry.name; + const filePath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw new SafeSchemaError('schema-tree-symlink'); + if (entry.isDirectory()) output.push(...(await generatedFiles(filePath, relative))); + else if (entry.isFile()) output.push(relative); + } + return output.sort(); +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson); + const record = object(value); + if (!record) return value; + return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonicalJson(record[key])])); +} + +async function canonicalFile(directory: string, relative: string): Promise { + const value = await readFile(path.join(directory, ...relative.split('/')), 'utf8'); + if (relative.endsWith('.json')) { + return Buffer.from(`${JSON.stringify(canonicalJson(JSON.parse(value) as unknown), null, 2)}\n`, 'utf8'); + } + return Buffer.from(`${value.replaceAll('\r\n', '\n').replace(/\n*$/, '')}\n`, 'utf8'); +} + +async function treesEqual(generated: string, existing: string): Promise { + const marker = await readFile(path.join(existing, markerName), 'utf8').catch(() => ''); + if (marker !== markerValue) return false; + const generatedList = await generatedFiles(generated); + const existingList = await generatedFiles(existing); + if (generatedList.length !== existingList.length || generatedList.some((file, index) => file !== existingList[index])) { + return false; + } + for (const file of generatedList) { + const [left, right] = await Promise.all([canonicalFile(generated, file), canonicalFile(existing, file)]); + if (!left.equals(right)) return false; + } + return true; +} + +async function prepareStage(source: string, label: string): Promise { + const stage = path.join(generatedRoot, `.${label}-next-${randomUUID()}`); + try { + await cp(source, stage, { recursive: true, force: false, errorOnExist: true }); + await writeFile(path.join(stage, markerName), markerValue, { encoding: 'utf8', mode: 0o600 }); + return stage; + } catch (error) { + await rm(stage, { recursive: true, force: true }); + throw error; + } +} + +interface GeneratedReplacement { + stage: string; + target: string; + backup: string | null; + backedUp: boolean; + installed: boolean; +} + +async function preflightGeneratedTarget(stage: string, target: string): Promise { + const staged = await lstat(stage).catch(() => null); + if (!staged?.isDirectory() || staged.isSymbolicLink()) { + throw new SafeSchemaError('schema-stage-unsafe'); + } + const stageMarker = await readFile(path.join(stage, markerName), 'utf8').catch(() => ''); + if (stageMarker !== markerValue) throw new SafeSchemaError('schema-stage-not-owned'); + const current = await lstat(target).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (current && (!current.isDirectory() || current.isSymbolicLink())) { + throw new SafeSchemaError('schema-target-unsafe'); + } + if (current) { + const marker = await readFile(path.join(target, markerName), 'utf8').catch(() => ''); + if (marker !== markerValue) throw new SafeSchemaError('schema-target-not-owned'); + } + return { + stage, + target, + backup: current ? `${target}.old-${randomUUID()}` : null, + backedUp: false, + installed: false, + }; +} + +async function rollbackGeneratedReplacements(replacements: GeneratedReplacement[]): Promise { + let rollbackFailed = false; + for (const replacement of [...replacements].reverse()) { + if (replacement.installed) { + await rm(replacement.target, { recursive: true, force: true }).catch(() => { + rollbackFailed = true; + }); + replacement.installed = false; + } + if (replacement.backedUp && replacement.backup) { + await rename(replacement.backup, replacement.target).catch(() => { + rollbackFailed = true; + }); + replacement.backedUp = false; + } + } + if (rollbackFailed) throw new SafeSchemaError('schema-rollback-failed'); +} + +export async function replaceGeneratedDirectories( + stages: Array<{ stage: string; target: string }>, +): Promise { + // Validate the whole pair before moving either current tree. Backups are kept + // until both new trees are installed so a failure cannot leave mixed versions. + const replacements = await Promise.all( + stages.map(async ({ stage, target }) => await preflightGeneratedTarget(stage, target)), + ); + try { + for (const replacement of replacements) { + if (!replacement.backup) continue; + await rename(replacement.target, replacement.backup); + replacement.backedUp = true; + } + for (const replacement of replacements) { + await rename(replacement.stage, replacement.target); + replacement.installed = true; + } + } catch (error) { + await rollbackGeneratedReplacements(replacements); + throw error; + } + await Promise.all( + replacements.map(async ({ backup }) => { + if (backup) await rm(backup, { recursive: true }); + }), + ); +} + +async function generateInto(typescriptDirectory: string, jsonDirectory: string): Promise { + const versionResult = await runBoundedCommand('codex', ['--version'], { timeoutMs: 10_000 }); + const version = versionResult.ok ? parseCodexVersion(versionResult.stdout)?.rawVersion : null; + if (!version) throw new SafeSchemaError(versionResult.safeCode ?? 'codex-version-unavailable'); + for (const args of [ + ['app-server', 'generate-ts', '--out', typescriptDirectory], + ['app-server', 'generate-json-schema', '--out', jsonDirectory], + ]) { + const result = await runBoundedCommand('codex', args, { timeoutMs: 120_000, maxOutputBytes: 64 * 1_024 }); + if (!result.ok) throw new SafeSchemaError(result.safeCode ?? 'codex-schema-command-failed'); + } + return version; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const check = args.length === 1 && args[0] === '--check'; + if (args.length > 1 || (args.length === 1 && !check)) { + console.error('usage: pnpm run codex:schemas | pnpm run codex:schemas:check'); + process.exitCode = 2; + return; + } + + const temporaryRoot = await mkdtemp(path.join(tmpdir(), 'codex-reset-request-schema-')); + const temporaryTypescript = path.join(temporaryRoot, 'typescript'); + const temporaryJson = path.join(temporaryRoot, 'json'); + let stages: string[] = []; + try { + const version = await generateInto(temporaryTypescript, temporaryJson); + await validateGeneratedSchemas(temporaryTypescript, temporaryJson); + if (check) { + const matches = + (await treesEqual(temporaryTypescript, typescriptTarget).catch(() => false)) && + (await treesEqual(temporaryJson, jsonTarget).catch(() => false)); + if (!matches) throw new SafeSchemaError('generated-schema-drift'); + console.log(JSON.stringify({ ok: true, code: 'generated-schemas-current', codexVersion: version })); + return; + } + + const rootState = await lstat(generatedRoot).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (rootState && (!rootState.isDirectory() || rootState.isSymbolicLink())) { + throw new SafeSchemaError('generated-root-unsafe'); + } + await mkdir(generatedRoot, { recursive: true, mode: 0o700 }); + const typescriptStage = await prepareStage(temporaryTypescript, 'codex-schema'); + stages = [typescriptStage]; + const jsonStage = await prepareStage(temporaryJson, 'codex-json-schema'); + stages.push(jsonStage); + await replaceGeneratedDirectories([ + { stage: typescriptStage, target: typescriptTarget }, + { stage: jsonStage, target: jsonTarget }, + ]); + stages = []; + console.log( + JSON.stringify({ + ok: true, + code: 'generated-schemas-updated', + codexVersion: version, + typescriptDirectory: '.tmp/codex-schema', + jsonDirectory: '.tmp/codex-json-schema', + }), + ); + } catch (error) { + const code = error instanceof SafeSchemaError ? error.code : 'schema-generation-failed'; + console.error(`generate-codex-schemas:${code}`); + process.exitCode = 1; + } finally { + await Promise.all(stages.map(async (stage) => await rm(stage, { recursive: true, force: true }))); + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/inspect-codex-rollout.ts b/scripts/inspect-codex-rollout.ts new file mode 100644 index 0000000..0612185 --- /dev/null +++ b/scripts/inspect-codex-rollout.ts @@ -0,0 +1,67 @@ +import { createReadStream } from 'node:fs'; +import { createInterface } from 'node:readline'; + +const filePath = process.argv[2]; +if (!filePath) { + console.error('Usage: tsx scripts/inspect-codex-rollout.ts '); + process.exitCode = 2; +} else { + const topLevelTypes = new Set(); + const payloadTypes = new Set(); + const eventTypes = new Set(); + const topLevelFields = new Set(); + const payloadFields = new Set(); + let hasErrorMetadata = false; + let inspected = 0; + + const lines = createInterface({ input: createReadStream(filePath, { encoding: 'utf8' }), crlfDelay: Number.POSITIVE_INFINITY }); + for await (const line of lines) { + if (inspected >= 10_000) { + break; + } + inspected += 1; + try { + const record = JSON.parse(line) as Record; + for (const field of Object.keys(record)) { + topLevelFields.add(field); + } + if (typeof record.type === 'string') { + topLevelTypes.add(record.type); + } + const payload = + record.payload && typeof record.payload === 'object' && !Array.isArray(record.payload) + ? (record.payload as Record) + : null; + if (payload) { + for (const field of Object.keys(payload)) { + payloadFields.add(field); + } + if (typeof payload.type === 'string') { + payloadTypes.add(payload.type); + } + if (typeof payload.event_type === 'string') { + eventTypes.add(payload.event_type); + } + hasErrorMetadata ||= 'codex_error_info' in payload || 'codexErrorInfo' in payload; + } + } catch { + // Invalid lines are counted but never printed. + } + } + + console.log( + JSON.stringify( + { + inspectedRecords: inspected, + topLevelRecordTypes: [...topLevelTypes].sort(), + payloadTypes: [...payloadTypes].sort(), + eventTypes: [...eventTypes].sort(), + topLevelFields: [...topLevelFields].sort(), + payloadFields: [...payloadFields].sort(), + hasErrorMetadata, + }, + null, + 2, + ), + ); +} diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index edc6552..6663f3e 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -138,8 +138,11 @@ function extractOperations( ): void { for (const pattern of OPERATION_PATTERNS) { pattern.regex.lastIndex = 0; // reset stateful regex - let match: RegExpExecArray | null; - while ((match = pattern.regex.exec(bundleContents)) !== null) { + while (true) { + const match = pattern.regex.exec(bundleContents); + if (match === null) { + break; + } const operationName = match[pattern.operationGroup]; const queryId = match[pattern.queryIdGroup]; if (!operationName || !queryId) continue; diff --git a/scripts/verify-attribution.ts b/scripts/verify-attribution.ts new file mode 100644 index 0000000..222bacb --- /dev/null +++ b/scripts/verify-attribution.ts @@ -0,0 +1,139 @@ +import { spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); +const baseCommit = 'a16f9901717008bf1ab3ea0b715dfd95dedc95b0'; +const baseTag = 'upstream-bird-v3-base'; + +interface Check { + code: string; + ok: boolean; +} + +function git(args: string[]) { + return spawnSync('git', args, { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: 4 * 1_024 * 1_024, + shell: false, + windowsHide: true, + }); +} + +async function text(file: string): Promise { + return await readFile(path.join(repositoryRoot, file), 'utf8'); +} + +function includesAll(value: string, fragments: string[]): boolean { + return fragments.every((fragment) => value.includes(fragment)); +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) { + console.error('usage: pnpm run verify:attribution'); + process.exitCode = 2; + return; + } + + const [license, upstream, notices, readme, chineseReadme, packageRaw] = await Promise.all([ + text('LICENSE'), + text('UPSTREAM.md'), + text('THIRD_PARTY_NOTICES.md'), + text('README.md'), + text('README.zh-CN.md'), + text('package.json'), + ]); + const packageJson = JSON.parse(packageRaw) as { + name?: unknown; + version?: unknown; + private?: unknown; + license?: unknown; + bin?: Record; + files?: unknown; + }; + const packagedFiles = Array.isArray(packageJson.files) ? packageJson.files : []; + const checks: Check[] = [ + { + code: 'license-notices', + ok: includesAll(license, [ + 'MIT License', + 'Copyright (c) 2024 Peter Steinberger (steipete)', + 'Copyright (c) 2025 Peter Steinberger', + 'Copyright (c) 2026 0xEnc0der', + 'Copyright (c) 2026 Codex Reset Request contributors', + 'Permission is hereby granted, free of charge', + 'The above copyright notice and this permission notice shall be included', + 'THE SOFTWARE IS PROVIDED "AS IS"', + ]), + }, + { + code: 'upstream-provenance', + ok: includesAll(upstream, [ + 'https://github.com/0xEnc0der/bird-x-cli', + 'https://github.com/zaydiscold/bird', + 'https://github.com/jawond/bird', + 'Peter Steinberger', + baseCommit, + baseTag, + 'Base package version: `0.9.0`', + 'Base README version: `Bird CLI v3.0.0`', + 'Date forked: `2026-08-28`', + ]), + }, + { + code: 'third-party-notices', + ok: includesAll(notices, [ + 'Peter Steinberger', + 'zaydiscold/bird', + '0xEnc0der/bird-x-cli', + 'MIT License', + 'independent project', + 'not endorsed', + ]), + }, + { + code: 'readme-attribution', + ok: [readme, chineseReadme].every((value) => + includesAll(value, ['0xEnc0der/bird-x-cli', 'zaydiscold/bird', 'jawond/bird', 'UPSTREAM.md', 'THIRD_PARTY_NOTICES.md', 'LICENSE']), + ), + }, + { + code: 'package-identity', + ok: + packageJson.name === 'codex-reset-request' && + packageJson.version === '0.1.0-alpha.0' && + packageJson.private === true && + packageJson.license === 'MIT' && + packageJson.bin?.bird === 'dist/cli.js' && + packageJson.bin?.['codex-reset-request'] === 'dist/reset/cli.js', + }, + { + code: 'packaged-attribution', + ok: ['LICENSE', 'README.md', 'DISCLAIMER.md', 'UPSTREAM.md', 'THIRD_PARTY_NOTICES.md'].every((file) => + packagedFiles.includes(file), + ), + }, + ]; + + const shallow = git(['rev-parse', '--is-shallow-repository']); + checks.push({ code: 'full-git-history', ok: shallow.status === 0 && shallow.stdout.trim() === 'false' }); + const baseObject = git(['cat-file', '-e', `${baseCommit}^{commit}`]); + checks.push({ code: 'base-object', ok: baseObject.status === 0 }); + const tag = git(['rev-parse', `${baseTag}^{commit}`]); + checks.push({ code: 'base-tag', ok: tag.status === 0 && tag.stdout.trim() === baseCommit }); + const ancestor = git(['merge-base', '--is-ancestor', baseCommit, 'HEAD']); + checks.push({ code: 'base-ancestry', ok: ancestor.status === 0 }); + + const failed = checks.filter((check) => !check.ok).map((check) => check.code).sort(); + if (failed.length > 0) { + for (const code of failed) console.error(`repository:1:${code}`); + process.exitCode = 1; + return; + } + console.log('verify-attribution: ok'); +} + +await main(); diff --git a/scripts/verify-no-polling.ts b/scripts/verify-no-polling.ts new file mode 100644 index 0000000..16f3dc0 --- /dev/null +++ b/scripts/verify-no-polling.ts @@ -0,0 +1,389 @@ +import type { Dirent } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import type { ResetRequestPaths } from '../src/reset/config/paths.js'; +import { renderLaunchAgent } from '../src/reset/service/launchd.js'; +import { renderSystemdUnit } from '../src/reset/service/systemd.js'; + +interface Finding { + file: string; + line: number; + code: string; +} + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); +const excludedRepositoryDirectories = new Set(['.git', '.tmp', 'coverage', 'dist', 'node_modules', '.pnpm-store']); +const productionScriptExtensions = new Set(['.cjs', '.js', '.mjs', '.ts', '.tsx']); + +function relativePath(filePath: string): string { + return path.relative(repositoryRoot, filePath).split(path.sep).join('/'); +} + +async function productionFiles(directory: string): Promise { + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await productionFiles(entryPath))); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files.sort(); +} + +async function repositoryFiles(directory = repositoryRoot): Promise { + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (excludedRepositoryDirectories.has(entry.name)) continue; + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await repositoryFiles(entryPath))); + else if (entry.isFile()) files.push(entryPath); + } + return files.sort(); +} + +function lineOf(source: ts.SourceFile, node: ts.Node): number { + return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; +} + +function callName(expression: ts.LeftHandSideExpression): string | null { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + return null; +} + +function propertyNameText(name: ts.PropertyName | undefined): string | null { + if (!name) return null; + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; + if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) return name.expression.text; + return null; +} + +function assignedPropertyName(expression: ts.Expression): string | null { + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + if (ts.isElementAccessExpression(expression) && expression.argumentExpression) { + return ts.isStringLiteralLike(expression.argumentExpression) ? expression.argumentExpression.text : null; + } + return null; +} + +interface BooleanBinding { + value: boolean | null; +} + +function literalBoolean(expression: ts.Expression | undefined): boolean | null { + if (!expression) return null; + if (expression.kind === ts.SyntaxKind.TrueKeyword) return true; + if (expression.kind === ts.SyntaxKind.FalseKeyword) return false; + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) || + ts.isTypeAssertionExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isNonNullExpression(expression) + ) { + return literalBoolean(expression.expression); + } + return null; +} + +function declarationListBinding( + declarations: ts.VariableDeclarationList, + name: string, +): BooleanBinding | undefined { + for (const declaration of declarations.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue; + const isConst = (declarations.flags & ts.NodeFlags.Const) !== 0; + return { value: isConst ? literalBoolean(declaration.initializer) : null }; + } + return undefined; +} + +function statementListBinding(statements: readonly ts.Statement[], name: string): BooleanBinding | undefined { + for (const statement of statements) { + if (ts.isVariableStatement(statement)) { + const binding = declarationListBinding(statement.declarationList, name); + if (binding) return binding; + } + if ( + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && + statement.name?.text === name + ) { + return { value: null }; + } + } + return undefined; +} + +function lexicalBooleanBinding(identifier: ts.Identifier): boolean | null { + const name = identifier.text; + for (let ancestor: ts.Node | undefined = identifier.parent; ancestor; ancestor = ancestor.parent) { + let binding: BooleanBinding | undefined; + if (ts.isBlock(ancestor) || ts.isSourceFile(ancestor) || ts.isModuleBlock(ancestor)) { + binding = statementListBinding(ancestor.statements, name); + } else if (ts.isCaseBlock(ancestor)) { + binding = statementListBinding( + ancestor.clauses.flatMap((clause) => [...clause.statements]), + name, + ); + } else if (ts.isFunctionLike(ancestor)) { + const parameter = ancestor.parameters.find( + (candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name, + ); + if (parameter) binding = { value: literalBoolean(parameter.initializer) }; + } else if (ts.isForStatement(ancestor) && ancestor.initializer && ts.isVariableDeclarationList(ancestor.initializer)) { + binding = declarationListBinding(ancestor.initializer, name); + } else if ( + (ts.isForInStatement(ancestor) || ts.isForOfStatement(ancestor)) && + ts.isVariableDeclarationList(ancestor.initializer) + ) { + binding = declarationListBinding(ancestor.initializer, name); + } else if (ts.isCatchClause(ancestor)) { + const catchName = ancestor.variableDeclaration?.name; + if (catchName && ts.isIdentifier(catchName) && catchName.text === name) binding = { value: null }; + } + if (binding) return binding.value; + } + return null; +} + +function isProvablyTrue(expression: ts.Expression): boolean { + const literal = literalBoolean(expression); + if (literal !== null) return literal; + return ts.isIdentifier(expression) && lexicalBooleanBinding(expression) === true; +} + +function loopContainsWait(loop: ts.IterationStatement): boolean { + let containsAwait = false; + let containsWaitCall = false; + const inspect = (node: ts.Node) => { + if (node !== loop && ts.isFunctionLike(node)) return; + if (ts.isAwaitExpression(node)) containsAwait = true; + if (ts.isCallExpression(node) && ['setTimeout', 'sleep'].includes(callName(node.expression) ?? '')) { + containsWaitCall = true; + } + ts.forEachChild(node, inspect); + }; + ts.forEachChild(loop, inspect); + return containsAwait && containsWaitCall; +} + +export function inspectProductionSource( + filePath: string, + value: string, +): { findings: Finding[]; rateLimitReads: number[] } { + const file = relativePath(filePath); + const extension = path.extname(filePath).toLowerCase(); + const scriptKind = extension === '.tsx' ? ts.ScriptKind.TSX : extension === '.ts' ? ts.ScriptKind.TS : ts.ScriptKind.JS; + const source = ts.createSourceFile(file, value, ts.ScriptTarget.Latest, true, scriptKind); + const findings: Finding[] = []; + const rateLimitReads: number[] = []; + const add = (node: ts.Node, code: string) => findings.push({ file, line: lineOf(source, node), code }); + + const visit = (node: ts.Node) => { + if (ts.isIdentifier(node)) { + if (node.text === 'setInterval') add(node, 'periodic-interval-reference'); + if (node.text === 'watchFile') add(node, 'filesystem-polling-reference'); + if (node.text === 'cron' || node.text === 'crontab') add(node, 'cron-runtime-reference'); + } + if ( + ts.isPropertyAssignment(node) && + propertyNameText(node.name) === 'usePolling' && + isProvablyTrue(node.initializer) + ) { + add(node, 'polling-option-enabled'); + } + if ( + ts.isShorthandPropertyAssignment(node) && + node.name.text === 'usePolling' && + lexicalBooleanBinding(node.name) === true + ) { + add(node, 'polling-option-enabled'); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + assignedPropertyName(node.left) === 'usePolling' && + isProvablyTrue(node.right) + ) { + add(node, 'polling-option-enabled'); + } + if (file.startsWith('src/reset/') && ts.isIterationStatement(node, false) && loopContainsWait(node)) { + add(node, 'timeout-or-sleep-loop'); + } + if (ts.isStringLiteralLike(node)) { + if (node.text === 'setInterval') add(node, 'periodic-interval-reference'); + if (node.text === 'watchFile') add(node, 'filesystem-polling-reference'); + const literalRules: Array<[RegExp, string]> = [ + [/StartInterval/i, 'launchd-start-interval'], + [/CalendarInterval/i, 'launchd-calendar-interval'], + [/OnCalendar\s*=/i, 'systemd-calendar-trigger'], + [/OnUnitActiveSec\s*=/i, 'systemd-active-timer'], + [/\bcron(?:tab)?\b/i, 'cron-runtime-string'], + ]; + for (const [pattern, code] of literalRules) { + if (pattern.test(node.text)) add(node, code); + } + if (node.text === 'account/rateLimits/read') rateLimitReads.push(lineOf(source, node)); + } + ts.forEachChild(node, visit); + }; + visit(source); + return { findings, rateLimitReads }; +} + +function isServiceArtifact(filePath: string): boolean { + const file = relativePath(filePath); + const basename = path.posix.basename(file).toLowerCase(); + const extension = path.posix.extname(basename); + return extension === '.plist' || extension === '.service' || extension === '.timer' || /(?:^|[._-])cron(?:tab)?(?:[._-]|$)/i.test(basename); +} + +async function inspectServiceArtifacts(): Promise { + const findings: Finding[] = []; + const rules: Array<[RegExp, string]> = [ + [/StartInterval/i, 'service-launchd-start-interval'], + [/CalendarInterval/i, 'service-launchd-calendar-interval'], + [/OnCalendar\s*=/i, 'service-systemd-calendar-trigger'], + [/OnUnitActiveSec\s*=/i, 'service-systemd-active-timer'], + [/\bcron(?:tab)?\b/i, 'service-cron-reference'], + ]; + for (const filePath of await repositoryFiles()) { + if (!isServiceArtifact(filePath)) continue; + const file = relativePath(filePath); + if (file.toLowerCase().endsWith('.timer')) { + findings.push({ file, line: 1, code: 'systemd-timer-file' }); + } + const lines = (await readFile(filePath, 'utf8')).split(/\r?\n/); + for (const [index, line] of lines.entries()) { + for (const [pattern, code] of rules) { + if (pattern.test(line)) findings.push({ file, line: index + 1, code }); + } + } + } + return findings; +} + +async function inspectWorkflows(): Promise { + const workflowsDirectory = path.join(repositoryRoot, '.github', 'workflows'); + let entries: Dirent[]; + try { + entries = await readdir(workflowsDirectory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const rules: Array<[RegExp, string]> = [ + [/^\s*pull_request_target\s*:/, 'privileged-pull-request-workflow'], + [/^\s*schedule\s*:/, 'scheduled-workflow'], + [/^\s*cron\s*:/, 'cron-workflow'], + [/CRR_LIVE_X\s*:\s*['"]?1\b/, 'live-x-gate-in-ci'], + [/BIRD_LIVE\s*:\s*['"]?1\b/, 'legacy-live-gate-in-ci'], + [/(?:codex-reset-request|cli\.js)\s+test\s+x-(?:read|reply)\b/, 'live-x-command-in-ci'], + ]; + const findings: Finding[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isFile() || !/\.ya?ml$/i.test(entry.name)) continue; + const filePath = path.join(workflowsDirectory, entry.name); + const lines = (await readFile(filePath, 'utf8')).split(/\r?\n/); + for (const [index, line] of lines.entries()) { + for (const [pattern, code] of rules) { + if (pattern.test(line)) findings.push({ file: relativePath(filePath), line: index + 1, code }); + } + } + } + return findings; +} + +function inspectRenderedServices(): Finding[] { + const paths: ResetRequestPaths = { + configDir: '/home/example/.config/codex-reset-request', + stateDir: '/home/example/.local/state/codex-reset-request', + logDir: '/home/example/.local/state/codex-reset-request/logs', + configFile: '/home/example/.config/codex-reset-request/config.json', + stateFile: '/home/example/.local/state/codex-reset-request/state.json', + cursorFile: '/home/example/.local/state/codex-reset-request/cursors.json', + auditLogFile: '/home/example/.local/state/codex-reset-request/logs/audit.jsonl', + daemonLockFile: '/home/example/.local/state/codex-reset-request/watcher.lock', + }; + const definitions = [ + renderLaunchAgent({ + nodePath: '/usr/local/bin/node', + cliPath: '/home/example/codex-reset-request/dist/reset/cli.js', + codexHome: '/home/example/.codex', + paths, + environmentPath: '/usr/local/bin:/usr/bin:/bin', + }), + renderSystemdUnit({ + nodePath: '/usr/local/bin/node', + cliPath: '/home/example/codex-reset-request/dist/reset/cli.js', + codexHome: '/home/example/.codex', + paths, + environmentPath: '/usr/local/bin:/usr/bin:/bin', + }), + ]; + const forbidden: Array<[RegExp, string]> = [ + [/StartInterval/i, 'rendered-launchd-start-interval'], + [/CalendarInterval/i, 'rendered-launchd-calendar-interval'], + [/OnCalendar\s*=/i, 'rendered-systemd-calendar-trigger'], + [/OnUnitActiveSec\s*=/i, 'rendered-systemd-active-timer'], + [/\.timer\b/i, 'rendered-systemd-timer-unit'], + [/\bcron(?:tab)?\b/i, 'rendered-cron-reference'], + ]; + const findings: Finding[] = []; + for (const [definitionIndex, definition] of definitions.entries()) { + for (const [lineIndex, line] of definition.split(/\r?\n/).entries()) { + for (const [pattern, code] of forbidden) { + if (pattern.test(line)) { + findings.push({ file: `rendered-service-${definitionIndex + 1}`, line: lineIndex + 1, code }); + } + } + } + } + return findings; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) { + console.error('usage: pnpm run verify:no-polling'); + process.exitCode = 2; + return; + } + + const findings: Finding[] = []; + const rateLimitReadLocations: Array<{ file: string; line: number }> = []; + for (const filePath of await productionFiles(path.join(repositoryRoot, 'src'))) { + if (!productionScriptExtensions.has(path.extname(filePath).toLowerCase())) continue; + const inspected = inspectProductionSource(filePath, await readFile(filePath, 'utf8')); + findings.push(...inspected.findings); + for (const line of inspected.rateLimitReads) rateLimitReadLocations.push({ file: relativePath(filePath), line }); + } + findings.push(...(await inspectServiceArtifacts())); + const expectedRead = rateLimitReadLocations.filter( + ({ file }) => file === 'src/reset/codex/app-server-client.ts', + ); + if (rateLimitReadLocations.length !== 1 || expectedRead.length !== 1) { + findings.push({ file: 'src/reset/codex/app-server-client.ts', line: 1, code: 'rate-limit-read-callsite-drift' }); + } + findings.push(...(await inspectWorkflows())); + findings.push(...inspectRenderedServices()); + + const unique = [...new Map(findings.map((finding) => [`${finding.file}:${finding.line}:${finding.code}`, finding])).values()] + .sort((left, right) => + `${left.file}:${left.line.toString().padStart(8, '0')}:${left.code}`.localeCompare( + `${right.file}:${right.line.toString().padStart(8, '0')}:${right.code}`, + ), + ); + if (unique.length > 0) { + for (const finding of unique) console.error(`${finding.file}:${finding.line}:${finding.code}`); + process.exitCode = 1; + return; + } + console.log('verify-no-polling: ok'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/verify-no-secrets.ts b/scripts/verify-no-secrets.ts new file mode 100644 index 0000000..3ecf464 --- /dev/null +++ b/scripts/verify-no-secrets.ts @@ -0,0 +1,237 @@ +import { spawnSync } from 'node:child_process'; +import { constants } from 'node:fs'; +import { lstat, open, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +interface Finding { + file: string; + line: number; + code: string; +} + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); +const maximumTextBytes = 4 * 1_024 * 1_024; +const excludedDirectories = new Set(['.git', '.tmp', 'coverage', 'dist', 'node_modules', '.pnpm-store']); + +const contentRules: Array<{ code: string; pattern: RegExp }> = [ + { code: 'private-key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ }, + { code: 'github-token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/ }, + { code: 'npm-token', pattern: /\bnpm_[A-Za-z0-9]{30,}\b/ }, + { code: 'aws-access-key', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ }, + { code: 'openai-key', pattern: /\bsk-(?:(?:proj|svcacct)-)?[A-Za-z0-9_-]{20,}\b/ }, + { code: 'slack-token', pattern: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/ }, + { code: 'google-api-key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ }, + { code: 'live-payment-key', pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b/ }, + { code: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{17,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/ }, + { code: 'x-auth-cookie', pattern: /\bauth_token\s*=\s*[a-f0-9]{40,}\b/i }, + { + code: 'x-auth-literal', + pattern: /\b(?:auth_token|authToken)['"]?\s*[:=]\s*['"]?[a-f0-9]{40,}['"]?/i, + }, + { code: 'x-csrf-literal', pattern: /\bct0['"]?\s*[:=]\s*['"]?[a-f0-9]{40,}['"]?/i }, + { + code: 'named-secret-literal', + pattern: /\b(?:api[_-]?key|client[_-]?secret|password)\s*[:=]\s*['"][A-Za-z0-9_+./=-]{24,}['"]/i, + }, + { code: 'credential-in-url', pattern: /https?:\/\/[^\s/:@]+:[^\s/@]{16,}@/i }, +]; + +function normalized(filePath: string): string { + return filePath.split(path.sep).join('/').replace(/^\.\//, ''); +} + +function relativePath(filePath: string): string { + return normalized(path.relative(repositoryRoot, filePath)); +} + +function sensitivePathCode(file: string): string | null { + const normalizedFile = normalized(file); + const basename = path.posix.basename(normalizedFile); + if (/^\.env(?:\..+)?$/i.test(basename) && !/^\.env\.(?:example|sample|template)$/i.test(basename)) { + return 'environment-file'; + } + if (/^(?:auth\.json|cookies?(?:\.(?:sqlite|sqlite3|db|binarycookies))?|login data)$/i.test(basename)) { + return 'credential-store-file'; + } + if (/\.(?:key|p12|pfx|pem)$/i.test(basename)) return 'private-key-file'; + if (/(?:^|\/)\.codex\/(?:auth\.json|sessions\/)/i.test(normalizedFile)) return 'codex-credential-or-session-file'; + if (/rollout-.*\.jsonl$/i.test(basename)) return 'real-rollout-file'; + return null; +} + +function scanText(file: string, value: string): Finding[] { + const findings: Finding[] = []; + for (const [index, line] of value.split(/\r?\n/).entries()) { + for (const rule of contentRules) { + if (rule.pattern.test(line)) findings.push({ file: normalized(file), line: index + 1, code: rule.code }); + } + } + return findings; +} + +export function scanSecretPayload(file: string, bytes: Uint8Array): Finding[] { + // UTF-8 decoding preserves ASCII credential material even when the payload is + // otherwise binary or contains NUL bytes. Secret-like bytes must not be able + // to bypass the gate by changing an extension or adding binary content. + return scanText(file, Buffer.from(bytes).toString('utf8')); +} + +async function workingTreeFiles(directory = repositoryRoot): Promise { + const output: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (excludedDirectories.has(entry.name)) continue; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + output.push(entryPath); + } else if (entry.isDirectory()) { + output.push(...(await workingTreeFiles(entryPath))); + } else if (entry.isFile()) { + output.push(entryPath); + } + } + return output.sort(); +} + +async function scanWorkingTree(): Promise { + const findings: Finding[] = []; + for (const filePath of await workingTreeFiles()) { + const file = relativePath(filePath); + const pathCode = sensitivePathCode(file); + if (pathCode) findings.push({ file, line: 1, code: pathCode }); + let handle: Awaited> | null = null; + try { + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; + handle = await open(filePath, constants.O_RDONLY | noFollow); + const [openedStats, pathStats] = await Promise.all([handle.stat(), lstat(filePath)]); + if (pathStats.isSymbolicLink()) { + findings.push({ file, line: 1, code: 'symlink-unscanned' }); + continue; + } + if ( + !openedStats.isFile() || + openedStats.dev !== pathStats.dev || + openedStats.ino !== pathStats.ino + ) { + findings.push({ file, line: 1, code: 'file-changed-during-scan' }); + continue; + } + if (openedStats.size > maximumTextBytes) { + findings.push({ file, line: 1, code: 'oversize-file-unscanned' }); + continue; + } + findings.push(...scanSecretPayload(file, await handle.readFile())); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ELOOP') { + findings.push({ file, line: 1, code: 'symlink-unscanned' }); + continue; + } + if (code === 'ENOENT') { + findings.push({ file, line: 1, code: 'file-changed-during-scan' }); + continue; + } + throw error; + } finally { + await handle?.close().catch(() => undefined); + } + } + return findings; +} + +function git( + args: string[], + options: { input?: string; maxBuffer?: number; encoding?: BufferEncoding | 'buffer' } = {}, +) { + return spawnSync('git', args, { + cwd: repositoryRoot, + encoding: options.encoding ?? 'utf8', + input: options.input, + maxBuffer: options.maxBuffer ?? 16 * 1_024 * 1_024, + shell: false, + windowsHide: true, + }); +} + +function requireGitText(args: string[], code: string): string { + const result = git(args); + if (result.status !== 0 || typeof result.stdout !== 'string') throw new Error(code); + return result.stdout; +} + +function scanHistory(): Finding[] { + const findings: Finding[] = []; + const shallow = requireGitText(['rev-parse', '--is-shallow-repository'], 'git-history-unavailable').trim(); + if (shallow !== 'false') throw new Error('git-history-shallow'); + const commits = requireGitText(['rev-list', 'HEAD'], 'git-history-unavailable') + .split(/\r?\n/) + .filter(Boolean); + const messages = requireGitText(['log', '--format=%B%x00', 'HEAD'], 'git-history-unavailable').replaceAll('\0', '\n'); + findings.push(...scanText('history/commit-messages', messages)); + for (const commit of commits) { + const changed = git(['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', commit]); + if (changed.status !== 0 || typeof changed.stdout !== 'string') throw new Error('git-history-paths-unavailable'); + for (const file of changed.stdout.split('\0').filter(Boolean)) { + const pathCode = sensitivePathCode(file); + if (pathCode) findings.push({ file: `history/${normalized(file)}`, line: 1, code: pathCode }); + } + } + + const objects = requireGitText(['rev-list', '--objects', 'HEAD'], 'git-history-unavailable') + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const separator = line.indexOf(' '); + return { sha: separator === -1 ? line : line.slice(0, separator), file: separator === -1 ? '' : line.slice(separator + 1) }; + }); + const uniqueObjects = [...new Map(objects.map((object) => [object.sha, object])).values()]; + const checked = git(['cat-file', '--batch-check'], { input: `${uniqueObjects.map(({ sha }) => sha).join('\n')}\n` }); + if (checked.status !== 0 || typeof checked.stdout !== 'string') throw new Error('git-history-objects-unavailable'); + const metadata = checked.stdout.split(/\r?\n/).filter(Boolean); + for (const [index, line] of metadata.entries()) { + const object = uniqueObjects[index]; + const match = /^([0-9a-f]+)\s+(\w+)\s+(\d+)$/.exec(line); + if (!object || !match || match[2] !== 'blob') continue; + const size = Number(match[3]); + if (!Number.isSafeInteger(size) || size > maximumTextBytes) { + findings.push({ file: `history/${normalized(object.file || 'unknown')}`, line: 1, code: 'oversize-blob-unscanned' }); + continue; + } + const blob = git(['cat-file', 'blob', object.sha], { maxBuffer: maximumTextBytes + 1, encoding: 'buffer' }); + if (blob.status !== 0 || !Buffer.isBuffer(blob.stdout)) throw new Error('git-history-blob-unavailable'); + findings.push(...scanSecretPayload(`history/${normalized(object.file || 'unknown')}`, blob.stdout)); + } + return findings; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) { + console.error('usage: pnpm run verify:no-secrets'); + process.exitCode = 2; + return; + } + let findings: Finding[]; + try { + findings = [...(await scanWorkingTree()), ...scanHistory()]; + } catch (error) { + const code = error instanceof Error ? error.message : 'secret-scan-failed'; + console.error(`repository:1:${code}`); + process.exitCode = 1; + return; + } + const unique = [...new Map(findings.map((finding) => [`${finding.file}:${finding.line}:${finding.code}`, finding])).values()] + .sort((left, right) => + `${left.file}:${left.line.toString().padStart(8, '0')}:${left.code}`.localeCompare( + `${right.file}:${right.line.toString().padStart(8, '0')}:${right.code}`, + ), + ); + if (unique.length > 0) { + for (const finding of unique) console.error(`${finding.file}:${finding.line}:${finding.code}`); + process.exitCode = 1; + return; + } + console.log('verify-no-secrets: ok'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/src/commands/check.ts b/src/commands/check.ts index 548d30b..0f1b689 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -13,13 +13,13 @@ export function registerCheckCommand(program: Command, ctx: CliContext): void { console.log('─'.repeat(40)); if (cookies.authToken) { - console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`); + console.log(`${ctx.p('ok')}auth_token: available (value hidden)`); } else { console.log(`${ctx.p('err')}auth_token: not found`); } if (cookies.ct0) { - console.log(`${ctx.p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`); + console.log(`${ctx.p('ok')}ct0: available (value hidden)`); } else { console.log(`${ctx.p('err')}ct0: not found`); } diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index e9b0211..b9c537e 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -233,3 +233,50 @@ export async function resolveCredentials(options: { return { cookies, warnings }; } + +/** + * Resolve credentials for local automation without accepting token arguments. + * Browser state is intentionally preferred; environment variables are a final + * compatibility fallback and values are never persisted by this function. + */ +export async function resolveBrowserFirstCredentials(options: { + cookieSource?: CookieSource | 'auto'; + chromeProfile?: string; + firefoxProfile?: string; + cookieTimeoutMs?: number; +}): Promise { + const warnings: string[] = []; + const sources = options.cookieSource && options.cookieSource !== 'auto' ? [options.cookieSource] : resolveSources(); + const cookieTimeoutMs = + typeof options.cookieTimeoutMs === 'number' && + Number.isFinite(options.cookieTimeoutMs) && + options.cookieTimeoutMs > 0 + ? options.cookieTimeoutMs + : process.platform === 'darwin' + ? DEFAULT_COOKIE_TIMEOUT_MS + : undefined; + + for (const source of sources) { + const result = await readTwitterCookiesFromBrowser({ + source, + chromeProfile: options.chromeProfile, + firefoxProfile: options.firefoxProfile, + cookieTimeoutMs, + }); + warnings.push(...result.warnings); + if (result.cookies.authToken && result.cookies.ct0) { + return { cookies: result.cookies, warnings }; + } + } + + const environmentCookies = buildEmpty(); + readEnvCookie(environmentCookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken'); + readEnvCookie(environmentCookies, ['CT0', 'TWITTER_CT0'], 'ct0'); + if (environmentCookies.authToken && environmentCookies.ct0) { + environmentCookies.cookieHeader = cookieHeader(environmentCookies.authToken, environmentCookies.ct0); + return { cookies: environmentCookies, warnings }; + } + + warnings.push('No complete X browser session or environment credential pair was available.'); + return { cookies: environmentCookies, warnings }; +} diff --git a/src/lib/index.ts b/src/lib/index.ts index dc7f987..1de8676 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -4,6 +4,7 @@ export { extractCookiesFromChrome, extractCookiesFromFirefox, extractCookiesFromSafari, + resolveBrowserFirstCredentials, resolveCredentials, type TwitterCookies, } from './cookies.js'; @@ -19,13 +20,19 @@ export { type TwitterUser, } from './twitter-client.js'; export type { HomeTimelineFetchOptions } from './twitter-client-home.js'; +export { normalizeHandle } from './normalize-handle.js'; export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js'; export type { SearchFetchOptions } from './twitter-client-search.js'; export type { TimelineFetchOptions } from './twitter-client-timelines.js'; export type { TweetFetchOptions } from './twitter-client-tweet-detail.js'; +export type { UserLookupResult } from './twitter-client-user-lookup.js'; +export type { UserTweetsFetchOptions, UserTweetsPaginationOptions } from './twitter-client-user-tweets.js'; export type { AboutAccountProfile, AboutAccountResult, + TweetMutationAttemptOptions, + TweetMutationAttemptResult, + TweetMutationStartResult, TweetResult, UploadMediaResult, } from './twitter-client-types.js'; diff --git a/src/lib/runtime-features.ts b/src/lib/runtime-features.ts index a4cfc69..01560e1 100644 --- a/src/lib/runtime-features.ts +++ b/src/lib/runtime-features.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; -// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension. import defaultOverrides from './features.json' with { type: 'json' }; export type FeatureOverrides = { diff --git a/src/lib/twitter-client-base.ts b/src/lib/twitter-client-base.ts index 4b2b641..30a819b 100644 --- a/src/lib/twitter-client-base.ts +++ b/src/lib/twitter-client-base.ts @@ -86,9 +86,15 @@ export abstract class TwitterClientBase { return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ'])); } - protected async fetchWithTimeout(url: string, init: RequestInit): Promise { - // Prepare transaction ID before making the request - if (process.env.NODE_ENV !== 'test') { + protected async fetchWithTimeout( + url: string, + init: RequestInit, + immediatelyBeforeRequest?: () => Promise, + ): Promise { + const prepareTransaction = async () => { + if (process.env.NODE_ENV === 'test') { + return; + } try { if (!this._clientTransaction) { this._clientTransaction = await ClientTransaction.create(await handleXMigration()); @@ -104,18 +110,40 @@ export abstract class TwitterClientBase { } catch { // Transaction ID generation failed; request may fail with 401/226 } - } + }; if (!this.timeoutMs || this.timeoutMs <= 0) { + await prepareTransaction(); + await immediatelyBeforeRequest?.(); return fetch(url, init); } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + let timeoutId: NodeJS.Timeout | undefined; + const deadline = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject(new Error('Request timed out')); + }, this.timeoutMs); + }); + void deadline.catch(() => undefined); try { - return await fetch(url, { ...init, signal: controller.signal }); + await Promise.race([prepareTransaction(), deadline]); + if (immediatelyBeforeRequest) { + // This hook may persist a mutation marker. Never detach it on timeout: + // a late atomic save could otherwise overwrite newer state. Once it + // settles, the aborted signal below still prevents the request. + await immediatelyBeforeRequest(); + } + if (controller.signal.aborted) { + throw new Error('Request timed out'); + } + const request = fetch(url, { ...init, signal: controller.signal }); + return await Promise.race([request, deadline]); } finally { - clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } } } diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index 85599ab..28470a8 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -1,4 +1,3 @@ -// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension. import queryIds from './query-ids.json' with { type: 'json' }; export const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; diff --git a/src/lib/twitter-client-news.ts b/src/lib/twitter-client-news.ts index b82c5f1..15e008d 100644 --- a/src/lib/twitter-client-news.ts +++ b/src/lib/twitter-client-news.ts @@ -179,13 +179,6 @@ export function withNews>( errors?: Array<{ message: string; code?: number; [key: string]: any }>; }; - // Debug: save response if BIRD_DEBUG_JSON is set - if (process.env.BIRD_DEBUG_JSON) { - const fs = await import('node:fs/promises'); - const debugPath = process.env.BIRD_DEBUG_JSON.replace('.json', `-${tabName}.json`); - await fs.writeFile(debugPath, JSON.stringify(data, null, 2)).catch(() => {}); - } - if (data.errors && data.errors.length > 0) { throw new Error(data.errors.map((e) => e.message).join('; ')); } diff --git a/src/lib/twitter-client-posting.ts b/src/lib/twitter-client-posting.ts index cacd853..12d69fe 100644 --- a/src/lib/twitter-client-posting.ts +++ b/src/lib/twitter-client-posting.ts @@ -5,16 +5,89 @@ import { buildNoteTweetFieldToggles, buildTweetCreateFeatures, } from './twitter-client-features.js'; -import type { CreateTweetResponse, TweetResult } from './twitter-client-types.js'; +import type { + CreateTweetResponse, + TweetMutationAttemptOptions, + TweetMutationAttemptResult, + TweetMutationStartResult, + TweetResult, +} from './twitter-client-types.js'; export interface TwitterClientPostingMethods { tweet(text: string, mediaIds?: string[]): Promise; reply(text: string, replyToTweetId: string, mediaIds?: string[]): Promise; + replySingleAttempt( + text: string, + replyToTweetId: string, + options?: TweetMutationAttemptOptions, + ): Promise; } const STANDARD_TWEET_MAX_WEIGHTED_LENGTH = 280; const URL_WEIGHTED_LENGTH = 23; const URL_REGEX = /https?:\/\/\S+/g; +const MAX_ONE_SHOT_RESPONSE_BYTES = 1 * 1_024 * 1_024; +const DEFAULT_ONE_SHOT_BODY_TIMEOUT_MS = 10_000; + +class BoundedResponseBodyError extends Error { + readonly code: 'write-body-timeout' | 'write-response-too-large'; + + constructor(code: BoundedResponseBodyError['code']) { + super(code); + this.name = 'BoundedResponseBodyError'; + this.code = code; + } +} + +class MutationStartError extends Error { + readonly safeCode: string; + + constructor(safeCode: string) { + super(safeCode); + this.name = 'MutationStartError'; + this.safeCode = safeCode; + } +} + +async function readBoundedResponseBody( + response: Response, + timeoutMs: number, + maximumBytes = MAX_ONE_SHOT_RESPONSE_BYTES, +): Promise { + if (!response.body) { + return ''; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let output = ''; + let timeout: NodeJS.Timeout | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new BoundedResponseBodyError('write-body-timeout')); + void reader.cancel().catch(() => undefined); + }, timeoutMs); + }); + void deadline.catch(() => undefined); + try { + while (true) { + const result = await Promise.race([reader.read(), deadline]); + if (result.done) { + return output + decoder.decode(); + } + totalBytes += result.value.byteLength; + if (totalBytes > maximumBytes) { + await reader.cancel().catch(() => undefined); + throw new BoundedResponseBodyError('write-response-too-large'); + } + output += decoder.decode(result.value, { stream: true }); + } + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} /** * Approximate X's weighted tweet length: URLs count as 23 chars (t.co wrapping), @@ -79,6 +152,130 @@ export function withPosting return this.createTweet(variables, this.featuresFor(operation), operation, this.fieldTogglesFor(operation)); } + /** + * Automation-safe reply primitive. Query discovery may happen before the + * write, but this method performs exactly one CreateTweet mutation request + * and never switches endpoints or retries an ambiguous outcome. + */ + async replySingleAttempt( + text: string, + replyToTweetId: string, + options: TweetMutationAttemptOptions = {}, + ): Promise { + if (!text || !/^\d+$/.test(replyToTweetId)) { + return { status: 'definitive-failure', safeCode: 'invalid-write-input' }; + } + + let queryId: string; + try { + queryId = await this.getQueryId('CreateTweet'); + await this.ensureClientUserId(); + } catch { + return { status: 'definitive-failure', safeCode: 'write-preflight-failed' }; + } + + const variables = { + tweet_text: text, + reply: { + in_reply_to_tweet_id: replyToTweetId, + exclude_reply_user_ids: [], + }, + dark_request: false, + media: { + media_entities: [], + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + }; + const url = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; + const body = JSON.stringify({ variables, features: buildTweetCreateFeatures(), queryId }); + + let response: Response; + try { + response = await this.fetchWithTimeout( + url, + { + method: 'POST', + redirect: 'manual', + headers: { ...this.getHeaders(), referer: 'https://x.com/compose/post' }, + body, + }, + async () => { + let startResult: TweetMutationStartResult | undefined; + try { + startResult = await options.onMutationStart?.(); + } catch { + throw new MutationStartError('write-state-persistence-failed'); + } + if (startResult && !startResult.ok) { + throw new MutationStartError( + /^[a-z0-9-]{1,100}$/.test(startResult.safeCode) + ? startResult.safeCode + : 'write-start-rejected', + ); + } + }, + ); + } catch (error) { + if (error instanceof MutationStartError) { + return { status: 'definitive-failure', safeCode: error.safeCode }; + } + return { status: 'unknown', safeCode: 'write-transport-ambiguous' }; + } + + if (response.status >= 500) { + return { status: 'unknown', safeCode: 'write-server-ambiguous', httpStatus: response.status }; + } + if (response.status >= 300 && response.status < 400) { + return { status: 'unknown', safeCode: 'write-redirect-ambiguous', httpStatus: response.status }; + } + if (!response.ok) { + return { + status: 'definitive-failure', + safeCode: response.status === 404 ? 'write-query-rejected' : 'write-http-rejected', + httpStatus: response.status, + }; + } + + let data: CreateTweetResponse; + try { + const responseBody = await readBoundedResponseBody( + response, + this.timeoutMs && this.timeoutMs > 0 ? this.timeoutMs : DEFAULT_ONE_SHOT_BODY_TIMEOUT_MS, + ); + data = JSON.parse(responseBody) as CreateTweetResponse; + } catch (error) { + if (error instanceof BoundedResponseBodyError) { + return { status: 'unknown', safeCode: error.code, httpStatus: response.status }; + } + return { status: 'unknown', safeCode: 'write-response-unparseable', httpStatus: response.status }; + } + const result = data.data?.create_tweet?.tweet_results?.result; + const tweetId = result?.rest_id ?? result?.tweet?.rest_id; + if (tweetId && /^\d+$/.test(tweetId)) { + return { status: 'sent', tweetId }; + } + if (data.errors && data.errors.length > 0) { + const definitiveCodes = new Set([ + 32, 34, 64, 88, 89, 99, 135, 144, 179, 185, 186, 187, 215, 220, 226, 261, 326, 385, + 386, + ]); + const definitive = data.errors.some( + (error) => typeof error.code === 'number' && definitiveCodes.has(error.code), + ); + return { + status: definitive ? 'definitive-failure' : 'unknown', + safeCode: data.errors.some((error) => error.code === 226) + ? 'write-automation-restricted' + : definitive + ? 'write-graphql-rejected' + : 'write-graphql-ambiguous', + httpStatus: response.status, + }; + } + return { status: 'unknown', safeCode: 'write-id-missing', httpStatus: response.status }; + } + private pickCreateOperation(text: string): 'CreateTweet' | 'CreateNoteTweet' { return weightedTweetLength(text) > STANDARD_TWEET_MAX_WEIGHTED_LENGTH ? 'CreateNoteTweet' : 'CreateTweet'; } diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 8048db9..af4d23f 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -34,6 +34,9 @@ export type GraphqlTweetResult = { favorite_count?: number; conversation_id_str?: string; in_reply_to_status_id_str?: string | null; + retweeted_status_result?: { + result?: GraphqlTweetResult; + }; entities?: { media?: GraphqlMediaEntity[]; }; @@ -206,6 +209,9 @@ export type GraphqlTweetResult = { }>; }; tweet?: GraphqlTweetResult; + retweeted_status_result?: { + result?: GraphqlTweetResult; + }; quoted_status_result?: { result?: GraphqlTweetResult; }; @@ -221,6 +227,19 @@ export type TweetResult = error: string; }; +export type TweetMutationAttemptResult = + | { status: 'sent'; tweetId: string } + | { status: 'definitive-failure'; safeCode: string; httpStatus?: number } + | { status: 'unknown'; safeCode: string; httpStatus?: number }; + +export interface TweetMutationAttemptOptions { + onMutationStart?(): Promise; +} + +export type TweetMutationStartResult = + | { ok: true } + | { ok: false; safeCode: string }; + export type BookmarkMutationResult = | { success: true; @@ -273,6 +292,15 @@ export interface TweetData { likeCount?: number; conversationId?: string; inReplyToStatusId?: string; + sourceEntryId?: string; + sourceInstructionType?: string; + sourceEntryType?: string; + tweetResultTypename?: string; + tweetWrapperTypename?: string; + isPinned?: boolean | null; + isRetweet?: boolean | null; + isReply?: boolean; + isQuote?: boolean; // Optional quoted tweet; depth controlled by quoteDepth (default: 1). quotedTweet?: TweetData; // Media attachments (photos, videos, GIFs) @@ -286,6 +314,37 @@ export interface TweetData { _raw?: GraphqlTweetResult; } +export interface TimelineItemContent { + itemType?: string; + tweet_results?: { + result?: GraphqlTweetResult; + }; +} + +export interface TimelineEntry { + entryId?: string; + content?: { + entryType?: string; + itemContent?: TimelineItemContent; + item?: { + itemContent?: TimelineItemContent; + }; + items?: Array<{ + item?: { itemContent?: TimelineItemContent }; + itemContent?: TimelineItemContent; + content?: { itemContent?: TimelineItemContent }; + }>; + cursorType?: string; + value?: string; + }; +} + +export interface TimelineInstruction { + type?: string; + entry?: TimelineEntry; + entries?: TimelineEntry[]; +} + export interface TweetWithMeta extends TweetData { isThread: boolean; threadPosition: 'root' | 'middle' | 'end' | 'standalone'; @@ -393,6 +452,9 @@ export interface CreateTweetResponse { tweet_results?: { result?: { rest_id?: string; + tweet?: { + rest_id?: string; + }; legacy?: { full_text?: string; }; diff --git a/src/lib/twitter-client-user-tweets.ts b/src/lib/twitter-client-user-tweets.ts index 2660273..d970d3d 100644 --- a/src/lib/twitter-client-user-tweets.ts +++ b/src/lib/twitter-client-user-tweets.ts @@ -1,7 +1,7 @@ import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildUserTweetsFeatures } from './twitter-client-features.js'; -import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js'; +import type { SearchResult, TimelineInstruction, TweetData } from './twitter-client-types.js'; import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js'; /** Options for user tweets fetch methods */ @@ -121,48 +121,7 @@ export function withUserTweets; - cursorType?: string; - value?: string; - }; - }>; - }>; + instructions?: TimelineInstruction[]; }; }; }; diff --git a/src/lib/twitter-client-users.ts b/src/lib/twitter-client-users.ts index d7dc6cd..6cd73f5 100644 --- a/src/lib/twitter-client-users.ts +++ b/src/lib/twitter-client-users.ts @@ -218,15 +218,21 @@ export function withUsers>( : (username ?? ''); const userId = - typeof data?.user_id === 'string' - ? data.user_id - : typeof data?.user_id_str === 'string' - ? data.user_id_str - : typeof data?.user?.id_str === 'string' - ? data.user.id_str - : typeof data?.user?.id === 'string' - ? data.user.id - : null; + typeof data?.id_str === 'string' + ? data.id_str + : typeof data?.id === 'number' + ? String(data.id) + : typeof data?.id === 'string' + ? data.id + : typeof data?.user_id === 'string' + ? data.user_id + : typeof data?.user_id_str === 'string' + ? data.user_id_str + : typeof data?.user?.id_str === 'string' + ? data.user.id_str + : typeof data?.user?.id === 'string' + ? data.user.id + : null; if (username && userId) { this.clientUserId = userId; @@ -272,6 +278,7 @@ export function withUsers>( const name = nameMatch?.[1]?.replace(/\\"/g, '"'); if (username && userId) { + this.clientUserId = userId; return { success: true, user: { diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts index 0abd856..c36e2db 100644 --- a/src/lib/twitter-client-utils.ts +++ b/src/lib/twitter-client-utils.ts @@ -1,4 +1,12 @@ -import type { GraphqlTweetResult, TweetData, TweetMedia, TwitterUser } from './twitter-client-types.js'; +import type { + GraphqlTweetResult, + TimelineEntry, + TimelineInstruction, + TimelineItemContent, + TweetData, + TweetMedia, + TwitterUser, +} from './twitter-client-types.js'; export function normalizeQuoteDepth(value?: number): number { if (value === undefined || value === null) { @@ -506,13 +514,13 @@ export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia } export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { - if (!result) { - return undefined; - } - if (result.tweet) { - return result.tweet; + let current = result; + const seen = new Set(); + while (current?.tweet && !seen.has(current)) { + seen.add(current); + current = current.tweet; } - return result; + return current; } export interface MapTweetResultOptions { @@ -528,41 +536,58 @@ export function mapTweetResult( typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions; const { quoteDepth, includeRaw = false } = options; - const userResult = result?.core?.user_results?.result; + const originalResult = result; + const normalizedResult = unwrapTweetResult(result); + const userResult = normalizedResult?.core?.user_results?.result; const userLegacy = userResult?.legacy; const userCore = userResult?.core; const username = userLegacy?.screen_name ?? userCore?.screen_name; const name = userLegacy?.name ?? userCore?.name ?? username; const userId = userResult?.rest_id; - if (!result?.rest_id || !username) { + if (!normalizedResult?.rest_id || !username) { return undefined; } - const text = extractTweetText(result); + const text = extractTweetText(normalizedResult); if (!text) { return undefined; } let quotedTweet: TweetData | undefined; if (quoteDepth > 0) { - const quotedResult = unwrapTweetResult(result.quoted_status_result?.result); + const quotedResult = unwrapTweetResult(normalizedResult.quoted_status_result?.result); if (quotedResult) { quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw }); } } - const media = extractMedia(result); - const article = extractArticleMetadata(result); + const media = extractMedia(normalizedResult); + const article = extractArticleMetadata(normalizedResult); + const hasRetweetStructure = Boolean( + normalizedResult.retweeted_status_result?.result ?? normalizedResult.legacy?.retweeted_status_result?.result, + ); + const isRetweet = hasRetweetStructure + ? true + : normalizedResult.__typename === 'Tweet' && normalizedResult.legacy + ? false + : null; const tweetData: TweetData = { - id: result.rest_id, + id: normalizedResult.rest_id, text, - createdAt: result.legacy?.created_at, - replyCount: result.legacy?.reply_count, - retweetCount: result.legacy?.retweet_count, - likeCount: result.legacy?.favorite_count, - conversationId: result.legacy?.conversation_id_str, - inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined, + createdAt: normalizedResult.legacy?.created_at, + replyCount: normalizedResult.legacy?.reply_count, + retweetCount: normalizedResult.legacy?.retweet_count, + likeCount: normalizedResult.legacy?.favorite_count, + conversationId: normalizedResult.legacy?.conversation_id_str, + inReplyToStatusId: normalizedResult.legacy?.in_reply_to_status_id_str ?? undefined, + tweetResultTypename: normalizedResult.__typename, + tweetWrapperTypename: + originalResult !== normalizedResult && originalResult?.__typename ? originalResult.__typename : undefined, + isPinned: null, + isRetweet, + isReply: Boolean(normalizedResult.legacy?.in_reply_to_status_id_str), + isQuote: Boolean(normalizedResult.quoted_status_result?.result), author: { username, name: name || username, @@ -574,7 +599,7 @@ export function mapTweetResult( }; if (includeRaw) { - (tweetData as TweetData & { _raw: GraphqlTweetResult })._raw = result; + (tweetData as TweetData & { _raw: GraphqlTweetResult })._raw = originalResult ?? normalizedResult; } return tweetData; @@ -612,58 +637,28 @@ export function findTweetInInstructions( return undefined; } -export function collectTweetResultsFromEntry(entry: { - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - items?: Array<{ - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }; -}): GraphqlTweetResult[] { - const results: GraphqlTweetResult[] = []; - const pushResult = (result?: GraphqlTweetResult) => { - if (result?.rest_id) { - results.push(result); +export interface CollectedTweetResult { + result: GraphqlTweetResult; + itemType?: string; +} + +export function collectTweetResultsFromEntry(entry: TimelineEntry): CollectedTweetResult[] { + const results: CollectedTweetResult[] = []; + const pushResult = (itemContent?: TimelineItemContent) => { + const result = itemContent?.tweet_results?.result; + if (unwrapTweetResult(result)?.rest_id && result) { + results.push({ result, itemType: itemContent?.itemType }); } }; const content = entry.content; - pushResult(content?.itemContent?.tweet_results?.result); - pushResult(content?.item?.itemContent?.tweet_results?.result); + pushResult(content?.itemContent); + pushResult(content?.item?.itemContent); for (const item of content?.items ?? []) { - pushResult(item?.item?.itemContent?.tweet_results?.result); - pushResult(item?.itemContent?.tweet_results?.result); - pushResult(item?.content?.itemContent?.tweet_results?.result); + pushResult(item?.item?.itemContent); + pushResult(item?.itemContent); + pushResult(item?.content?.itemContent); } return results; @@ -675,71 +670,63 @@ export interface ParseTweetsOptions { } export function parseTweetsFromInstructions( - instructions: - | Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - items?: Array<{ - item?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }; - }>; - }> - | undefined, + instructions: TimelineInstruction[] | undefined, quoteDepthOrOptions: number | ParseTweetsOptions, ): TweetData[] { const options: ParseTweetsOptions = typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions; const { quoteDepth, includeRaw = false } = options; - const tweets: TweetData[] = []; - const seen = new Set(); + const tweetsById = new Map(); + + const mergeEvidence = (left: boolean | null | undefined, right: boolean | null | undefined) => { + if (left === true || right === true) { + return true; + } + if (left === false || right === false) { + return false; + } + return null; + }; for (const instruction of instructions ?? []) { - for (const entry of instruction.entries ?? []) { - const results = collectTweetResultsFromEntry(entry); - for (const result of results) { - const mapped = mapTweetResult(result, { quoteDepth, includeRaw }); - if (!mapped || seen.has(mapped.id)) { + const entries = [...(instruction.entry ? [instruction.entry] : []), ...(instruction.entries ?? [])]; + for (const entry of entries) { + const collectedResults = collectTweetResultsFromEntry(entry); + for (const collected of collectedResults) { + const mapped = mapTweetResult(collected.result, { quoteDepth, includeRaw }); + if (!mapped) { + continue; + } + mapped.sourceEntryId = entry.entryId; + mapped.sourceInstructionType = instruction.type; + mapped.sourceEntryType = entry.content?.entryType ?? collected.itemType; + mapped.isPinned = + instruction.type === 'TimelinePinEntry' + ? true + : instruction.type === 'TimelineAddEntries' + ? false + : null; + + const existing = tweetsById.get(mapped.id); + if (!existing) { + tweetsById.set(mapped.id, mapped); continue; } - seen.add(mapped.id); - tweets.push(mapped); + const mergedPinned = mergeEvidence(existing.isPinned, mapped.isPinned); + const mergedRetweet = mergeEvidence(existing.isRetweet, mapped.isRetweet); + if (mapped.isPinned === true) { + existing.sourceEntryId = mapped.sourceEntryId; + existing.sourceInstructionType = mapped.sourceInstructionType; + existing.sourceEntryType = mapped.sourceEntryType; + } + existing.isPinned = mergedPinned; + existing.isRetweet = mergedRetweet; } } } - return tweets; + return [...tweetsById.values()]; } export function extractCursorFromInstructions( @@ -810,7 +797,7 @@ export function parseUsersFromInstructions( ? (rawUserResult.user as typeof rawUserResult) : rawUserResult; - if (!userResult || userResult.__typename !== 'User') { + if (userResult?.__typename !== 'User') { continue; } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 04894c9..691151a 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -63,6 +63,9 @@ export type { ListsResult, SearchResult, TweetData, + TweetMutationAttemptOptions, + TweetMutationAttemptResult, + TweetMutationStartResult, TweetResult, TwitterClientOptions, TwitterList, diff --git a/src/reset/cli.ts b/src/reset/cli.ts new file mode 100644 index 0000000..f69518c --- /dev/null +++ b/src/reset/cli.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { createResetRequestProgram } from './program.js'; +import { redactForLog } from './utils/redaction.js'; + +try { + await createResetRequestProgram().parseAsync(process.argv); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(String(redactForLog(message))); + process.exitCode = 1; +} diff --git a/src/reset/codex/app-server-client.ts b/src/reset/codex/app-server-client.ts new file mode 100644 index 0000000..57ac881 --- /dev/null +++ b/src/reset/codex/app-server-client.ts @@ -0,0 +1,321 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { z } from 'zod'; +import type { ResetRequestConfig } from '../config/schema.js'; +import { LineBuffer, MAX_JSONL_LINE_BYTES } from '../watcher/line-buffer.js'; +import { terminateChild } from '../utils/process.js'; +import { resolveCodexHome } from './codex-home.js'; +import { + AppServerSafeError, + type AppServerFailureCode, + type AppServerStage, +} from './compatibility.js'; +import { + parseRateLimitsResponse, + type ParsedRateLimitsResponse, +} from './rate-limit-confirmation.js'; + +const APP_VERSION = '0.1.0'; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_PROTOCOL_BYTES = 4 * 1_024 * 1_024; +const MAX_PROTOCOL_MESSAGES = 10_000; + +const initializeResultSchema = z + .object({ + userAgent: z.string(), + codexHome: z.string(), + platformFamily: z.string(), + platformOs: z.string(), + }) + .passthrough(); + +interface PendingResponse { + resolve(value: Record): void; +} + +export interface AppServerProcessSpec { + command: string; + args: string[]; + environment?: NodeJS.ProcessEnv; +} + +export interface ReadRateLimitsOptions { + process?: AppServerProcessSpec; + timeoutMs?: number; + expectedCodexHome?: string; +} + +export type RateLimitsReadOutcome = + | { ok: true; value: ParsedRateLimitsResponse } + | { ok: false; stage: AppServerStage; code: AppServerFailureCode }; + +const CODEX_ENV_ALLOWLIST = new Set([ + 'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'LC_CTYPE', + 'TERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', + 'ALL_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'all_proxy', 'SystemRoot', 'ComSpec', + 'PATHEXT', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE', 'USERNAME', 'HOMEDRIVE', 'HOMEPATH', + 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', +]); + +export function buildCodexAppServerEnvironment( + environment: NodeJS.ProcessEnv, + codexHome: string, +): NodeJS.ProcessEnv { + const safe: NodeJS.ProcessEnv = { CODEX_HOME: codexHome }; + for (const [key, value] of Object.entries(environment)) { + if (value !== undefined && CODEX_ENV_ALLOWLIST.has(key)) { + safe[key] = value; + } + } + return safe; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function sameCodexHome(first: string, second: string): boolean { + const normalizedFirst = path.resolve(first); + const normalizedSecond = path.resolve(second); + return process.platform === 'win32' + ? normalizedFirst.toLowerCase() === normalizedSecond.toLowerCase() + : normalizedFirst === normalizedSecond; +} + +function responseResult( + response: Record, + stage: 'initialize' | 'rate-limits', +): unknown { + const hasResult = Object.hasOwn(response, 'result'); + const hasError = Object.hasOwn(response, 'error'); + if (hasResult === hasError) { + throw new AppServerSafeError('unexpected-response', stage); + } + if (hasError) { + throw new AppServerSafeError( + stage === 'initialize' ? 'initialize-rejected' : 'rate-limits-rejected', + stage, + ); + } + return response.result; +} + +export async function readCodexRateLimits(options: ReadRateLimitsOptions = {}): Promise { + const defaultEnvironment = buildCodexAppServerEnvironment( + process.env, + options.expectedCodexHome ?? process.env.CODEX_HOME ?? path.join(homedir(), '.codex'), + ); + const processSpec = options.process ?? { + command: 'codex', + args: ['app-server'], + environment: defaultEnvironment, + }; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let stage: AppServerStage = 'spawn'; + let child: ChildProcessWithoutNullStreams | null = null; + let completed = false; + let rejectFatal: ((error: AppServerSafeError) => void) | null = null; + const fatal = new Promise((_resolve, reject) => { + rejectFatal = reject; + }); + void fatal.catch(() => undefined); + + const fail = (error: AppServerSafeError) => { + if (!completed) { + rejectFatal?.(error); + } + }; + + let timeout: NodeJS.Timeout | null = null; + try { + try { + child = spawn(processSpec.command, processSpec.args, { + env: processSpec.environment ?? defaultEnvironment, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } catch { + throw new AppServerSafeError('spawn-failed', 'spawn'); + } + + timeout = setTimeout(() => fail(new AppServerSafeError('timeout', stage)), timeoutMs); + const pending = new Map(); + const completedIds = new Set(); + const lineBuffer = new LineBuffer({ maxLineBytes: MAX_JSONL_LINE_BYTES }); + let protocolBytes = 0; + let protocolMessages = 0; + + const processProtocolMessage = (value: unknown) => { + if (!isRecord(value)) { + fail(new AppServerSafeError('unexpected-response', stage)); + return; + } + if (!Object.hasOwn(value, 'id')) { + if (typeof value.method === 'string') { + return; + } + fail(new AppServerSafeError('unexpected-response', stage)); + return; + } + if (typeof value.id !== 'number' || !Number.isInteger(value.id)) { + fail(new AppServerSafeError('unexpected-response', stage)); + return; + } + const waiter = pending.get(value.id); + if (!waiter || completedIds.has(value.id) || typeof value.method === 'string') { + fail(new AppServerSafeError('unexpected-response', stage)); + return; + } + pending.delete(value.id); + completedIds.add(value.id); + waiter.resolve(value); + }; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + protocolBytes += Buffer.byteLength(chunk, 'utf8'); + if (protocolBytes > MAX_PROTOCOL_BYTES) { + fail(new AppServerSafeError('response-too-large', stage)); + return; + } + const parsed = lineBuffer.push(chunk); + if (parsed.oversizeLines > 0) { + fail(new AppServerSafeError('response-too-large', stage)); + return; + } + for (const line of parsed.lines) { + if (line.text.trim().length === 0) { + continue; + } + protocolMessages += 1; + if (protocolMessages > MAX_PROTOCOL_MESSAGES) { + fail(new AppServerSafeError('response-too-large', stage)); + return; + } + try { + processProtocolMessage(JSON.parse(line.text) as unknown); + } catch { + fail(new AppServerSafeError('invalid-json', stage)); + return; + } + } + }); + child.stdout.once('end', () => { + if (!completed && lineBuffer.trailingPartialLine.trim().length > 0) { + fail(new AppServerSafeError('invalid-json', stage)); + } + }); + child.stderr.resume(); + child.once('error', (error: NodeJS.ErrnoException) => { + fail(new AppServerSafeError(error.code === 'ENOENT' ? 'binary-not-found' : 'spawn-failed', 'spawn')); + }); + child.once('exit', () => { + if (!completed) { + fail(new AppServerSafeError('process-exited', stage)); + } + }); + child.stdin.once('error', () => { + fail(new AppServerSafeError('process-exited', stage)); + }); + + const writeMessage = async (message: Record): Promise => { + if (!child) { + throw new AppServerSafeError('process-exited', stage); + } + const serialized = `${JSON.stringify(message)}\n`; + const write = new Promise((resolve, reject) => { + child?.stdin.write(serialized, (error) => { + if (error) { + reject(new AppServerSafeError('process-exited', stage)); + } else { + resolve(); + } + }); + }); + await Promise.race([write, fatal]); + }; + + const request = async (id: number, message: Record): Promise> => { + const response = new Promise>((resolve) => { + pending.set(id, { resolve }); + }); + await writeMessage(message); + return await Promise.race([response, fatal]); + }; + + stage = 'initialize'; + const initializeResponse = await request(0, { + method: 'initialize', + id: 0, + params: { + clientInfo: { + name: 'codex_reset_request', + title: 'Codex Reset Request', + version: APP_VERSION, + }, + }, + }); + try { + const initializeResult = initializeResultSchema.parse( + responseResult(initializeResponse, 'initialize'), + ); + if ( + options.expectedCodexHome && + !sameCodexHome(initializeResult.codexHome, options.expectedCodexHome) + ) { + throw new AppServerSafeError('codex-home-mismatch', 'initialize'); + } + } catch (error) { + if (error instanceof AppServerSafeError) { + throw error; + } + throw new AppServerSafeError('initialize-schema', 'initialize'); + } + + stage = 'rate-limits'; + await writeMessage({ method: 'initialized', params: {} }); + const rateLimitsResponse = await request(1, { method: 'account/rateLimits/read', id: 1 }); + let value: ParsedRateLimitsResponse; + try { + value = parseRateLimitsResponse(responseResult(rateLimitsResponse, 'rate-limits')); + } catch (error) { + if (error instanceof AppServerSafeError) { + throw error; + } + throw new AppServerSafeError('rate-limits-schema', 'rate-limits'); + } + + completed = true; + return { ok: true, value }; + } catch (error) { + const safeError = + error instanceof AppServerSafeError ? error : new AppServerSafeError('unexpected-response', stage); + completed = true; + return { ok: false, stage: safeError.stage, code: safeError.code }; + } finally { + if (timeout) { + clearTimeout(timeout); + } + if (child) { + await terminateChild(child); + } + } +} + +export async function readConfiguredCodexRateLimits( + config: ResetRequestConfig, + options: Omit = {}, +): Promise { + const codexHome = resolveCodexHome(config); + return await readCodexRateLimits({ + ...options, + process: options.process ?? { + command: 'codex', + args: ['app-server'], + environment: buildCodexAppServerEnvironment(process.env, codexHome), + }, + expectedCodexHome: codexHome, + }); +} diff --git a/src/reset/codex/codex-home.ts b/src/reset/codex/codex-home.ts new file mode 100644 index 0000000..2ba7539 --- /dev/null +++ b/src/reset/codex/codex-home.ts @@ -0,0 +1,14 @@ +import { homedir } from 'node:os'; +import path from 'node:path'; +import type { ResetRequestConfig } from '../config/schema.js'; + +export function resolveCodexHome(config: ResetRequestConfig, env: NodeJS.ProcessEnv = process.env): string { + return path.resolve(env.CRR_CODEX_HOME ?? config.codexHome ?? env.CODEX_HOME ?? path.join(homedir(), '.codex')); +} + +export function resolveCodexSessionsDirectory( + config: ResetRequestConfig, + env: NodeJS.ProcessEnv = process.env, +): string { + return path.join(resolveCodexHome(config, env), 'sessions'); +} diff --git a/src/reset/codex/compatibility.ts b/src/reset/codex/compatibility.ts new file mode 100644 index 0000000..4a2916d --- /dev/null +++ b/src/reset/codex/compatibility.ts @@ -0,0 +1,53 @@ +export const TESTED_CODEX_VERSION = '0.140.0'; + +export type AppServerStage = 'spawn' | 'initialize' | 'rate-limits'; + +export type AppServerFailureCode = + | 'binary-not-found' + | 'spawn-failed' + | 'timeout' + | 'process-exited' + | 'stdin-failed' + | 'invalid-json' + | 'response-too-large' + | 'unexpected-response' + | 'initialize-rejected' + | 'initialize-schema' + | 'codex-home-mismatch' + | 'rate-limits-rejected' + | 'rate-limits-schema'; + +export interface ParsedCodexVersion { + rawVersion: string; + major: number; + minor: number; + patch: number; + support: 'tested' | 'untested'; +} + +export function parseCodexVersion(value: string): ParsedCodexVersion | null { + const match = value.trim().match(/(?:codex-cli\s+)?(\d+)\.(\d+)\.(\d+)/i); + if (!match) { + return null; + } + const rawVersion = `${match[1]}.${match[2]}.${match[3]}`; + return { + rawVersion, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + support: rawVersion === TESTED_CODEX_VERSION ? 'tested' : 'untested', + }; +} + +export class AppServerSafeError extends Error { + readonly code: AppServerFailureCode; + readonly stage: AppServerStage; + + constructor(code: AppServerFailureCode, stage: AppServerStage) { + super(code); + this.name = 'AppServerSafeError'; + this.code = code; + this.stage = stage; + } +} diff --git a/src/reset/codex/rate-limit-confirmation.ts b/src/reset/codex/rate-limit-confirmation.ts new file mode 100644 index 0000000..35fd131 --- /dev/null +++ b/src/reset/codex/rate-limit-confirmation.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; + +const rateLimitWindowSchema = z + .object({ + usedPercent: z.number().finite(), + windowDurationMins: z.number().finite().nullable().optional(), + resetsAt: z.number().int().finite().nonnegative().nullable().optional(), + }) + .passthrough(); + +const rateLimitSnapshotSchema = z + .object({ + limitId: z.string().nullable().optional(), + limitName: z.string().nullable().optional(), + primary: rateLimitWindowSchema.nullable().optional(), + secondary: rateLimitWindowSchema.nullable().optional(), + credits: z.unknown().nullable().optional(), + individualLimit: z.unknown().nullable().optional(), + planType: z.string().nullable().optional(), + rateLimitReachedType: z.string().nullable().optional(), + }) + .passthrough(); + +export const rateLimitsResponseSchema = z + .object({ + rateLimits: rateLimitSnapshotSchema, + rateLimitsByLimitId: z.record(z.string(), rateLimitSnapshotSchema).nullable().optional(), + }) + .passthrough(); + +export type RateLimitWindow = z.infer; +export type RateLimitSnapshot = z.infer; +export type ParsedRateLimitsResponse = z.infer; + +export type RateLimitConfirmation = + | { + confirmed: true; + reason: 'window'; + bucketKey: string; + limitId: string | null; + matchedWindow: 'primary' | 'secondary'; + resetsAt: number; + } + | { + confirmed: false; + safeCode: 'not-reached' | 'ambiguous-buckets'; + bucketKey?: string; + limitId?: string | null; + }; + +interface SelectedBucket { + key: string; + snapshot: RateLimitSnapshot; +} + +function selectBucket(response: ParsedRateLimitsResponse): SelectedBucket | null { + const entries = Object.entries(response.rateLimitsByLimitId ?? {}); + if (entries.length === 0) { + return { key: response.rateLimits.limitId ?? 'legacy', snapshot: response.rateLimits }; + } + if (entries.length === 1) { + return { key: entries[0][0], snapshot: entries[0][1] }; + } + + const codexBuckets = entries.filter(([key, snapshot]) => key === 'codex' || snapshot.limitId === 'codex'); + if (codexBuckets.length === 1) { + return { key: codexBuckets[0][0], snapshot: codexBuckets[0][1] }; + } + if (codexBuckets.length > 1) { + return null; + } + + return null; +} + +export function parseRateLimitsResponse(value: unknown): ParsedRateLimitsResponse { + return rateLimitsResponseSchema.parse(value); +} + +export function confirmRateLimit( + response: ParsedRateLimitsResponse, + now: Date = new Date(), +): RateLimitConfirmation { + const selected = selectBucket(response); + if (!selected) { + return { confirmed: false, safeCode: 'ambiguous-buckets' }; + } + + const nowSeconds = Math.floor(now.getTime() / 1_000); + for (const windowName of ['primary', 'secondary'] as const) { + const window = selected.snapshot[windowName]; + if (window && window.usedPercent >= 100 && typeof window.resetsAt === 'number' && window.resetsAt > nowSeconds) { + return { + confirmed: true, + reason: 'window', + bucketKey: selected.key, + limitId: selected.snapshot.limitId ?? null, + matchedWindow: windowName, + resetsAt: window.resetsAt, + }; + } + } + + return { + confirmed: false, + safeCode: 'not-reached', + bucketKey: selected.key, + limitId: selected.snapshot.limitId ?? null, + }; +} diff --git a/src/reset/codex/rollout-classifier.ts b/src/reset/codex/rollout-classifier.ts new file mode 100644 index 0000000..4397947 --- /dev/null +++ b/src/reset/codex/rollout-classifier.ts @@ -0,0 +1,83 @@ +import { sha256 } from '../utils/hash.js'; +import type { RolloutObservationContext, UsageLimitCandidate } from './rollout-types.js'; + +const STRUCTURED_USAGE_LIMIT_VALUES = new Set([ + 'UsageLimitExceeded', + 'usageLimitExceeded', + 'usage_limit_exceeded', +]); + +const FALLBACK_PATTERNS = [ + /you(?:'|’)ve hit your usage limit/i, + /usage limit (?:has been )?(?:reached|exceeded)/i, +]; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function structuredErrorType(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + const record = asRecord(value); + if (!record) { + return null; + } + for (const key of ['type', 'kind', 'code']) { + if (typeof record[key] === 'string') { + return record[key]; + } + } + return null; +} + +export function extractUsageLimitCandidate( + record: unknown, + context: RolloutObservationContext, +): UsageLimitCandidate | null { + const topLevel = asRecord(record); + if (topLevel?.type !== 'event_msg') { + return null; + } + const payload = asRecord(topLevel.payload); + if (payload?.type !== 'error' && payload?.type !== 'stream_error') { + return null; + } + + const normalizedRecordType = `event_msg:${payload.type}` as UsageLimitCandidate['normalizedRecordType']; + const metadataValue = structuredErrorType(payload.codex_error_info ?? payload.codexErrorInfo); + let tier: UsageLimitCandidate['tier'] | null = null; + + if (metadataValue && STRUCTURED_USAGE_LIMIT_VALUES.has(metadataValue)) { + tier = 'structured'; + } else if ( + typeof payload.message === 'string' && + FALLBACK_PATTERNS.some((pattern) => pattern.test(payload.message as string)) + ) { + tier = 'text-fallback'; + } + + if (!tier) { + return null; + } + + const normalizedErrorType = 'usage_limit_exceeded' as const; + return { + tier, + normalizedRecordType, + normalizedErrorType, + safeFileName: context.safeFileName, + fileIdentity: context.fileIdentity, + byteOffset: context.byteOffset, + observedAt: context.observedAt.toISOString(), + eventFingerprint: sha256( + context.fileIdentity ?? context.pathHash ?? context.safeFileName, + context.byteOffset, + normalizedRecordType, + normalizedErrorType, + ), + }; +} diff --git a/src/reset/codex/rollout-types.ts b/src/reset/codex/rollout-types.ts new file mode 100644 index 0000000..ef5a2bf --- /dev/null +++ b/src/reset/codex/rollout-types.ts @@ -0,0 +1,18 @@ +export interface RolloutObservationContext { + safeFileName: string; + fileIdentity: string | null; + pathHash?: string; + byteOffset: number; + observedAt: Date; +} + +export interface UsageLimitCandidate { + tier: 'structured' | 'text-fallback'; + normalizedRecordType: 'event_msg:error' | 'event_msg:stream_error'; + normalizedErrorType: 'usage_limit_exceeded'; + safeFileName: string; + fileIdentity: string | null; + byteOffset: number; + observedAt: string; + eventFingerprint: string; +} diff --git a/src/reset/commands/config.ts b/src/reset/commands/config.ts new file mode 100644 index 0000000..c9def35 --- /dev/null +++ b/src/reset/commands/config.ts @@ -0,0 +1,86 @@ +import type { Command } from 'commander'; +import { loadConfig } from '../config/load.js'; +import { saveConfig } from '../config/save.js'; +import { + createDefaultConfig, + normalizeHandleInput, + resetRequestConfigSchema, + type ResetRequestConfig, +} from '../config/schema.js'; + +const SUPPORTED_KEYS = new Set([ + 'codexHome', + 'mode', + 'targetHandle', + 'replyText', + 'expectedXHandle', + 'cookieSource', + 'chromeProfile', + 'firefoxProfile', + 'maxPostAgeHours', + 'maxAttemptsPer24Hours', +]); + +function parseNullable(value: string): string | null { + return value === 'null' ? null : value; +} + +export function setConfigValue(config: ResetRequestConfig, key: string, rawValue: string): ResetRequestConfig { + if (!SUPPORTED_KEYS.has(key)) { + throw new Error(`Unsupported config key: ${key}`); + } + if (key === 'mode' && rawValue === 'auto') { + throw new Error('Use codex-reset-request enable-auto to enable automatic posting'); + } + if (key === 'mode' && rawValue === 'notify') { + throw new Error('Notify mode is not supported'); + } + + const next = structuredClone(config) as Record; + let value: unknown = rawValue; + + if (key === 'targetHandle' || key === 'expectedXHandle') { + value = rawValue === 'null' ? null : normalizeHandleInput(rawValue); + } else if (key === 'codexHome' || key === 'chromeProfile' || key === 'firefoxProfile') { + value = parseNullable(rawValue); + } else if (key === 'maxPostAgeHours' || key === 'maxAttemptsPer24Hours') { + value = Number(rawValue); + } + + const segments = key.split('.'); + if (segments.length === 1) { + next[key] = value; + } else { + const parent = next[segments[0]] as Record; + parent[segments[1]] = value; + } + + return resetRequestConfigSchema.parse(next); +} + +export function registerConfigCommand(program: Command): void { + const command = program.command('config').description('Show or update local configuration'); + + command + .command('show') + .description('Print the validated configuration (never browser credentials)') + .action(async () => { + console.log(JSON.stringify(await loadConfig(), null, 2)); + }); + + command + .command('set ') + .description('Set one validated configuration value') + .action(async (key: string, value: string) => { + const saved = await saveConfig(setConfigValue(await loadConfig(), key, value)); + console.log(`Saved ${key} (${saved.mode})`); + }); + + command + .command('reset') + .description('Reset configuration to safe defaults') + .action(async () => { + await saveConfig(createDefaultConfig()); + console.log('Configuration reset. Mode is dry-run.'); + }); +} diff --git a/src/reset/commands/disable-auto.ts b/src/reset/commands/disable-auto.ts new file mode 100644 index 0000000..20b2908 --- /dev/null +++ b/src/reset/commands/disable-auto.ts @@ -0,0 +1,16 @@ +import type { Command } from 'commander'; +import { loadConfig } from '../config/load.js'; +import { saveConfig } from '../config/save.js'; + +export function registerDisableAutoCommand(program: Command): void { + program + .command('disable-auto') + .description('Disable automatic posting and continue in dry-run mode') + .action(async () => { + const config = await loadConfig(); + config.mode = 'dry-run'; + config.consent.automaticPostingAccepted = false; + await saveConfig(config); + console.log('Automatic posting disabled. Mode: dry-run.'); + }); +} diff --git a/src/reset/commands/doctor.ts b/src/reset/commands/doctor.ts new file mode 100644 index 0000000..b4df180 --- /dev/null +++ b/src/reset/commands/doctor.ts @@ -0,0 +1,234 @@ +import { watch, type FSWatcher } from 'node:fs'; +import { access, lstat } from 'node:fs/promises'; +import type { Command } from 'commander'; +import { readConfiguredCodexRateLimits } from '../codex/app-server-client.js'; +import { resolveCodexSessionsDirectory } from '../codex/codex-home.js'; +import { parseCodexVersion } from '../codex/compatibility.js'; +import { confirmRateLimit } from '../codex/rate-limit-confirmation.js'; +import { loadConfig } from '../config/load.js'; +import { getAppPaths } from '../config/paths.js'; +import { createDefaultConfig, hasCurrentAutomaticPostingConsent } from '../config/schema.js'; +import { acquireSingleInstanceLock, LockHeldError } from '../state/lock.js'; +import { StateStore } from '../state/store.js'; +import { inspectService, type ServiceResult } from '../service/index.js'; +import { runBoundedCommand } from '../utils/process.js'; +import { redactForLog } from '../utils/redaction.js'; +import { BirdXReplyProvider } from '../x/bird-provider.js'; + +type DoctorStatus = 'PASS' | 'WARN' | 'FAIL'; + +export interface DoctorCheck { + name: string; + status: DoctorStatus; + code: string; + detail?: string; +} + +export function serviceDoctorCheck(service: ServiceResult): DoctorCheck { + return { + name: 'Service installation', + status: !service.supported ? 'WARN' : !service.ok ? 'FAIL' : service.installed && service.running ? 'PASS' : 'WARN', + code: service.code, + detail: service.definitionPath ? String(redactForLog(service.definitionPath)) : undefined, + }; +} + +async function singleInstanceLockCheck(lockPath: string): Promise { + try { + const lock = await acquireSingleInstanceLock(lockPath); + await lock.release(); + return { name: 'Single-instance lock', status: 'PASS', code: 'lock-functional' }; + } catch (error) { + if (error instanceof LockHeldError) { + return { name: 'Single-instance lock', status: 'PASS', code: 'lock-held-by-watcher' }; + } + return { name: 'Single-instance lock', status: 'FAIL', code: 'lock-unavailable' }; + } +} + +async function probeNativeWatcher(directoryPath: string): Promise { + return await new Promise((resolve) => { + let settled = false; + let nativeWatcher: FSWatcher | null = null; + let timer: NodeJS.Timeout; + const finish = (result: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + nativeWatcher?.close(); + resolve(result); + }; + timer = setTimeout(() => finish(true), 50); + try { + nativeWatcher = watch(directoryPath, { persistent: false }); + nativeWatcher.once('error', () => finish(false)); + } catch { + finish(false); + } + }); +} + +export async function runDoctor(): Promise { + const checks: DoctorCheck[] = []; + const nodeMajor = Number(process.versions.node.split('.')[0]); + checks.push({ + name: 'Node.js', + status: nodeMajor >= 22 ? 'PASS' : 'FAIL', + code: nodeMajor >= 22 ? 'node-supported' : 'node-too-old', + detail: process.versions.node, + }); + + const pnpm = await runBoundedCommand('pnpm', ['--version']); + checks.push({ + name: 'pnpm', + status: pnpm.ok ? 'PASS' : 'WARN', + code: pnpm.ok ? 'pnpm-available' : 'pnpm-optional-missing', + detail: pnpm.ok ? pnpm.stdout : undefined, + }); + + const paths = getAppPaths(); + let config = createDefaultConfig(); + try { + config = await loadConfig(paths); + checks.push({ name: 'Configuration', status: 'PASS', code: 'config-valid' }); + } catch { + checks.push({ name: 'Configuration', status: 'FAIL', code: 'config-invalid' }); + } + try { + await new StateStore(paths).load(); + checks.push({ name: 'State', status: 'PASS', code: 'state-valid' }); + } catch { + checks.push({ name: 'State', status: 'FAIL', code: 'state-invalid' }); + } + + const codexVersionCommand = await runBoundedCommand('codex', ['--version']); + const codexVersion = codexVersionCommand.ok ? parseCodexVersion(codexVersionCommand.stdout) : null; + checks.push({ + name: 'Codex binary', + status: codexVersionCommand.ok ? 'PASS' : 'FAIL', + code: codexVersionCommand.ok ? 'codex-binary-available' : (codexVersionCommand.safeCode ?? 'codex-binary-failed'), + }); + checks.push({ + name: 'Codex version', + status: codexVersion?.support === 'tested' ? 'PASS' : codexVersion ? 'WARN' : 'FAIL', + code: codexVersion ? `codex-version-${codexVersion.support}` : 'codex-version-invalid', + detail: codexVersion?.rawVersion, + }); + + const sessionsDirectory = resolveCodexSessionsDirectory(config); + const sessionsStats = await lstat(sessionsDirectory).catch(() => null); + const sessionsReadable = + Boolean(sessionsStats?.isDirectory() && !sessionsStats.isSymbolicLink()) && + (await access(sessionsDirectory).then( + () => true, + () => false, + )); + checks.push({ + name: 'Codex sessions', + status: sessionsReadable ? 'PASS' : 'FAIL', + code: sessionsReadable ? 'sessions-readable' : 'sessions-unavailable', + }); + + const rateLimits = await readConfiguredCodexRateLimits(config); + checks.push({ + name: 'Codex App Server', + status: rateLimits.ok ? 'PASS' : 'FAIL', + code: rateLimits.ok ? 'app-server-compatible' : rateLimits.code, + }); + checks.push({ + name: 'Codex login and rate limits', + status: rateLimits.ok ? 'PASS' : 'FAIL', + code: rateLimits.ok ? 'rate-limits-readable' : rateLimits.code, + }); + if (rateLimits.ok) { + const confirmation = confirmRateLimit(rateLimits.value); + checks.push({ + name: 'Current usage-limit state', + status: confirmation.confirmed ? 'PASS' : 'WARN', + code: confirmation.confirmed ? 'usage-limit-confirmed' : confirmation.safeCode, + }); + } + + const nativeWatcherAvailable = sessionsReadable && (await probeNativeWatcher(sessionsDirectory)); + checks.push({ + name: 'Native watcher', + status: nativeWatcherAvailable ? 'PASS' : 'FAIL', + code: nativeWatcherAvailable ? 'native-watch-available' : 'native-watch-unavailable', + }); + checks.push({ name: 'Polling disabled', status: 'PASS', code: 'event-driven-watcher-configured' }); + + const service = await inspectService(); + checks.push(serviceDoctorCheck(service)); + checks.push(await singleInstanceLockCheck(paths.daemonLockFile)); + + const consentReady = config.mode !== 'auto' || hasCurrentAutomaticPostingConsent(config); + checks.push({ + name: 'Automatic posting consent', + status: consentReady ? 'PASS' : 'FAIL', + code: consentReady ? 'consent-valid' : 'consent-required', + }); + + const birdProvider = new BirdXReplyProvider(config); + const birdDoctor = await birdProvider.doctor(); + checks.push({ + name: 'Bird browser session', + status: birdDoctor.ok ? 'PASS' : 'FAIL', + code: birdDoctor.ok ? 'browser-session-readable' : birdDoctor.safeCode, + }); + checks.push({ + name: 'Current X account', + status: birdDoctor.ok ? 'PASS' : 'FAIL', + code: birdDoctor.ok ? 'x-account-readable' : birdDoctor.safeCode, + }); + if (birdDoctor.ok && birdDoctor.account) { + const expectedMatches = + config.expectedXHandle !== null && config.expectedXHandle.toLowerCase() === birdDoctor.account.handle; + checks.push({ + name: 'Expected X account', + status: config.expectedXHandle === null ? 'WARN' : expectedMatches ? 'PASS' : 'FAIL', + code: config.expectedXHandle === null ? 'expected-account-not-recorded' : expectedMatches ? 'account-match' : 'wrong-account', + }); + const target = await birdProvider.findTargetPost({ + targetHandle: config.targetHandle, + maxPostAgeHours: config.maxPostAgeHours, + }); + const accountLookupPassed = target.status === 'found' || target.safeCode !== 'target-user-unavailable'; + checks.push({ + name: 'Target account lookup', + status: accountLookupPassed ? 'PASS' : 'FAIL', + code: accountLookupPassed ? 'target-account-readable' : target.safeCode, + }); + checks.push({ + name: 'Target post read', + status: target.status === 'found' ? 'PASS' : 'FAIL', + code: target.status === 'found' ? 'target-post-readable' : target.safeCode, + }); + } else { + checks.push({ name: 'Expected X account', status: 'FAIL', code: 'account-unavailable' }); + checks.push({ name: 'Target account lookup', status: 'FAIL', code: 'credentials-unavailable' }); + checks.push({ name: 'Target post read', status: 'FAIL', code: 'credentials-unavailable' }); + } + return checks; +} + +export function registerDoctorCommand(program: Command): void { + program + .command('doctor') + .description('Run safe local compatibility checks') + .option('--json', 'Print JSON') + .action(async (options: { json?: boolean }) => { + const checks = await runDoctor(); + if (options.json) { + console.log(JSON.stringify({ checks }, null, 2)); + } else { + for (const check of checks) { + console.log(`${check.status} ${check.name}: ${check.code}${check.detail ? ` (${check.detail})` : ''}`); + } + } + if (checks.some((check) => check.status === 'FAIL')) { + process.exitCode = 1; + } + }); +} diff --git a/src/reset/commands/enable-auto.ts b/src/reset/commands/enable-auto.ts new file mode 100644 index 0000000..742321c --- /dev/null +++ b/src/reset/commands/enable-auto.ts @@ -0,0 +1,63 @@ +import { createInterface } from 'node:readline/promises'; +import type { Command } from 'commander'; +import { stdin as input, stdout as output } from 'node:process'; +import { readConfiguredCodexRateLimits } from '../codex/app-server-client.js'; +import { loadConfig } from '../config/load.js'; +import { saveConfig } from '../config/save.js'; +import { CURRENT_DISCLAIMER_VERSION } from '../config/schema.js'; +import { AUTO_CONFIRMATION } from './setup.js'; +import { BirdXReplyProvider } from '../x/bird-provider.js'; + +export function registerEnableAutoCommand(program: Command): void { + program + .command('enable-auto') + .description('Explicitly opt in to one-shot automatic X replies') + .option('--confirmation ', 'Exact confirmation text') + .action(async (options: { confirmation?: string }) => { + console.log('Automatic posting uses your active X browser account and can cause account restrictions or suspension.'); + console.log('The tool cannot guarantee a reset. One ambiguous write is never retried.'); + + const config = await loadConfig(); + if (!config.expectedXHandle) { + throw new Error('Run setup and record the expected X account before enabling auto mode'); + } + const rateLimits = await readConfiguredCodexRateLimits(config); + if (!rateLimits.ok) { + throw new Error(`Codex App Server preflight failed (${rateLimits.code})`); + } + const provider = new BirdXReplyProvider(config); + const currentAccount = await provider.getCurrentAccount(); + if (!currentAccount.ok) { + throw new Error(`X account preflight failed (${currentAccount.safeCode})`); + } + if (currentAccount.handle !== config.expectedXHandle.toLowerCase()) { + throw new Error('The active X browser account does not match expectedXHandle'); + } + const target = await provider.findTargetPost({ + targetHandle: config.targetHandle, + maxPostAgeHours: config.maxPostAgeHours, + }); + if (target.status !== 'found') { + throw new Error(`Target-post read preflight failed (${target.safeCode})`); + } + + const prompt = createInterface({ input, output }); + try { + const confirmation = + options.confirmation ?? (await prompt.question(`Type exactly "${AUTO_CONFIRMATION}": `)); + if (confirmation !== AUTO_CONFIRMATION) { + throw new Error('Automatic posting confirmation did not match exactly'); + } + config.mode = 'auto'; + config.consent = { + disclaimerVersion: CURRENT_DISCLAIMER_VERSION, + acceptedAt: new Date().toISOString(), + automaticPostingAccepted: true, + }; + await saveConfig(config); + console.log('Automatic posting enabled. Runtime preflight checks remain mandatory.'); + } finally { + prompt.close(); + } + }); +} diff --git a/src/reset/commands/install.ts b/src/reset/commands/install.ts new file mode 100644 index 0000000..e5fef1f --- /dev/null +++ b/src/reset/commands/install.ts @@ -0,0 +1,121 @@ +import type { Command } from 'commander'; +import { loadConfig } from '../config/load.js'; +import { saveConfig } from '../config/save.js'; +import type { ResetRequestConfig } from '../config/schema.js'; +import { inspectService, manageService, type ServiceAction, type ServiceResult } from '../service/index.js'; +import { prepareSetup, type SetupOptions } from './setup.js'; + +export interface InstallDependencies { + platform: NodeJS.Platform; + load(): Promise; + prepare(options: SetupOptions): Promise; + save(config: ResetRequestConfig): Promise; + manage(action: ServiceAction): Promise; + inspect(): Promise; +} + +const DEFAULT_DEPENDENCIES: InstallDependencies = { + platform: process.platform, + load: loadConfig, + prepare: prepareSetup, + save: saveConfig, + manage: manageService, + inspect: inspectService, +}; + +export async function runInstall(options: SetupOptions, dependencies: Partial = {}): Promise { + const deps = { ...DEFAULT_DEPENDENCIES, ...dependencies }; + if (deps.platform !== 'darwin' && deps.platform !== 'linux') { + throw new Error('One-command background installation is unsupported on this platform; run codex-reset-request watch'); + } + + console.log(`This will install and start a background watcher in ${options.mode} mode.`); + console.log('Automatic X replies require the explicit risk confirmation. No local OS notifications are used.'); + let desiredConfig: ResetRequestConfig; + const existingConfig = await deps.load(); + const disabledConfig = structuredClone(existingConfig); + disabledConfig.mode = 'dry-run'; + disabledConfig.consent.automaticPostingAccepted = false; + await deps.save(disabledConfig); + + desiredConfig = await deps.prepare(options); + const safeConfig = structuredClone(desiredConfig); + safeConfig.mode = 'dry-run'; + safeConfig.consent.automaticPostingAccepted = false; + await deps.save(safeConfig); + + const stopAfterFailure = async (originalError: unknown): Promise => { + let stopped: ServiceResult; + try { + stopped = await deps.manage('stop'); + } catch { + throw new Error( + 'Background service rollback also failed; automatic replies remain disabled in configuration, but inspect and stop the service manually', + { cause: originalError }, + ); + } + if (!stopped.ok) { + throw new Error( + `Background service rollback also failed (${stopped.code}); automatic replies remain disabled in configuration, but inspect and stop the service manually`, + { cause: originalError }, + ); + } + throw originalError; + }; + + let service: ServiceResult; + try { + service = await deps.manage('install'); + } catch (error) { + return await stopAfterFailure(error); + } + if (!service.ok) { + return await stopAfterFailure( + new Error(`Background service installation failed (${service.code}); automatic replies remain disabled`), + ); + } + let status: ServiceResult; + try { + status = await deps.inspect(); + } catch (error) { + return await stopAfterFailure(error); + } + if (!status.ok || !status.running) { + return await stopAfterFailure( + new Error(`Background service failed its startup check (${status.code}); automatic replies remain disabled`), + ); + } + + try { + await deps.save(desiredConfig); + } catch (error) { + return await stopAfterFailure(error); + } + console.log('Installed and running. Use codex-reset-request disable-auto to stop automatic replies.'); +} + +/** + * The smallest deployment flow: automatic posting is the default only after + * the user completes the explicit risk confirmation in setup. + */ +export function registerInstallCommand(program: Command): void { + program + .command('install') + .description('Configure automatic replies and install the running user service') + .option('--mode ', 'dry-run or auto', 'auto') + .option('--reply-text ', 'Reply text, up to 100 Unicode characters') + .option('--accept-disclaimer', 'Record acceptance without a yes/no prompt') + .option('--confirmation ', 'Exact automatic-posting confirmation text') + .option('--expected-x-handle ', 'Expected current X account handle') + .action( + async (options: { + mode: string; + replyText?: string; + acceptDisclaimer?: boolean; + confirmation?: string; + expectedXHandle?: string; + }) => { + await runInstall(options); + }, + ); +} diff --git a/src/reset/commands/logs.ts b/src/reset/commands/logs.ts new file mode 100644 index 0000000..9f4c07f --- /dev/null +++ b/src/reset/commands/logs.ts @@ -0,0 +1,15 @@ +import type { Command } from 'commander'; +import { readAuditTail } from '../state/audit-log.js'; + +export function registerLogsCommand(program: Command): void { + program + .command('logs') + .description('Print redacted local audit events') + .option('--tail ', 'Number of entries', '100') + .action(async (options: { tail: string }) => { + const count = Number.parseInt(options.tail, 10); + for (const line of await readAuditTail(count)) { + console.log(line); + } + }); +} diff --git a/src/reset/commands/service.ts b/src/reset/commands/service.ts new file mode 100644 index 0000000..4c432ef --- /dev/null +++ b/src/reset/commands/service.ts @@ -0,0 +1,30 @@ +import type { Command } from 'commander'; +import { manageService, type ServiceAction } from '../service/index.js'; +import { redactForLog } from '../utils/redaction.js'; + +const ACTIONS: ServiceAction[] = ['install', 'start', 'stop', 'restart', 'uninstall', 'status']; + +export function registerServiceCommand(program: Command): void { + const service = program.command('service').description('Manage the event-driven user background service'); + for (const action of ACTIONS) { + service + .command(action) + .description(`${action[0]?.toUpperCase()}${action.slice(1)} the user service`) + .option('--json', 'Print JSON') + .action(async (options: { json?: boolean }) => { + const result = await manageService(action); + const safeResult = redactForLog(result) as typeof result; + if (options.json) { + console.log(JSON.stringify(safeResult, null, 2)); + } else { + console.log(`${safeResult.code}${safeResult.definitionPath ? `: ${safeResult.definitionPath}` : ''}`); + if (safeResult.message) { + console.log(safeResult.message); + } + } + if (!result.ok) { + process.exitCode = 1; + } + }); + } +} diff --git a/src/reset/commands/setup.ts b/src/reset/commands/setup.ts new file mode 100644 index 0000000..5affda5 --- /dev/null +++ b/src/reset/commands/setup.ts @@ -0,0 +1,153 @@ +import { createInterface } from 'node:readline/promises'; +import type { Command } from 'commander'; +import { stdin as input, stdout as output } from 'node:process'; +import { + readConfiguredCodexRateLimits, + type RateLimitsReadOutcome, +} from '../codex/app-server-client.js'; +import { resolveCodexHome } from '../codex/codex-home.js'; +import { loadConfig } from '../config/load.js'; +import { saveConfig } from '../config/save.js'; +import { + CURRENT_DISCLAIMER_VERSION, + normalizeHandleInput, + resetRequestConfigSchema, + type RunMode, + type ResetRequestConfig, +} from '../config/schema.js'; +import { BirdXReplyProvider } from '../x/bird-provider.js'; +import type { TargetPostResult, XAccountResult } from '../x/provider.js'; + +const AUTO_CONFIRMATION = 'I UNDERSTAND THE X ACCOUNT RISK'; + +export interface SetupOptions { + mode: string; + expectedXHandle?: string; + replyText?: string; + acceptDisclaimer?: boolean; + confirmation?: string; +} + +interface SetupPrompt { + question(message: string): Promise; + close(): void; +} + +export interface SetupDependencies { + load(): Promise; + readRateLimits(config: ResetRequestConfig): Promise; + createProvider(config: ResetRequestConfig): { + getCurrentAccount(): Promise; + findTargetPost(input: { targetHandle: string; maxPostAgeHours: number }): Promise; + }; + createPrompt(): SetupPrompt; + resolveCodexHome(config: ResetRequestConfig): string; + now(): Date; +} + +const DEFAULT_DEPENDENCIES: SetupDependencies = { + load: loadConfig, + readRateLimits: readConfiguredCodexRateLimits, + createProvider: (config) => new BirdXReplyProvider(config), + createPrompt: () => createInterface({ input, output }), + resolveCodexHome, + now: () => new Date(), +}; + +function printDisclaimerSummary(): void { + console.log('Codex Reset Request is unofficial and cannot reset an account or guarantee a reset.'); + console.log('It uses an existing authenticated X browser session. Automated replies may put that account at risk.'); + console.log('Review DISCLAIMER.md and applicable platform rules before enabling automatic posting.'); +} + +export async function prepareSetup( + options: SetupOptions, + dependencies: Partial = {}, +): Promise { + const deps = { ...DEFAULT_DEPENDENCIES, ...dependencies }; + if (!['dry-run', 'auto'].includes(options.mode)) { + throw new Error('Mode must be dry-run or auto'); + } + if (Number(process.versions.node.split('.')[0]) < 22) { + throw new Error('Node.js 22 or newer is required'); + } + const config = await deps.load(); + if (options.replyText !== undefined) { + config.replyText = options.replyText; + } + resetRequestConfigSchema.parse(config); + const rateLimits = await deps.readRateLimits(config); + if (!rateLimits.ok) { + throw new Error(`Codex App Server preflight failed (${rateLimits.code})`); + } + const provider = deps.createProvider(config); + const currentAccount = await provider.getCurrentAccount(); + if (!currentAccount.ok) { + throw new Error(`X browser-session preflight failed (${currentAccount.safeCode})`); + } + if ( + options.expectedXHandle && + normalizeHandleInput(options.expectedXHandle).toLowerCase() !== currentAccount.handle.toLowerCase() + ) { + throw new Error('The supplied expected X handle does not match the active browser account'); + } + const target = await provider.findTargetPost({ + targetHandle: config.targetHandle, + maxPostAgeHours: config.maxPostAgeHours, + }); + if (target.status !== 'found') { + throw new Error(`Target-post read preflight failed (${target.safeCode})`); + } + console.log(`Preflight passed for the active X account and @${config.targetHandle}.`); + console.log(`Configured reply text: ${JSON.stringify(config.replyText)}`); + printDisclaimerSummary(); + + const prompt = deps.createPrompt(); + try { + const accepted = + options.acceptDisclaimer || (await prompt.question('Type YES to acknowledge the disclaimer: ')).trim() === 'YES'; + if (!accepted) { + throw new Error('Disclaimer was not accepted; configuration was not changed'); + } + + let automaticPostingAccepted = false; + if (options.mode === 'auto') { + const confirmation = options.confirmation ?? (await prompt.question(`Type exactly "${AUTO_CONFIRMATION}": `)); + automaticPostingAccepted = confirmation === AUTO_CONFIRMATION; + if (!automaticPostingAccepted) { + throw new Error('Automatic posting confirmation did not match exactly'); + } + } + + config.mode = options.mode as RunMode; + config.codexHome = deps.resolveCodexHome(config); + config.expectedXHandle = currentAccount.handle; + config.consent = { + disclaimerVersion: CURRENT_DISCLAIMER_VERSION, + acceptedAt: deps.now().toISOString(), + automaticPostingAccepted, + }; + return config; + } finally { + prompt.close(); + } +} + +export async function runSetup(options: SetupOptions): Promise { + const saved = await saveConfig(await prepareSetup(options)); + console.log(`Setup saved. Mode: ${saved.mode}.`); +} + +export function registerSetupCommand(program: Command): void { + program + .command('setup') + .description('Create a safe local configuration and record explicit consent') + .option('--mode ', 'dry-run or auto', 'dry-run') + .option('--expected-x-handle ', 'Expected current X account handle') + .option('--reply-text ', 'Reply text, up to 100 Unicode characters') + .option('--accept-disclaimer', 'Record acceptance without a yes/no prompt') + .option('--confirmation ', 'Exact automatic-posting confirmation text') + .action(async (options: SetupOptions) => await runSetup(options)); +} + +export { AUTO_CONFIRMATION }; diff --git a/src/reset/commands/status.ts b/src/reset/commands/status.ts new file mode 100644 index 0000000..0acd750 --- /dev/null +++ b/src/reset/commands/status.ts @@ -0,0 +1,53 @@ +import type { Command } from 'commander'; +import { loadConfig } from '../config/load.js'; +import { + ABSOLUTE_MAX_ATTEMPTS_PER_24_HOURS, + hasCurrentAutomaticPostingConsent, +} from '../config/schema.js'; +import { countRollingWriteAttempts } from '../pipeline/rate-guard.js'; +import { StateStore } from '../state/store.js'; + +export function registerStatusCommand(program: Command): void { + program + .command('status') + .description('Show local mode, consent, and latest safe action status') + .option('--json', 'Print JSON') + .action(async (options: { json?: boolean }) => { + const config = await loadConfig(); + const state = await new StateStore().load(); + const lastAction = state.actions.at(-1); + const status = { + mode: config.mode, + automaticPostingReady: hasCurrentAutomaticPostingConsent(config), + expectedXHandle: config.expectedXHandle, + targetHandle: config.targetHandle, + actionCount: state.actions.length, + attemptsInLast24Hours: countRollingWriteAttempts(state), + configuredMaximumPer24Hours: config.maxAttemptsPer24Hours, + absoluteMaximumPer24Hours: ABSOLUTE_MAX_ATTEMPTS_PER_24_HOURS, + lastAction: lastAction + ? { + actionId: lastAction.actionId, + status: lastAction.status, + detectedAt: lastAction.detectedAt, + completedAt: lastAction.completedAt, + safeCode: lastAction.safeCode, + targetPostUrl: lastAction.targetPostUrl, + replyUrl: lastAction.replyUrl, + } + : null, + }; + if (options.json) { + console.log(JSON.stringify(status, null, 2)); + return; + } + console.log(`Mode: ${status.mode}`); + console.log(`Automatic posting ready: ${status.automaticPostingReady ? 'yes' : 'no'}`); + console.log(`Expected X account: ${status.expectedXHandle ? `@${status.expectedXHandle}` : 'not recorded'}`); + console.log(`Actions: ${status.actionCount}`); + console.log( + `Write attempts (rolling 24h): ${status.attemptsInLast24Hours}/${status.configuredMaximumPer24Hours} (hard maximum ${status.absoluteMaximumPer24Hours})`, + ); + console.log(`Latest: ${status.lastAction?.status ?? 'none'}`); + }); +} diff --git a/src/reset/commands/test.ts b/src/reset/commands/test.ts new file mode 100644 index 0000000..26b93a4 --- /dev/null +++ b/src/reset/commands/test.ts @@ -0,0 +1,344 @@ +import type { Command } from 'commander'; +import { resolveBrowserFirstCredentials } from '../../lib/cookies.js'; +import { normalizeHandle } from '../../lib/normalize-handle.js'; +import { TwitterClient } from '../../lib/twitter-client.js'; +import type { + CurrentUserResult, + GetTweetResult, +} from '../../lib/twitter-client-types.js'; +import { extractUsageLimitCandidate } from '../codex/rollout-classifier.js'; +import { loadConfig } from '../config/load.js'; +import { getAppPaths, type ResetRequestPaths } from '../config/paths.js'; +import type { ResetRequestConfig } from '../config/schema.js'; +import { ActionStateMachine } from '../pipeline/action-state-machine.js'; +import { createActionId, createActionKey } from '../pipeline/fingerprints.js'; +import { evaluateActionRateGuard, evaluatePreTargetRateGuard } from '../pipeline/rate-guard.js'; +import { acquireSingleInstanceLock, type SingleInstanceLock } from '../state/lock.js'; +import type { ActionRecord } from '../state/schema.js'; +import { StateStore } from '../state/store.js'; +import { sha256 } from '../utils/hash.js'; +import { BirdXReplyProvider } from '../x/bird-provider.js'; +import type { TargetPost, XReplyProvider, XTargetReader } from '../x/provider.js'; + +const LIVE_ENVIRONMENT_VARIABLE = 'CRR_LIVE_X'; +const PROTECTED_TEST_TARGET = 'thsottiaux'; + +export interface ParsedOwnedPostUrl { + handle: string; + postId: string; + canonicalUrl: string; +} + +interface LiveTestPostReader { + getCurrentUser(): Promise; + getTweet(tweetId: string): Promise; +} + +export interface TestCommandDependencies { + env?: NodeJS.ProcessEnv; + paths?: ResetRequestPaths; + now?(): Date; + loadConfiguration?(): Promise; + createTargetReader?(config: ResetRequestConfig): XTargetReader; + createReplyProvider?(config: ResetRequestConfig): XReplyProvider; + createPostReader?(config: ResetRequestConfig): Promise; + createStateStore?(paths: ResetRequestPaths): StateStore; + acquireLock?(lockPath: string): Promise; +} + +function dependenciesFrom(partial: TestCommandDependencies): Required { + const paths = partial.paths ?? getAppPaths(); + return { + env: partial.env ?? process.env, + paths, + now: partial.now ?? (() => new Date()), + loadConfiguration: partial.loadConfiguration ?? (async () => await loadConfig(paths)), + createTargetReader: partial.createTargetReader ?? ((config) => new BirdXReplyProvider(config)), + createReplyProvider: partial.createReplyProvider ?? ((config) => new BirdXReplyProvider(config)), + createPostReader: + partial.createPostReader ?? + (async (config) => { + const credentials = await resolveBrowserFirstCredentials({ + cookieSource: config.cookieSource, + chromeProfile: config.chromeProfile ?? undefined, + firefoxProfile: config.firefoxProfile ?? undefined, + }); + if (!credentials.cookies.authToken || !credentials.cookies.ct0) { + throw new Error('X browser-session credentials are unavailable'); + } + return new TwitterClient({ cookies: credentials.cookies, timeoutMs: 10_000, quoteDepth: 1 }); + }), + createStateStore: partial.createStateStore ?? ((resolvedPaths) => new StateStore(resolvedPaths)), + acquireLock: partial.acquireLock ?? acquireSingleInstanceLock, + }; +} + +export function assertLiveXTestEnabled(env: NodeJS.ProcessEnv, liveFlagRequired: boolean, live: boolean): void { + if (env.CI) { + throw new Error('Live X tests are disabled in CI'); + } + if (env[LIVE_ENVIRONMENT_VARIABLE] !== '1') { + throw new Error(`Set ${LIVE_ENVIRONMENT_VARIABLE}=1 to enable a live X test`); + } + if (liveFlagRequired && !live) { + throw new Error('The live reply test also requires --live'); + } +} + +export function parseOwnedPostUrl(value: string): ParsedOwnedPostUrl { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error('Expected an HTTPS X status URL for a user-owned test post'); + } + const hostname = parsed.hostname.toLowerCase(); + const allowedHost = ['x.com', 'www.x.com', 'twitter.com', 'www.twitter.com'].includes(hostname); + if ( + parsed.protocol !== 'https:' || + !allowedHost || + parsed.username !== '' || + parsed.password !== '' || + parsed.port !== '' + ) { + throw new Error('Expected an HTTPS x.com or twitter.com status URL without credentials or a port'); + } + const match = /^\/([A-Za-z0-9_]{1,15})\/status\/(\d+)\/?$/.exec(parsed.pathname); + const handle = normalizeHandle(match?.[1]); + const postId = match?.[2]; + if (!handle || !postId) { + throw new Error('Expected a canonical //status/ URL'); + } + return { + handle: handle.toLowerCase(), + postId, + canonicalUrl: `https://x.com/${handle.toLowerCase()}/status/${postId}`, + }; +} + +export function runSyntheticTriggerTest(now: Date = new Date()): Record { + const candidate = extractUsageLimitCandidate( + { + type: 'event_msg', + payload: { type: 'error', codex_error_info: 'UsageLimitExceeded' }, + }, + { + safeFileName: 'synthetic-diagnostic.jsonl', + fileIdentity: 'synthetic-diagnostic', + byteOffset: 0, + observedAt: now, + }, + ); + if (!candidate) { + throw new Error('Synthetic trigger classifier self-test failed'); + } + return { + ok: true, + code: 'synthetic-trigger-detected', + synthetic: true, + mode: 'dry-run', + normalizedErrorType: candidate.normalizedErrorType, + networkRequests: 0, + mutationAttempts: 0, + stateWritten: false, + notice: 'Synthetic diagnostic only; no Codex or X action occurred.', + }; +} + +export async function runXReadTest( + partial: TestCommandDependencies = {}, +): Promise<{ code: string; currentAccount: string; targetHandle: string; targetPostUrl: string }> { + const dependencies = dependenciesFrom(partial); + assertLiveXTestEnabled(dependencies.env, false, false); + const config = await dependencies.loadConfiguration(); + const reader = dependencies.createTargetReader(config); + const account = await reader.getCurrentAccount(); + if (!account.ok) { + throw new Error(`X account read failed (${account.safeCode})`); + } + const target = await reader.findTargetPost({ + targetHandle: PROTECTED_TEST_TARGET, + maxPostAgeHours: config.maxPostAgeHours, + }); + if (target.status !== 'found') { + throw new Error(`X target read failed (${target.safeCode})`); + } + return { + code: 'x-read-ok', + currentAccount: `@${account.handle}`, + targetHandle: `@${PROTECTED_TEST_TARGET}`, + targetPostUrl: target.post.url, + }; +} + +function validatedCurrentAccount(current: CurrentUserResult, expectedHandle: string | null): { id: string; handle: string } { + const handle = normalizeHandle(current.user?.username)?.toLowerCase() ?? null; + if (!current.success || !current.user || !/^\d+$/.test(current.user.id) || !handle) { + throw new Error('Current X account metadata is unavailable'); + } + if (!expectedHandle || handle !== expectedHandle.toLowerCase()) { + throw new Error('Current X account does not match the account recorded by setup'); + } + return { id: current.user.id, handle }; +} + +function manualWriteInputs(config: ResetRequestConfig): Record { + return { + expectedXHandle: config.expectedXHandle, + replyText: config.replyText, + cookieSource: config.cookieSource, + chromeProfile: config.chromeProfile, + firefoxProfile: config.firefoxProfile, + maxAttemptsPer24Hours: config.maxAttemptsPer24Hours, + }; +} + +function manualWriteInputsEqual( + current: ResetRequestConfig, + initial: Record, +): boolean { + const currentInputs = manualWriteInputs(current); + return Object.keys(initial).every((key) => currentInputs[key] === initial[key]); +} + +function validateOwnedPost( + result: GetTweetResult, + parsed: ParsedOwnedPostUrl, + current: { id: string; handle: string }, +): TargetPost { + const authorHandle = normalizeHandle(result.tweet?.author.username)?.toLowerCase() ?? null; + if ( + !result.success || + !result.tweet || + result.tweet.id !== parsed.postId || + result.tweet.authorId !== current.id || + authorHandle !== current.handle || + parsed.handle !== current.handle + ) { + throw new Error('The supplied post is not verifiably owned by the current X account'); + } + if (authorHandle === PROTECTED_TEST_TARGET) { + throw new Error('Live tests must never reply to @thsottiaux'); + } + return { + id: parsed.postId, + authorHandle, + authorId: current.id, + createdAt: result.tweet.createdAt ?? new Date(0).toISOString(), + url: parsed.canonicalUrl, + selectionEvidence: { source: 'manual-live-test', ownershipVerified: true }, + }; +} + +export async function runXReplyTest( + url: string, + live: boolean, + partial: TestCommandDependencies = {}, +): Promise { + const dependencies = dependenciesFrom(partial); + assertLiveXTestEnabled(dependencies.env, true, live); + const parsed = parseOwnedPostUrl(url); + if (parsed.handle === PROTECTED_TEST_TARGET) { + throw new Error('Live tests must never reply to @thsottiaux'); + } + + const config = await dependencies.loadConfiguration(); + const initialWriteInputs = manualWriteInputs(config); + const lock = await dependencies.acquireLock(dependencies.paths.daemonLockFile); + try { + const postReader = await dependencies.createPostReader(config); + const current = validatedCurrentAccount(await postReader.getCurrentUser(), config.expectedXHandle); + const targetPost = validateOwnedPost(await postReader.getTweet(parsed.postId), parsed, current); + const eventFingerprint = sha256('manual-live-test-event', parsed.canonicalUrl, config.replyText); + const actionId = createActionId(eventFingerprint); + const limitWindowKey = sha256('manual-live-test-window', parsed.canonicalUrl); + const actionKey = createActionKey(limitWindowKey, parsed.postId, config.replyText); + const store = dependencies.createStateStore(dependencies.paths); + const state = await store.load(); + if (!state.actions.some((action) => action.actionId === actionId)) { + const preTargetGuard = evaluatePreTargetRateGuard({ + state, + actionId, + limitWindowKey, + configuredMaximum: config.maxAttemptsPer24Hours, + now: dependencies.now(), + }); + if (!preTargetGuard.allowed) { + throw new Error(`Live reply test blocked (${preTargetGuard.safeCode})`); + } + const actionGuard = evaluateActionRateGuard({ + state, + actionId, + actionKey, + attemptsIn24Hours: preTargetGuard.attemptsIn24Hours, + }); + if (!actionGuard.allowed) { + throw new Error(`Live reply test blocked (${actionGuard.safeCode})`); + } + } + + const provider = dependencies.createReplyProvider(config); + const machine = new ActionStateMachine(store, { now: dependencies.now }); + return await machine.execute({ + actionId, + eventFingerprint, + limitWindowKey, + actionKey, + detectedAt: dependencies.now().toISOString(), + targetHandle: current.handle, + targetPost, + replyText: config.replyText, + expectedXHandle: current.handle, + provider, + authorizeMutation: async () => { + if (dependencies.env.CI || dependencies.env[LIVE_ENVIRONMENT_VARIABLE] !== '1' || !live) { + return false; + } + const currentConfig = await dependencies.loadConfiguration(); + return manualWriteInputsEqual(currentConfig, initialWriteInputs); + }, + }); + } finally { + await lock.release(); + } +} + +export function registerTestCommand(program: Command): void { + const test = program.command('test').description('Run explicitly gated diagnostics'); + test + .command('trigger') + .description('Run a synthetic local classifier rehearsal with no network or state writes') + .action(() => { + console.log(JSON.stringify(runSyntheticTriggerTest(), null, 2)); + }); + test + .command('x-read') + .description('Run an explicitly enabled, read-only X integration test') + .action(async () => { + console.log(JSON.stringify(await runXReadTest(), null, 2)); + }); + test + .command('x-reply') + .description('Send one guarded reply to a post owned by the current X account') + .requiredOption('--url ', 'HTTPS URL of a user-owned X test post') + .option('--live', 'Acknowledge that this command performs one real X write') + .action(async (options: { url: string; live?: boolean }) => { + const result = await runXReplyTest(options.url, options.live === true); + console.log( + JSON.stringify( + { + code: `x-reply-${result.status}`, + actionId: result.actionId, + status: result.status, + replyUrl: result.replyUrl, + safeCode: result.safeCode, + }, + null, + 2, + ), + ); + if (result.status !== 'sent') { + process.exitCode = 1; + } + }); +} diff --git a/src/reset/commands/watch.ts b/src/reset/commands/watch.ts new file mode 100644 index 0000000..a4c0227 --- /dev/null +++ b/src/reset/commands/watch.ts @@ -0,0 +1,86 @@ +import type { Command } from 'commander'; +import { loadConfig } from '../config/load.js'; +import { getAppPaths } from '../config/paths.js'; +import { resolveCodexHome, resolveCodexSessionsDirectory } from '../codex/codex-home.js'; +import { ActionPipeline } from '../pipeline/action-pipeline.js'; +import { appendAuditEvent } from '../state/audit-log.js'; +import { StateStore } from '../state/store.js'; +import { CodexSessionWatcher } from '../watcher/codex-session-watcher.js'; + +function waitForTerminationSignal(): { promise: Promise; cleanup(): void } { + let resolvePromise: (() => void) | null = null; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + const handler = () => resolvePromise?.(); + process.once('SIGINT', handler); + process.once('SIGTERM', handler); + return { + promise, + cleanup(): void { + process.off('SIGINT', handler); + process.off('SIGTERM', handler); + }, + }; +} + +export function registerWatchCommand(program: Command): void { + program + .command('watch') + .description('Watch Codex rollout appends using native filesystem events') + .action(async () => { + const paths = getAppPaths(); + const config = await loadConfig(paths); + const watchedCodexHome = resolveCodexHome(config); + const pipeline = new ActionPipeline(new StateStore(paths), { + loadConfiguration: async () => { + const current = await loadConfig(paths); + if (resolveCodexHome(current) !== watchedCodexHome) { + throw new Error('Codex home changed; restart the watcher before processing more events'); + } + return current; + }, + }); + let rejectFatal: ((error: Error) => void) | null = null; + const fatal = new Promise((_resolve, reject) => { + rejectFatal = reject; + }); + const watcher = new CodexSessionWatcher({ + sessionsDirectory: resolveCodexSessionsDirectory(config), + paths, + async onReady() { + const recovered = await pipeline.stateMachine.recoverStaleAttempts(); + if (recovered > 0) { + await appendAuditEvent( + { level: 'warn', code: 'stale-attempts-recovered', detail: { count: recovered } }, + paths, + ); + } + }, + async onCandidate(candidate) { + const result = await pipeline.handleCandidate(candidate); + console.log( + `Usage-limit action ${result.status}${result.safeCode ? ` (${result.safeCode})` : ''}.`, + ); + }, + async onWarning(warning) { + await appendAuditEvent({ level: 'warn', code: warning.code, detail: { file: warning.safeBasename } }, paths); + }, + onFatal(error) { + rejectFatal?.(error); + }, + }); + + const signal = waitForTerminationSignal(); + try { + await watcher.start(); + await appendAuditEvent({ level: 'info', code: 'watcher-started' }, paths); + console.log('Watching Codex sessions with native filesystem events. No polling is active.'); + await Promise.race([signal.promise, fatal]); + } finally { + signal.cleanup(); + await watcher.stop(); + await appendAuditEvent({ level: 'info', code: 'watcher-stopped' }, paths); + } + }); +} diff --git a/src/reset/config/load.ts b/src/reset/config/load.ts new file mode 100644 index 0000000..f3460a1 --- /dev/null +++ b/src/reset/config/load.ts @@ -0,0 +1,20 @@ +import { readJsonFile } from '../utils/atomic-file.js'; +import type { ResetRequestPaths } from './paths.js'; +import { getAppPaths } from './paths.js'; +import { createDefaultConfig, resetRequestConfigSchema, type ResetRequestConfig } from './schema.js'; + +export async function loadConfig(paths: ResetRequestPaths = getAppPaths()): Promise { + const raw = await readJsonFile(paths.configFile); + if (raw === null) { + return createDefaultConfig(); + } + if (typeof raw === 'object' && !Array.isArray(raw)) { + const migrated = { ...raw } as Record; + if (migrated.mode === 'notify') { + migrated.mode = 'dry-run'; + } + delete migrated.notifications; + return resetRequestConfigSchema.parse(migrated); + } + return resetRequestConfigSchema.parse(raw); +} diff --git a/src/reset/config/paths.ts b/src/reset/config/paths.ts new file mode 100644 index 0000000..5ccf8b9 --- /dev/null +++ b/src/reset/config/paths.ts @@ -0,0 +1,69 @@ +import { homedir } from 'node:os'; +import path from 'node:path'; +import { ensurePrivateDirectory } from '../utils/atomic-file.js'; + +export interface ResetRequestPaths { + configDir: string; + stateDir: string; + logDir: string; + configFile: string; + stateFile: string; + cursorFile: string; + auditLogFile: string; + daemonLockFile: string; +} + +export interface PathResolutionOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + homeDirectory?: string; +} + +export function getAppPaths(options: PathResolutionOptions = {}): ResetRequestPaths { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const homeDirectory = options.homeDirectory ?? homedir(); + const pathApi = platform === 'win32' ? path.win32 : path.posix; + + let configDir: string; + let stateDir: string; + let logDir: string; + + if (platform === 'darwin') { + const applicationSupport = pathApi.join(homeDirectory, 'Library', 'Application Support', 'codex-reset-request'); + configDir = env.CRR_CONFIG_DIR ?? applicationSupport; + stateDir = env.CRR_STATE_DIR ?? pathApi.join(applicationSupport, 'state'); + logDir = env.CRR_LOG_DIR ?? pathApi.join(homeDirectory, 'Library', 'Logs', 'codex-reset-request'); + } else if (platform === 'win32') { + const appData = env.APPDATA ?? pathApi.join(homeDirectory, 'AppData', 'Roaming'); + const localAppData = env.LOCALAPPDATA ?? pathApi.join(homeDirectory, 'AppData', 'Local'); + configDir = env.CRR_CONFIG_DIR ?? pathApi.join(appData, 'codex-reset-request'); + stateDir = env.CRR_STATE_DIR ?? pathApi.join(localAppData, 'codex-reset-request'); + logDir = env.CRR_LOG_DIR ?? pathApi.join(stateDir, 'logs'); + } else { + configDir = + env.CRR_CONFIG_DIR ?? + pathApi.join(env.XDG_CONFIG_HOME ?? pathApi.join(homeDirectory, '.config'), 'codex-reset-request'); + stateDir = + env.CRR_STATE_DIR ?? + pathApi.join(env.XDG_STATE_HOME ?? pathApi.join(homeDirectory, '.local', 'state'), 'codex-reset-request'); + logDir = env.CRR_LOG_DIR ?? pathApi.join(stateDir, 'logs'); + } + + return { + configDir, + stateDir, + logDir, + configFile: pathApi.join(configDir, 'config.json'), + stateFile: pathApi.join(stateDir, 'state.json'), + cursorFile: pathApi.join(stateDir, 'cursors.json'), + auditLogFile: pathApi.join(logDir, 'audit.jsonl'), + daemonLockFile: pathApi.join(stateDir, 'watcher.lock'), + }; +} + +export async function ensureAppDirectories(paths: ResetRequestPaths): Promise { + await ensurePrivateDirectory(paths.configDir); + await ensurePrivateDirectory(paths.stateDir); + await ensurePrivateDirectory(paths.logDir); +} diff --git a/src/reset/config/save.ts b/src/reset/config/save.ts new file mode 100644 index 0000000..668589d --- /dev/null +++ b/src/reset/config/save.ts @@ -0,0 +1,14 @@ +import { writeJsonAtomic } from '../utils/atomic-file.js'; +import type { ResetRequestPaths } from './paths.js'; +import { ensureAppDirectories, getAppPaths } from './paths.js'; +import { resetRequestConfigSchema, type ResetRequestConfig } from './schema.js'; + +export async function saveConfig( + config: ResetRequestConfig, + paths: ResetRequestPaths = getAppPaths(), +): Promise { + const validated = resetRequestConfigSchema.parse(config); + await ensureAppDirectories(paths); + await writeJsonAtomic(paths.configFile, validated); + return validated; +} diff --git a/src/reset/config/schema.ts b/src/reset/config/schema.ts new file mode 100644 index 0000000..a414d3a --- /dev/null +++ b/src/reset/config/schema.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +export const CURRENT_DISCLAIMER_VERSION = '2026-08-28-v1'; +export const ABSOLUTE_MAX_ATTEMPTS_PER_24_HOURS = 3; + +const handleSchema = z + .string() + .min(1) + .max(15) + .regex(/^[A-Za-z0-9_]+$/, 'Expected one X handle without @ or a URL') + .transform((value) => value.toLowerCase()); + +const replyTextSchema = z.string().superRefine((value, context) => { + if (value.trim().length === 0) { + context.addIssue({ code: 'custom', message: 'Reply text must not be empty' }); + } + if ([...value].length > 100) { + context.addIssue({ code: 'custom', message: 'Reply text must contain at most 100 Unicode code points' }); + } + if ( + [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); + }) + ) { + context.addIssue({ code: 'custom', message: 'Reply text must not contain control characters' }); + } +}); + +export const resetRequestConfigSchema = z.object({ + version: z.literal(1), + codexHome: z.string().min(1).nullable(), + mode: z.enum(['dry-run', 'auto']), + targetHandle: handleSchema, + replyText: replyTextSchema, + expectedXHandle: handleSchema.nullable(), + cookieSource: z.enum(['auto', 'safari', 'chrome', 'firefox']), + chromeProfile: z.string().min(1).nullable(), + firefoxProfile: z.string().min(1).nullable(), + requireRateLimitConfirmation: z.literal(true), + maxPostAgeHours: z.number().int().min(1).max(168), + maxAttemptsPer24Hours: z.number().int().min(0).max(ABSOLUTE_MAX_ATTEMPTS_PER_24_HOURS), + consent: z.object({ + disclaimerVersion: z.string().min(1).nullable(), + acceptedAt: z.iso.datetime().nullable(), + automaticPostingAccepted: z.boolean(), + }), +}); + +export type RunMode = z.infer['mode']; +export type ResetRequestConfig = z.infer; + +export function createDefaultConfig(): ResetRequestConfig { + return { + version: 1, + codexHome: null, + mode: 'dry-run', + targetHandle: 'thsottiaux', + replyText: 'reset', + expectedXHandle: null, + cookieSource: 'auto', + chromeProfile: null, + firefoxProfile: null, + requireRateLimitConfirmation: true, + maxPostAgeHours: 72, + maxAttemptsPer24Hours: 1, + consent: { + disclaimerVersion: null, + acceptedAt: null, + automaticPostingAccepted: false, + }, + }; +} + +export const DEFAULT_CONFIG = createDefaultConfig(); + +export function hasCurrentAutomaticPostingConsent(config: ResetRequestConfig): boolean { + return ( + config.mode === 'auto' && + config.consent.automaticPostingAccepted && + config.consent.disclaimerVersion === CURRENT_DISCLAIMER_VERSION && + config.consent.acceptedAt !== null && + config.expectedXHandle !== null + ); +} + +export function normalizeHandleInput(value: string): string { + const trimmed = value.trim(); + if (trimmed.includes('://') || trimmed.includes('/') || trimmed.includes(',')) { + throw new Error('Expected one X handle, not a URL or list'); + } + return (trimmed.startsWith('@') ? trimmed.slice(1) : trimmed).toLowerCase(); +} diff --git a/src/reset/pipeline/action-pipeline.ts b/src/reset/pipeline/action-pipeline.ts new file mode 100644 index 0000000..18eca2a --- /dev/null +++ b/src/reset/pipeline/action-pipeline.ts @@ -0,0 +1,335 @@ +import type { RateLimitsReadOutcome } from '../codex/app-server-client.js'; +import { readConfiguredCodexRateLimits } from '../codex/app-server-client.js'; +import { resolveCodexHome } from '../codex/codex-home.js'; +import { confirmRateLimit } from '../codex/rate-limit-confirmation.js'; +import type { UsageLimitCandidate } from '../codex/rollout-types.js'; +import { loadConfig } from '../config/load.js'; +import type { ResetRequestPaths } from '../config/paths.js'; +import { getAppPaths } from '../config/paths.js'; +import { hasCurrentAutomaticPostingConsent, type ResetRequestConfig } from '../config/schema.js'; +import { appendAuditEvent, type AuditEvent } from '../state/audit-log.js'; +import type { ActionRecord, ActionStatus } from '../state/schema.js'; +import { StateStore } from '../state/store.js'; +import { sha256 } from '../utils/hash.js'; +import { BirdXReplyProvider } from '../x/bird-provider.js'; +import type { TargetPostResult, XAccountResult, XReplyProvider } from '../x/provider.js'; +import { ActionStateMachine } from './action-state-machine.js'; +import { findEventAction, isSafelyResumableBeforeWrite } from './deduplication.js'; +import { + createActionId, + createActionKey, + createFallbackLimitWindowKey, + createLimitWindowKey, +} from './fingerprints.js'; +import { evaluateActionRateGuard, evaluatePreTargetRateGuard } from './rate-guard.js'; + +export interface PipelineResult { + actionId: string; + status: ActionStatus | 'deduplicated'; + safeCode?: string; + targetPostUrl?: string; + replyUrl?: string; +} + +export interface ActionPipelineDependencies { + loadConfiguration(): Promise; + readRateLimits(config: ResetRequestConfig): Promise; + createProvider(config: ResetRequestConfig): XReplyProvider; + audit(event: AuditEvent): Promise; + now(): Date; + resolveCodexHome(config: ResetRequestConfig): string; +} + +function automationInputsEqual(first: ResetRequestConfig, second: ResetRequestConfig): boolean { + return ( + first.targetHandle === second.targetHandle && + first.replyText === second.replyText && + first.codexHome === second.codexHome && + first.expectedXHandle === second.expectedXHandle && + first.cookieSource === second.cookieSource && + first.chromeProfile === second.chromeProfile && + first.firefoxProfile === second.firefoxProfile && + first.maxPostAgeHours === second.maxPostAgeHours && + first.maxAttemptsPer24Hours === second.maxAttemptsPer24Hours && + first.requireRateLimitConfirmation === second.requireRateLimitConfirmation + ); +} + +function toResult(record: ActionRecord): PipelineResult { + return { + actionId: record.actionId, + status: record.status, + safeCode: record.safeCode, + targetPostUrl: record.targetPostUrl, + replyUrl: record.replyUrl, + }; +} + +export class ActionPipeline { + readonly stateMachine: ActionStateMachine; + private readonly store: StateStore; + private readonly dependencies: ActionPipelineDependencies; + private processingQueue: Promise = Promise.resolve(); + + constructor( + store: StateStore = new StateStore(), + dependencies: Partial = {}, + ) { + this.store = store; + const paths = store.paths; + this.dependencies = { + loadConfiguration: () => loadConfig(paths), + readRateLimits: readConfiguredCodexRateLimits, + createProvider: (config) => new BirdXReplyProvider(config), + audit: (event) => appendAuditEvent(event, paths), + now: () => new Date(), + resolveCodexHome: (config) => resolveCodexHome(config), + ...dependencies, + }; + this.stateMachine = new ActionStateMachine(store, { now: this.dependencies.now }); + } + + private async audit(event: AuditEvent): Promise { + try { + await this.dependencies.audit(event); + } catch { + // State is the durable source of truth; audit logging is best effort. + } + } + + private async replaceAction(actionId: string, update: Partial): Promise { + let output: ActionRecord | null = null; + await this.store.update((state) => { + const index = state.actions.findIndex((action) => action.actionId === actionId); + if (index === -1) { + throw new Error('Action state is missing'); + } + output = { ...state.actions[index], ...update }; + state.actions[index] = output; + return undefined; + }); + if (!output) { + throw new Error('Action state update failed'); + } + return output; + } + + private async finish(actionId: string, status: ActionStatus, safeCode: string): Promise { + const record = await this.replaceAction(actionId, { + status, + safeCode, + completedAt: this.dependencies.now().toISOString(), + }); + await this.audit({ level: status === 'dry-run' ? 'info' : 'warn', code: safeCode, actionId }); + return toResult(record); + } + + handleCandidate(candidate: UsageLimitCandidate): Promise { + const result = this.processingQueue.then(async () => await this.processCandidate(candidate)); + this.processingQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async processCandidate(candidate: UsageLimitCandidate): Promise { + const initialConfig = await this.dependencies.loadConfiguration(); + const generatedActionId = createActionId(candidate.eventFingerprint); + const detectionNow = this.dependencies.now(); + const provisionalLimitWindowKey = createFallbackLimitWindowKey( + this.dependencies.resolveCodexHome(initialConfig), + detectionNow, + ); + const stateBefore = await this.store.load(); + const duplicate = findEventAction(stateBefore, candidate.eventFingerprint); + const actionId = duplicate?.actionId ?? generatedActionId; + if (duplicate && !isSafelyResumableBeforeWrite(duplicate)) { + await this.audit({ level: 'info', code: 'event-deduplicated', actionId: duplicate.actionId }); + return { actionId: duplicate.actionId, status: 'deduplicated', safeCode: 'same-event' }; + } + + const candidateRecord: ActionRecord = { + actionId, + eventFingerprint: candidate.eventFingerprint, + limitWindowKey: provisionalLimitWindowKey, + detectedAt: candidate.observedAt, + status: 'candidate', + targetHandle: initialConfig.targetHandle, + }; + if (duplicate) { + await this.replaceAction(duplicate.actionId, { + ...candidateRecord, + actionId: duplicate.actionId, + detectedAt: duplicate.detectedAt, + actionKey: undefined, + confirmedAt: undefined, + attemptStartedAt: undefined, + mutationStartedAt: undefined, + completedAt: undefined, + targetPostId: undefined, + targetPostUrl: undefined, + replyTextHash: undefined, + replyTweetId: undefined, + replyUrl: undefined, + verifiedBy: undefined, + safeCode: undefined, + }); + await this.audit({ level: 'warn', code: 'pre-write-action-resumed', actionId }); + } else { + await this.store.update((state) => { + state.actions.push(candidateRecord); + return undefined; + }); + } + await this.audit({ + level: 'info', + code: 'usage-limit-candidate', + actionId, + detail: { tier: candidate.tier, safeFileName: candidate.safeFileName }, + }); + const rateLimits = await this.dependencies.readRateLimits(initialConfig); + if (!rateLimits.ok) { + return await this.finish(actionId, 'confirmation-failed', rateLimits.code); + } + const confirmationNow = this.dependencies.now(); + const confirmation = confirmRateLimit(rateLimits.value, confirmationNow); + if (!confirmation.confirmed) { + return await this.finish(actionId, 'confirmation-failed', confirmation.safeCode); + } + + const limitWindowKey = createLimitWindowKey({ + codexHome: this.dependencies.resolveCodexHome(initialConfig), + limitId: confirmation.limitId ?? confirmation.bucketKey, + resetsAt: confirmation.resetsAt, + now: confirmationNow, + }); + await this.replaceAction(actionId, { + status: 'confirmed', + limitWindowKey, + confirmedAt: this.dependencies.now().toISOString(), + safeCode: undefined, + }); + await this.audit({ level: 'info', code: 'usage-limit-confirmed', actionId }); + const preTargetGuard = evaluatePreTargetRateGuard({ + state: await this.store.load(), + actionId, + limitWindowKey, + configuredMaximum: initialConfig.maxAttemptsPer24Hours, + now: confirmationNow, + }); + if (initialConfig.mode === 'auto' && !preTargetGuard.allowed) { + return await this.finish(actionId, 'rate-guarded', preTargetGuard.safeCode); + } + + const provider = this.dependencies.createProvider(initialConfig); + let targetResult: TargetPostResult; + try { + targetResult = await provider.findTargetPost({ + targetHandle: initialConfig.targetHandle, + maxPostAgeHours: initialConfig.maxPostAgeHours, + }); + } catch { + return await this.finish(actionId, 'target-not-found', 'target-provider-failed'); + } + if (targetResult.status !== 'found') { + return await this.finish(actionId, 'target-not-found', targetResult.safeCode); + } + const targetPost = targetResult.post; + const actionKey = createActionKey(limitWindowKey, targetPost.id, initialConfig.replyText); + const actionGuard = evaluateActionRateGuard({ + state: await this.store.load(), + actionId, + actionKey, + attemptsIn24Hours: preTargetGuard.attemptsIn24Hours, + }); + if (initialConfig.mode === 'auto' && !actionGuard.allowed) { + return await this.finish(actionId, 'rate-guarded', actionGuard.safeCode); + } + await this.replaceAction(actionId, { + status: 'target-resolved', + actionKey, + targetPostId: targetPost.id, + targetPostUrl: targetPost.url, + replyTextHash: sha256(initialConfig.replyText), + safeCode: undefined, + }); + + let account: XAccountResult; + try { + account = await provider.getCurrentAccount(); + } catch { + return await this.finish(actionId, 'wrong-account', 'account-unavailable'); + } + if (!account.ok) { + return await this.finish(actionId, 'wrong-account', account.safeCode); + } + if (!initialConfig.expectedXHandle) { + return await this.finish(actionId, 'wrong-account', 'expected-account-not-recorded'); + } + const expectedXHandle = initialConfig.expectedXHandle; + if (account.handle.toLowerCase() !== expectedXHandle.toLowerCase()) { + return await this.finish(actionId, 'wrong-account', 'wrong-account'); + } + + const latestConfig = await this.dependencies.loadConfiguration(); + if (!automationInputsEqual(initialConfig, latestConfig)) { + return await this.finish(actionId, 'rejected', 'configuration-changed'); + } + if (initialConfig.mode === 'dry-run' || latestConfig.mode === 'dry-run') { + return await this.finish(actionId, 'dry-run', 'would-reply'); + } + if ( + initialConfig.mode !== 'auto' || + latestConfig.mode !== 'auto' || + !hasCurrentAutomaticPostingConsent(latestConfig) + ) { + return await this.finish(actionId, 'rejected', 'auto-consent-required'); + } + + const finalGuard = evaluatePreTargetRateGuard({ + state: await this.store.load(), + actionId, + limitWindowKey, + configuredMaximum: latestConfig.maxAttemptsPer24Hours, + now: this.dependencies.now(), + }); + if (!finalGuard.allowed) { + return await this.finish(actionId, 'rate-guarded', finalGuard.safeCode); + } + + const result = await this.stateMachine.execute({ + actionId, + eventFingerprint: candidate.eventFingerprint, + limitWindowKey, + actionKey, + detectedAt: candidate.observedAt, + targetHandle: latestConfig.targetHandle, + targetPost, + replyText: latestConfig.replyText, + expectedXHandle, + provider, + authorizeMutation: async () => { + const currentConfig = await this.dependencies.loadConfiguration(); + return ( + currentConfig.mode === 'auto' && + hasCurrentAutomaticPostingConsent(currentConfig) && + automationInputsEqual(latestConfig, currentConfig) + ); + }, + }); + if (result.status === 'definitive-failure' && result.safeCode === 'write-authorization-revoked') { + return await this.finish(actionId, 'rejected', 'write-authorization-revoked'); + } + await this.audit({ + level: result.status === 'sent' ? 'info' : result.status === 'unknown' ? 'warn' : 'error', + code: result.status === 'sent' ? 'reply-sent' : result.safeCode ?? 'reply-failed', + actionId, + }); + return toResult(result); + } +} + +export function createActionPipeline(paths: ResetRequestPaths = getAppPaths()): ActionPipeline { + return new ActionPipeline(new StateStore(paths)); +} diff --git a/src/reset/pipeline/action-state-machine.ts b/src/reset/pipeline/action-state-machine.ts new file mode 100644 index 0000000..2dc2e3f --- /dev/null +++ b/src/reset/pipeline/action-state-machine.ts @@ -0,0 +1,199 @@ +import type { StateStore } from '../state/store.js'; +import type { ActionRecord, ActionStatus } from '../state/schema.js'; +import { sha256 } from '../utils/hash.js'; +import type { TargetPost, XReplyProvider, XWriteResult } from '../x/provider.js'; + +const TERMINAL_STATUSES = new Set([ + 'dry-run', + 'notified', + 'target-not-found', + 'wrong-account', + 'sent', + 'definitive-failure', + 'unknown', + 'deduplicated', + 'rate-guarded', + 'confirmation-failed', + 'rejected', +]); + +export interface ExecuteReplyInput { + actionId: string; + eventFingerprint: string; + limitWindowKey: string; + actionKey: string; + detectedAt: string; + targetHandle: string; + targetPost: TargetPost; + replyText: string; + expectedXHandle: string; + provider: XReplyProvider; + authorizeMutation?(): Promise; +} + +function trustedSafeCode(value: string, fallback: string): string { + return /^[a-z0-9-]{1,100}$/.test(value) ? value : fallback; +} + +export class ActionStateMachine { + private readonly store: StateStore; + private readonly now: () => Date; + private executionQueue: Promise = Promise.resolve(); + + constructor(store: StateStore, options: { now?: () => Date } = {}) { + this.store = store; + this.now = options.now ?? (() => new Date()); + } + + private async replaceAction(record: ActionRecord): Promise { + await this.store.update((state) => { + const index = state.actions.findIndex((action) => action.actionId === record.actionId); + if (index === -1) { + state.actions.push(record); + } else { + state.actions[index] = record; + } + return undefined; + }); + return record; + } + + async recoverStaleAttempts(): Promise { + let recovered = 0; + const completedAt = this.now().toISOString(); + await this.store.update((state) => { + state.actions = state.actions.map((action) => { + if (action.status !== 'attempting') { + return action; + } + recovered += 1; + return { + ...action, + status: 'unknown', + safeCode: 'restart-ambiguous', + completedAt, + }; + }); + return undefined; + }); + return recovered; + } + + execute(input: ExecuteReplyInput): Promise { + const result = this.executionQueue.then(async () => await this.executeOnce(input)); + this.executionQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async executeOnce(input: ExecuteReplyInput): Promise { + const state = await this.store.load(); + const existing = state.actions.find((action) => action.actionId === input.actionId); + if (existing?.status === 'attempting') { + const recovered: ActionRecord = { + ...existing, + status: 'unknown', + safeCode: 'restart-ambiguous', + completedAt: this.now().toISOString(), + }; + return await this.replaceAction(recovered); + } + if (existing && TERMINAL_STATUSES.has(existing.status)) { + return existing; + } + + const attemptStartedAt = this.now(); + let attempting: ActionRecord = { + actionId: input.actionId, + eventFingerprint: input.eventFingerprint, + limitWindowKey: input.limitWindowKey, + actionKey: input.actionKey, + detectedAt: existing?.detectedAt ?? input.detectedAt, + confirmedAt: existing?.confirmedAt, + status: 'attempting', + targetHandle: input.targetHandle.toLowerCase(), + targetPostId: input.targetPost.id, + targetPostUrl: input.targetPost.url, + replyTextHash: sha256(input.replyText), + attemptStartedAt: attemptStartedAt.toISOString(), + }; + await this.replaceAction(attempting); + + let writeResult: XWriteResult; + try { + writeResult = await input.provider.replyOnce({ + targetPost: input.targetPost, + text: input.replyText, + attemptId: input.actionId, + expectedXHandle: input.expectedXHandle, + onMutationStart: async () => { + if (input.authorizeMutation && !(await input.authorizeMutation())) { + return { ok: false, safeCode: 'write-authorization-revoked' }; + } + const mutationStarting: ActionRecord = { + ...attempting, + mutationStartedAt: this.now().toISOString(), + }; + await this.replaceAction(mutationStarting); + attempting = mutationStarting; + return { ok: true }; + }, + }); + } catch { + writeResult = { + status: 'unknown', + safeCode: 'write-provider-threw', + targetPostUrl: input.targetPost.url, + }; + } + + if (writeResult.status === 'sent') { + return await this.replaceAction({ + ...attempting, + status: 'sent', + replyTweetId: writeResult.tweetId, + replyUrl: writeResult.url, + verifiedBy: writeResult.verifiedBy, + completedAt: this.now().toISOString(), + }); + } + if (writeResult.status === 'definitive-failure') { + return await this.replaceAction({ + ...attempting, + status: 'definitive-failure', + safeCode: trustedSafeCode(writeResult.safeCode, 'write-rejected'), + completedAt: this.now().toISOString(), + }); + } + + const unknown = await this.replaceAction({ + ...attempting, + status: 'unknown', + safeCode: trustedSafeCode(writeResult.safeCode, 'write-result-unknown'), + completedAt: this.now().toISOString(), + }); + try { + const verification = await input.provider.verifyReply({ + targetPostId: input.targetPost.id, + replyText: input.replyText, + attemptStartedAt, + }); + if (verification.status === 'verified') { + return await this.replaceAction({ + ...unknown, + status: 'sent', + replyTweetId: verification.tweetId, + replyUrl: verification.url, + verifiedBy: 'read-after-write', + safeCode: undefined, + completedAt: this.now().toISOString(), + }); + } + } catch { + // A read-only verification failure leaves the action unknown. + } + return unknown; + } +} diff --git a/src/reset/pipeline/deduplication.ts b/src/reset/pipeline/deduplication.ts new file mode 100644 index 0000000..21b5cca --- /dev/null +++ b/src/reset/pipeline/deduplication.ts @@ -0,0 +1,53 @@ +import type { ActionRecord, ResetState } from '../state/schema.js'; + +export function findEventAction(state: ResetState, eventFingerprint: string): ActionRecord | null { + return state.actions.find((action) => action.eventFingerprint === eventFingerprint) ?? null; +} + +export function isSafelyResumableBeforeWrite(action: ActionRecord): boolean { + return ( + (action.status === 'candidate' || + action.status === 'confirmed' || + action.status === 'target-resolved') && + action.attemptStartedAt === undefined && + action.mutationStartedAt === undefined && + !occupiesWriteGuard(action) + ); +} + +export function occupiesWriteGuard(action: ActionRecord): boolean { + return ( + action.status === 'attempting' || + action.status === 'sent' || + action.status === 'unknown' || + action.mutationStartedAt !== undefined + ); +} + +export function findLimitWindowAttempt( + state: ResetState, + limitWindowKey: string, + excludeActionId?: string, +): ActionRecord | null { + return ( + state.actions.find( + (action) => + action.actionId !== excludeActionId && + action.limitWindowKey === limitWindowKey && + occupiesWriteGuard(action), + ) ?? null + ); +} + +export function findActionKeyAttempt( + state: ResetState, + actionKey: string, + excludeActionId?: string, +): ActionRecord | null { + return ( + state.actions.find( + (action) => + action.actionId !== excludeActionId && action.actionKey === actionKey && occupiesWriteGuard(action), + ) ?? null + ); +} diff --git a/src/reset/pipeline/fingerprints.ts b/src/reset/pipeline/fingerprints.ts new file mode 100644 index 0000000..8526e56 --- /dev/null +++ b/src/reset/pipeline/fingerprints.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; +import { sha256 } from '../utils/hash.js'; + +export interface LimitWindowFingerprintInput { + codexHome: string; + limitId: string | null; + resetsAt: number | null; + now?: Date; + platform?: NodeJS.Platform; +} + +export function codexHomeIdentity( + codexHome: string, + platform: NodeJS.Platform = process.platform, +): string { + const resolved = platform === 'win32' ? path.win32.resolve(codexHome) : path.resolve(codexHome); + return sha256(platform === 'win32' ? resolved.toLowerCase() : resolved); +} + +export function normalizeReplyText(replyText: string): string { + return replyText.normalize('NFC').trim(); +} + +export function createFallbackLimitWindowKey( + codexHome: string, + now: Date = new Date(), + platform: NodeJS.Platform = process.platform, +): string { + return sha256(codexHomeIdentity(codexHome, platform), 'usage-limit', now.toISOString().slice(0, 10)); +} + +export function createLimitWindowKey(input: LimitWindowFingerprintInput): string { + const hasStableWindowIdentity = input.resetsAt !== null; + if (!hasStableWindowIdentity) { + return createFallbackLimitWindowKey(input.codexHome, input.now, input.platform); + } + return sha256( + codexHomeIdentity(input.codexHome, input.platform), + input.limitId, + input.resetsAt, + ); +} + +export function createActionKey(limitWindowKey: string, targetPostId: string, replyText: string): string { + return sha256(limitWindowKey, targetPostId, normalizeReplyText(replyText)); +} + +export function createActionId(eventFingerprint: string): string { + return sha256('usage-limit-action', eventFingerprint); +} diff --git a/src/reset/pipeline/rate-guard.ts b/src/reset/pipeline/rate-guard.ts new file mode 100644 index 0000000..a19a22d --- /dev/null +++ b/src/reset/pipeline/rate-guard.ts @@ -0,0 +1,65 @@ +import { ABSOLUTE_MAX_ATTEMPTS_PER_24_HOURS } from '../config/schema.js'; +import type { ActionRecord, ResetState } from '../state/schema.js'; +import { findActionKeyAttempt, findLimitWindowAttempt, occupiesWriteGuard } from './deduplication.js'; + +const ROLLING_WINDOW_MS = 24 * 60 * 60 * 1_000; + +export type RateGuardCode = + | 'same-limit-window' + | 'same-action' + | 'rolling-24-hour-limit' + | 'hard-24-hour-limit'; + +export type RateGuardResult = + | { allowed: true; attemptsIn24Hours: number } + | { allowed: false; safeCode: RateGuardCode; attemptsIn24Hours: number }; + +function attemptTimestamp(action: ActionRecord): number | null { + if (!occupiesWriteGuard(action)) { + return null; + } + const timestamp = Date.parse(action.mutationStartedAt ?? action.attemptStartedAt ?? ''); + return Number.isFinite(timestamp) ? timestamp : null; +} + +export function countRollingWriteAttempts(state: ResetState, now: Date = new Date()): number { + const threshold = now.getTime() - ROLLING_WINDOW_MS; + return state.actions.filter((action) => { + const timestamp = attemptTimestamp(action); + // A future timestamp may reflect a backward wall-clock correction. Counting + // it fails closed instead of letting clock skew erase a real write attempt. + return timestamp !== null && timestamp >= threshold; + }).length; +} + +export function evaluatePreTargetRateGuard(input: { + state: ResetState; + actionId: string; + limitWindowKey: string; + configuredMaximum: number; + now?: Date; +}): RateGuardResult { + const attemptsIn24Hours = countRollingWriteAttempts(input.state, input.now); + if (findLimitWindowAttempt(input.state, input.limitWindowKey, input.actionId)) { + return { allowed: false, safeCode: 'same-limit-window', attemptsIn24Hours }; + } + if (attemptsIn24Hours >= ABSOLUTE_MAX_ATTEMPTS_PER_24_HOURS) { + return { allowed: false, safeCode: 'hard-24-hour-limit', attemptsIn24Hours }; + } + if (attemptsIn24Hours >= input.configuredMaximum) { + return { allowed: false, safeCode: 'rolling-24-hour-limit', attemptsIn24Hours }; + } + return { allowed: true, attemptsIn24Hours }; +} + +export function evaluateActionRateGuard(input: { + state: ResetState; + actionId: string; + actionKey: string; + attemptsIn24Hours: number; +}): RateGuardResult { + if (findActionKeyAttempt(input.state, input.actionKey, input.actionId)) { + return { allowed: false, safeCode: 'same-action', attemptsIn24Hours: input.attemptsIn24Hours }; + } + return { allowed: true, attemptsIn24Hours: input.attemptsIn24Hours }; +} diff --git a/src/reset/program.ts b/src/reset/program.ts new file mode 100644 index 0000000..68f2a15 --- /dev/null +++ b/src/reset/program.ts @@ -0,0 +1,33 @@ +import { Command } from 'commander'; +import { registerConfigCommand } from './commands/config.js'; +import { registerDisableAutoCommand } from './commands/disable-auto.js'; +import { registerDoctorCommand } from './commands/doctor.js'; +import { registerEnableAutoCommand } from './commands/enable-auto.js'; +import { registerInstallCommand } from './commands/install.js'; +import { registerLogsCommand } from './commands/logs.js'; +import { registerServiceCommand } from './commands/service.js'; +import { registerSetupCommand } from './commands/setup.js'; +import { registerStatusCommand } from './commands/status.js'; +import { registerTestCommand } from './commands/test.js'; +import { registerWatchCommand } from './commands/watch.js'; + +export function createResetRequestProgram(): Command { + const program = new Command(); + program + .name('codex-reset-request') + .description('Event-driven local Codex usage-limit action tool') + .version('0.1.0-alpha.0'); + + registerSetupCommand(program); + registerInstallCommand(program); + registerDoctorCommand(program); + registerStatusCommand(program); + registerWatchCommand(program); + registerEnableAutoCommand(program); + registerDisableAutoCommand(program); + registerConfigCommand(program); + registerLogsCommand(program); + registerServiceCommand(program); + registerTestCommand(program); + return program; +} diff --git a/src/reset/service/index.ts b/src/reset/service/index.ts new file mode 100644 index 0000000..a360276 --- /dev/null +++ b/src/reset/service/index.ts @@ -0,0 +1,582 @@ +import type { Stats } from 'node:fs'; +import { lstat, realpath, unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveBrowserFirstCredentials } from '../../lib/cookies.js'; +import { resolveCodexHome } from '../codex/codex-home.js'; +import { loadConfig } from '../config/load.js'; +import { ensureAppDirectories, getAppPaths, type ResetRequestPaths } from '../config/paths.js'; +import type { ResetRequestConfig } from '../config/schema.js'; +import { rejectSymlink, writeFileAtomic } from '../utils/atomic-file.js'; +import { runBoundedCommand, type BoundedCommandResult } from '../utils/process.js'; +import { + LAUNCHD_LABEL, + launchAgentPath, + launchdDomain, + launchdServiceTarget, + renderLaunchAgent, +} from './launchd.js'; +import { renderSystemdUnit, SYSTEMD_UNIT_NAME, systemdUnitPath } from './systemd.js'; +import { WINDOWS_SERVICE_MESSAGE, WINDOWS_SERVICE_UNSUPPORTED_CODE } from './windows.js'; + +export type ServiceAction = 'install' | 'start' | 'stop' | 'restart' | 'uninstall' | 'status'; + +export interface ServiceResult { + ok: boolean; + supported: boolean; + installed: boolean; + running: boolean; + code: string; + message?: string; + definitionPath?: string; +} + +interface ServiceRuntime { + platform: NodeJS.Platform; + homeDirectory: string; + env: NodeJS.ProcessEnv; + uid: number | null; + nodePath: string; + cliPath: string; + paths: ResetRequestPaths; + environmentPath: string; +} + +interface ManagerState { + available: boolean; + loaded: boolean; + running: boolean; + safeCode?: string; +} + +export interface ServiceDependencies { + platform?: NodeJS.Platform; + homeDirectory?: string; + env?: NodeJS.ProcessEnv; + uid?: number | null; + nodePath?: string; + cliPath?: string; + codexHome?: string; + paths?: ResetRequestPaths; + runCommand?(binary: string, args: string[]): Promise; + lstat?(filePath: string): Promise; + realpath?(filePath: string): Promise; + unlink?(filePath: string): Promise; + writeDefinition?(filePath: string, value: string): Promise; + ensureDirectories?(paths: ResetRequestPaths): Promise; + loadConfiguration?(paths: ResetRequestPaths): Promise; + validateBackgroundCredentials?(config: ResetRequestConfig): Promise; +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; +} + +function unsupported(): ServiceResult { + return { + ok: false, + supported: false, + installed: false, + running: false, + code: WINDOWS_SERVICE_UNSUPPORTED_CODE, + message: WINDOWS_SERVICE_MESSAGE, + }; +} + +function definitionPath(runtime: ServiceRuntime): string { + if (runtime.platform === 'darwin') { + return launchAgentPath(runtime.homeDirectory); + } + return systemdUnitPath(runtime.homeDirectory, runtime.env); +} + +function assertSingleLine(value: string, label: string): void { + if (value.includes('\0') || value.includes('\r') || value.includes('\n')) { + throw new Error(`${label} must be a single-line value`); + } +} + +function validateServicePaths(runtime: ServiceRuntime): void { + const pathApi = runtime.platform === 'win32' ? path.win32 : path.posix; + const values: Array<[string, string]> = [ + ['Service home directory', runtime.homeDirectory], + ['Service definition path', definitionPath(runtime)], + ['Config directory', runtime.paths.configDir], + ['State directory', runtime.paths.stateDir], + ['Log directory', runtime.paths.logDir], + ['Config file', runtime.paths.configFile], + ['State file', runtime.paths.stateFile], + ['Cursor file', runtime.paths.cursorFile], + ['Audit log file', runtime.paths.auditLogFile], + ['Daemon lock file', runtime.paths.daemonLockFile], + ]; + for (const [label, value] of values) { + assertSingleLine(value, label); + if (!pathApi.isAbsolute(value)) { + throw new Error(`${label} must be absolute for service management`); + } + } + assertSingleLine(runtime.environmentPath, 'Service PATH'); +} + +async function regularFileState( + filePath: string, + inspect: (target: string) => Promise, +): Promise<'regular' | 'missing' | 'unsafe'> { + try { + const stats = await inspect(filePath); + return stats.isFile() && !stats.isSymbolicLink() ? 'regular' : 'unsafe'; + } catch (error) { + if (isMissing(error)) { + return 'missing'; + } + throw error; + } +} + +function runtimeFrom(dependencies: ServiceDependencies): ServiceRuntime { + const platform = dependencies.platform ?? process.platform; + const homeDirectory = dependencies.homeDirectory ?? homedir(); + const env = dependencies.env ?? process.env; + return { + platform, + homeDirectory, + env, + uid: dependencies.uid === undefined ? (process.getuid?.() ?? null) : dependencies.uid, + nodePath: dependencies.nodePath ?? process.execPath, + cliPath: dependencies.cliPath ?? fileURLToPath(new URL('../cli.js', import.meta.url)), + paths: + dependencies.paths ?? + getAppPaths({ + platform, + homeDirectory, + env, + }), + environmentPath: env.PATH ?? '/usr/local/bin:/usr/bin:/bin', + }; +} + +async function validatedRuntime( + runtime: ServiceRuntime, + dependencies: ServiceDependencies, +): Promise { + validateServicePaths(runtime); + const resolveRealpath = dependencies.realpath ?? realpath; + const inspect = dependencies.lstat ?? lstat; + const pathApi = runtime.platform === 'win32' ? path.win32 : path.posix; + if (!pathApi.isAbsolute(runtime.nodePath) || !pathApi.isAbsolute(runtime.cliPath)) { + throw new Error('Service installation requires absolute Node and built CLI paths'); + } + assertSingleLine(runtime.nodePath, 'Node path'); + assertSingleLine(runtime.cliPath, 'CLI path'); + const nodePath = await resolveRealpath(runtime.nodePath).catch(() => runtime.nodePath); + const cliPath = await resolveRealpath(runtime.cliPath).catch(() => runtime.cliPath); + if ( + (await regularFileState(nodePath, inspect)) !== 'regular' || + (await regularFileState(cliPath, inspect)) !== 'regular' + ) { + throw new Error('Service installation requires absolute regular Node and built CLI files'); + } + if (runtime.platform === 'darwin' && (runtime.uid === null || runtime.uid < 0)) { + throw new Error('A user id is required for launchd installation'); + } + return { ...runtime, nodePath, cliPath }; +} + +async function serviceFileInstalled(runtime: ServiceRuntime, dependencies: ServiceDependencies): Promise { + const state = await regularFileState(definitionPath(runtime), dependencies.lstat ?? lstat); + if (state === 'unsafe') { + throw new Error('Refusing unsafe service definition path'); + } + return state === 'regular'; +} + +function managerUnavailable(result: BoundedCommandResult): ManagerState { + return { + available: false, + loaded: false, + running: false, + safeCode: result.safeCode ?? 'manager-query-failed', + }; +} + +function launchdOutputIsRunning(stdout: string): boolean { + return /\bstate\s*=\s*running\b/i.test(stdout) || /\bpid\s*=\s*[1-9]\d*\b/i.test(stdout); +} + +async function queryManager(runtime: ServiceRuntime, dependencies: ServiceDependencies): Promise { + const run = dependencies.runCommand ?? runBoundedCommand; + if (runtime.platform === 'darwin') { + if (runtime.uid === null || runtime.uid < 0) { + return { available: false, loaded: false, running: false, safeCode: 'service-user-id-unavailable' }; + } + const result = await run('launchctl', ['print', launchdServiceTarget(runtime.uid)]); + if (result.ok) { + return { available: true, loaded: true, running: launchdOutputIsRunning(result.stdout) }; + } + if (!result.safeCode && (result.exitCode === 3 || result.exitCode === 113)) { + return { available: true, loaded: false, running: false }; + } + return managerUnavailable(result); + } + + const active = await run('systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]); + if (active.safeCode) { + return managerUnavailable(active); + } + if (active.ok) { + return { available: true, loaded: true, running: true }; + } + const activeState = active.stdout.trim().toLowerCase(); + const expectedInactive = new Set(['inactive', 'failed', 'activating', 'deactivating', 'unknown']); + if (active.exitCode !== 3 && active.exitCode !== 4 && !expectedInactive.has(activeState)) { + return managerUnavailable(active); + } + + const enabled = await run('systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT_NAME]); + if (enabled.safeCode) { + return managerUnavailable(enabled); + } + const enabledState = enabled.stdout.trim().toLowerCase(); + const knownEnabledStates = new Set([ + 'enabled', + 'enabled-runtime', + 'linked', + 'linked-runtime', + 'alias', + 'static', + 'indirect', + 'generated', + 'transient', + 'disabled', + 'masked', + 'masked-runtime', + 'not-found', + 'bad', + ]); + if (!enabled.ok && !knownEnabledStates.has(enabledState)) { + return managerUnavailable(enabled); + } + return { + available: true, + loaded: enabled.ok || (knownEnabledStates.has(enabledState) && !['not-found', 'bad'].includes(enabledState)), + running: false, + }; +} + +function unavailableResult(installed: boolean, filePath: string, manager: ManagerState): ServiceResult { + return { + ok: false, + supported: true, + installed, + running: false, + code: manager.safeCode === 'service-user-id-unavailable' ? manager.safeCode : 'service-status-unavailable', + message: manager.safeCode, + definitionPath: filePath, + }; +} + +async function status(runtime: ServiceRuntime, dependencies: ServiceDependencies): Promise { + const installed = await serviceFileInstalled(runtime, dependencies); + const filePath = definitionPath(runtime); + const manager = await queryManager(runtime, dependencies); + if (!manager.available) { + return unavailableResult(installed, filePath, manager); + } + let code: string; + if (manager.running) { + code = installed ? 'service-running' : 'service-running-definition-missing'; + } else if (manager.loaded) { + code = installed ? 'service-installed-stopped' : 'service-loaded-definition-missing'; + } else { + code = installed ? 'service-installed-stopped' : 'service-not-installed'; + } + return { + ok: true, + supported: true, + installed, + running: manager.running, + code, + definitionPath: filePath, + }; +} + +async function validateDefaultBackgroundCredentials(config: ResetRequestConfig): Promise { + const result = await resolveBrowserFirstCredentials({ + cookieSource: config.cookieSource, + chromeProfile: config.chromeProfile ?? undefined, + firefoxProfile: config.firefoxProfile ?? undefined, + }); + if (!result.cookies.authToken || !result.cookies.ct0) { + throw new Error('Service installation requires a readable X browser session'); + } + if (result.cookies.source?.startsWith('env ')) { + throw new Error('Environment-only X credentials are unsupported for background services'); + } +} + +async function install(runtime: ServiceRuntime, dependencies: ServiceDependencies): Promise { + const checked = await validatedRuntime(runtime, dependencies); + const filePath = definitionPath(checked); + const installedBefore = await serviceFileInstalled(checked, dependencies); + const managerBefore = await queryManager(checked, dependencies); + if (!managerBefore.available) { + return unavailableResult(installedBefore, filePath, managerBefore); + } + + const config = await (dependencies.loadConfiguration ?? loadConfig)(checked.paths); + await (dependencies.validateBackgroundCredentials ?? validateDefaultBackgroundCredentials)(config); + const codexHome = dependencies.codexHome ?? resolveCodexHome(config, checked.env); + assertSingleLine(codexHome, 'Codex home'); + const pathApi = checked.platform === 'win32' ? path.win32 : path.posix; + if (!pathApi.isAbsolute(codexHome)) { + throw new Error('Codex home must be absolute for service installation'); + } + + await (dependencies.ensureDirectories ?? ensureAppDirectories)(checked.paths); + const definition = + checked.platform === 'darwin' + ? renderLaunchAgent({ + nodePath: checked.nodePath, + cliPath: checked.cliPath, + codexHome, + paths: checked.paths, + environmentPath: checked.environmentPath, + }) + : renderSystemdUnit({ + nodePath: checked.nodePath, + cliPath: checked.cliPath, + codexHome, + paths: checked.paths, + environmentPath: checked.environmentPath, + }); + const write = + dependencies.writeDefinition ?? + (async (target: string, value: string) => { + await writeFileAtomic(target, value, { preserveDirectoryMode: true }); + }); + await write(filePath, definition); + + const run = dependencies.runCommand ?? runBoundedCommand; + let command: BoundedCommandResult; + if (checked.platform === 'darwin') { + const uid = checked.uid; + if (uid === null || uid < 0) { + throw new Error('A user id is required for launchd installation'); + } + const target = launchdServiceTarget(uid); + if (managerBefore.loaded) { + const bootout = await run('launchctl', ['bootout', target]); + if (!bootout.ok) { + return { + ok: false, + supported: true, + installed: true, + running: managerBefore.running, + code: 'service-command-failed', + definitionPath: filePath, + }; + } + } + command = await run('launchctl', ['bootstrap', launchdDomain(uid), filePath]); + } else { + const reload = await run('systemctl', ['--user', 'daemon-reload']); + if (!reload.ok) { + command = reload; + } else { + const enable = await run('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT_NAME]); + command = enable.ok && managerBefore.running + ? await run('systemctl', ['--user', 'restart', SYSTEMD_UNIT_NAME]) + : enable; + } + } + return { + ok: command.ok, + supported: true, + installed: true, + running: command.ok, + code: command.ok ? 'service-installed-running' : 'service-command-failed', + definitionPath: filePath, + }; +} + +async function lifecycle( + action: Exclude, + runtime: ServiceRuntime, + dependencies: ServiceDependencies, +): Promise { + const filePath = definitionPath(runtime); + const installed = await serviceFileInstalled(runtime, dependencies); + const manager = await queryManager(runtime, dependencies); + if (!manager.available) { + return unavailableResult(installed, filePath, manager); + } + if (action !== 'stop' && !installed) { + return { + ok: false, + supported: true, + installed: false, + running: manager.running, + code: 'service-not-installed', + definitionPath: filePath, + }; + } + + const run = dependencies.runCommand ?? runBoundedCommand; + let result: BoundedCommandResult = { ok: true, exitCode: 0, stdout: '' }; + if (runtime.platform === 'darwin') { + if (runtime.uid === null || runtime.uid < 0) { + throw new Error('A user id is required for launchd management'); + } + const target = launchdServiceTarget(runtime.uid); + if (action === 'stop') { + if (manager.loaded) { + result = await run('launchctl', ['bootout', target]); + } + } else if (action === 'start') { + result = manager.loaded + ? await run('launchctl', ['kickstart', target]) + : await run('launchctl', ['bootstrap', launchdDomain(runtime.uid), filePath]); + } else { + result = manager.loaded + ? await run('launchctl', ['kickstart', '-k', target]) + : await run('launchctl', ['bootstrap', launchdDomain(runtime.uid), filePath]); + } + } else if (action === 'stop') { + if (manager.loaded || installed) { + result = await run('systemctl', ['--user', 'stop', SYSTEMD_UNIT_NAME]); + } + } else { + result = await run('systemctl', ['--user', action, SYSTEMD_UNIT_NAME]); + } + + return { + ok: result.ok, + supported: true, + installed, + running: result.ok && action !== 'stop', + code: result.ok + ? action === 'stop' + ? 'service-stopped' + : action === 'start' + ? 'service-started' + : 'service-restarted' + : 'service-command-failed', + definitionPath: filePath, + }; +} + +async function uninstallService(runtime: ServiceRuntime, dependencies: ServiceDependencies): Promise { + const filePath = definitionPath(runtime); + const installed = await serviceFileInstalled(runtime, dependencies); + const manager = await queryManager(runtime, dependencies); + if (!manager.available) { + return unavailableResult(installed, filePath, manager); + } + if (!installed && !manager.loaded && !manager.running) { + return { + ok: true, + supported: true, + installed: false, + running: false, + code: 'service-not-installed', + definitionPath: filePath, + }; + } + + const run = dependencies.runCommand ?? runBoundedCommand; + let unloadResult: BoundedCommandResult | null = null; + if (runtime.platform === 'darwin') { + if (runtime.uid === null || runtime.uid < 0) { + throw new Error('A user id is required for launchd management'); + } + if (manager.loaded) { + unloadResult = await run('launchctl', ['bootout', launchdServiceTarget(runtime.uid)]); + } + } else { + unloadResult = await run('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT_NAME]); + } + if (unloadResult && !unloadResult.ok) { + return { + ok: false, + supported: true, + installed, + running: manager.running, + code: 'service-command-failed', + definitionPath: filePath, + }; + } + + if (installed) { + if (!dependencies.unlink) { + await rejectSymlink(filePath); + } + await (dependencies.unlink ?? unlink)(filePath); + } + if (runtime.platform !== 'darwin') { + const reload = await run('systemctl', ['--user', 'daemon-reload']); + if (!reload.ok) { + return { + ok: false, + supported: true, + installed: false, + running: false, + code: 'service-command-failed', + definitionPath: filePath, + }; + } + } + return { + ok: true, + supported: true, + installed: false, + running: false, + code: 'service-uninstalled', + definitionPath: filePath, + }; +} + +export async function manageService( + action: ServiceAction, + dependencies: ServiceDependencies = {}, +): Promise { + const runtime = runtimeFrom(dependencies); + if (runtime.platform === 'win32') { + return unsupported(); + } + if (runtime.platform !== 'darwin' && runtime.platform !== 'linux') { + return { + ...unsupported(), + code: 'platform-service-unsupported', + message: 'Background service installation is unsupported on this platform; use foreground watch mode.', + }; + } + validateServicePaths(runtime); + if (action === 'status') { + return await status(runtime, dependencies); + } + if (action === 'install') { + return await install(runtime, dependencies); + } + if (action === 'uninstall') { + return await uninstallService(runtime, dependencies); + } + return await lifecycle(action, runtime, dependencies); +} + +export async function inspectService(dependencies: ServiceDependencies = {}): Promise { + try { + return await manageService('status', dependencies); + } catch { + return { + ok: false, + supported: true, + installed: false, + running: false, + code: 'service-status-unavailable', + }; + } +} + +export { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME }; diff --git a/src/reset/service/launchd.ts b/src/reset/service/launchd.ts new file mode 100644 index 0000000..06b4492 --- /dev/null +++ b/src/reset/service/launchd.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import type { ResetRequestPaths } from '../config/paths.js'; + +export const LAUNCHD_LABEL = 'io.github.ncihxaonn.codex-reset-request'; + +export interface LaunchdDefinitionInput { + nodePath: string; + cliPath: string; + codexHome: string; + paths: ResetRequestPaths; + environmentPath: string; +} + +function xml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function stringEntry(value: string): string { + return ` ${xml(value)}`; +} + +export function launchAgentPath(homeDirectory: string): string { + return path.posix.join(homeDirectory, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); +} + +export function launchdServiceTarget(uid: number): string { + return `gui/${uid}/${LAUNCHD_LABEL}`; +} + +export function launchdDomain(uid: number): string { + return `gui/${uid}`; +} + +export function renderLaunchAgent(input: LaunchdDefinitionInput): string { + const environment = [ + ['CRR_CONFIG_DIR', input.paths.configDir], + ['CRR_STATE_DIR', input.paths.stateDir], + ['CRR_LOG_DIR', input.paths.logDir], + ['CRR_CODEX_HOME', input.codexHome], + ['PATH', input.environmentPath], + ] as const; + const environmentXml = environment + .flatMap(([name, value]) => [` ${name}`, stringEntry(value)]) + .join('\n'); + return ` + + + + Label + ${LAUNCHD_LABEL} + ProgramArguments + +${stringEntry(input.nodePath)} +${stringEntry(input.cliPath)} + watch + + EnvironmentVariables + +${environmentXml} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 5 + ProcessType + Background + StandardOutPath + ${xml(path.posix.join(input.paths.logDir, 'service.stdout.log'))} + StandardErrorPath + ${xml(path.posix.join(input.paths.logDir, 'service.stderr.log'))} + + +`; +} diff --git a/src/reset/service/systemd.ts b/src/reset/service/systemd.ts new file mode 100644 index 0000000..59e80d4 --- /dev/null +++ b/src/reset/service/systemd.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import type { ResetRequestPaths } from '../config/paths.js'; + +export const SYSTEMD_UNIT_NAME = 'codex-reset-request.service'; + +export interface SystemdDefinitionInput { + nodePath: string; + cliPath: string; + codexHome: string; + paths: ResetRequestPaths; + environmentPath: string; +} + +function assertSingleLine(value: string): void { + if (value.includes('\0') || value.includes('\r') || value.includes('\n')) { + throw new Error('Service definition values must be single-line strings'); + } +} + +function systemdQuoted(value: string): string { + assertSingleLine(value); + return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`; +} + +export function systemdUnitPath(homeDirectory: string, env: NodeJS.ProcessEnv = process.env): string { + const configHome = env.XDG_CONFIG_HOME ?? path.posix.join(homeDirectory, '.config'); + return path.posix.join(configHome, 'systemd', 'user', SYSTEMD_UNIT_NAME); +} + +export function renderSystemdUnit(input: SystemdDefinitionInput): string { + const environment = [ + ['CRR_CONFIG_DIR', input.paths.configDir], + ['CRR_STATE_DIR', input.paths.stateDir], + ['CRR_LOG_DIR', input.paths.logDir], + ['CRR_CODEX_HOME', input.codexHome], + ['PATH', input.environmentPath], + ] as const; + for (const value of [input.nodePath, input.cliPath, ...environment.map((entry) => entry[1])]) { + assertSingleLine(value); + } + return `[Unit] +Description=Codex Reset Request event-driven watcher + +[Service] +Type=simple +ExecStart=:${systemdQuoted(input.nodePath)} ${systemdQuoted(input.cliPath)} watch +${environment.map(([name, value]) => `Environment=${systemdQuoted(`${name}=${value}`)}`).join('\n')} +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +`; +} diff --git a/src/reset/service/windows.ts b/src/reset/service/windows.ts new file mode 100644 index 0000000..1dd41b5 --- /dev/null +++ b/src/reset/service/windows.ts @@ -0,0 +1,4 @@ +export const WINDOWS_SERVICE_UNSUPPORTED_CODE = 'windows-service-unsupported'; + +export const WINDOWS_SERVICE_MESSAGE = + 'Windows service installation is unsupported in v0.1; run `codex-reset-request watch` in the foreground.'; diff --git a/src/reset/state/audit-log.ts b/src/reset/state/audit-log.ts new file mode 100644 index 0000000..8e6f616 --- /dev/null +++ b/src/reset/state/audit-log.ts @@ -0,0 +1,88 @@ +import { constants } from 'node:fs'; +import { lstat, open, type FileHandle } from 'node:fs/promises'; +import { ensureAppDirectories, getAppPaths, type ResetRequestPaths } from '../config/paths.js'; +import { rejectSymlink } from '../utils/atomic-file.js'; +import { redactForLog } from '../utils/redaction.js'; + +export const MAX_AUDIT_LOG_BYTES = 16 * 1024 * 1024; + +async function openValidatedAuditLog(filePath: string, flags: number, mode?: number): Promise { + await rejectSymlink(filePath); + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; + const handle = await open(filePath, flags | noFollow, mode); + try { + const [stats, pathStats] = await Promise.all([handle.stat(), lstat(filePath)]); + if ( + !stats.isFile() || + !pathStats.isFile() || + pathStats.isSymbolicLink() || + stats.dev !== pathStats.dev || + stats.ino !== pathStats.ino || + stats.nlink !== 1 || + stats.size > MAX_AUDIT_LOG_BYTES + ) { + throw new Error('Invalid audit log file'); + } + return handle; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } +} + +export interface AuditEvent { + at?: string; + level: 'info' | 'warn' | 'error'; + code: string; + actionId?: string; + detail?: Record; +} + +export async function appendAuditEvent( + event: AuditEvent, + paths: ResetRequestPaths = getAppPaths(), +): Promise { + await ensureAppDirectories(paths); + const safeEvent = redactForLog({ ...event, at: event.at ?? new Date().toISOString() }); + const serialized = `${JSON.stringify(safeEvent)}\n`; + const handle = await openValidatedAuditLog( + paths.auditLogFile, + constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY, + 0o600, + ); + try { + const stats = await handle.stat(); + if (stats.size + Buffer.byteLength(serialized, 'utf8') > MAX_AUDIT_LOG_BYTES) { + throw new Error('Audit log size limit exceeded'); + } + await handle.writeFile(serialized, 'utf8'); + await handle.sync(); + if (process.platform !== 'win32') { + await handle.chmod(0o600); + } + } finally { + await handle.close(); + } +} + +export async function readAuditTail( + limit: number, + paths: ResetRequestPaths = getAppPaths(), +): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) { + throw new Error('Tail limit must be between 1 and 10000'); + } + let handle: FileHandle | null = null; + try { + handle = await openValidatedAuditLog(paths.auditLogFile, constants.O_RDONLY); + const contents = await handle.readFile('utf8'); + return contents.trimEnd().split('\n').filter(Boolean).slice(-limit); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } finally { + await handle?.close().catch(() => undefined); + } +} diff --git a/src/reset/state/lock.ts b/src/reset/state/lock.ts new file mode 100644 index 0000000..8ac5392 --- /dev/null +++ b/src/reset/state/lock.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'node:crypto'; +import { chmod, open, readFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { ensurePrivateDirectory, rejectSymlink } from '../utils/atomic-file.js'; + +interface LockRecord { + pid: number; + startedAt: string; + token: string; +} + +export class LockHeldError extends Error { + readonly pid: number | null; + + constructor(pid: number | null) { + super(pid ? `Watcher is already running with PID ${pid}` : 'Watcher lock is already held'); + this.name = 'LockHeldError'; + this.pid = pid; + } +} + +function parseLockRecord(value: string): LockRecord | null { + try { + const raw = JSON.parse(value) as Partial; + if (typeof raw.pid !== 'number' || typeof raw.startedAt !== 'string' || typeof raw.token !== 'string') { + return null; + } + return { pid: raw.pid, startedAt: raw.startedAt, token: raw.token }; + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +async function readLock(lockPath: string): Promise { + try { + return parseLockRecord(await readFile(lockPath, 'utf8')); + } catch { + return null; + } +} + +export interface SingleInstanceLock { + record: LockRecord; + release(): Promise; +} + +export async function acquireSingleInstanceLock(lockPath: string): Promise { + await ensurePrivateDirectory(path.dirname(lockPath)); + await rejectSymlink(lockPath); + + const record: LockRecord = { + pid: process.pid, + startedAt: new Date().toISOString(), + token: randomUUID(), + }; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const handle = await open(lockPath, 'wx', 0o600); + await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); + await handle.sync(); + await handle.close(); + if (process.platform !== 'win32') { + await chmod(lockPath, 0o600); + } + + let released = false; + return { + record, + async release(): Promise { + if (released) { + return; + } + const current = await readLock(lockPath); + if (current?.token === record.token) { + await unlink(lockPath).catch(() => undefined); + } + released = true; + }, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + const existing = await readLock(lockPath); + if (attempt === 0 && (!existing || !isProcessAlive(existing.pid))) { + await unlink(lockPath); + continue; + } + throw new LockHeldError(existing?.pid ?? null); + } + } + + throw new LockHeldError(null); +} diff --git a/src/reset/state/migrations.ts b/src/reset/state/migrations.ts new file mode 100644 index 0000000..5869180 --- /dev/null +++ b/src/reset/state/migrations.ts @@ -0,0 +1,137 @@ +import { z } from 'zod'; +import { createEmptyState, resetStateSchema, type ActionRecord, type ResetState } from './schema.js'; + +// The first v1 builds accepted a looser action shape. Upgrades must retain those +// records because even incomplete write evidence is a reason to fail closed. +const legacyActionSchema = z.object({ + actionId: z.string().min(1), + eventFingerprint: z.string().regex(/^[a-f0-9]{64}$/), + limitWindowKey: z.string().regex(/^[a-f0-9]{64}$/), + actionKey: z.string().regex(/^[a-f0-9]{64}$/).optional(), + detectedAt: z.iso.datetime(), + confirmedAt: z.iso.datetime().optional(), + attemptStartedAt: z.iso.datetime().optional(), + mutationStartedAt: z.iso.datetime().optional(), + completedAt: z.iso.datetime().optional(), + status: z.enum([ + 'candidate', + 'confirmed', + 'target-resolved', + 'dry-run', + 'notified', + 'target-not-found', + 'wrong-account', + 'attempting', + 'sent', + 'definitive-failure', + 'unknown', + 'deduplicated', + 'rate-guarded', + 'confirmation-failed', + 'rejected', + ]), + targetHandle: z.string().regex(/^[A-Za-z0-9_]{1,15}$/), + targetPostId: z.string().regex(/^\d+$/).optional(), + targetPostUrl: z.url().optional(), + replyTextHash: z.string().regex(/^[a-f0-9]{64}$/).optional(), + replyTweetId: z.string().regex(/^\d+$/).optional(), + replyUrl: z.url().optional(), + verifiedBy: z.enum(['mutation-response', 'read-after-write']).optional(), + safeCode: z.string().regex(/^[a-z0-9-]+$/).max(100).optional(), +}); + +const legacyStateSchema = z.object({ + version: z.literal(1), + updatedAt: z.iso.datetime(), + actions: z.array(legacyActionSchema), +}); + +type LegacyAction = z.infer; + +function migrationPriority(action: LegacyAction): number { + if (action.status === 'attempting') return 5; + if (action.status === 'sent' || action.status === 'unknown') return 4; + if (action.attemptStartedAt) return 3; + if (action.completedAt) return 2; + return 1; +} + +function normalizeLegacyHandle(handle: string): string { + const normalized = handle.replace(/^@/, '').toLowerCase(); + return /^[a-z0-9_]{1,15}$/i.test(normalized) ? normalized : 'unknown'; +} + +function normalizeLegacyAction(action: LegacyAction): ActionRecord { + const terminal = !['candidate', 'confirmed', 'target-resolved', 'attempting'].includes(action.status); + const fallbackTimestamp = action.completedAt ?? action.attemptStartedAt ?? action.detectedAt; + const incompleteTarget = + action.status === 'target-resolved' && + (!action.confirmedAt || !action.actionKey || !action.targetPostId || !action.targetPostUrl || !action.replyTextHash); + const unsafePreWrite = + (action.status === 'candidate' || action.status === 'confirmed' || action.status === 'target-resolved') && + (action.attemptStartedAt !== undefined || action.mutationStartedAt !== undefined); + let status = action.status; + if (unsafePreWrite || action.status === 'attempting') { + status = 'unknown'; + } else if (action.status === 'confirmed' && !action.confirmedAt) { + status = 'candidate'; + } else if (incompleteTarget) { + status = action.confirmedAt ? 'confirmed' : 'candidate'; + } + const wasWriteState = + action.status === 'attempting' || + action.status === 'sent' || + action.status === 'unknown' || + action.mutationStartedAt !== undefined; + const normalizedSafeCode = /^[a-z0-9-]{1,100}$/.test(action.safeCode ?? '') + ? action.safeCode + : 'legacy-state-imported'; + return { + ...action, + status, + targetHandle: normalizeLegacyHandle(action.targetHandle), + attemptStartedAt: + wasWriteState || unsafePreWrite ? (action.attemptStartedAt ?? fallbackTimestamp) : action.attemptStartedAt, + mutationStartedAt: wasWriteState || unsafePreWrite + ? (action.mutationStartedAt ?? action.attemptStartedAt ?? fallbackTimestamp) + : undefined, + completedAt: terminal || status === 'unknown' ? fallbackTimestamp : action.completedAt, + safeCode: normalizedSafeCode, + legacyImported: true, + }; +} + +function migrateLegacyState(raw: unknown): ResetState { + const legacy = legacyStateSchema.parse(raw); + const ordered = [...legacy.actions].sort((left, right) => migrationPriority(right) - migrationPriority(left)); + const actionIds = new Set(); + const eventFingerprints = new Set(); + const selected: LegacyAction[] = []; + for (const action of ordered) { + if (actionIds.has(action.actionId) || eventFingerprints.has(action.eventFingerprint)) { + continue; + } + actionIds.add(action.actionId); + eventFingerprints.add(action.eventFingerprint); + selected.push(action); + } + return resetStateSchema.parse({ + ...legacy, + actions: selected.map(normalizeLegacyAction), + }); +} + +export function migrateState(raw: unknown): ResetState { + if (raw === null || raw === undefined) { + return createEmptyState(); + } + if (!raw || typeof raw !== 'object') { + throw new Error('State data is not an object'); + } + const version = (raw as { version?: unknown }).version; + if (version !== 1) { + throw new Error(`Unsupported state version: ${String(version)}`); + } + const current = resetStateSchema.safeParse(raw); + return current.success ? current.data : migrateLegacyState(raw); +} diff --git a/src/reset/state/schema.ts b/src/reset/state/schema.ts new file mode 100644 index 0000000..af516f4 --- /dev/null +++ b/src/reset/state/schema.ts @@ -0,0 +1,156 @@ +import { z } from 'zod'; + +// `notified` is retained only so existing v1 state can still be read. Current +// code never emits it because local OS notifications are not a product mode. +export const actionStatusSchema = z.enum([ + 'candidate', + 'confirmed', + 'target-resolved', + 'dry-run', + 'notified', + 'target-not-found', + 'wrong-account', + 'attempting', + 'sent', + 'definitive-failure', + 'unknown', + 'deduplicated', + 'rate-guarded', + 'confirmation-failed', + 'rejected', +]); + +export const actionRecordSchema = z + .object({ + actionId: z.string().min(1), + eventFingerprint: z.string().regex(/^[a-f0-9]{64}$/), + limitWindowKey: z.string().regex(/^[a-f0-9]{64}$/), + actionKey: z.string().regex(/^[a-f0-9]{64}$/).optional(), + detectedAt: z.iso.datetime(), + confirmedAt: z.iso.datetime().optional(), + attemptStartedAt: z.iso.datetime().optional(), + mutationStartedAt: z.iso.datetime().optional(), + completedAt: z.iso.datetime().optional(), + status: actionStatusSchema, + targetHandle: z.string().regex(/^[A-Za-z0-9_]{1,15}$/), + targetPostId: z.string().regex(/^\d+$/).optional(), + targetPostUrl: z.url().optional(), + replyTextHash: z.string().regex(/^[a-f0-9]{64}$/).optional(), + replyTweetId: z.string().regex(/^\d+$/).optional(), + replyUrl: z.url().optional(), + verifiedBy: z.enum(['mutation-response', 'read-after-write']).optional(), + safeCode: z.string().regex(/^[a-z0-9-]+$/).max(100).optional(), + /** Added only while importing records created by the early, looser v1 schema. */ + legacyImported: z.literal(true).optional(), + }) + .superRefine((record, context) => { + const requireFields = (fields: Array, reason: string) => { + for (const field of fields) { + if (!record[field]) { + context.addIssue({ code: 'custom', path: [field], message: `${field} is required ${reason}` }); + } + } + }; + const preWriteStatuses = new Set(['candidate', 'confirmed', 'target-resolved']); + const terminalStatuses = new Set([ + 'dry-run', + 'notified', + 'target-not-found', + 'wrong-account', + 'sent', + 'definitive-failure', + 'unknown', + 'deduplicated', + 'rate-guarded', + 'confirmation-failed', + 'rejected', + ]); + if (record.status === 'confirmed') { + requireFields(['confirmedAt'], 'when confirmed'); + } + if (record.status === 'target-resolved') { + requireFields( + ['confirmedAt', 'actionKey', 'targetPostId', 'targetPostUrl', 'replyTextHash'], + 'when target-resolved', + ); + } + if (record.status === 'attempting') { + requireFields( + ['actionKey', 'targetPostId', 'targetPostUrl', 'replyTextHash', 'attemptStartedAt'], + 'while attempting', + ); + } + if (record.status === 'sent' && !record.legacyImported) { + requireFields( + [ + 'actionKey', + 'targetPostId', + 'targetPostUrl', + 'replyTextHash', + 'attemptStartedAt', + 'mutationStartedAt', + 'replyTweetId', + 'replyUrl', + 'verifiedBy', + 'completedAt', + ], + 'when sent', + ); + } + if (terminalStatuses.has(record.status)) { + requireFields(['completedAt'], 'for a terminal action'); + } + if ( + preWriteStatuses.has(record.status) && + (record.attemptStartedAt !== undefined || record.mutationStartedAt !== undefined) + ) { + context.addIssue({ + code: 'custom', + path: ['status'], + message: 'A pre-write action cannot carry write-attempt markers', + }); + } + if (record.mutationStartedAt && !record.attemptStartedAt) { + context.addIssue({ + code: 'custom', + path: ['mutationStartedAt'], + message: 'mutationStartedAt requires attemptStartedAt', + }); + } + }); + +export const resetStateSchema = z + .object({ + version: z.literal(1), + updatedAt: z.iso.datetime(), + // Never discard an event fingerprint or limit-window write guard: either + // could turn a replay into a second external mutation. Operators may archive + // the whole state file only while the watcher is stopped. + actions: z.array(actionRecordSchema), + }) + .superRefine((state, context) => { + const actionIds = new Set(); + const eventFingerprints = new Set(); + state.actions.forEach((action, index) => { + if (actionIds.has(action.actionId)) { + context.addIssue({ code: 'custom', path: ['actions', index, 'actionId'], message: 'Duplicate actionId' }); + } + if (eventFingerprints.has(action.eventFingerprint)) { + context.addIssue({ + code: 'custom', + path: ['actions', index, 'eventFingerprint'], + message: 'Duplicate eventFingerprint', + }); + } + actionIds.add(action.actionId); + eventFingerprints.add(action.eventFingerprint); + }); + }); + +export type ActionStatus = z.infer; +export type ActionRecord = z.infer; +export type ResetState = z.infer; + +export function createEmptyState(now: Date = new Date()): ResetState { + return { version: 1, updatedAt: now.toISOString(), actions: [] }; +} diff --git a/src/reset/state/store.ts b/src/reset/state/store.ts new file mode 100644 index 0000000..c6f6ac5 --- /dev/null +++ b/src/reset/state/store.ts @@ -0,0 +1,34 @@ +import { readJsonFile, writeJsonAtomic } from '../utils/atomic-file.js'; +import type { ResetRequestPaths } from '../config/paths.js'; +import { ensureAppDirectories, getAppPaths } from '../config/paths.js'; +import { migrateState } from './migrations.js'; +import { resetStateSchema, type ResetState } from './schema.js'; + +export class StateStore { + readonly paths: ResetRequestPaths; + + constructor(paths: ResetRequestPaths = getAppPaths()) { + this.paths = paths; + } + + async load(): Promise { + return migrateState(await readJsonFile(this.paths.stateFile)); + } + + async save(state: ResetState): Promise { + const validated = resetStateSchema.parse({ + ...state, + updatedAt: new Date().toISOString(), + }); + await ensureAppDirectories(this.paths); + await writeJsonAtomic(this.paths.stateFile, validated); + return validated; + } + + async update(mutator: (state: ResetState) => ResetState | undefined): Promise { + const current = await this.load(); + const draft = structuredClone(current); + const result = mutator(draft); + return this.save(result ?? draft); + } +} diff --git a/src/reset/utils/atomic-file.ts b/src/reset/utils/atomic-file.ts new file mode 100644 index 0000000..f6dae8c --- /dev/null +++ b/src/reset/utils/atomic-file.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'; +import path from 'node:path'; + +export const MAX_JSON_FILE_BYTES = 4 * 1024 * 1024; + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; +} + +export async function rejectSymlink(targetPath: string): Promise { + try { + const stats = await lstat(targetPath); + if (stats.isSymbolicLink()) { + throw new Error(`Refusing symbolic link at ${path.basename(targetPath)}`); + } + } catch (error) { + if (!isMissing(error)) { + throw error; + } + } +} + +export async function ensurePrivateDirectory(directoryPath: string): Promise { + await rejectSymlink(directoryPath); + await mkdir(directoryPath, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') { + await chmod(directoryPath, 0o700); + } +} + +export async function writeFileAtomic( + filePath: string, + value: string, + options: { preserveDirectoryMode?: boolean } = {}, +): Promise { + const directoryPath = path.dirname(filePath); + if (options.preserveDirectoryMode) { + await rejectSymlink(directoryPath); + await mkdir(directoryPath, { recursive: true, mode: 0o700 }); + } else { + await ensurePrivateDirectory(directoryPath); + } + await rejectSymlink(filePath); + + const temporaryPath = path.join( + directoryPath, + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle: Awaited> | null = null; + + try { + handle = await open(temporaryPath, 'wx', 0o600); + await handle.writeFile(value, 'utf8'); + await handle.sync(); + await handle.close(); + handle = null; + await rename(temporaryPath, filePath); + if (process.platform !== 'win32') { + await chmod(filePath, 0o600); + } + + try { + const directoryHandle = await open(directoryPath, constants.O_RDONLY); + await directoryHandle.sync(); + await directoryHandle.close(); + } catch { + // Directory fsync is not supported on every platform/filesystem. + } + } finally { + if (handle) { + await handle.close().catch(() => undefined); + } + await unlink(temporaryPath).catch(() => undefined); + } +} + +export async function writeJsonAtomic(filePath: string, value: unknown): Promise { + const serialized = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(serialized, 'utf8') > MAX_JSON_FILE_BYTES) { + throw new Error(`Local data file is too large: ${path.basename(filePath)}`); + } + await writeFileAtomic(filePath, serialized); +} + +export async function readJsonFile(filePath: string): Promise { + let handle: Awaited> | null = null; + try { + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; + handle = await open(filePath, constants.O_RDONLY | noFollow); + const [openedStats, pathStats] = await Promise.all([handle.stat(), lstat(filePath)]); + if ( + pathStats.isSymbolicLink() || + !openedStats.isFile() || + openedStats.dev !== pathStats.dev || + openedStats.ino !== pathStats.ino || + openedStats.size > MAX_JSON_FILE_BYTES + ) { + throw new Error(`Invalid local data file: ${path.basename(filePath)}`); + } + return JSON.parse(await handle.readFile('utf8')) as unknown; + } catch (error) { + if (isMissing(error)) { + return null; + } + if ((error as NodeJS.ErrnoException | undefined)?.code === 'ELOOP') { + throw new Error(`Invalid local data file: ${path.basename(filePath)}`); + } + throw error; + } finally { + await handle?.close().catch(() => undefined); + } +} diff --git a/src/reset/utils/hash.ts b/src/reset/utils/hash.ts new file mode 100644 index 0000000..eded950 --- /dev/null +++ b/src/reset/utils/hash.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto'; + +export function sha256(...parts: Array): string { + const hash = createHash('sha256'); + for (const part of parts) { + hash.update(String(part ?? '')); + hash.update('\0'); + } + return hash.digest('hex'); +} diff --git a/src/reset/utils/process.ts b/src/reset/utils/process.ts new file mode 100644 index 0000000..bd3f708 --- /dev/null +++ b/src/reset/utils/process.ts @@ -0,0 +1,113 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; + +export interface BoundedCommandResult { + ok: boolean; + exitCode: number | null; + stdout: string; + safeCode?: 'binary-not-found' | 'spawn-failed' | 'timeout' | 'output-too-large'; +} + +function waitForExit(child: ChildProcessWithoutNullStreams, milliseconds: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.off('exit', onExit); + resolve(false); + }, milliseconds); + const onExit = () => { + clearTimeout(timer); + resolve(true); + }; + child.once('exit', onExit); + }); +} + +export async function terminateChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + child.stdin.end(); + if (await waitForExit(child, 100)) { + return; + } + child.kill('SIGTERM'); + if (await waitForExit(child, 250)) { + return; + } + child.kill('SIGKILL'); + await waitForExit(child, 250); +} + +export async function runBoundedCommand( + binary: string, + args: string[], + options: { timeoutMs?: number; maxOutputBytes?: number; environment?: NodeJS.ProcessEnv } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 5_000; + const maxOutputBytes = options.maxOutputBytes ?? 64 * 1_024; + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(binary, args, { + env: options.environment ?? process.env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } catch { + return { ok: false, exitCode: null, stdout: '', safeCode: 'spawn-failed' }; + } + + child.stdin.end(); + let stdout = ''; + let stdoutBytes = 0; + let outputTooLarge = false; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + const chunkBytes = Buffer.byteLength(chunk, 'utf8'); + stdoutBytes += chunkBytes; + if (stdoutBytes <= maxOutputBytes) { + stdout += chunk; + } else { + outputTooLarge = true; + } + }); + child.stderr.resume(); + + return await new Promise((resolve) => { + let settled = false; + const finish = (result: BoundedCommandResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + void terminateChild(child).finally(() => { + resolve({ ok: false, exitCode: child.exitCode, stdout: '', safeCode: 'timeout' }); + }); + }, timeoutMs); + child.once('error', (error: NodeJS.ErrnoException) => { + finish({ + ok: false, + exitCode: null, + stdout: '', + safeCode: error.code === 'ENOENT' ? 'binary-not-found' : 'spawn-failed', + }); + }); + child.once('close', (exitCode) => { + if (outputTooLarge) { + finish({ ok: false, exitCode, stdout: '', safeCode: 'output-too-large' }); + } else { + finish({ ok: exitCode === 0, exitCode, stdout: stdout.trim() }); + } + }); + }); +} diff --git a/src/reset/utils/redaction.ts b/src/reset/utils/redaction.ts new file mode 100644 index 0000000..eec4432 --- /dev/null +++ b/src/reset/utils/redaction.ts @@ -0,0 +1,35 @@ +import { homedir } from 'node:os'; + +const SECRET_KEY = /auth[_-]?token|ct0|cookie|authorization|bearer|chatgpt.*token|access[_-]?token/i; +const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g; +const LONG_HEX = /\b[a-f0-9]{40,}\b/gi; +const BEARER = /\bBearer\s+[^\s,;]+/gi; +const COOKIE_ASSIGNMENT = /\b(?:auth_token|ct0)=[^\s;]+/gi; + +function redactString(value: string): string { + const home = homedir(); + return value + .replace(JWT, '[REDACTED_JWT]') + .replace(LONG_HEX, '[REDACTED_HEX]') + .replace(BEARER, 'Bearer [REDACTED]') + .replace(COOKIE_ASSIGNMENT, '[REDACTED_COOKIE]') + .split(home) + .join('~'); +} + +export function redactForLog(value: unknown): unknown { + if (typeof value === 'string') { + return redactString(value); + } + if (Array.isArray(value)) { + return value.map((item) => redactForLog(item)); + } + if (value && typeof value === 'object') { + const output: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + output[key] = SECRET_KEY.test(key) ? '[REDACTED]' : redactForLog(nested); + } + return output; + } + return value; +} diff --git a/src/reset/utils/time.ts b/src/reset/utils/time.ts new file mode 100644 index 0000000..4134d60 --- /dev/null +++ b/src/reset/utils/time.ts @@ -0,0 +1,8 @@ +export function nowIso(now: Date = new Date()): string { + return now.toISOString(); +} + +export function isWithinPreviousHours(timestamp: string, hours: number, now: Date = new Date()): boolean { + const value = Date.parse(timestamp); + return Number.isFinite(value) && value <= now.getTime() && value >= now.getTime() - hours * 60 * 60 * 1000; +} diff --git a/src/reset/watcher/codex-session-watcher.ts b/src/reset/watcher/codex-session-watcher.ts new file mode 100644 index 0000000..b3ee95f --- /dev/null +++ b/src/reset/watcher/codex-session-watcher.ts @@ -0,0 +1,313 @@ +import { watch, type Dirent, type FSWatcher, type Stats } from 'node:fs'; +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { UsageLimitCandidate } from '../codex/rollout-types.js'; +import { extractUsageLimitCandidate } from '../codex/rollout-classifier.js'; +import type { ResetRequestPaths } from '../config/paths.js'; +import { getAppPaths } from '../config/paths.js'; +import { acquireSingleInstanceLock, type SingleInstanceLock } from '../state/lock.js'; +import { sha256 } from '../utils/hash.js'; +import { CursorStore, type CursorState } from './cursor-store.js'; +import { IncrementalTailer, type TailWarning } from './incremental-tailer.js'; + +function isPathGone(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + +async function discoverRolloutFiles(directoryPath: string, tolerateMissing = false): Promise { + const output: string[] = []; + let entries: Dirent[]; + try { + entries = await readdir(directoryPath, { withFileTypes: true }); + } catch (error) { + if (tolerateMissing && isPathGone(error)) { + return output; + } + throw error; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue; + } + const absolutePath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + output.push(...(await discoverRolloutFiles(absolutePath, true))); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + output.push(absolutePath); + } + } + return output; +} + +export interface CodexSessionWatcherOptions { + sessionsDirectory: string; + paths?: ResetRequestPaths; + onCandidate(candidate: UsageLimitCandidate): Promise | void; + onWarning?(warning: TailWarning): Promise | void; + onFatal?(error: Error): Promise | void; + onReady?(): Promise | void; +} + +export class CodexSessionWatcher { + private readonly sessionsDirectory: string; + private readonly paths: ResetRequestPaths; + private readonly cursorStore: CursorStore; + private readonly tailer: IncrementalTailer; + private readonly onFatal: NonNullable; + private readonly onReady: NonNullable; + private cursorState: CursorState | null = null; + private directoryWatchers = new Map(); + private lock: SingleInstanceLock | null = null; + private pendingEvents = new Set(); + private drainPromise: Promise | null = null; + private stopping = false; + private started = false; + private startupError: Error | null = null; + + constructor(options: CodexSessionWatcherOptions) { + this.sessionsDirectory = path.resolve(options.sessionsDirectory); + this.paths = options.paths ?? getAppPaths(); + this.cursorStore = new CursorStore(this.paths); + this.tailer = new IncrementalTailer({ + sessionsDirectory: this.sessionsDirectory, + onWarning: options.onWarning, + onRecord: async (record, context) => { + const candidate = extractUsageLimitCandidate(record, context); + if (candidate) { + await options.onCandidate(candidate); + } + }, + }); + this.onFatal = options.onFatal ?? (() => undefined); + this.onReady = options.onReady ?? (() => undefined); + } + + async start(): Promise { + if (this.stopping) { + throw new Error('Stopped watcher instances cannot be restarted'); + } + if (this.started || this.directoryWatchers.size > 0) { + throw new Error('Watcher is already started'); + } + const sessionsStats = await lstat(this.sessionsDirectory).catch(() => null); + if (!sessionsStats?.isDirectory() || sessionsStats.isSymbolicLink()) { + throw new Error('Codex sessions directory is unavailable or unsafe'); + } + + this.lock = await acquireSingleInstanceLock(this.paths.daemonLockFile); + try { + const loaded = await this.cursorStore.load(); + this.cursorState = loaded.state; + const sessionsRootHash = sha256(this.sessionsDirectory); + const sessionsRootChanged = + loaded.existed && this.cursorState.sessionsRootHash !== sessionsRootHash; + if (sessionsRootChanged) { + this.cursorState.cursors = {}; + this.cursorState.initializedAt = new Date().toISOString(); + } + this.cursorState.sessionsRootHash = sessionsRootHash; + const existingFiles = await discoverRolloutFiles(this.sessionsDirectory); + this.pruneMissingCursors(existingFiles); + for (const filePath of existingFiles) { + const endCursor = await this.tailer.cursorAtEnd(filePath); + if (!endCursor) { + continue; + } + if (!loaded.existed || sessionsRootChanged) { + this.cursorState.cursors[endCursor.pathHash] = endCursor; + } + } + await this.cursorStore.save(this.cursorState); + await this.onReady(); + + await this.attachDirectoryTree(this.sessionsDirectory); + await new Promise((resolve) => setImmediate(resolve)); + this.assertNoStartupError(); + this.pendingEvents.add(''); + await this.drainEvents(); + this.assertNoStartupError(); + this.started = true; + } catch (error) { + for (const directoryWatcher of this.directoryWatchers.values()) { + directoryWatcher.close(); + } + this.directoryWatchers.clear(); + await this.lock.release(); + this.lock = null; + throw error; + } + } + + private async attachDirectoryTree(directoryPath: string): Promise { + const resolved = path.resolve(directoryPath); + if (this.directoryWatchers.has(resolved) || this.stopping) { + return; + } + let stats: Stats; + try { + stats = await lstat(resolved); + } catch (error) { + if (isPathGone(error)) { + return; + } + throw error; + } + if (!stats?.isDirectory() || stats.isSymbolicLink() || this.stopping) { + return; + } + + let directoryWatcher: FSWatcher; + try { + directoryWatcher = watch(resolved, { persistent: true }, (_eventType, filename) => { + const absolute = filename ? path.join(resolved, filename.toString()) : resolved; + const relative = path.relative(this.sessionsDirectory, absolute); + this.enqueueEvent(relative); + void lstat(absolute) + .then(async (entryStats) => { + if (entryStats.isDirectory() && !entryStats.isSymbolicLink()) { + await this.attachDirectoryTree(absolute); + } + }) + .catch(async (error: unknown) => { + if (isPathGone(error)) { + this.detachDirectoryTree(absolute); + return; + } + const attachError = error instanceof Error ? error : new Error(String(error)); + if (!this.started) { + this.startupError = attachError; + } else { + await this.handleFatal(attachError); + } + }); + }); + } catch (error) { + throw new Error(`Native watcher unavailable: ${(error as Error).message}`); + } + directoryWatcher.on('error', (error) => { + if (!this.started) { + this.startupError = error; + return; + } + void this.handleFatal(error); + }); + directoryWatcher.on('close', () => { + if (this.directoryWatchers.get(resolved) === directoryWatcher) { + this.directoryWatchers.delete(resolved); + } + }); + this.directoryWatchers.set(resolved, directoryWatcher); + + const entries = await readdir(resolved, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { + await this.attachDirectoryTree(path.join(resolved, entry.name)); + } + } + } + + private enqueueEvent(relativePath: string): void { + if (this.stopping) { + return; + } + this.pendingEvents.add(relativePath); + if (!this.started) { + return; + } + if (!this.drainPromise) { + this.drainPromise = this.drainEvents() + .catch(async (error: unknown) => { + await this.onFatal(error instanceof Error ? error : new Error(String(error))); + }) + .finally(() => { + this.drainPromise = null; + if (this.pendingEvents.size > 0 && !this.stopping) { + this.enqueueEvent(''); + } + }); + } + } + + private detachDirectoryTree(directoryPath: string): void { + const resolved = path.resolve(directoryPath); + const prefix = `${resolved}${path.sep}`; + for (const [watchedPath, directoryWatcher] of this.directoryWatchers) { + if (watchedPath === resolved || watchedPath.startsWith(prefix)) { + this.directoryWatchers.delete(watchedPath); + directoryWatcher.close(); + } + } + } + + private async drainEvents(): Promise { + const events = [...this.pendingEvents]; + this.pendingEvents.clear(); + for (const relativePath of events) { + await this.processEvent(relativePath); + } + if (this.pendingEvents.size > 0) { + await this.drainEvents(); + } + } + + private async processEvent(relativePath: string): Promise { + if (!this.cursorState) { + return; + } + const candidatePath = path.resolve(this.sessionsDirectory, relativePath); + let pathsToTail: string[]; + const stats = relativePath ? await lstat(candidatePath).catch(() => null) : null; + if (stats?.isFile() && candidatePath.endsWith('.jsonl')) { + pathsToTail = [candidatePath]; + } else { + pathsToTail = await discoverRolloutFiles(this.sessionsDirectory); + this.pruneMissingCursors(pathsToTail); + } + + for (const filePath of pathsToTail) { + await this.tailer.tail(filePath, this.cursorState); + } + await this.cursorStore.save(this.cursorState); + } + + private pruneMissingCursors(existingFiles: string[]): void { + if (!this.cursorState) { + return; + } + const liveHashes = new Set(existingFiles.map((filePath) => this.tailer.pathHash(filePath))); + for (const pathHash of Object.keys(this.cursorState.cursors)) { + if (!liveHashes.has(pathHash)) { + delete this.cursorState.cursors[pathHash]; + } + } + } + + private async handleFatal(error: Error): Promise { + await this.stop(); + await this.onFatal(error); + } + + private assertNoStartupError(): void { + const startupError = this.startupError; + if (startupError) { + throw new Error(`Native watcher unavailable: ${startupError.message}`); + } + } + + async stop(): Promise { + if (this.stopping) { + return; + } + this.stopping = true; + for (const directoryWatcher of this.directoryWatchers.values()) { + directoryWatcher.close(); + } + this.directoryWatchers.clear(); + this.started = false; + await this.drainPromise; + this.pendingEvents.clear(); + await this.lock?.release(); + this.lock = null; + } +} diff --git a/src/reset/watcher/cursor-store.ts b/src/reset/watcher/cursor-store.ts new file mode 100644 index 0000000..af1d732 --- /dev/null +++ b/src/reset/watcher/cursor-store.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import type { ResetRequestPaths } from '../config/paths.js'; +import { ensureAppDirectories, getAppPaths } from '../config/paths.js'; +import { readJsonFile, writeJsonAtomic } from '../utils/atomic-file.js'; + +export const fileCursorSchema = z.object({ + pathHash: z.string().regex(/^[a-f0-9]{64}$/), + safeBasename: z.string().min(1).max(255), + fileIdentity: z.string().nullable(), + byteOffset: z.number().int().nonnegative(), + fileSize: z.number().int().nonnegative(), + trailingPartialLine: z.string(), + lastObservedAt: z.iso.datetime(), + mtimeMs: z.number().nonnegative().optional(), + discardingOversizeLine: z.boolean().optional(), +}); + +const cursorStateSchema = z.object({ + version: z.literal(1), + initializedAt: z.iso.datetime(), + sessionsRootHash: z.string().regex(/^[a-f0-9]{64}$/).optional(), + cursors: z.record(z.string(), fileCursorSchema), +}); + +export type FileCursor = z.infer; +export type CursorState = z.infer; + +export interface LoadedCursorState { + existed: boolean; + state: CursorState; +} + +export class CursorStore { + readonly paths: ResetRequestPaths; + + constructor(paths: ResetRequestPaths = getAppPaths()) { + this.paths = paths; + } + + async load(): Promise { + const raw = await readJsonFile(this.paths.cursorFile); + if (raw === null) { + return { + existed: false, + state: { version: 1, initializedAt: new Date().toISOString(), cursors: {} }, + }; + } + return { existed: true, state: cursorStateSchema.parse(raw) }; + } + + async save(state: CursorState): Promise { + await ensureAppDirectories(this.paths); + await writeJsonAtomic(this.paths.cursorFile, cursorStateSchema.parse(state)); + } +} diff --git a/src/reset/watcher/file-identity.ts b/src/reset/watcher/file-identity.ts new file mode 100644 index 0000000..d95098f --- /dev/null +++ b/src/reset/watcher/file-identity.ts @@ -0,0 +1,17 @@ +import type { Stats } from 'node:fs'; +import { lstat } from 'node:fs/promises'; + +export function identityFromStats(stats: Stats): string | null { + if (Number.isFinite(stats.dev) && Number.isFinite(stats.ino) && (stats.dev !== 0 || stats.ino !== 0)) { + return `${stats.dev}:${stats.ino}`; + } + return null; +} + +export async function readFileIdentity(filePath: string): Promise<{ identity: string | null; stats: Stats }> { + const stats = await lstat(filePath); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('Rollout path is not a regular file'); + } + return { identity: identityFromStats(stats), stats }; +} diff --git a/src/reset/watcher/incremental-tailer.ts b/src/reset/watcher/incremental-tailer.ts new file mode 100644 index 0000000..1fcc1b4 --- /dev/null +++ b/src/reset/watcher/incremental-tailer.ts @@ -0,0 +1,151 @@ +import { createReadStream } from 'node:fs'; +import path from 'node:path'; +import { sha256 } from '../utils/hash.js'; +import type { RolloutObservationContext } from '../codex/rollout-types.js'; +import type { CursorState, FileCursor } from './cursor-store.js'; +import { readFileIdentity } from './file-identity.js'; +import { LineBuffer } from './line-buffer.js'; + +export interface TailWarning { + code: 'invalid-json' | 'oversize-line' | 'unsafe-path' | 'file-unavailable'; + safeBasename: string; +} + +export interface IncrementalTailerOptions { + sessionsDirectory: string; + onRecord(record: unknown, context: RolloutObservationContext): Promise | void; + onWarning?(warning: TailWarning): Promise | void; +} + +function isInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative); +} + +export class IncrementalTailer { + private readonly sessionsDirectory: string; + private readonly onRecord: IncrementalTailerOptions['onRecord']; + private readonly onWarning: NonNullable; + + constructor(options: IncrementalTailerOptions) { + this.sessionsDirectory = path.resolve(options.sessionsDirectory); + this.onRecord = options.onRecord; + this.onWarning = options.onWarning ?? (() => undefined); + } + + pathHash(filePath: string): string { + return sha256(path.resolve(filePath)); + } + + async cursorAtEnd(filePath: string, observedAt: Date = new Date()): Promise { + const resolved = path.resolve(filePath); + const safeBasename = path.basename(resolved); + if (!isInside(this.sessionsDirectory, resolved) || !resolved.endsWith('.jsonl')) { + await this.onWarning({ code: 'unsafe-path', safeBasename }); + return null; + } + try { + const { identity, stats } = await readFileIdentity(resolved); + return { + pathHash: this.pathHash(resolved), + safeBasename, + fileIdentity: identity, + byteOffset: stats.size, + fileSize: stats.size, + trailingPartialLine: '', + lastObservedAt: observedAt.toISOString(), + mtimeMs: stats.mtimeMs, + discardingOversizeLine: false, + }; + } catch { + await this.onWarning({ code: 'file-unavailable', safeBasename }); + return null; + } + } + + async tail(filePath: string, cursorState: CursorState, observedAt: Date = new Date()): Promise { + const resolved = path.resolve(filePath); + const safeBasename = path.basename(resolved); + if (!isInside(this.sessionsDirectory, resolved) || !resolved.endsWith('.jsonl')) { + await this.onWarning({ code: 'unsafe-path', safeBasename }); + return null; + } + + let identity: string | null; + let size: number; + let mtimeMs: number; + try { + const file = await readFileIdentity(resolved); + identity = file.identity; + size = file.stats.size; + mtimeMs = file.stats.mtimeMs; + } catch { + await this.onWarning({ code: 'file-unavailable', safeBasename }); + return null; + } + + const key = this.pathHash(resolved); + const previous = cursorState.cursors[key]; + const replaced = previous !== undefined && previous.fileIdentity !== identity; + const truncated = previous !== undefined && size < previous.byteOffset; + const sameSizeRewrite = + previous !== undefined && size === previous.byteOffset && previous.mtimeMs !== undefined && mtimeMs > previous.mtimeMs; + const startOffset = !previous || replaced || truncated || sameSizeRewrite ? 0 : previous.byteOffset; + const initialPartial = startOffset === 0 ? '' : previous.trailingPartialLine; + const lineBuffer = new LineBuffer({ + trailingPartialLine: initialPartial, + discardingOversizeLine: startOffset === 0 ? false : previous?.discardingOversizeLine, + }); + let lineOffset = Math.max(0, startOffset - Buffer.byteLength(initialPartial, 'utf8')); + + if (size > startOffset) { + const stream = createReadStream(resolved, { + start: startOffset, + end: size - 1, + encoding: 'utf8', + }); + for await (const chunk of stream) { + const result = lineBuffer.push(String(chunk)); + lineOffset += result.discardedBytes; + for (let index = 0; index < result.oversizeLines; index += 1) { + await this.onWarning({ code: 'oversize-line', safeBasename }); + } + for (const line of result.lines) { + const currentOffset = lineOffset; + lineOffset += line.consumedBytes; + if (line.text.trim().length === 0) { + continue; + } + let record: unknown; + try { + record = JSON.parse(line.text) as unknown; + } catch { + await this.onWarning({ code: 'invalid-json', safeBasename }); + continue; + } + await this.onRecord(record, { + safeFileName: safeBasename, + fileIdentity: identity, + pathHash: key, + byteOffset: currentOffset, + observedAt, + }); + } + } + } + + const cursor: FileCursor = { + pathHash: key, + safeBasename, + fileIdentity: identity, + byteOffset: size, + fileSize: size, + trailingPartialLine: lineBuffer.trailingPartialLine, + lastObservedAt: observedAt.toISOString(), + mtimeMs, + discardingOversizeLine: lineBuffer.isDiscardingOversizeLine, + }; + cursorState.cursors[key] = cursor; + return cursor; + } +} diff --git a/src/reset/watcher/line-buffer.ts b/src/reset/watcher/line-buffer.ts new file mode 100644 index 0000000..7d9c1f0 --- /dev/null +++ b/src/reset/watcher/line-buffer.ts @@ -0,0 +1,75 @@ +export const MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024; + +export interface CompleteLine { + text: string; + consumedBytes: number; +} + +export interface LineBufferResult { + lines: CompleteLine[]; + oversizeLines: number; + discardedBytes: number; +} + +export class LineBuffer { + private buffered: string; + private discardingOversizeLine: boolean; + private readonly maxLineBytes: number; + + constructor(options: { trailingPartialLine?: string; discardingOversizeLine?: boolean; maxLineBytes?: number } = {}) { + this.buffered = options.trailingPartialLine ?? ''; + this.discardingOversizeLine = options.discardingOversizeLine ?? false; + this.maxLineBytes = options.maxLineBytes ?? MAX_JSONL_LINE_BYTES; + } + + push(chunk: string): LineBufferResult { + let input = chunk; + const lines: CompleteLine[] = []; + let oversizeLines = 0; + let discardedBytes = 0; + + if (this.discardingOversizeLine) { + const newline = input.indexOf('\n'); + if (newline === -1) { + discardedBytes += Buffer.byteLength(input, 'utf8'); + return { lines, oversizeLines, discardedBytes }; + } + discardedBytes += Buffer.byteLength(input.slice(0, newline + 1), 'utf8'); + input = input.slice(newline + 1); + this.discardingOversizeLine = false; + } + + this.buffered += input; + while (true) { + const newline = this.buffered.indexOf('\n'); + if (newline === -1) { + break; + } + const rawLine = this.buffered.slice(0, newline); + this.buffered = this.buffered.slice(newline + 1); + const consumedBytes = Buffer.byteLength(rawLine, 'utf8') + 1; + if (Buffer.byteLength(rawLine, 'utf8') > this.maxLineBytes) { + oversizeLines += 1; + continue; + } + lines.push({ text: rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine, consumedBytes }); + } + + if (Buffer.byteLength(this.buffered, 'utf8') > this.maxLineBytes) { + discardedBytes += Buffer.byteLength(this.buffered, 'utf8'); + this.buffered = ''; + this.discardingOversizeLine = true; + oversizeLines += 1; + } + + return { lines, oversizeLines, discardedBytes }; + } + + get trailingPartialLine(): string { + return this.buffered; + } + + get isDiscardingOversizeLine(): boolean { + return this.discardingOversizeLine; + } +} diff --git a/src/reset/x/bird-provider.ts b/src/reset/x/bird-provider.ts new file mode 100644 index 0000000..2478897 --- /dev/null +++ b/src/reset/x/bird-provider.ts @@ -0,0 +1,258 @@ +import { + resolveBrowserFirstCredentials, + type CookieExtractionResult, +} from '../../lib/cookies.js'; +import { normalizeHandle } from '../../lib/normalize-handle.js'; +import { TwitterClient } from '../../lib/twitter-client.js'; +import type { + CurrentUserResult, + SearchResult, + TweetMutationAttemptOptions, + TweetMutationAttemptResult, + TweetMutationStartResult, + TwitterClientOptions, +} from '../../lib/twitter-client-types.js'; +import type { UserLookupResult } from '../../lib/twitter-client-user-lookup.js'; +import type { UserTweetsPaginationOptions } from '../../lib/twitter-client-user-tweets.js'; +import type { ResetRequestConfig } from '../config/schema.js'; +import type { + TargetPostResult, + XAccountResult, + XProviderDoctorResult, + XReplyProvider, + XWriteResult, + ReplyVerificationResult, + TargetPost, +} from './provider.js'; +import { verifyReplyCandidates } from './reply-verifier.js'; +import { crossCheckTargetPost, selectTargetPost } from './target-selector.js'; + +interface BirdReadClient { + getCurrentUser(): Promise; + getUserIdByUsername(username: string): Promise; + getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise; + search(query: string, count?: number, options?: { includeRaw?: boolean }): Promise; + replySingleAttempt( + text: string, + replyToTweetId: string, + options?: TweetMutationAttemptOptions, + ): Promise; +} + +export interface BirdProviderDependencies { + resolveCredentials(options: { + cookieSource?: 'auto' | 'safari' | 'chrome' | 'firefox'; + chromeProfile?: string; + firefoxProfile?: string; + }): Promise; + createClient(options: TwitterClientOptions): BirdReadClient; + now(): Date; +} + +const DEFAULT_DEPENDENCIES: BirdProviderDependencies = { + resolveCredentials: resolveBrowserFirstCredentials, + createClient: (options) => new TwitterClient(options), + now: () => new Date(), +}; + +export class BirdXReplyProvider implements XReplyProvider { + private readonly config: ResetRequestConfig; + private readonly dependencies: BirdProviderDependencies; + + constructor(config: ResetRequestConfig, dependencies: Partial = {}) { + this.config = config; + this.dependencies = { ...DEFAULT_DEPENDENCIES, ...dependencies }; + } + + private async client(): Promise { + const result = await this.dependencies.resolveCredentials({ + cookieSource: this.config.cookieSource, + chromeProfile: this.config.chromeProfile ?? undefined, + firefoxProfile: this.config.firefoxProfile ?? undefined, + }); + if (!result.cookies.authToken || !result.cookies.ct0) { + return null; + } + return this.dependencies.createClient({ cookies: result.cookies, timeoutMs: 10_000, quoteDepth: 1 }); + } + + async getCurrentAccount(): Promise { + const client = await this.client(); + if (!client) { + return { ok: false, safeCode: 'credentials-unavailable' }; + } + const current = await client.getCurrentUser(); + if (!current.success || !current.user || !/^\d+$/.test(current.user.id)) { + return { ok: false, safeCode: 'account-unavailable' }; + } + const handle = normalizeHandle(current.user.username); + if (!handle) { + return { ok: false, safeCode: 'account-unavailable' }; + } + return { ok: true, id: current.user.id, handle: handle.toLowerCase() }; + } + + async doctor(): Promise { + const account = await this.getCurrentAccount(); + if (!account.ok) { + return { ok: false, safeCode: account.safeCode }; + } + return { + ok: true, + safeCode: 'x-provider-ready', + account: { id: account.id, handle: account.handle }, + }; + } + + async findTargetPost(input: { targetHandle: string; maxPostAgeHours: number }): Promise { + const targetHandle = normalizeHandle(input.targetHandle); + if (!targetHandle) { + return { status: 'not-found', safeCode: 'target-user-unavailable' }; + } + const client = await this.client(); + if (!client) { + return { status: 'not-found', safeCode: 'credentials-unavailable' }; + } + + const user = await client.getUserIdByUsername(targetHandle); + if ( + !user.success || + !user.userId || + !/^\d+$/.test(user.userId) || + !user.username || + user.username.toLowerCase() !== targetHandle.toLowerCase() + ) { + return { status: 'not-found', safeCode: 'target-user-unavailable' }; + } + + const timeline = await client.getUserTweetsPaged(user.userId, 20, { + includeRaw: true, + maxPages: 1, + pageDelayMs: 0, + }); + if (!timeline.success) { + return { status: 'not-found', safeCode: 'target-timeline-unavailable' }; + } + const selected = selectTargetPost(timeline.tweets, { + targetHandle, + targetAuthorId: user.userId, + maxPostAgeHours: input.maxPostAgeHours, + now: this.dependencies.now(), + }); + if (selected.status !== 'found') { + return selected; + } + + const search = await client.search(`from:${targetHandle} -filter:replies -filter:retweets`, 20, { + includeRaw: true, + }); + if (!search.success) { + return selected; + } + return crossCheckTargetPost(selected.post, search.tweets, { + targetHandle, + targetAuthorId: user.userId, + maxPostAgeHours: input.maxPostAgeHours, + now: this.dependencies.now(), + }); + } + + async replyOnce(input: { + targetPost: TargetPost; + text: string; + attemptId: string; + expectedXHandle: string; + onMutationStart?(): Promise; + }): Promise { + void input.attemptId; + let client: BirdReadClient | null; + try { + client = await this.client(); + } catch { + return { status: 'definitive-failure', safeCode: 'credentials-unavailable' }; + } + if (!client) { + return { status: 'definitive-failure', safeCode: 'credentials-unavailable' }; + } + let current: CurrentUserResult; + try { + current = await client.getCurrentUser(); + } catch { + return { status: 'definitive-failure', safeCode: 'account-unavailable' }; + } + const currentHandle = current.user ? normalizeHandle(current.user.username) : null; + if ( + !current.success || + !current.user || + !currentHandle || + currentHandle.toLowerCase() !== input.expectedXHandle.toLowerCase() + ) { + return { status: 'definitive-failure', safeCode: 'wrong-account' }; + } + + let result: TweetMutationAttemptResult; + try { + result = await client.replySingleAttempt(input.text, input.targetPost.id, { + onMutationStart: input.onMutationStart, + }); + } catch { + return { status: 'unknown', safeCode: 'write-provider-threw', targetPostUrl: input.targetPost.url }; + } + if (result.status === 'sent') { + return { + status: 'sent', + tweetId: result.tweetId, + url: `https://x.com/${currentHandle.toLowerCase()}/status/${result.tweetId}`, + verifiedBy: 'mutation-response', + }; + } + if (result.status === 'definitive-failure') { + return { + status: 'definitive-failure', + safeCode: result.safeCode, + httpStatus: result.httpStatus, + }; + } + return { status: 'unknown', safeCode: result.safeCode, targetPostUrl: input.targetPost.url }; + } + + async verifyReply(input: { + targetPostId: string; + replyText: string; + attemptStartedAt: Date; + }): Promise { + const client = await this.client(); + if (!client || !this.config.expectedXHandle) { + return { status: 'not-verified', safeCode: 'verification-unavailable' }; + } + try { + const current = await client.getCurrentUser(); + const currentHandle = current.user ? normalizeHandle(current.user.username) : null; + if ( + !current.success || + !current.user || + !currentHandle || + currentHandle.toLowerCase() !== this.config.expectedXHandle.toLowerCase() + ) { + return { status: 'not-verified', safeCode: 'verification-unavailable' }; + } + const timeline = await client.getUserTweetsPaged(current.user.id, 20, { + includeRaw: false, + maxPages: 1, + pageDelayMs: 0, + }); + if (!timeline.success) { + return { status: 'not-verified', safeCode: 'verification-unavailable' }; + } + return verifyReplyCandidates(timeline.tweets, { + currentAccountId: current.user.id, + currentAccountHandle: currentHandle, + targetPostId: input.targetPostId, + replyText: input.replyText, + attemptStartedAt: input.attemptStartedAt, + }); + } catch { + return { status: 'not-verified', safeCode: 'verification-unavailable' }; + } + } +} diff --git a/src/reset/x/provider.ts b/src/reset/x/provider.ts new file mode 100644 index 0000000..c087fba --- /dev/null +++ b/src/reset/x/provider.ts @@ -0,0 +1,79 @@ +import type { TweetData, TweetMutationStartResult } from '../../lib/twitter-client-types.js'; +import type { XWriteResult } from './write-result.js'; +export type { XWriteResult } from './write-result.js'; + +export interface ResetCandidateTweet extends TweetData { + sourceEntryId?: string; + isPinned: boolean | null; + isRetweet: boolean | null; + isReply: boolean; + isQuote: boolean; +} + +export interface TargetPost { + id: string; + authorHandle: string; + authorId: string; + createdAt: string; + url: string; + selectionEvidence: + | { + source: 'timeline' | 'timeline+search'; + isPinned: false; + isRetweet: false; + isReply: false; + } + | { + source: 'manual-live-test'; + ownershipVerified: true; + }; +} + +export type TargetPostResult = + | { status: 'found'; post: TargetPost } + | { + status: 'not-found'; + safeCode: + | 'credentials-unavailable' + | 'target-user-unavailable' + | 'target-timeline-unavailable' + | 'target-metadata-ambiguous' + | 'target-no-eligible-post' + | 'target-search-ambiguous' + | 'target-search-mismatch'; + }; + +export type XAccountResult = + | { ok: true; id: string; handle: string } + | { ok: false; safeCode: 'credentials-unavailable' | 'account-unavailable' }; + +export interface XProviderDoctorResult { + ok: boolean; + safeCode: 'x-provider-ready' | 'credentials-unavailable' | 'account-unavailable'; + account?: { id: string; handle: string }; +} + +export type ReplyVerificationResult = + | { status: 'verified'; tweetId: string; url: string } + | { status: 'not-verified'; safeCode: 'no-match' | 'multiple-matches' | 'verification-unavailable' }; + +export interface XTargetReader { + doctor(): Promise; + getCurrentAccount(): Promise; + findTargetPost(input: { targetHandle: string; maxPostAgeHours: number }): Promise; +} + +export interface XReplyProvider extends XTargetReader { + replyOnce(input: { + targetPost: TargetPost; + text: string; + attemptId: string; + expectedXHandle: string; + onMutationStart?(): Promise; + }): Promise; + verifyReply(input: { + targetPostId: string; + replyText: string; + attemptStartedAt: Date; + }): Promise; +} diff --git a/src/reset/x/reply-verifier.ts b/src/reset/x/reply-verifier.ts new file mode 100644 index 0000000..d8f6098 --- /dev/null +++ b/src/reset/x/reply-verifier.ts @@ -0,0 +1,37 @@ +import type { TweetData } from '../../lib/twitter-client-types.js'; +import type { ReplyVerificationResult } from './provider.js'; + +export function verifyReplyCandidates( + tweets: TweetData[], + input: { + currentAccountId: string; + currentAccountHandle: string; + targetPostId: string; + replyText: string; + attemptStartedAt: Date; + }, +): ReplyVerificationResult { + const earliest = input.attemptStartedAt.getTime(); + const latest = earliest + 15 * 60 * 1_000; + const matches = tweets.filter((tweet) => { + const createdAt = tweet.createdAt ? new Date(tweet.createdAt).getTime() : Number.NaN; + return ( + /^\d+$/.test(tweet.id) && + tweet.inReplyToStatusId === input.targetPostId && + tweet.text === input.replyText && + tweet.authorId === input.currentAccountId && + Number.isFinite(createdAt) && + createdAt >= earliest && + createdAt <= latest + ); + }); + if (matches.length !== 1) { + return { status: 'not-verified', safeCode: matches.length === 0 ? 'no-match' : 'multiple-matches' }; + } + const match = matches[0]; + return { + status: 'verified', + tweetId: match.id, + url: `https://x.com/${input.currentAccountHandle.toLowerCase()}/status/${match.id}`, + }; +} diff --git a/src/reset/x/target-selector.ts b/src/reset/x/target-selector.ts new file mode 100644 index 0000000..52f3405 --- /dev/null +++ b/src/reset/x/target-selector.ts @@ -0,0 +1,156 @@ +import type { TweetData } from '../../lib/twitter-client-types.js'; +import type { TargetPost, TargetPostResult } from './provider.js'; + +const NUMERIC_ID = /^\d+$/; + +interface TargetSelectionInput { + targetHandle: string; + targetAuthorId: string; + maxPostAgeHours: number; + now?: Date; +} + +interface ValidatedTweet { + tweet: TweetData; + createdAt: Date; +} + +function containsResetReplyInvitation(text: string): boolean { + if (!/\bcodex\b/i.test(text)) { + return false; + } + const invitation = /\b(?:please\s+)?(?:reply|comment|respond)\b[\s\S]{0,40}\b(?:with\s+)?reset\b/i; + const negative = /\b(?:do\s+not|don't|dont|never|cannot|can't|cant|no|not|without|avoid(?:\s+using)?|refrain\s+from|anything\s+(?:but|other\s+than)|other\s+than|except)\b/i; + return invitation.test(text) && !negative.test(text); +} + +function validateBaseTweet(tweet: TweetData, input: TargetSelectionInput): ValidatedTweet | null { + if (!NUMERIC_ID.test(tweet.id) || !NUMERIC_ID.test(input.targetAuthorId)) { + return null; + } + if (tweet.authorId !== input.targetAuthorId) { + return null; + } + if (tweet.author.username.toLowerCase() !== input.targetHandle.toLowerCase()) { + return null; + } + if (!containsResetReplyInvitation(tweet.text)) { + return null; + } + if (!tweet.createdAt) { + return null; + } + const createdAt = new Date(tweet.createdAt); + if (!Number.isFinite(createdAt.getTime())) { + return null; + } + const now = input.now ?? new Date(); + const ageMs = now.getTime() - createdAt.getTime(); + if (ageMs < 0 || ageMs > input.maxPostAgeHours * 60 * 60 * 1_000) { + return null; + } + return { tweet, createdAt }; +} + +function latest(tweets: ValidatedTweet[]): ValidatedTweet | null { + return ( + tweets.sort((left, right) => { + const timeDifference = right.createdAt.getTime() - left.createdAt.getTime(); + if (timeDifference !== 0) { + return timeDifference; + } + const rightId = BigInt(right.tweet.id); + const leftId = BigInt(left.tweet.id); + return rightId === leftId ? 0 : rightId > leftId ? 1 : -1; + })[0] ?? null + ); +} + +export function selectTargetPost(tweets: TweetData[], input: TargetSelectionInput): TargetPostResult { + const eligible: ValidatedTweet[] = []; + let hasAmbiguousMetadata = false; + + for (const tweet of tweets) { + const validated = validateBaseTweet(tweet, input); + if (!validated) { + continue; + } + if ( + typeof tweet.isPinned !== 'boolean' || + typeof tweet.isRetweet !== 'boolean' || + typeof tweet.isReply !== 'boolean' + ) { + hasAmbiguousMetadata = true; + continue; + } + if (tweet.isPinned || tweet.isRetweet || tweet.isReply) { + continue; + } + eligible.push(validated); + } + + if (hasAmbiguousMetadata) { + return { status: 'not-found', safeCode: 'target-metadata-ambiguous' }; + } + const selected = latest(eligible); + if (!selected) { + return { status: 'not-found', safeCode: 'target-no-eligible-post' }; + } + + const canonicalHandle = input.targetHandle.toLowerCase(); + const post: TargetPost = { + id: selected.tweet.id, + authorHandle: canonicalHandle, + authorId: input.targetAuthorId, + createdAt: selected.createdAt.toISOString(), + url: `https://x.com/${canonicalHandle}/status/${selected.tweet.id}`, + selectionEvidence: { + source: 'timeline', + isPinned: false, + isRetweet: false, + isReply: false, + }, + }; + return { status: 'found', post }; +} + +export function crossCheckTargetPost( + post: TargetPost, + searchTweets: TweetData[], + input: TargetSelectionInput, +): TargetPostResult { + const candidates: ValidatedTweet[] = []; + let ambiguous = false; + for (const tweet of searchTweets) { + const validated = validateBaseTweet(tweet, input); + if (!validated) { + continue; + } + if (typeof tweet.isRetweet !== 'boolean' || typeof tweet.isReply !== 'boolean') { + ambiguous = true; + continue; + } + if (!tweet.isRetweet && !tweet.isReply) { + candidates.push(validated); + } + } + if (ambiguous) { + return { status: 'not-found', safeCode: 'target-search-ambiguous' }; + } + const searchLatest = latest(candidates); + if (!searchLatest || searchLatest.tweet.id !== post.id) { + return { status: 'not-found', safeCode: 'target-search-mismatch' }; + } + return { + status: 'found', + post: { + ...post, + selectionEvidence: { + source: 'timeline+search', + isPinned: false, + isRetweet: false, + isReply: false, + }, + }, + }; +} diff --git a/src/reset/x/write-result.ts b/src/reset/x/write-result.ts new file mode 100644 index 0000000..6649825 --- /dev/null +++ b/src/reset/x/write-result.ts @@ -0,0 +1,9 @@ +export type XWriteResult = + | { + status: 'sent'; + tweetId: string; + url: string; + verifiedBy: 'mutation-response' | 'read-after-write'; + } + | { status: 'definitive-failure'; safeCode: string; httpStatus?: number } + | { status: 'unknown'; safeCode: string; targetPostUrl: string }; diff --git a/tests/fixtures/codex/structured-usage-limit.jsonl b/tests/fixtures/codex/structured-usage-limit.jsonl new file mode 100644 index 0000000..4c17d92 --- /dev/null +++ b/tests/fixtures/codex/structured-usage-limit.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-08-28T00:00:00.000Z","type":"event_msg","payload":{"type":"error","message":"redacted","codex_error_info":"usage_limit_exceeded"}} diff --git a/tests/fixtures/codex/text-usage-limit.jsonl b/tests/fixtures/codex/text-usage-limit.jsonl new file mode 100644 index 0000000..729db8b --- /dev/null +++ b/tests/fixtures/codex/text-usage-limit.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-08-28T00:00:00.000Z","type":"event_msg","payload":{"type":"stream_error","message":"You've hit your usage limit"}} diff --git a/tests/fixtures/x/search-match.json b/tests/fixtures/x/search-match.json new file mode 100644 index 0000000..d7ff631 --- /dev/null +++ b/tests/fixtures/x/search-match.json @@ -0,0 +1,15 @@ +{ + "tweets": [ + { + "id": "107", + "text": "Codex users can reply reset here", + "author": { "username": "thsottiaux", "name": "Tibo" }, + "authorId": "42", + "createdAt": "2026-08-28T07:30:00.000Z", + "isPinned": false, + "isRetweet": false, + "isReply": false, + "isQuote": true + } + ] +} diff --git a/tests/fixtures/x/search-mismatch.json b/tests/fixtures/x/search-mismatch.json new file mode 100644 index 0000000..306ea91 --- /dev/null +++ b/tests/fixtures/x/search-mismatch.json @@ -0,0 +1,15 @@ +{ + "tweets": [ + { + "id": "999", + "text": "Codex users can reply reset on this different post", + "author": { "username": "thsottiaux", "name": "Tibo" }, + "authorId": "42", + "createdAt": "2026-08-28T08:00:00.000Z", + "isPinned": false, + "isRetweet": false, + "isReply": false, + "isQuote": false + } + ] +} diff --git a/tests/fixtures/x/user-timeline-mixed.json b/tests/fixtures/x/user-timeline-mixed.json new file mode 100644 index 0000000..26d2278 --- /dev/null +++ b/tests/fixtures/x/user-timeline-mixed.json @@ -0,0 +1,271 @@ +{ + "data": { + "user": { + "result": { + "timeline": { + "timeline": { + "instructions": [ + { + "type": "TimelinePinEntry", + "entry": { + "entryId": "tweet-110", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "110", + "legacy": { + "full_text": "Pinned", + "created_at": "Fri Aug 28 09:00:00 +0000 2026", + "conversation_id_str": "110" + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + } + } + } + } + } + } + }, + { + "type": "TimelineAddEntries", + "entries": [ + { + "entryId": "tweet-110-duplicate", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "110", + "legacy": { + "full_text": "Pinned", + "created_at": "Fri Aug 28 09:00:00 +0000 2026", + "conversation_id_str": "110" + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + } + } + } + } + } + }, + { + "entryId": "tweet-109", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "109", + "legacy": { + "full_text": "RT @someone: original", + "created_at": "Fri Aug 28 08:30:00 +0000 2026", + "conversation_id_str": "109", + "retweeted_status_result": { + "result": { "__typename": "Tweet", "rest_id": "900" } + } + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + } + } + } + } + } + }, + { + "entryId": "tweet-108", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "108", + "legacy": { + "full_text": "A reply", + "created_at": "Fri Aug 28 08:00:00 +0000 2026", + "conversation_id_str": "108", + "in_reply_to_status_id_str": "700" + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + } + } + } + } + } + }, + { + "entryId": "tweet-107", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "107", + "legacy": { + "full_text": "Codex users can reply reset here", + "created_at": "Fri Aug 28 07:30:00 +0000 2026", + "conversation_id_str": "107" + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + }, + "quoted_status_result": { + "result": { + "__typename": "Tweet", + "rest_id": "501", + "legacy": { + "full_text": "Quoted", + "created_at": "Thu Aug 27 07:00:00 +0000 2026" + }, + "core": { + "user_results": { + "result": { + "rest_id": "77", + "legacy": { "screen_name": "someone", "name": "Someone" } + } + } + } + } + } + } + } + } + } + }, + { + "entryId": "tweet-106", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "TweetWithVisibilityResults", + "tweet": { + "__typename": "Tweet", + "rest_id": "106", + "legacy": { + "full_text": "Original wrapped post", + "created_at": "Fri Aug 28 07:00:00 +0000 2026", + "conversation_id_str": "106" + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + } + } + } + } + } + } + }, + { + "entryId": "tweet-105", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "105", + "legacy": { + "full_text": "Old post", + "created_at": "Mon Aug 17 07:00:00 +0000 2026", + "conversation_id_str": "105" + }, + "core": { + "user_results": { + "result": { + "rest_id": "42", + "legacy": { "screen_name": "thsottiaux", "name": "Tibo" } + } + } + } + } + } + } + } + }, + { + "entryId": "tweet-104", + "content": { + "entryType": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "104", + "legacy": { + "full_text": "Wrong author", + "created_at": "Fri Aug 28 06:30:00 +0000 2026", + "conversation_id_str": "104" + }, + "core": { + "user_results": { + "result": { + "rest_id": "88", + "legacy": { "screen_name": "other", "name": "Other" } + } + } + } + } + } + } + } + } + ] + } + ] + } + } + } + } + } +} diff --git a/tests/helpers/fake-app-server-child.mjs b/tests/helpers/fake-app-server-child.mjs new file mode 100644 index 0000000..7ad2449 --- /dev/null +++ b/tests/helpers/fake-app-server-child.mjs @@ -0,0 +1,76 @@ +import { appendFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; + +const scenario = process.env.FAKE_APP_SERVER_SCENARIO ?? 'happy'; +const logFile = process.env.FAKE_APP_SERVER_LOG; +const initDelay = Number(process.env.FAKE_APP_SERVER_INIT_DELAY ?? 0); +const rateDelay = Number(process.env.FAKE_APP_SERVER_RATE_DELAY ?? 0); +const result = JSON.parse(process.env.FAKE_APP_SERVER_RATE_RESULT ?? '{}'); + +if (scenario === 'early-exit') { + process.exit(7); +} + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const send = (message, crlf = false) => { + process.stdout.write(`${JSON.stringify(message)}${crlf ? '\r\n' : '\n'}`); +}; + +const lines = createInterface({ input: process.stdin, crlfDelay: Number.POSITIVE_INFINITY }); +for await (const line of lines) { + const message = JSON.parse(line); + if (logFile) { + await appendFile(logFile, `${JSON.stringify(message)}\n`, 'utf8'); + } + + if (scenario === 'timeout') { + continue; + } + if (message.method === 'initialize') { + await delay(initDelay); + if (scenario === 'invalid-json') { + process.stdout.write('{not-json\n'); + continue; + } + if (scenario === 'wrong-id') { + send({ id: 99, result: {} }); + continue; + } + if (scenario === 'init-error') { + send({ id: 0, error: { code: -32_000, message: 'secret-canary-in-rpc-error' } }); + continue; + } + send({ method: 'account/rateLimits/updated', params: { ignored: true } }, true); + const response = JSON.stringify({ + id: 0, + result: { + userAgent: 'fake-codex', + codexHome: process.env.FAKE_APP_SERVER_CODEX_HOME ?? '/secret/canary/home', + platformFamily: 'unix', + platformOs: 'macos', + }, + }); + process.stdout.write(response.slice(0, 17)); + process.stdout.write(`${response.slice(17)}\r\n`); + if (scenario === 'exit-after-init') { + process.exit(3); + } + continue; + } + if (message.method === 'account/rateLimits/read') { + await delay(rateDelay); + if (scenario === 'rate-error') { + process.stderr.write('authorization: Bearer secret-canary\n'); + send({ id: 1, error: { code: -32_001, message: 'secret-canary-rate-error' } }); + continue; + } + if (scenario === 'missing-rate-limits') { + send({ id: 1, result: {} }); + continue; + } + process.stderr.write('non-fatal fake App Server warning with secret-canary\n'); + process.stdout.write( + `${JSON.stringify({ method: 'unrelated/notification', params: {} })}\n${JSON.stringify({ id: 1, result })}\n`, + ); + } +} diff --git a/tests/helpers/fake-app-server.ts b/tests/helpers/fake-app-server.ts new file mode 100644 index 0000000..c4b340f --- /dev/null +++ b/tests/helpers/fake-app-server.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + buildCodexAppServerEnvironment, + type AppServerProcessSpec, +} from '../../src/reset/codex/app-server-client.js'; + +export interface FakeAppServer { + process: AppServerProcessSpec; + readRequests(): Promise>>; +} + +export function createFakeAppServer( + root: string, + input: { + scenario?: string; + rateResult?: unknown; + initDelayMs?: number; + rateDelayMs?: number; + codexHome?: string; + } = {}, +): FakeAppServer { + const logFile = path.join(root, `fake-app-server-${crypto.randomUUID()}.jsonl`); + const codexHome = input.codexHome ?? '/secret/canary/home'; + return { + process: { + command: process.execPath, + args: [path.join(process.cwd(), 'tests', 'helpers', 'fake-app-server-child.mjs')], + environment: { + ...buildCodexAppServerEnvironment(process.env, codexHome), + FAKE_APP_SERVER_SCENARIO: input.scenario ?? 'happy', + FAKE_APP_SERVER_LOG: logFile, + FAKE_APP_SERVER_RATE_RESULT: JSON.stringify(input.rateResult ?? {}), + FAKE_APP_SERVER_INIT_DELAY: String(input.initDelayMs ?? 0), + FAKE_APP_SERVER_RATE_DELAY: String(input.rateDelayMs ?? 0), + FAKE_APP_SERVER_CODEX_HOME: codexHome, + }, + }, + async readRequests(): Promise>> { + const contents = await readFile(logFile, 'utf8').catch(() => ''); + return contents + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + }, + }; +} diff --git a/tests/helpers/fake-bird-provider.ts b/tests/helpers/fake-bird-provider.ts new file mode 100644 index 0000000..bc790e2 --- /dev/null +++ b/tests/helpers/fake-bird-provider.ts @@ -0,0 +1,98 @@ +import type { + ReplyVerificationResult, + TargetPost, + TargetPostResult, + XAccountResult, + XProviderDoctorResult, + XReplyProvider, + XWriteResult, +} from '../../src/reset/x/provider.js'; + +export const FAKE_TARGET_POST: TargetPost = { + id: '100', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: '2026-08-28T09:00:00.000Z', + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { + source: 'timeline', + isPinned: false, + isRetweet: false, + isReply: false, + }, +}; + +export interface FakeBirdProviderOptions { + targetResult?: TargetPostResult; + accountResult?: XAccountResult; + writeResult?: XWriteResult; + verificationResult?: ReplyVerificationResult; +} + +export class FakeBirdProvider implements XReplyProvider { + beforeMutationStart?: () => Promise | void; + afterMutationStart?: () => Promise | void; + readonly calls = { + doctor: 0, + accountReads: 0, + targetReads: 0, + mutationAttempts: 0, + verificationReads: 0, + }; + + private readonly options: Required; + + constructor(options: FakeBirdProviderOptions = {}) { + this.options = { + targetResult: options.targetResult ?? { status: 'found', post: FAKE_TARGET_POST }, + accountResult: options.accountResult ?? { ok: true, id: '7', handle: 'example' }, + writeResult: + options.writeResult ?? + ({ + status: 'sent', + tweetId: '9001', + url: 'https://x.com/example/status/9001', + verifiedBy: 'mutation-response', + } satisfies XWriteResult), + verificationResult: options.verificationResult ?? { status: 'not-verified', safeCode: 'no-match' }, + }; + } + + async doctor(): Promise { + this.calls.doctor += 1; + const account = this.options.accountResult; + return account.ok + ? { ok: true, safeCode: 'x-provider-ready', account: { id: account.id, handle: account.handle } } + : { ok: false, safeCode: account.safeCode }; + } + + async getCurrentAccount(): Promise { + this.calls.accountReads += 1; + return this.options.accountResult; + } + + async findTargetPost(): Promise { + this.calls.targetReads += 1; + return this.options.targetResult; + } + + async replyOnce(input: Parameters[0]): Promise { + await this.beforeMutationStart?.(); + try { + const startResult = await input.onMutationStart?.(); + if (startResult && !startResult.ok) { + return { status: 'definitive-failure', safeCode: startResult.safeCode }; + } + } catch { + return { status: 'definitive-failure', safeCode: 'write-state-persistence-failed' }; + } + await this.afterMutationStart?.(); + this.calls.mutationAttempts += 1; + return this.options.writeResult; + } + + async verifyReply(): Promise { + this.calls.verificationReads += 1; + return this.options.verificationResult; + } +} diff --git a/tests/helpers/native-watcher.ts b/tests/helpers/native-watcher.ts new file mode 100644 index 0000000..a518fcc --- /dev/null +++ b/tests/helpers/native-watcher.ts @@ -0,0 +1,31 @@ +const DEFAULT_NATIVE_WATCHER_DEADLINE_MS = process.env.CI ? 15_000 : 10_000; + +export async function withNativeWatcherDeadline( + promise: Promise, + label: string, + milliseconds = DEFAULT_NATIVE_WATCHER_DEADLINE_MS, +): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`native watcher test timed out: ${label}`)), milliseconds); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +export function canSkipNativeWatcherFailure( + error: unknown, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.CRR_REQUIRE_NATIVE_WATCH === '1' || !(error instanceof Error)) { + return false; + } + return /^Native watcher unavailable:/i.test(error.message) && /\b(?:EMFILE|ENFILE|ENOSPC)\b/.test(error.message); +} diff --git a/tests/helpers/temporary-home.ts b/tests/helpers/temporary-home.ts new file mode 100644 index 0000000..52cee38 --- /dev/null +++ b/tests/helpers/temporary-home.ts @@ -0,0 +1,30 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { getAppPaths, type ResetRequestPaths } from '../../src/reset/config/paths.js'; + +export interface TemporaryHome { + root: string; + paths: ResetRequestPaths; + cleanup(): Promise; +} + +export async function createTemporaryHome(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'codex-reset-request-')); + const paths = getAppPaths({ + platform: process.platform, + homeDirectory: root, + env: { + CRR_CONFIG_DIR: path.join(root, 'config'), + CRR_STATE_DIR: path.join(root, 'state'), + CRR_LOG_DIR: path.join(root, 'logs'), + }, + }); + return { + root, + paths, + async cleanup(): Promise { + await rm(root, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/integration/action-pipeline.test.ts b/tests/integration/action-pipeline.test.ts new file mode 100644 index 0000000..fa147c3 --- /dev/null +++ b/tests/integration/action-pipeline.test.ts @@ -0,0 +1,326 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { RateLimitsReadOutcome } from '../../src/reset/codex/app-server-client.js'; +import type { UsageLimitCandidate } from '../../src/reset/codex/rollout-types.js'; +import { CURRENT_DISCLAIMER_VERSION, createDefaultConfig } from '../../src/reset/config/schema.js'; +import { ActionPipeline, type ActionPipelineDependencies } from '../../src/reset/pipeline/action-pipeline.js'; +import { createActionId } from '../../src/reset/pipeline/fingerprints.js'; +import type { ActionRecord } from '../../src/reset/state/schema.js'; +import type { AuditEvent } from '../../src/reset/state/audit-log.js'; +import { StateStore } from '../../src/reset/state/store.js'; +import { FakeBirdProvider } from '../helpers/fake-bird-provider.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; +const NOW = new Date('2026-08-28T12:00:00.000Z'); + +function confirmedRateLimits(resetsAt = Math.floor(NOW.getTime() / 1_000) + 3_600): RateLimitsReadOutcome { + return { + ok: true, + value: { + rateLimits: { + limitId: 'codex', + rateLimitReachedType: 'usage_limit_exceeded', + primary: { usedPercent: 100, resetsAt }, + }, + }, + }; +} + +function candidate(index: number): UsageLimitCandidate { + return { + tier: 'structured', + normalizedRecordType: 'event_msg:error', + normalizedErrorType: 'usage_limit_exceeded', + safeFileName: `rollout-${index}.jsonl`, + fileIdentity: `file-${index}`, + byteOffset: index, + observedAt: NOW.toISOString(), + eventFingerprint: index.toString(16).padStart(64, '0'), + }; +} + +async function fixture(options: { + provider?: FakeBirdProvider; + mode?: 'dry-run' | 'auto'; + modeBeforeWrite?: 'dry-run' | 'auto'; + rateLimits?: RateLimitsReadOutcome; + auditFails?: boolean; + now?: () => Date; +} = {}) { + const home = await createTemporaryHome(); + homes.push(home); + const store = new StateStore(home.paths); + const provider = options.provider ?? new FakeBirdProvider(); + const config = createDefaultConfig(); + config.mode = options.mode ?? 'auto'; + config.expectedXHandle = 'example'; + if (config.mode === 'auto') { + config.consent = { + automaticPostingAccepted: true, + disclaimerVersion: CURRENT_DISCLAIMER_VERSION, + acceptedAt: NOW.toISOString(), + }; + } + const counters = { appServerReads: 0, providerCreations: 0, configReads: 0 }; + const audits: AuditEvent[] = []; + const dependencies: Partial = { + loadConfiguration: async () => { + counters.configReads += 1; + const loaded = structuredClone(config); + if (counters.configReads > 1 && options.modeBeforeWrite) { + loaded.mode = options.modeBeforeWrite; + } + return loaded; + }, + readRateLimits: async () => { + counters.appServerReads += 1; + return options.rateLimits ?? confirmedRateLimits(); + }, + createProvider: () => { + counters.providerCreations += 1; + return provider; + }, + audit: async (event) => { + if (options.auditFails) { + throw new Error('synthetic audit failure'); + } + audits.push(event); + }, + now: options.now ?? (() => NOW), + resolveCodexHome: () => '/tmp/fake-codex-home', + }; + const pipeline = new ActionPipeline(store, dependencies); + return { store, provider, config, counters, audits, pipeline }; +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +describe('usage-limit action pipeline', () => { + it('confirms, selects, persists attempting, and performs exactly one mutation', async () => { + const { pipeline, store, provider, audits } = await fixture(); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ status: 'sent' }); + expect(provider.calls).toMatchObject({ targetReads: 1, accountReads: 1, mutationAttempts: 1 }); + expect((await store.load()).actions[0]).toMatchObject({ + status: 'sent', + mutationStartedAt: NOW.toISOString(), + replyTweetId: '9001', + }); + expect(JSON.stringify(await store.load())).not.toContain('"replyText":"reset"'); + expect(JSON.stringify(audits)).not.toContain('reset'); + }); + + it('deduplicates the same event before network and the same window before X reads', async () => { + const { pipeline, provider, counters } = await fixture(); + await pipeline.handleCandidate(candidate(1)); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'deduplicated', + safeCode: 'same-event', + }); + expect(counters.appServerReads).toBe(1); + + expect(await pipeline.handleCandidate(candidate(2))).toMatchObject({ + status: 'rate-guarded', + safeCode: 'same-limit-window', + }); + expect(counters.appServerReads).toBe(2); + expect(provider.calls.targetReads).toBe(1); + expect(provider.calls.mutationAttempts).toBe(1); + }); + + it('serializes concurrent candidate delivery before duplicate and rate-guard checks', async () => { + const { pipeline, provider, counters } = await fixture(); + const [first, second] = await Promise.all([ + pipeline.handleCandidate(candidate(1)), + pipeline.handleCandidate(candidate(1)), + ]); + expect(first.status).toBe('sent'); + expect(second.status).toBe('deduplicated'); + expect(counters.appServerReads).toBe(1); + expect(provider.calls.mutationAttempts).toBe(1); + }); + + it.each(['candidate', 'confirmed', 'target-resolved'] as const)( + 'safely resumes a persisted pre-write %s action after restart', + async (status) => { + const setup = await fixture(); + const observed = candidate(1); + const seeded: ActionRecord = { + actionId: createActionId(observed.eventFingerprint), + eventFingerprint: observed.eventFingerprint, + limitWindowKey: 'a'.repeat(64), + detectedAt: observed.observedAt, + confirmedAt: status === 'candidate' ? undefined : observed.observedAt, + status, + targetHandle: 'thsottiaux', + actionKey: status === 'target-resolved' ? 'b'.repeat(64) : undefined, + targetPostId: status === 'target-resolved' ? '100' : undefined, + targetPostUrl: + status === 'target-resolved' ? 'https://x.com/thsottiaux/status/100' : undefined, + replyTextHash: status === 'target-resolved' ? 'c'.repeat(64) : undefined, + }; + await setup.store.save({ version: 1, updatedAt: observed.observedAt, actions: [seeded] }); + expect(await setup.pipeline.handleCandidate(observed)).toMatchObject({ status: 'sent' }); + expect(setup.provider.calls.mutationAttempts).toBe(1); + expect((await setup.store.load()).actions).toHaveLength(1); + }, + ); + + it('rejects a pre-write-looking record that carries a mutation marker', async () => { + const setup = await fixture(); + const observed = candidate(1); + await expect( + setup.store.save({ + version: 1, + updatedAt: observed.observedAt, + actions: [ + { + actionId: createActionId(observed.eventFingerprint), + eventFingerprint: observed.eventFingerprint, + limitWindowKey: 'a'.repeat(64), + detectedAt: observed.observedAt, + confirmedAt: observed.observedAt, + status: 'target-resolved', + targetHandle: 'thsottiaux', + actionKey: 'b'.repeat(64), + targetPostId: '100', + targetPostUrl: 'https://x.com/thsottiaux/status/100', + replyTextHash: 'c'.repeat(64), + attemptStartedAt: observed.observedAt, + mutationStartedAt: observed.observedAt, + }, + ], + }), + ).rejects.toThrow(/pre-write action/i); + expect(setup.provider.calls.mutationAttempts).toBe(0); + }); + + it('keeps an ambiguous result unknown and never retries it', async () => { + const provider = new FakeBirdProvider({ + writeResult: { + status: 'unknown', + safeCode: 'write-transport-ambiguous', + targetPostUrl: 'https://x.com/thsottiaux/status/100', + }, + verificationResult: { status: 'not-verified', safeCode: 'no-match' }, + }); + const { pipeline } = await fixture({ provider }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ status: 'unknown' }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ status: 'deduplicated' }); + expect(await pipeline.handleCandidate(candidate(2))).toMatchObject({ status: 'rate-guarded' }); + expect(provider.calls.mutationAttempts).toBe(1); + expect(provider.calls.verificationReads).toBe(1); + }); + + it('runs confirmation, target selection, and account verification in dry-run without writing', async () => { + const { pipeline, provider, store } = await fixture({ mode: 'dry-run' }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ status: 'dry-run', safeCode: 'would-reply' }); + expect(provider.calls).toMatchObject({ targetReads: 1, accountReads: 1, mutationAttempts: 0 }); + expect((await store.load()).actions[0].status).toBe('dry-run'); + }); + + it('fails closed when App Server cannot confirm and stays idle with zero network calls', async () => { + const { pipeline, counters, provider } = await fixture({ + rateLimits: { ok: false, stage: 'rate-limits', code: 'timeout' }, + }); + expect(counters).toEqual({ appServerReads: 0, providerCreations: 0, configReads: 0 }); + expect(provider.calls.targetReads).toBe(0); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'confirmation-failed', + safeCode: 'timeout', + }); + expect(counters).toEqual({ appServerReads: 1, providerCreations: 0, configReads: 1 }); + expect(provider.calls.mutationAttempts).toBe(0); + }); + + it('ignores a reached type without a stable saturated reset window', async () => { + const { pipeline, provider } = await fixture({ + rateLimits: { + ok: true, + value: { + rateLimits: { + limitId: 'codex', + rateLimitReachedType: 'usage_limit_exceeded', + }, + }, + }, + }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'confirmation-failed', + safeCode: 'not-reached', + }); + expect(provider.calls.mutationAttempts).toBe(0); + }); + + it('keeps audit failures from changing the durable action result', async () => { + const { pipeline, store } = await fixture({ auditFails: true }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ status: 'sent' }); + expect((await store.load()).actions[0].status).toBe('sent'); + }); + + it('reloads configuration before writing so disable-auto takes effect immediately', async () => { + const { pipeline, provider, counters } = await fixture({ modeBeforeWrite: 'dry-run' }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'dry-run', + safeCode: 'would-reply', + }); + expect(counters.configReads).toBe(2); + expect(provider.calls.mutationAttempts).toBe(0); + }); + + it('rejects a changed X account before entering the write state machine', async () => { + const provider = new FakeBirdProvider({ accountResult: { ok: true, id: '8', handle: 'different' } }); + const { pipeline } = await fixture({ provider }); + expect(await pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'wrong-account', + safeCode: 'wrong-account', + }); + expect(provider.calls.mutationAttempts).toBe(0); + }); + + it('rechecks auto authorization at the mutation boundary', async () => { + const setup = await fixture(); + setup.provider.beforeMutationStart = () => { + setup.config.mode = 'dry-run'; + }; + expect(await setup.pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'rejected', + safeCode: 'write-authorization-revoked', + }); + expect(setup.provider.calls.mutationAttempts).toBe(0); + expect((await setup.store.load()).actions[0].mutationStartedAt).toBeUndefined(); + }); + + it('does not let rolling write guards truncate the read-only dry-run path', async () => { + const setup = await fixture(); + expect(await setup.pipeline.handleCandidate(candidate(1))).toMatchObject({ status: 'sent' }); + setup.config.mode = 'dry-run'; + expect(await setup.pipeline.handleCandidate(candidate(2))).toMatchObject({ status: 'dry-run' }); + expect(setup.provider.calls).toMatchObject({ targetReads: 2, accountReads: 2, mutationAttempts: 1 }); + }); + + it('checks reset expiry using time captured after the App Server response', async () => { + const beforeReset = new Date('2026-08-28T12:00:00.000Z'); + const afterReset = new Date('2026-08-28T12:00:02.000Z'); + const times = [beforeReset, afterReset, afterReset]; + const setup = await fixture({ + rateLimits: { + ok: true, + value: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 100, resetsAt: Math.floor(beforeReset.getTime() / 1_000) + 1 }, + rateLimitReachedType: null, + }, + }, + }, + now: () => times.shift() ?? afterReset, + }); + expect(await setup.pipeline.handleCandidate(candidate(1))).toMatchObject({ + status: 'confirmation-failed', + safeCode: 'not-reached', + }); + expect(setup.provider.calls.targetReads).toBe(0); + }); +}); diff --git a/tests/integration/full-flow-native.test.ts b/tests/integration/full-flow-native.test.ts new file mode 100644 index 0000000..c0a3f98 --- /dev/null +++ b/tests/integration/full-flow-native.test.ts @@ -0,0 +1,195 @@ +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../../src/lib/twitter-client.js'; +import type { TweetData } from '../../src/lib/twitter-client-types.js'; +import { readConfiguredCodexRateLimits } from '../../src/reset/codex/app-server-client.js'; +import type { UsageLimitCandidate } from '../../src/reset/codex/rollout-types.js'; +import { CURRENT_DISCLAIMER_VERSION, createDefaultConfig } from '../../src/reset/config/schema.js'; +import { ActionPipeline, type PipelineResult } from '../../src/reset/pipeline/action-pipeline.js'; +import { StateStore } from '../../src/reset/state/store.js'; +import { CodexSessionWatcher } from '../../src/reset/watcher/codex-session-watcher.js'; +import { BirdXReplyProvider, type BirdProviderDependencies } from '../../src/reset/x/bird-provider.js'; +import { createFakeAppServer } from '../helpers/fake-app-server.js'; +import { canSkipNativeWatcherFailure, withNativeWatcherDeadline } from '../helpers/native-watcher.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; + +afterEach(async () => { + vi.unstubAllGlobals(); + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +it('runs native append through confirmation and exactly one guarded Bird mutation', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + const dateDirectory = path.join(sessionsDirectory, '2026', '08', '28'); + await mkdir(dateDirectory, { recursive: true }); + const rolloutFile = path.join(dateDirectory, 'rollout-full-flow.jsonl'); + await writeFile(rolloutFile, '', 'utf8'); + + const now = new Date('2026-08-28T12:00:00.000Z'); + const fakeAppServer = createFakeAppServer(home.root, { + codexHome: path.join(home.root, 'codex'), + rateResult: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 100, resetsAt: Math.floor(now.getTime() / 1_000) + 3_600 }, + secondary: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: null, + }, + }); + const config = createDefaultConfig(); + config.codexHome = path.join(home.root, 'codex'); + config.mode = 'auto'; + config.expectedXHandle = 'example'; + config.consent = { + automaticPostingAccepted: true, + disclaimerVersion: CURRENT_DISCLAIMER_VERSION, + acceptedAt: now.toISOString(), + }; + const store = new StateStore(home.paths); + const targetTweet: TweetData = { + id: '100', + text: 'Codex users can reply reset here', + author: { username: 'thsottiaux', name: 'Tibo' }, + authorId: '42', + createdAt: '2026-08-28T11:00:00.000Z', + isPinned: false, + isRetweet: false, + isReply: false, + }; + const realClient = new TwitterClient({ + cookies: { + authToken: 'memory-only-auth', + ct0: 'memory-only-ct0', + cookieHeader: 'auth_token=memory-only-auth; ct0=memory-only-ct0', + source: 'test', + }, + timeoutMs: 1_000, + }); + const birdClient = { + getCurrentUser: vi.fn(async () => ({ + success: true as const, + user: { id: '7', username: 'example', name: 'Example' }, + })), + getUserIdByUsername: vi.fn(async () => ({ + success: true as const, + userId: '42', + username: 'thsottiaux', + })), + getUserTweetsPaged: vi.fn(async () => ({ success: true as const, tweets: [targetTweet] })), + search: vi.fn(async () => ({ success: false as const, error: 'synthetic search unavailable' })), + replySingleAttempt: realClient.replySingleAttempt.bind(realClient), + }; + const provider = new BirdXReplyProvider(config, { + resolveCredentials: async () => ({ + cookies: { + authToken: 'memory-only-auth', + ct0: 'memory-only-ct0', + cookieHeader: 'auth_token=memory-only-auth; ct0=memory-only-ct0', + source: 'test', + }, + warnings: [], + }), + createClient: () => birdClient, + now: () => now, + } satisfies Partial); + const mutationFetch = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + expect(init?.method).toBe('POST'); + expect(await store.load()).toMatchObject({ + actions: [{ status: 'attempting', mutationStartedAt: now.toISOString() }], + }); + return new Response( + JSON.stringify({ data: { create_tweet: { tweet_results: { result: { rest_id: '9001' } } } } }), + { status: 200 }, + ); + }); + vi.stubGlobal('fetch', mutationFetch); + const pipeline = new ActionPipeline(store, { + loadConfiguration: async () => structuredClone(config), + readRateLimits: async (runtimeConfig) => + await readConfiguredCodexRateLimits(runtimeConfig, { + process: fakeAppServer.process, + timeoutMs: 4_000, + }), + createProvider: () => provider, + audit: async () => undefined, + now: () => now, + resolveCodexHome: () => path.join(home.root, 'codex'), + }); + + const results: PipelineResult[] = []; + let resolveResult: ((result: PipelineResult) => void) | null = null; + let rejectResult: ((error: Error) => void) | null = null; + const nextResult = () => + new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + let resultPromise = nextResult(); + void resultPromise.catch(() => undefined); + let fatalError: Error | null = null; + const watcher = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + async onReady() { + await pipeline.stateMachine.recoverStaleAttempts(); + }, + async onCandidate(candidate: UsageLimitCandidate) { + const result = await pipeline.handleCandidate(candidate); + results.push(result); + resolveResult?.(result); + }, + onFatal(error) { + fatalError = error; + rejectResult?.(error); + }, + }); + + try { + await watcher.start(); + expect(await fakeAppServer.readRequests()).toEqual([]); + expect(birdClient.getUserIdByUsername).not.toHaveBeenCalled(); + expect(mutationFetch).not.toHaveBeenCalled(); + + const fixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + await writeFile(path.join(dateDirectory, 'rollout-second-session.jsonl'), fixture, 'utf8'); + expect(await withNativeWatcherDeadline(resultPromise, 'first action')).toMatchObject({ status: 'sent' }); + expect(mutationFetch).toHaveBeenCalledOnce(); + expect(birdClient.getUserIdByUsername).toHaveBeenCalledOnce(); + expect(birdClient.getCurrentUser).toHaveBeenCalledTimes(2); + expect((await store.load()).actions[0]).toMatchObject({ status: 'sent', replyTweetId: '9001' }); + expect((await fakeAppServer.readRequests()).map((request) => request.method)).toEqual([ + 'initialize', + 'initialized', + 'account/rateLimits/read', + ]); + + resultPromise = nextResult(); + void resultPromise.catch(() => undefined); + await appendFile(rolloutFile, fixture, 'utf8'); + expect(await withNativeWatcherDeadline(resultPromise, 'same-window guard')).toMatchObject({ + status: 'rate-guarded', + safeCode: 'same-limit-window', + }); + expect(mutationFetch).toHaveBeenCalledOnce(); + expect(results).toHaveLength(2); + expect(fatalError).toBeNull(); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await watcher.stop(); + } +}); diff --git a/tests/integration/native-watcher.test.ts b/tests/integration/native-watcher.test.ts new file mode 100644 index 0000000..fc2247d --- /dev/null +++ b/tests/integration/native-watcher.test.ts @@ -0,0 +1,339 @@ +import { appendFile, mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import type { UsageLimitCandidate } from '../../src/reset/codex/rollout-types.js'; +import { CodexSessionWatcher } from '../../src/reset/watcher/codex-session-watcher.js'; +import { CursorStore } from '../../src/reset/watcher/cursor-store.js'; +import { canSkipNativeWatcherFailure, withNativeWatcherDeadline } from '../helpers/native-watcher.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +it('ignores old events, wakes on append, and discovers a new date directory', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + const originalDirectory = path.join(sessionsDirectory, '2026', '08', '28'); + await mkdir(originalDirectory, { recursive: true }); + const existingFile = path.join(originalDirectory, 'rollout-existing.jsonl'); + const structuredFixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + await writeFile(existingFile, structuredFixture, 'utf8'); + + const candidates: UsageLimitCandidate[] = []; + let resolveCandidate: ((candidate: UsageLimitCandidate) => void) | null = null; + const nextCandidate = () => + new Promise((resolve) => { + resolveCandidate = resolve; + }); + let candidatePromise = nextCandidate(); + let resolveFatal: ((error: Error) => void) | null = null; + const fatalPromise = new Promise((resolve) => { + resolveFatal = resolve; + }); + const candidateOrFatal = () => + Promise.race([ + candidatePromise, + fatalPromise.then((error) => { + throw error; + }), + ]); + const watcher = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + onCandidate(candidate) { + candidates.push(candidate); + resolveCandidate?.(candidate); + }, + onFatal(error) { + resolveFatal?.(error); + }, + }); + + try { + await watcher.start(); + await appendFile(existingFile, '{"type":"event_msg","payload":{"type":"log","message":"safe"}}\n', 'utf8'); + expect(candidates).toHaveLength(0); + + await appendFile(existingFile, structuredFixture, 'utf8'); + const appended = await withNativeWatcherDeadline(candidateOrFatal(), 'existing-file append'); + expect(appended.tier).toBe('structured'); + expect(candidates).toHaveLength(1); + + candidatePromise = nextCandidate(); + const newDirectory = path.join(sessionsDirectory, '2026', '08', '29'); + await mkdir(newDirectory, { recursive: true }); + await writeFile(path.join(newDirectory, 'rollout-new.jsonl'), structuredFixture, 'utf8'); + const created = await withNativeWatcherDeadline(candidateOrFatal(), 'new-date-directory'); + expect(created.safeFileName).toBe('rollout-new.jsonl'); + expect(candidates).toHaveLength(2); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await watcher.stop(); + } +}); + +it('catches up new, replaced, and onReady-race rollouts after restart', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + const dateDirectory = path.join(sessionsDirectory, '2026', '08', '28'); + await mkdir(dateDirectory, { recursive: true }); + const originalFile = path.join(dateDirectory, 'rollout-original.jsonl'); + await writeFile(originalFile, '', 'utf8'); + + const first = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + onCandidate() { + throw new Error('first startup must not process history'); + }, + }); + let second: CodexSessionWatcher | null = null; + try { + await first.start(); + await first.stop(); + + const fixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + const replacement = path.join(dateDirectory, 'rollout-replacement.tmp'); + await writeFile(replacement, fixture, 'utf8'); + await rename(replacement, originalFile); + await writeFile(path.join(dateDirectory, 'rollout-created-offline.jsonl'), fixture, 'utf8'); + + const candidates: UsageLimitCandidate[] = []; + second = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + async onReady() { + await writeFile(path.join(dateDirectory, 'rollout-on-ready.jsonl'), fixture, 'utf8'); + }, + onCandidate(candidate) { + candidates.push(candidate); + }, + }); + await second.start(); + expect(candidates.map((candidate) => candidate.safeFileName).sort()).toEqual([ + 'rollout-created-offline.jsonl', + 'rollout-on-ready.jsonl', + 'rollout-original.jsonl', + ]); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await second?.stop(); + await first.stop(); + } +}); + +it('restarts from byte zero after an offline truncate', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + const dateDirectory = path.join(sessionsDirectory, '2026', '08', '28'); + await mkdir(dateDirectory, { recursive: true }); + const rolloutFile = path.join(dateDirectory, 'rollout-truncated.jsonl'); + const safeLine = `${JSON.stringify({ type: 'event_msg', payload: { type: 'log', message: 'safe' } })}\n`; + await writeFile(rolloutFile, safeLine.repeat(100), 'utf8'); + + const first = new CodexSessionWatcher({ sessionsDirectory, paths: home.paths, onCandidate: () => undefined }); + let second: CodexSessionWatcher | null = null; + try { + await first.start(); + await first.stop(); + const fixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + await writeFile(rolloutFile, fixture, 'utf8'); + const candidates: UsageLimitCandidate[] = []; + second = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + onCandidate(candidate) { + candidates.push(candidate); + }, + }); + await second.start(); + expect(candidates).toHaveLength(1); + expect(candidates[0].byteOffset).toBe(0); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await second?.stop(); + await first.stop(); + } +}); + +it('seeds pre-existing history at EOF when the watched Codex home changes', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const fixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + const firstSessions = path.join(home.root, 'codex-a', 'sessions'); + const secondSessions = path.join(home.root, 'codex-b', 'sessions'); + await mkdir(path.join(firstSessions, '2026', '08', '28'), { recursive: true }); + await mkdir(path.join(secondSessions, '2026', '08', '28'), { recursive: true }); + await writeFile(path.join(firstSessions, '2026', '08', '28', 'rollout-old-a.jsonl'), fixture, 'utf8'); + await writeFile(path.join(secondSessions, '2026', '08', '28', 'rollout-old-b.jsonl'), fixture, 'utf8'); + + const candidates: UsageLimitCandidate[] = []; + const first = new CodexSessionWatcher({ + sessionsDirectory: firstSessions, + paths: home.paths, + onCandidate(candidate) { + candidates.push(candidate); + }, + }); + let second: CodexSessionWatcher | null = null; + try { + await first.start(); + await first.stop(); + second = new CodexSessionWatcher({ + sessionsDirectory: secondSessions, + paths: home.paths, + onCandidate(candidate) { + candidates.push(candidate); + }, + }); + await second.start(); + expect(candidates).toEqual([]); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await second?.stop(); + await first.stop(); + } +}); + +it('survives a normal rollout deletion and continues watching subsequent files', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + const dateDirectory = path.join(sessionsDirectory, '2026', '08', '28'); + await mkdir(dateDirectory, { recursive: true }); + const deletedFile = path.join(dateDirectory, 'rollout-deleted.jsonl'); + await writeFile(deletedFile, '', 'utf8'); + + let fatalError: Error | null = null; + let resolveCandidate: ((candidate: UsageLimitCandidate) => void) | null = null; + const candidatePromise = new Promise((resolve) => { + resolveCandidate = resolve; + }); + const watcher = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + onCandidate(candidate) { + resolveCandidate?.(candidate); + }, + onFatal(error) { + fatalError = error; + }, + }); + + try { + await watcher.start(); + await unlink(deletedFile); + await new Promise((resolve) => setTimeout(resolve, 250)); + const fixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + await writeFile(path.join(dateDirectory, 'rollout-after-delete.jsonl'), fixture, 'utf8'); + expect((await withNativeWatcherDeadline(candidatePromise, 'post-delete event')).safeFileName).toBe( + 'rollout-after-delete.jsonl', + ); + await new Promise((resolve) => setImmediate(resolve)); + expect( + Object.values((await new CursorStore(home.paths).load()).state.cursors).map((cursor) => cursor.safeBasename), + ).not.toContain('rollout-deleted.jsonl'); + expect(fatalError).toBeNull(); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await watcher.stop(); + } +}); + +it('reattaches after a date directory is deleted and recreated at the same path', async (context) => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + const dateDirectory = path.join(sessionsDirectory, '2026', '08', '28'); + await mkdir(dateDirectory, { recursive: true }); + await writeFile(path.join(dateDirectory, 'rollout-before-delete.jsonl'), '', 'utf8'); + + let fatalError: Error | null = null; + let resolveCandidate: ((candidate: UsageLimitCandidate) => void) | null = null; + const candidatePromise = new Promise((resolve) => { + resolveCandidate = resolve; + }); + const watcher = new CodexSessionWatcher({ + sessionsDirectory, + paths: home.paths, + onCandidate(candidate) { + resolveCandidate?.(candidate); + }, + onFatal(error) { + fatalError = error; + }, + }); + + try { + await watcher.start(); + await rm(dateDirectory, { recursive: true }); + await new Promise((resolve) => setTimeout(resolve, 250)); + await mkdir(dateDirectory, { recursive: true }); + const recreatedFile = path.join(dateDirectory, 'rollout-recreated.jsonl'); + await writeFile(recreatedFile, '', 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 500)); + const fixture = await readFile( + path.join(process.cwd(), 'tests', 'fixtures', 'codex', 'structured-usage-limit.jsonl'), + 'utf8', + ); + await appendFile(recreatedFile, fixture, 'utf8'); + expect((await withNativeWatcherDeadline(candidatePromise, 'same-path directory recreation')).safeFileName).toBe( + 'rollout-recreated.jsonl', + ); + expect(fatalError).toBeNull(); + } catch (error) { + if (canSkipNativeWatcherFailure(error)) { + context.skip('host sandbox does not permit native filesystem watchers'); + return; + } + throw error; + } finally { + await watcher.stop(); + } +}); diff --git a/tests/unit/action-state-machine.test.ts b/tests/unit/action-state-machine.test.ts new file mode 100644 index 0000000..f4b0705 --- /dev/null +++ b/tests/unit/action-state-machine.test.ts @@ -0,0 +1,221 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionStateMachine, type ExecuteReplyInput } from '../../src/reset/pipeline/action-state-machine.js'; +import type { ActionRecord } from '../../src/reset/state/schema.js'; +import { StateStore } from '../../src/reset/state/store.js'; +import type { XReplyProvider } from '../../src/reset/x/provider.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; +const targetPost = { + id: '100', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: '2026-08-28T09:00:00.000Z', + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { + source: 'timeline' as const, + isPinned: false as const, + isRetweet: false as const, + isReply: false as const, + }, +}; + +async function fixture() { + const home = await createTemporaryHome(); + homes.push(home); + const store = new StateStore(home.paths); + const times = [ + new Date('2026-08-28T10:00:00.000Z'), + new Date('2026-08-28T10:00:01.000Z'), + new Date('2026-08-28T10:00:02.000Z'), + new Date('2026-08-28T10:00:03.000Z'), + ]; + const machine = new ActionStateMachine(store, { now: () => times.shift() ?? new Date('2026-08-28T10:00:04.000Z') }); + return { home, store, machine }; +} + +function provider(overrides: Partial = {}): XReplyProvider { + return { + doctor: vi.fn(), + getCurrentAccount: vi.fn(), + findTargetPost: vi.fn(), + replyOnce: vi.fn(async (input) => { + await input.onMutationStart?.(); + return { + status: 'sent' as const, + tweetId: '9001', + url: 'https://x.com/example/status/9001', + verifiedBy: 'mutation-response' as const, + }; + }), + verifyReply: vi.fn(), + ...overrides, + }; +} + +function input(replyProvider: XReplyProvider): ExecuteReplyInput { + return { + actionId: 'action-1', + eventFingerprint: 'a'.repeat(64), + limitWindowKey: 'b'.repeat(64), + actionKey: 'c'.repeat(64), + detectedAt: '2026-08-28T09:59:00.000Z', + targetHandle: 'thsottiaux', + targetPost, + replyText: 'reset', + expectedXHandle: 'example', + provider: replyProvider, + }; +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +describe('one-shot action state machine', () => { + it('persists attempting before the one mutation and then records sent', async () => { + const { store, machine } = await fixture(); + const write = vi.fn(async (writeInput: Parameters[0]) => { + const beforeMutation = (await store.load()).actions[0]; + expect(beforeMutation).toMatchObject({ status: 'attempting', targetPostId: '100' }); + expect(beforeMutation.mutationStartedAt).toBeUndefined(); + expect(JSON.stringify(beforeMutation)).not.toContain('reset'); + await writeInput.onMutationStart?.(); + expect((await store.load()).actions[0].mutationStartedAt).toBeDefined(); + return { + status: 'sent' as const, + tweetId: '9001', + url: 'https://x.com/example/status/9001', + verifiedBy: 'mutation-response' as const, + }; + }); + const replyProvider = provider({ replyOnce: write }); + + const result = await machine.execute(input(replyProvider)); + + expect(result).toMatchObject({ + status: 'sent', + replyTweetId: '9001', + verifiedBy: 'mutation-response', + }); + expect(write).toHaveBeenCalledOnce(); + expect(replyProvider.verifyReply).not.toHaveBeenCalled(); + }); + + it('serializes concurrent claims for one action so only one mutation can start', async () => { + const { machine } = await fixture(); + const replyProvider = provider(); + const [first, second] = await Promise.all([ + machine.execute(input(replyProvider)), + machine.execute(input(replyProvider)), + ]); + expect(first.status).toBe('sent'); + expect(second.status).toBe('sent'); + expect(replyProvider.replyOnce).toHaveBeenCalledOnce(); + }); + + it('persists unknown before one read-only verification and can verify sent', async () => { + const { store, machine } = await fixture(); + const replyProvider = provider({ + replyOnce: vi.fn(async (writeInput) => { + await writeInput.onMutationStart?.(); + return { status: 'unknown' as const, safeCode: 'write-timeout', targetPostUrl: targetPost.url }; + }), + verifyReply: vi.fn(async () => { + expect((await store.load()).actions[0].status).toBe('unknown'); + return { + status: 'verified' as const, + tweetId: '9001', + url: 'https://x.com/example/status/9001', + }; + }), + }); + + const result = await machine.execute(input(replyProvider)); + + expect(result).toMatchObject({ status: 'sent', verifiedBy: 'read-after-write' }); + expect(replyProvider.replyOnce).toHaveBeenCalledOnce(); + expect(replyProvider.verifyReply).toHaveBeenCalledOnce(); + }); + + it('keeps zero or multiple verification matches unknown and never writes again', async () => { + const { machine } = await fixture(); + const replyProvider = provider({ + replyOnce: vi.fn(async (writeInput) => { + await writeInput.onMutationStart?.(); + return { status: 'unknown' as const, safeCode: 'write-timeout', targetPostUrl: targetPost.url }; + }), + verifyReply: vi.fn(async () => ({ status: 'not-verified' as const, safeCode: 'multiple-matches' as const })), + }); + + expect(await machine.execute(input(replyProvider))).toMatchObject({ status: 'unknown' }); + expect(await machine.execute(input(replyProvider))).toMatchObject({ status: 'unknown' }); + expect(replyProvider.replyOnce).toHaveBeenCalledOnce(); + expect(replyProvider.verifyReply).toHaveBeenCalledOnce(); + }); + + it('does not verify or retry a definitive failure', async () => { + const { machine } = await fixture(); + const replyProvider = provider({ + replyOnce: vi.fn(async () => ({ status: 'definitive-failure' as const, safeCode: 'wrong-account' })), + }); + expect(await machine.execute(input(replyProvider))).toMatchObject({ + status: 'definitive-failure', + safeCode: 'wrong-account', + }); + expect(replyProvider.replyOnce).toHaveBeenCalledOnce(); + expect(replyProvider.verifyReply).not.toHaveBeenCalled(); + }); + + it('converts stale attempting to unknown without any provider call', async () => { + const { store, machine } = await fixture(); + const stale: ActionRecord = { + actionId: 'action-1', + eventFingerprint: 'a'.repeat(64), + limitWindowKey: 'b'.repeat(64), + actionKey: 'c'.repeat(64), + detectedAt: '2026-08-28T09:59:00.000Z', + status: 'attempting', + targetHandle: 'thsottiaux', + targetPostId: '100', + targetPostUrl: targetPost.url, + replyTextHash: 'd'.repeat(64), + attemptStartedAt: '2026-08-28T10:00:00.000Z', + }; + await store.save({ version: 1, updatedAt: stale.detectedAt, actions: [stale] }); + const replyProvider = provider(); + + expect(await machine.execute(input(replyProvider))).toMatchObject({ + status: 'unknown', + safeCode: 'restart-ambiguous', + }); + expect(replyProvider.replyOnce).not.toHaveBeenCalled(); + expect(replyProvider.verifyReply).not.toHaveBeenCalled(); + }); + + it('recovers all stale attempts under a supervisor-held startup hook', async () => { + const { store, machine } = await fixture(); + const seed = input(provider()); + await store.save({ + version: 1, + updatedAt: seed.detectedAt, + actions: [ + { + actionId: seed.actionId, + eventFingerprint: seed.eventFingerprint, + limitWindowKey: seed.limitWindowKey, + actionKey: seed.actionKey, + detectedAt: seed.detectedAt, + status: 'attempting', + targetHandle: seed.targetHandle, + targetPostId: targetPost.id, + targetPostUrl: targetPost.url, + replyTextHash: 'd'.repeat(64), + attemptStartedAt: seed.detectedAt, + }, + ], + }); + expect(await machine.recoverStaleAttempts()).toBe(1); + expect((await store.load()).actions[0]).toMatchObject({ status: 'unknown', safeCode: 'restart-ambiguous' }); + }); +}); diff --git a/tests/unit/app-server-client.test.ts b/tests/unit/app-server-client.test.ts new file mode 100644 index 0000000..6315897 --- /dev/null +++ b/tests/unit/app-server-client.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { buildCodexAppServerEnvironment, readCodexRateLimits } from '../../src/reset/codex/app-server-client.js'; +import { createFakeAppServer } from '../helpers/fake-app-server.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; + +async function fixture() { + const home = await createTemporaryHome(); + homes.push(home); + return home; +} + +const validRateResult = { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 100, resetsAt: 1_900_000_000 }, + secondary: null, + rateLimitReachedType: null, + }, + rateLimitsByLimitId: null, +}; + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +describe('Codex App Server JSONL client', () => { + it('passes only allowlisted runtime variables to the Codex subprocess', () => { + const environment = buildCodexAppServerEnvironment( + { + HOME: '/home/user', + PATH: '/usr/bin', + AUTH_TOKEN: 'x-secret', + CT0: 'x-csrf-secret', + GH_TOKEN: 'github-secret', + OPENAI_API_KEY: 'api-secret', + }, + '/home/user/.codex', + ); + expect(environment).toEqual({ HOME: '/home/user', PATH: '/usr/bin', CODEX_HOME: '/home/user/.codex' }); + }); + + it('performs only initialize, initialized, and rateLimits/read', async () => { + const home = await fixture(); + const fake = createFakeAppServer(home.root, { rateResult: validRateResult }); + + const outcome = await readCodexRateLimits({ process: fake.process, timeoutMs: 2_000 }); + + expect(outcome).toEqual({ ok: true, value: validRateResult }); + const requests = await fake.readRequests(); + expect(requests).toEqual([ + { + method: 'initialize', + id: 0, + params: { + clientInfo: { + name: 'codex_reset_request', + title: 'Codex Reset Request', + version: '0.1.0', + }, + }, + }, + { method: 'initialized', params: {} }, + { method: 'account/rateLimits/read', id: 1 }, + ]); + expect(requests.some((request) => /thread|turn/i.test(String(request.method)))).toBe(false); + }); + + it.each([ + ['init-error', 'initialize', 'initialize-rejected'], + ['rate-error', 'rate-limits', 'rate-limits-rejected'], + ['invalid-json', 'initialize', 'invalid-json'], + ['wrong-id', 'initialize', 'unexpected-response'], + ['exit-after-init', 'rate-limits', 'process-exited'], + ['missing-rate-limits', 'rate-limits', 'rate-limits-schema'], + ] as const)('returns only safe failure data for %s', async (scenario, stage, code) => { + const home = await fixture(); + const fake = createFakeAppServer(home.root, { scenario, rateResult: validRateResult }); + + const outcome = await readCodexRateLimits({ process: fake.process, timeoutMs: 1_000 }); + + expect(outcome).toEqual({ ok: false, stage, code }); + expect(JSON.stringify(outcome)).not.toContain('secret-canary'); + expect(JSON.stringify(outcome)).not.toContain('/secret/canary/home'); + }); + + it('uses one total timeout across initialization and rate reading', async () => { + const home = await fixture(); + const fake = createFakeAppServer(home.root, { + rateResult: validRateResult, + initDelayMs: 70, + rateDelayMs: 70, + }); + + const outcome = await readCodexRateLimits({ process: fake.process, timeoutMs: 110 }); + + expect(outcome).toMatchObject({ ok: false, code: 'timeout' }); + }); + + it('reports an early process exit without exposing process output', async () => { + const home = await fixture(); + const fake = createFakeAppServer(home.root, { scenario: 'early-exit' }); + expect(await readCodexRateLimits({ process: fake.process, timeoutMs: 1_000 })).toEqual({ + ok: false, + stage: 'initialize', + code: 'process-exited', + }); + }); + + it('fails closed when App Server initializes for a different Codex home', async () => { + const home = await fixture(); + const fake = createFakeAppServer(home.root, { rateResult: validRateResult, codexHome: '/other/codex' }); + expect( + await readCodexRateLimits({ + process: fake.process, + timeoutMs: 1_000, + expectedCodexHome: '/expected/codex', + }), + ).toEqual({ ok: false, stage: 'initialize', code: 'codex-home-mismatch' }); + }); + + it('classifies a missing binary safely', async () => { + const outcome = await readCodexRateLimits({ + process: { command: 'codex-reset-request-definitely-missing-binary', args: [] }, + timeoutMs: 500, + }); + expect(outcome).toEqual({ ok: false, stage: 'spawn', code: 'binary-not-found' }); + }); +}); diff --git a/tests/unit/bird-baseline.test.ts b/tests/unit/bird-baseline.test.ts new file mode 100644 index 0000000..5056e5c --- /dev/null +++ b/tests/unit/bird-baseline.test.ts @@ -0,0 +1,461 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const getCookiesMock = vi.hoisted(() => vi.fn()); +const transactionIdMock = vi.hoisted(() => vi.fn()); +const queryIdGetMock = vi.hoisted(() => vi.fn(async () => null)); +const queryIdRefreshMock = vi.hoisted(() => vi.fn(async () => null)); + +vi.mock('@steipete/sweet-cookie', () => ({ getCookies: getCookiesMock })); +vi.mock('x-client-transaction-id', () => ({ + ClientTransaction: { + create: vi.fn(async () => ({ generateTransactionId: transactionIdMock })), + }, + handleXMigration: vi.fn(async () => ({})), +})); +vi.mock('../../src/lib/runtime-query-ids.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runtimeQueryIds: { + getQueryId: queryIdGetMock, + refresh: queryIdRefreshMock, + }, + }; +}); + +import { resolveBrowserFirstCredentials, resolveCredentials } from '../../src/lib/cookies.js'; +import type { CliContext } from '../../src/cli/shared.js'; +import { registerCheckCommand } from '../../src/commands/check.js'; +import { createRuntimeQueryIdStore } from '../../src/lib/runtime-query-ids.js'; +import { TwitterClientBase } from '../../src/lib/twitter-client-base.js'; +import { TwitterClient } from '../../src/lib/twitter-client.js'; +import { parseTweetsFromInstructions } from '../../src/lib/twitter-client-utils.js'; +import type { CurrentUserResult, GraphqlTweetResult, TwitterClientOptions } from '../../src/lib/twitter-client-types.js'; + +const originalEnvironment = { + AUTH_TOKEN: process.env.AUTH_TOKEN, + TWITTER_AUTH_TOKEN: process.env.TWITTER_AUTH_TOKEN, + CT0: process.env.CT0, + TWITTER_CT0: process.env.TWITTER_CT0, + NODE_ENV: process.env.NODE_ENV, +}; + +function restoreEnvironment(): void { + for (const [name, value] of Object.entries(originalEnvironment)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } +} + +function clearCredentialEnvironment(): void { + delete process.env.AUTH_TOKEN; + delete process.env.TWITTER_AUTH_TOKEN; + delete process.env.CT0; + delete process.env.TWITTER_CT0; +} + +function client(): TwitterClient { + return new TwitterClient({ + cookies: { + authToken: 'test-auth-token', + ct0: 'test-ct0', + cookieHeader: 'auth_token=test-auth-token; ct0=test-ct0', + source: 'test', + }, + }); +} + +function tweetResult(id: string, text = 'hello'): GraphqlTweetResult { + return { + __typename: 'Tweet', + rest_id: id, + legacy: { + full_text: text, + created_at: 'Thu Aug 27 10:00:00 +0000 2026', + conversation_id_str: id, + }, + core: { + user_results: { + result: { + rest_id: '42', + legacy: { screen_name: 'thsottiaux', name: 'Tibo' }, + }, + }, + }, + }; +} + +function timelinePayload(id: string): Record { + return { + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + type: 'TimelineAddEntries', + entries: [ + { + entryId: `tweet-${id}`, + content: { itemContent: { tweet_results: { result: tweetResult(id) } } }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }; +} + +function searchPayload(id: string): Record { + return { + data: { + search_by_raw_query: { + search_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { itemContent: { tweet_results: { result: tweetResult(id) } } }, + }, + ], + }, + ], + }, + }, + }, + }, + }; +} + +beforeEach(() => { + clearCredentialEnvironment(); + process.env.NODE_ENV = 'test'; + vi.restoreAllMocks(); + getCookiesMock.mockReset(); + transactionIdMock.mockReset(); + queryIdGetMock.mockReset().mockResolvedValue(null); + queryIdRefreshMock.mockReset().mockResolvedValue(null); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + restoreEnvironment(); +}); + +describe('Bird credential resolution baseline', () => { + it('reports credential presence without printing token prefixes', async () => { + const output: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...values: unknown[]) => { + output.push(values.map(String).join(' ')); + }); + const program = new Command(); + registerCheckCommand( + program, + { + p: () => '', + l: () => '', + resolveCredentialsFromOptions: async () => ({ + cookies: { + authToken: 'do-not-print-auth-token', + ct0: 'do-not-print-csrf-token', + cookieHeader: null, + source: 'synthetic test', + }, + warnings: [], + }), + } as unknown as CliContext, + ); + + await program.parseAsync(['node', 'bird', 'check']); + + expect(output.join('\n')).toContain('auth_token: available (value hidden)'); + expect(output.join('\n')).toContain('ct0: available (value hidden)'); + expect(output.join('\n')).not.toContain('do-not-print'); + }); + + it('tries browser sources in order and keeps cookie values in memory', async () => { + getCookiesMock + .mockResolvedValueOnce({ cookies: [], warnings: [] }) + .mockResolvedValueOnce({ + cookies: [ + { name: 'auth_token', value: 'browser-auth', domain: '.x.com' }, + { name: 'ct0', value: 'browser-ct0', domain: '.x.com' }, + ], + warnings: [], + }); + + const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] }); + + expect(result.cookies).toMatchObject({ + authToken: 'browser-auth', + ct0: 'browser-ct0', + source: 'Chrome default profile', + }); + expect(getCookiesMock.mock.calls.map(([input]) => input.browsers)).toEqual([['safari'], ['chrome']]); + }); + + it('documents the upstream environment-before-browser priority', async () => { + process.env.AUTH_TOKEN = 'env-auth'; + process.env.CT0 = 'env-ct0'; + + const result = await resolveCredentials({ cookieSource: 'safari' }); + + expect(result.cookies.source).toBe('env AUTH_TOKEN'); + expect(getCookiesMock).not.toHaveBeenCalled(); + }); + + it('uses browser-first credential priority for reset automation', async () => { + process.env.AUTH_TOKEN = 'environment-auth'; + process.env.CT0 = 'environment-ct0'; + getCookiesMock.mockResolvedValue({ + cookies: [ + { name: 'auth_token', value: 'browser-auth', domain: '.x.com' }, + { name: 'ct0', value: 'browser-ct0', domain: '.x.com' }, + ], + warnings: [], + }); + + const result = await resolveBrowserFirstCredentials({ cookieSource: 'safari' }); + + expect(result.cookies).toMatchObject({ authToken: 'browser-auth', ct0: 'browser-ct0', source: 'Safari' }); + expect(getCookiesMock).toHaveBeenCalledOnce(); + }); +}); + +describe('Bird read and write baseline', () => { + it('parses a top-level verify-credentials user ID', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ id_str: '88', screen_name: 'example', name: 'Example' }), { status: 200 }), + ), + ); + expect(await client().getCurrentUser()).toEqual({ + success: true, + user: { id: '88', username: 'example', name: 'Example' }, + }); + }); + + it('looks up a user through the GraphQL result shape', async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '42', + legacy: { screen_name: 'thsottiaux', name: 'Tibo' }, + }, + }, + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await client().getUserIdByUsername('@thsottiaux'); + + expect(result).toEqual({ success: true, userId: '42', username: 'thsottiaux', name: 'Tibo' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('parses a user timeline page and preserves raw tweet data', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(timelinePayload('100')), { status: 200 }))); + + const result = await client().getUserTweetsPaged('42', 1, { includeRaw: true, maxPages: 1, pageDelayMs: 0 }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.tweets[0]).toMatchObject({ id: '100', authorId: '42', author: { username: 'thsottiaux' } }); + expect(result.tweets[0]._raw?.rest_id).toBe('100'); + } + }); + + it('preserves pinned, retweet, reply, quote, wrapper, and entry metadata', async () => { + const payload = JSON.parse( + await readFile(path.join(process.cwd(), 'tests', 'fixtures', 'x', 'user-timeline-mixed.json'), 'utf8'), + ) as { + data: { + user: { + result: { timeline: { timeline: { instructions: Parameters[0] } } }; + }; + }; + }; + + const tweets = parseTweetsFromInstructions(payload.data.user.result.timeline.timeline.instructions, { + quoteDepth: 1, + includeRaw: true, + }); + + expect(tweets.find((tweet) => tweet.id === '110')).toMatchObject({ + isPinned: true, + sourceInstructionType: 'TimelinePinEntry', + }); + expect(tweets.find((tweet) => tweet.id === '109')).toMatchObject({ isRetweet: true }); + expect(tweets.find((tweet) => tweet.id === '108')).toMatchObject({ isReply: true }); + expect(tweets.find((tweet) => tweet.id === '107')).toMatchObject({ isQuote: true, isRetweet: false }); + expect(tweets.find((tweet) => tweet.id === '106')).toMatchObject({ + tweetWrapperTypename: 'TweetWithVisibilityResults', + tweetResultTypename: 'Tweet', + isPinned: false, + isRetweet: false, + }); + }); + + it('parses latest-search results', async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + new Response(JSON.stringify(searchPayload('101')), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await client().search('from:thsottiaux -filter:replies -filter:retweets', 1); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.tweets.map((tweet) => tweet.id)).toEqual(['101']); + } + expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe('POST'); + }); + + it('returns the explicit tweet ID from a successful reply mutation', async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ data: { create_tweet: { tweet_results: { result: { rest_id: '9001' } } } } }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await client().reply('reset', '100'); + + expect(result).toEqual({ success: true, tweetId: '9001' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(request.method).toBe('POST'); + expect(JSON.parse(String(request.body))).toMatchObject({ + variables: { tweet_text: 'reset', reply: { in_reply_to_tweet_id: '100' } }, + }); + }); +}); + +describe('Bird transaction and query ID baseline', () => { + it('binds one generated transaction ID to the actual request URL path', async () => { + process.env.NODE_ENV = 'production'; + transactionIdMock.mockResolvedValue('url-bound-transaction'); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + class ExposedClient extends TwitterClientBase { + protected async getCurrentUser(): Promise { + return { success: false }; + } + + async post(url: string): Promise> { + const headers = this.getHeaders(); + await this.fetchWithTimeout(url, { method: 'POST', headers }); + return headers; + } + } + + const options: TwitterClientOptions = { + cookies: { + authToken: 'test-auth-token', + ct0: 'test-ct0', + cookieHeader: null, + source: 'test', + }, + }; + const headers = await new ExposedClient(options).post('https://x.com/i/api/graphql/query/CreateTweet'); + + expect(transactionIdMock).toHaveBeenCalledOnce(); + expect(transactionIdMock).toHaveBeenCalledWith('POST', '/i/api/graphql/query/CreateTweet'); + expect(headers['x-client-transaction-id']).toBe('url-bound-transaction'); + }); + + it('runs final write authorization after async transaction preparation and immediately before fetch', async () => { + process.env.NODE_ENV = 'production'; + const transactionGate: { release?: (value: string) => void } = {}; + transactionIdMock.mockImplementation( + async () => + await new Promise((resolve) => { + transactionGate.release = resolve; + }), + ); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + let autoEnabled = true; + let authorizationChecks = 0; + + class ExposedClient extends TwitterClientBase { + protected async getCurrentUser(): Promise { + return { success: false }; + } + + async guardedPost(url: string): Promise { + await this.fetchWithTimeout(url, { method: 'POST', headers: this.getHeaders() }, async () => { + authorizationChecks += 1; + if (!autoEnabled) { + throw new Error('authorization revoked'); + } + }); + } + } + + const options: TwitterClientOptions = { + cookies: { authToken: 'test-auth-token', ct0: 'test-ct0', cookieHeader: null, source: 'test' }, + }; + const outcome = new ExposedClient(options) + .guardedPost('https://x.com/i/api/graphql/query/CreateTweet') + .then( + () => null, + (error: unknown) => error, + ); + await vi.waitFor(() => expect(transactionIdMock).toHaveBeenCalledOnce()); + expect(authorizationChecks).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + + autoEnabled = false; + transactionGate.release?.('prepared-transaction'); + expect(await outcome).toBeInstanceOf(Error); + expect(authorizationChecks).toBe(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('discovers, caches, and reloads a runtime query ID', async () => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'bird-query-ids-')); + const cachePath = path.join(temporaryDirectory, 'query-ids.json'); + const bundleUrl = 'https://abs.twimg.com/responsive-web/client-web/main.abc123.js'; + const fetchImpl = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url === bundleUrl) { + return new Response('e.exports={queryId:"fresh-id",operationName:"CreateTweet"}', { status: 200 }); + } + return new Response(``, { status: 200 }); + }) as typeof fetch; + + try { + const store = createRuntimeQueryIdStore({ cachePath, fetchImpl, ttlMs: 60_000 }); + await store.refresh(['CreateTweet'], { force: true }); + + expect(await store.getQueryId('CreateTweet')).toBe('fresh-id'); + expect(JSON.parse(await readFile(cachePath, 'utf8')).ids).toEqual({ CreateTweet: 'fresh-id' }); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/bird-provider.test.ts b/tests/unit/bird-provider.test.ts new file mode 100644 index 0000000..4d43168 --- /dev/null +++ b/tests/unit/bird-provider.test.ts @@ -0,0 +1,194 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseTweetsFromInstructions } from '../../src/lib/twitter-client-utils.js'; +import { createDefaultConfig } from '../../src/reset/config/schema.js'; +import { BirdXReplyProvider, type BirdProviderDependencies } from '../../src/reset/x/bird-provider.js'; + +async function timelineTweets() { + const fixture = JSON.parse( + await readFile(path.join(process.cwd(), 'tests', 'fixtures', 'x', 'user-timeline-mixed.json'), 'utf8'), + ) as { + data: { user: { result: { timeline: { timeline: { instructions: Parameters[0] } } } } }; + }; + return parseTweetsFromInstructions(fixture.data.user.result.timeline.timeline.instructions, { + quoteDepth: 1, + includeRaw: true, + }); +} + +const credentials = { + cookies: { + authToken: 'memory-only-auth', + ct0: 'memory-only-ct0', + cookieHeader: 'auth_token=memory-only-auth; ct0=memory-only-ct0', + source: 'test', + }, + warnings: [], +}; + +describe('Bird target provider', () => { + const resolveCredentials = vi.fn(); + const createClient = vi.fn(); + + beforeEach(() => { + vi.restoreAllMocks(); + resolveCredentials.mockReset().mockResolvedValue(credentials); + createClient.mockReset(); + }); + + function provider(client: object) { + createClient.mockReturnValue(client); + const dependencies: Partial = { + resolveCredentials, + createClient, + now: () => new Date('2026-08-28T10:00:00.000Z'), + }; + return new BirdXReplyProvider(createDefaultConfig(), dependencies); + } + + it('selects the newest eligible timeline post and cross-checks search', async () => { + const tweets = await timelineTweets(); + const searchFixture = JSON.parse( + await readFile(path.join(process.cwd(), 'tests', 'fixtures', 'x', 'search-match.json'), 'utf8'), + ) as { tweets: typeof tweets }; + const client = { + getCurrentUser: vi.fn(), + getUserIdByUsername: vi.fn().mockResolvedValue({ + success: true, + userId: '42', + username: 'thsottiaux', + }), + getUserTweetsPaged: vi.fn().mockResolvedValue({ success: true, tweets }), + search: vi.fn().mockResolvedValue({ success: true, tweets: searchFixture.tweets }), + }; + + const result = await provider(client).findTargetPost({ targetHandle: 'thsottiaux', maxPostAgeHours: 72 }); + + expect(result).toMatchObject({ + status: 'found', + post: { id: '107', selectionEvidence: { source: 'timeline+search' } }, + }); + expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, { + includeRaw: true, + maxPages: 1, + pageDelayMs: 0, + }); + expect(client.search).toHaveBeenCalledWith('from:thsottiaux -filter:replies -filter:retweets', 20, { + includeRaw: true, + }); + }); + + it('continues from strong timeline evidence when search is unavailable', async () => { + const tweets = await timelineTweets(); + const client = { + getCurrentUser: vi.fn(), + getUserIdByUsername: vi.fn().mockResolvedValue({ success: true, userId: '42', username: 'thsottiaux' }), + getUserTweetsPaged: vi.fn().mockResolvedValue({ success: true, tweets }), + search: vi.fn().mockResolvedValue({ success: false, error: 'safe fake failure' }), + }; + expect(await provider(client).findTargetPost({ targetHandle: 'thsottiaux', maxPostAgeHours: 72 })).toMatchObject({ + status: 'found', + post: { id: '107', selectionEvidence: { source: 'timeline' } }, + }); + }); + + it('fails closed when successful search disagrees', async () => { + const tweets = await timelineTweets(); + const searchFixture = JSON.parse( + await readFile(path.join(process.cwd(), 'tests', 'fixtures', 'x', 'search-mismatch.json'), 'utf8'), + ) as { tweets: typeof tweets }; + const client = { + getCurrentUser: vi.fn(), + getUserIdByUsername: vi.fn().mockResolvedValue({ success: true, userId: '42', username: 'thsottiaux' }), + getUserTweetsPaged: vi.fn().mockResolvedValue({ success: true, tweets }), + search: vi.fn().mockResolvedValue({ success: true, tweets: searchFixture.tweets }), + }; + expect(await provider(client).findTargetPost({ targetHandle: 'thsottiaux', maxPostAgeHours: 72 })).toEqual({ + status: 'not-found', + safeCode: 'target-search-mismatch', + }); + }); + + it('maps missing credentials to a stable code without creating a client', async () => { + resolveCredentials.mockResolvedValue({ + cookies: { authToken: null, ct0: null, cookieHeader: null, source: null }, + warnings: ['secret-bearing warning must not escape'], + }); + const result = await provider({}).findTargetPost({ targetHandle: 'thsottiaux', maxPostAgeHours: 72 }); + expect(result).toEqual({ status: 'not-found', safeCode: 'credentials-unavailable' }); + expect(createClient).not.toHaveBeenCalled(); + }); + + it('rechecks the active account immediately before writing', async () => { + const mutation = vi.fn(); + const client = { + getCurrentUser: vi.fn().mockResolvedValue({ + success: true, + user: { id: '88', username: 'different', name: 'Different' }, + }), + getUserIdByUsername: vi.fn(), + getUserTweetsPaged: vi.fn(), + search: vi.fn(), + replySingleAttempt: mutation, + }; + const result = await provider(client).replyOnce({ + targetPost: { + id: '100', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: '2026-08-28T09:00:00.000Z', + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { source: 'timeline', isPinned: false, isRetweet: false, isReply: false }, + }, + text: 'reset', + attemptId: 'attempt-1', + expectedXHandle: 'example', + }); + expect(result).toEqual({ status: 'definitive-failure', safeCode: 'wrong-account' }); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('uses the same freshly authenticated client for account check and one mutation', async () => { + const order: string[] = []; + const client = { + getCurrentUser: vi.fn(async () => { + order.push('account'); + return { success: true, user: { id: '88', username: 'example', name: 'Example' } }; + }), + getUserIdByUsername: vi.fn(), + getUserTweetsPaged: vi.fn(), + search: vi.fn(), + replySingleAttempt: vi.fn(async (_text, _targetId, options) => { + await options?.onMutationStart?.(); + order.push('mutation'); + return { status: 'sent' as const, tweetId: '9001' }; + }), + }; + const result = await provider(client).replyOnce({ + targetPost: { + id: '100', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: '2026-08-28T09:00:00.000Z', + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { source: 'timeline', isPinned: false, isRetweet: false, isReply: false }, + }, + text: 'reset', + attemptId: 'attempt-1', + expectedXHandle: 'example', + onMutationStart: async () => { + order.push('persisted'); + return { ok: true }; + }, + }); + expect(result).toEqual({ + status: 'sent', + tweetId: '9001', + url: 'https://x.com/example/status/9001', + verifiedBy: 'mutation-response', + }); + expect(order).toEqual(['account', 'persisted', 'mutation']); + expect(client.replySingleAttempt).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/config-state.test.ts b/tests/unit/config-state.test.ts new file mode 100644 index 0000000..2789906 --- /dev/null +++ b/tests/unit/config-state.test.ts @@ -0,0 +1,379 @@ +import { chmod, link, stat, symlink, truncate, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../../src/reset/config/load.js'; +import { ensureAppDirectories, getAppPaths } from '../../src/reset/config/paths.js'; +import { saveConfig } from '../../src/reset/config/save.js'; +import { + CURRENT_DISCLAIMER_VERSION, + createDefaultConfig, + hasCurrentAutomaticPostingConsent, + resetRequestConfigSchema, +} from '../../src/reset/config/schema.js'; +import { setConfigValue } from '../../src/reset/commands/config.js'; +import { acquireSingleInstanceLock, LockHeldError } from '../../src/reset/state/lock.js'; +import { createEmptyState } from '../../src/reset/state/schema.js'; +import { StateStore } from '../../src/reset/state/store.js'; +import { + appendAuditEvent, + MAX_AUDIT_LOG_BYTES, + readAuditTail, +} from '../../src/reset/state/audit-log.js'; +import { MAX_JSON_FILE_BYTES } from '../../src/reset/utils/atomic-file.js'; +import { redactForLog } from '../../src/reset/utils/redaction.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; + +async function temporaryHome(): Promise { + const home = await createTemporaryHome(); + homes.push(home); + return home; +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +describe('configuration', () => { + it('uses the required safe defaults', () => { + expect(createDefaultConfig()).toMatchObject({ + mode: 'dry-run', + targetHandle: 'thsottiaux', + replyText: 'reset', + requireRateLimitConfirmation: true, + maxPostAgeHours: 72, + maxAttemptsPer24Hours: 1, + consent: { automaticPostingAccepted: false }, + }); + }); + + it('validates handles, Unicode length, control characters, and hard attempt bounds', () => { + expect(() => resetRequestConfigSchema.parse({ ...createDefaultConfig(), targetHandle: 'https://x.com/a' })).toThrow(); + expect(() => resetRequestConfigSchema.parse({ ...createDefaultConfig(), replyText: 'x'.repeat(101) })).toThrow(); + expect(() => resetRequestConfigSchema.parse({ ...createDefaultConfig(), replyText: ' ' })).toThrow(); + expect(() => resetRequestConfigSchema.parse({ ...createDefaultConfig(), replyText: 'reset\n' })).toThrow(); + expect(() => resetRequestConfigSchema.parse({ ...createDefaultConfig(), maxAttemptsPer24Hours: 4 })).toThrow(); + expect(() => resetRequestConfigSchema.parse({ ...createDefaultConfig(), mode: 'notify' })).toThrow(); + expect(resetRequestConfigSchema.parse({ ...createDefaultConfig(), replyText: '🪶'.repeat(100) }).replyText).toHaveLength(200); + }); + + it('does not allow config set to bypass automatic-posting consent', () => { + expect(() => setConfigValue(createDefaultConfig(), 'mode', 'auto')).toThrow(/enable-auto/); + expect(() => setConfigValue(createDefaultConfig(), 'mode', 'notify')).toThrow(/not supported/); + expect(setConfigValue(createDefaultConfig(), 'targetHandle', '@thsottiaux').targetHandle).toBe('thsottiaux'); + expect(setConfigValue(createDefaultConfig(), 'targetHandle', '@ThSoTtIaUx').targetHandle).toBe('thsottiaux'); + expect(() => setConfigValue(createDefaultConfig(), 'requireRateLimitConfirmation', 'false')).toThrow( + /Unsupported/, + ); + }); + + it('invalidates auto mode when the disclaimer version changes', () => { + const config = createDefaultConfig(); + config.mode = 'auto'; + config.expectedXHandle = 'example'; + config.consent = { + disclaimerVersion: 'old-version', + acceptedAt: new Date().toISOString(), + automaticPostingAccepted: true, + }; + expect(hasCurrentAutomaticPostingConsent(config)).toBe(false); + config.consent.disclaimerVersion = CURRENT_DISCLAIMER_VERSION; + expect(hasCurrentAutomaticPostingConsent(config)).toBe(true); + }); + + it('saves validated config atomically with private Unix permissions', async () => { + const home = await temporaryHome(); + const config = createDefaultConfig(); + await saveConfig(config, home.paths); + + expect((await loadConfig(home.paths)).mode).toBe('dry-run'); + if (process.platform !== 'win32') { + expect((await stat(home.paths.configDir)).mode & 0o777).toBe(0o700); + expect((await stat(home.paths.configFile)).mode & 0o777).toBe(0o600); + } + }); + + it('refuses to load configuration through a symbolic link', async () => { + const home = await temporaryHome(); + await ensureAppDirectories(home.paths); + const outside = `${home.root}/outside-config.json`; + await writeFile(outside, JSON.stringify(createDefaultConfig()), 'utf8'); + await symlink(outside, home.paths.configFile); + + await expect(loadConfig(home.paths)).rejects.toThrow(/invalid local data file/i); + }); + + it('migrates legacy notify configuration to dry-run without local notification settings', async () => { + const home = await temporaryHome(); + await ensureAppDirectories(home.paths); + await writeFile( + home.paths.configFile, + `${JSON.stringify({ + ...createDefaultConfig(), + mode: 'notify', + notifications: { detection: true, success: true, failure: true, unknown: true }, + })}\n`, + 'utf8', + ); + + const migrated = await loadConfig(home.paths); + expect(migrated.mode).toBe('dry-run'); + expect(migrated).not.toHaveProperty('notifications'); + }); + + it('uses platform-standard paths and explicit test overrides', () => { + const linux = getAppPaths({ + platform: 'linux', + homeDirectory: '/home/tester', + env: { XDG_CONFIG_HOME: '/config', XDG_STATE_HOME: '/state' }, + }); + expect(linux.configDir).toBe('/config/codex-reset-request'); + expect(linux.stateDir).toBe('/state/codex-reset-request'); + + const macos = getAppPaths({ + platform: 'darwin', + homeDirectory: '/Users/tester', + env: {}, + }); + expect(macos.configDir).toBe('/Users/tester/Library/Application Support/codex-reset-request'); + expect(macos.logDir).toBe('/Users/tester/Library/Logs/codex-reset-request'); + + const windows = getAppPaths({ + platform: 'win32', + homeDirectory: String.raw`C:\Users\tester`, + env: { + APPDATA: String.raw`D:\Profiles\Roaming`, + LOCALAPPDATA: String.raw`D:\Profiles\Local`, + }, + }); + expect(windows.configDir).toBe(String.raw`D:\Profiles\Roaming\codex-reset-request`); + expect(windows.stateDir).toBe(String.raw`D:\Profiles\Local\codex-reset-request`); + expect(windows.configFile).toBe(String.raw`D:\Profiles\Roaming\codex-reset-request\config.json`); + + const overridden = getAppPaths({ + platform: 'darwin', + homeDirectory: '/Users/tester', + env: { CRR_CONFIG_DIR: '/tmp/config', CRR_STATE_DIR: '/tmp/state', CRR_LOG_DIR: '/tmp/logs' }, + }); + expect(overridden).toMatchObject({ configDir: '/tmp/config', stateDir: '/tmp/state', logDir: '/tmp/logs' }); + }); +}); + +describe('state and locking', () => { + it('recovers the committed atomic state while ignoring an orphan temporary file', async () => { + const home = await temporaryHome(); + const store = new StateStore(home.paths); + const state = createEmptyState(); + await store.save(state); + await writeFile(`${home.paths.stateFile}.orphan.tmp`, '{invalid', 'utf8'); + + expect(await store.load()).toMatchObject({ version: 1, actions: [] }); + }); + + it('prevents a second live instance and permits reacquisition after release', async () => { + const home = await temporaryHome(); + const first = await acquireSingleInstanceLock(home.paths.daemonLockFile); + await expect(acquireSingleInstanceLock(home.paths.daemonLockFile)).rejects.toBeInstanceOf(LockHeldError); + await first.release(); + const second = await acquireSingleInstanceLock(home.paths.daemonLockFile); + await second.release(); + }); + + it('replaces a stale lock without sending a signal', async () => { + const home = await temporaryHome(); + await ensureAppDirectories(home.paths); + await writeFile( + home.paths.daemonLockFile, + `${JSON.stringify({ pid: 2_147_483_647, startedAt: new Date(0).toISOString(), token: 'stale' })}\n`, + { mode: 0o600 }, + ); + if (process.platform !== 'win32') { + await chmod(home.paths.daemonLockFile, 0o600); + } + const lock = await acquireSingleInstanceLock(home.paths.daemonLockFile); + expect(lock.record.pid).toBe(process.pid); + await lock.release(); + }); + + it('rejects duplicate action identities without evicting durable deduplication history', async () => { + const home = await temporaryHome(); + const store = new StateStore(home.paths); + const completedAt = new Date().toISOString(); + const actions = Array.from({ length: 1_501 }, (_value, index) => ({ + actionId: `action-${index}`, + eventFingerprint: index.toString(16).padStart(64, '0'), + limitWindowKey: (index + 10_000).toString(16).padStart(64, '0'), + detectedAt: completedAt, + completedAt, + status: 'dry-run' as const, + targetHandle: 'thsottiaux', + safeCode: 'would-reply', + })); + await store.save({ version: 1, updatedAt: completedAt, actions }); + expect((await store.load()).actions).toHaveLength(actions.length); + + const duplicate = actions.at(-1); + if (!duplicate) { + throw new Error('Expected a generated action'); + } + await expect( + store.save({ version: 1, updatedAt: completedAt, actions: [duplicate, { ...duplicate }] }), + ).rejects.toThrow(/Duplicate/); + }); + + it('migrates loose early-v1 sent records conservatively instead of dropping their guard', async () => { + const home = await temporaryHome(); + const store = new StateStore(home.paths); + const timestamp = new Date().toISOString(); + await ensureAppDirectories(home.paths); + await writeFile( + home.paths.stateFile, + `${JSON.stringify({ + version: 1, + updatedAt: timestamp, + actions: [ + { + actionId: 'legacy-action', + eventFingerprint: 'a'.repeat(64), + limitWindowKey: 'b'.repeat(64), + detectedAt: timestamp, + status: 'sent', + targetHandle: 'Example', + }, + ], + })}\n`, + 'utf8', + ); + + expect((await store.load()).actions[0]).toMatchObject({ + status: 'sent', + targetHandle: 'example', + mutationStartedAt: timestamp, + completedAt: timestamp, + legacyImported: true, + }); + }); + + it.each([ + { + label: 'notified terminal', + action: { status: 'notified' as const }, + expected: { status: 'notified', completedAt: expect.any(String) }, + }, + { + label: 'incomplete target-resolved', + action: { status: 'target-resolved' as const, confirmedAt: '2026-08-28T12:00:00.000Z' }, + expected: { status: 'confirmed', confirmedAt: '2026-08-28T12:00:00.000Z' }, + }, + { + label: 'mutation-marked candidate', + action: { + status: 'candidate' as const, + attemptStartedAt: '2026-08-28T12:00:00.000Z', + mutationStartedAt: '2026-08-28T12:00:01.000Z', + }, + expected: { + status: 'unknown', + mutationStartedAt: '2026-08-28T12:00:01.000Z', + completedAt: expect.any(String), + }, + }, + ])('migrates an early-v1 $label record without losing safety evidence', async ({ action, expected }) => { + const home = await temporaryHome(); + const store = new StateStore(home.paths); + const timestamp = '2026-08-28T12:00:00.000Z'; + await ensureAppDirectories(home.paths); + await writeFile( + home.paths.stateFile, + `${JSON.stringify({ + version: 1, + updatedAt: timestamp, + actions: [ + { + actionId: `legacy-${action.status}`, + eventFingerprint: 'c'.repeat(64), + limitWindowKey: 'd'.repeat(64), + detectedAt: timestamp, + targetHandle: 'Example', + ...action, + }, + ], + })}\n`, + 'utf8', + ); + + expect((await store.load()).actions[0]).toMatchObject({ ...expected, legacyImported: true }); + }); + + it('refuses an oversized state before atomic replacement and keeps the prior state readable', async () => { + const home = await temporaryHome(); + const store = new StateStore(home.paths); + const empty = createEmptyState(); + await store.save(empty); + const timestamp = new Date().toISOString(); + await expect( + store.save({ + version: 1, + updatedAt: timestamp, + actions: [ + { + actionId: 'x'.repeat(MAX_JSON_FILE_BYTES), + eventFingerprint: 'e'.repeat(64), + limitWindowKey: 'f'.repeat(64), + detectedAt: timestamp, + completedAt: timestamp, + status: 'dry-run', + targetHandle: 'thsottiaux', + }, + ], + }), + ).rejects.toThrow(/too large/i); + expect(await store.load()).toMatchObject({ version: 1, actions: [] }); + }); +}); + +describe('log redaction', () => { + it('refuses a symbolic-link audit log for writes and reads', async () => { + const home = await temporaryHome(); + await ensureAppDirectories(home.paths); + const outside = `${home.root}/outside.log`; + await writeFile(outside, 'outside\n', 'utf8'); + await symlink(outside, home.paths.auditLogFile); + await expect(appendAuditEvent({ level: 'info', code: 'test' }, home.paths)).rejects.toThrow(/symbolic link/i); + await expect(readAuditTail(10, home.paths)).rejects.toThrow(/symbolic link/i); + }); + + it('refuses a hard-linked audit log', async () => { + const home = await temporaryHome(); + await ensureAppDirectories(home.paths); + const outside = `${home.root}/outside-hard-link.log`; + await writeFile(outside, 'outside\n', 'utf8'); + await link(outside, home.paths.auditLogFile); + await expect(appendAuditEvent({ level: 'info', code: 'test' }, home.paths)).rejects.toThrow( + /invalid audit log/i, + ); + await expect(readAuditTail(10, home.paths)).rejects.toThrow(/invalid audit log/i); + }); + + it('refuses to append to an oversized audit log', async () => { + const home = await temporaryHome(); + await ensureAppDirectories(home.paths); + await writeFile(home.paths.auditLogFile, '', 'utf8'); + await truncate(home.paths.auditLogFile, MAX_AUDIT_LOG_BYTES + 1); + await expect(appendAuditEvent({ level: 'info', code: 'test' }, home.paths)).rejects.toThrow( + /invalid audit log/i, + ); + }); + + it('redacts credential keys, cookies, bearer values, JWTs, and long hex strings', () => { + const value = redactForLog({ + auth_token: 'secret', + message: + 'auth_token=secret; ct0=secret Bearer abc.def eyJaaaaaaaaaaa.bbbbbbbbbbb.ccccccccccc 0123456789abcdef0123456789abcdef01234567', + }); + expect(JSON.stringify(value)).not.toContain('secret'); + expect(JSON.stringify(value)).not.toContain('0123456789abcdef'); + expect(redactForLog(`${homedir()}/Library/example`)).toBe('~/Library/example'); + }); +}); diff --git a/tests/unit/fingerprints-rate-guard.test.ts b/tests/unit/fingerprints-rate-guard.test.ts new file mode 100644 index 0000000..f6501f6 --- /dev/null +++ b/tests/unit/fingerprints-rate-guard.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import { + createActionKey, + codexHomeIdentity, + createFallbackLimitWindowKey, + createLimitWindowKey, +} from '../../src/reset/pipeline/fingerprints.js'; +import { + countRollingWriteAttempts, + evaluateActionRateGuard, + evaluatePreTargetRateGuard, +} from '../../src/reset/pipeline/rate-guard.js'; +import { createEmptyState, type ActionRecord, type ResetState } from '../../src/reset/state/schema.js'; + +const NOW = new Date('2026-08-28T12:00:00.000Z'); + +function action( + index: number, + overrides: Partial = {}, +): ActionRecord { + return { + actionId: `action-${index}`, + eventFingerprint: index.toString(16).padStart(64, '0'), + limitWindowKey: `${index + 10}`.padStart(64, '0'), + actionKey: `${index + 20}`.padStart(64, '0'), + detectedAt: new Date(NOW.getTime() - index * 1_000).toISOString(), + attemptStartedAt: new Date(NOW.getTime() - index * 60_000).toISOString(), + status: 'sent', + targetHandle: 'thsottiaux', + targetPostId: `${100 + index}`, + targetPostUrl: `https://x.com/thsottiaux/status/${100 + index}`, + replyTextHash: 'a'.repeat(64), + replyTweetId: `${9000 + index}`, + replyUrl: `https://x.com/example/status/${9000 + index}`, + verifiedBy: 'mutation-response', + completedAt: NOW.toISOString(), + ...overrides, + }; +} + +function state(actions: ActionRecord[]): ResetState { + return { ...createEmptyState(NOW), actions }; +} + +describe('action fingerprints', () => { + it('creates stable window keys without reading Codex credentials', () => { + const first = createLimitWindowKey({ + codexHome: '/Users/example/.codex', + limitId: 'codex', + resetsAt: 1_777_777_777, + }); + expect(first).toHaveLength(64); + expect( + createLimitWindowKey({ + codexHome: '/Users/example/.codex', + limitId: 'codex', + resetsAt: 1_777_777_777, + }), + ).toBe(first); + expect( + createLimitWindowKey({ + codexHome: '/Users/example/.codex', + limitId: 'codex', + resetsAt: 1_777_777_778, + }), + ).not.toBe(first); + }); + + it('canonicalizes case-insensitive Windows Codex home paths', () => { + expect(codexHomeIdentity('C:\\Users\\Example\\.codex', 'win32')).toBe( + codexHomeIdentity('c:\\users\\example\\.CODEX', 'win32'), + ); + }); + + it('falls back to one UTC-day window and normalizes action reply text', () => { + const morning = new Date('2026-08-28T00:00:01.000Z'); + const evening = new Date('2026-08-28T23:59:59.000Z'); + expect(createFallbackLimitWindowKey('/tmp/codex', morning)).toBe( + createFallbackLimitWindowKey('/tmp/codex', evening), + ); + expect(createFallbackLimitWindowKey('/tmp/codex', evening)).not.toBe( + createFallbackLimitWindowKey('/tmp/codex', new Date('2026-08-29T00:00:00.000Z')), + ); + expect(createActionKey('a'.repeat(64), '100', ' reset ')).toBe( + createActionKey('a'.repeat(64), '100', 'reset'), + ); + expect(createActionKey('a'.repeat(64), '100', 're\u0301set')).toBe( + createActionKey('a'.repeat(64), '100', 'r\u00e9set'), + ); + expect( + createLimitWindowKey({ + codexHome: '/tmp/codex', + limitId: 'codex', + resetsAt: null, + now: morning, + }), + ).toBe(createFallbackLimitWindowKey('/tmp/codex', morning)); + }); +}); + +describe('write guards', () => { + it('blocks the same limit window and the same action key', () => { + const prior = action(1); + expect( + evaluatePreTargetRateGuard({ + state: state([prior]), + actionId: 'new', + limitWindowKey: prior.limitWindowKey, + configuredMaximum: 3, + now: NOW, + }), + ).toMatchObject({ allowed: false, safeCode: 'same-limit-window' }); + expect( + evaluateActionRateGuard({ + state: state([prior]), + actionId: 'new', + actionKey: prior.actionKey ?? '', + attemptsIn24Hours: 1, + }), + ).toMatchObject({ allowed: false, safeCode: 'same-action' }); + }); + + it('enforces the configured rolling maximum and non-configurable hard maximum', () => { + expect( + evaluatePreTargetRateGuard({ + state: state([action(1)]), + actionId: 'new', + limitWindowKey: 'f'.repeat(64), + configuredMaximum: 1, + now: NOW, + }), + ).toMatchObject({ allowed: false, safeCode: 'rolling-24-hour-limit' }); + expect( + evaluatePreTargetRateGuard({ + state: state([action(1), action(2), action(3)]), + actionId: 'new', + limitWindowKey: 'f'.repeat(64), + configuredMaximum: 3, + now: NOW, + }), + ).toMatchObject({ allowed: false, safeCode: 'hard-24-hour-limit' }); + }); + + it('counts ambiguous writes but not failures known to occur before mutation', () => { + const beforeRequest = action(1, { + status: 'definitive-failure', + mutationStartedAt: undefined, + replyTweetId: undefined, + replyUrl: undefined, + verifiedBy: undefined, + }); + const afterRequest = action(2, { + status: 'definitive-failure', + mutationStartedAt: new Date(NOW.getTime() - 2_000).toISOString(), + replyTweetId: undefined, + replyUrl: undefined, + verifiedBy: undefined, + }); + const stale = action(3, { + status: 'unknown', + attemptStartedAt: new Date(NOW.getTime() - 25 * 60 * 60 * 1_000).toISOString(), + replyTweetId: undefined, + replyUrl: undefined, + verifiedBy: undefined, + }); + expect(countRollingWriteAttempts(state([beforeRequest, afterRequest, stale]), NOW)).toBe(1); + }); + + it('fails closed when a backward clock correction leaves a write timestamp in the future', () => { + const futureWrite = action(1, { + mutationStartedAt: new Date(NOW.getTime() + 60 * 60 * 1_000).toISOString(), + attemptStartedAt: new Date(NOW.getTime() + 60 * 60 * 1_000).toISOString(), + }); + expect(countRollingWriteAttempts(state([futureWrite]), NOW)).toBe(1); + expect( + evaluatePreTargetRateGuard({ + state: state([futureWrite]), + actionId: 'new', + limitWindowKey: 'f'.repeat(64), + configuredMaximum: 1, + now: NOW, + }), + ).toMatchObject({ allowed: false, safeCode: 'rolling-24-hour-limit' }); + }); +}); diff --git a/tests/unit/incremental-tailer.test.ts b/tests/unit/incremental-tailer.test.ts new file mode 100644 index 0000000..97c4d51 --- /dev/null +++ b/tests/unit/incremental-tailer.test.ts @@ -0,0 +1,154 @@ +import { appendFile, mkdir, rename, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CursorStore, type CursorState } from '../../src/reset/watcher/cursor-store.js'; +import { IncrementalTailer } from '../../src/reset/watcher/incremental-tailer.js'; +import { LineBuffer } from '../../src/reset/watcher/line-buffer.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; + +async function fixture() { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + await mkdir(sessionsDirectory, { recursive: true }); + const records: unknown[] = []; + const warnings: string[] = []; + const tailer = new IncrementalTailer({ + sessionsDirectory, + onRecord(record) { + records.push(record); + }, + onWarning(warning) { + warnings.push(warning.code); + }, + }); + const state: CursorState = { + version: 1, + initializedAt: new Date().toISOString(), + cursors: {}, + }; + return { home, sessionsDirectory, records, warnings, tailer, state }; +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +describe('incremental JSONL tailer', () => { + it('starts an existing rollout at EOF', async () => { + const { sessionsDirectory, records, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'existing.jsonl'); + await writeFile(filePath, '{"old":true}\n', 'utf8'); + const cursor = await tailer.cursorAtEnd(filePath); + expect(cursor).not.toBeNull(); + if (cursor) { + state.cursors[cursor.pathHash] = cursor; + } + await appendFile(filePath, '{"new":true}\n', 'utf8'); + + await tailer.tail(filePath, state); + + expect(records).toEqual([{ new: true }]); + }); + + it('starts a newly observed rollout at byte zero', async () => { + const { sessionsDirectory, records, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'new.jsonl'); + await writeFile(filePath, '{"first":true}\n', 'utf8'); + + await tailer.tail(filePath, state); + + expect(records).toEqual([{ first: true }]); + }); + + it('buffers a partial line across appends', async () => { + const { sessionsDirectory, records, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'partial.jsonl'); + await writeFile(filePath, '{"partial":', 'utf8'); + await tailer.tail(filePath, state); + expect(records).toEqual([]); + + await appendFile(filePath, 'true}\n', 'utf8'); + await tailer.tail(filePath, state); + + expect(records).toEqual([{ partial: true }]); + }); + + it('processes multiple lines from one append', async () => { + const { sessionsDirectory, records, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'multiple.jsonl'); + await writeFile(filePath, '{"one":1}\n{"two":2}\n', 'utf8'); + + await tailer.tail(filePath, state); + + expect(records).toEqual([{ one: 1 }, { two: 2 }]); + }); + + it('restarts at zero after truncation', async () => { + const { sessionsDirectory, records, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'truncate.jsonl'); + await writeFile(filePath, '{"longValue":"before-truncation"}\n', 'utf8'); + await tailer.tail(filePath, state); + records.length = 0; + await writeFile(filePath, '{"after":true}\n', 'utf8'); + + await tailer.tail(filePath, state); + + expect(records).toEqual([{ after: true }]); + }); + + it('restarts at zero when a file is replaced', async () => { + const { sessionsDirectory, records, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'replace.jsonl'); + const replacementPath = path.join(sessionsDirectory, 'replacement.tmp'); + await writeFile(filePath, '{"before":true}\n', 'utf8'); + await tailer.tail(filePath, state); + records.length = 0; + await writeFile(replacementPath, '{"replacement":true}\n', 'utf8'); + await rename(replacementPath, filePath); + + await tailer.tail(filePath, state); + + expect(records).toEqual([{ replacement: true }]); + }); + + it('persists only hashed paths and safe basenames', async () => { + const { home, sessionsDirectory, tailer, state } = await fixture(); + const filePath = path.join(sessionsDirectory, 'safe.jsonl'); + await writeFile(filePath, '{"safe":true}\n', 'utf8'); + await tailer.tail(filePath, state); + await new CursorStore(home.paths).save(state); + + const serialized = JSON.stringify((await new CursorStore(home.paths).load()).state); + expect(serialized).not.toContain(sessionsDirectory); + expect(serialized).toContain('safe.jsonl'); + }); + + it('drops oversized lines without exposing their content', () => { + const buffer = new LineBuffer({ maxLineBytes: 10 }); + const result = buffer.push('12345678901\n{"ok":1}\n'); + expect(result.oversizeLines).toBe(1); + expect(result.lines.map((line) => line.text)).toEqual(['{"ok":1}']); + }); + + it('propagates downstream callback failures instead of consuming the event', async () => { + const home = await createTemporaryHome(); + homes.push(home); + const sessionsDirectory = path.join(home.root, 'codex', 'sessions'); + await mkdir(sessionsDirectory, { recursive: true }); + const state: CursorState = { version: 1, initializedAt: new Date().toISOString(), cursors: {} }; + const filePath = path.join(sessionsDirectory, 'pipeline-error.jsonl'); + await writeFile(filePath, '{"valid":true}\n', 'utf8'); + const tailer = new IncrementalTailer({ + sessionsDirectory, + onRecord() { + throw new Error('pipeline-failed'); + }, + }); + + await expect(tailer.tail(filePath, state)).rejects.toThrow('pipeline-failed'); + expect(state.cursors).toEqual({}); + }); +}); diff --git a/tests/unit/install-command.test.ts b/tests/unit/install-command.test.ts new file mode 100644 index 0000000..4216b68 --- /dev/null +++ b/tests/unit/install-command.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runInstall } from '../../src/reset/commands/install.js'; +import { AUTO_CONFIRMATION, prepareSetup } from '../../src/reset/commands/setup.js'; +import { createDefaultConfig, type ResetRequestConfig } from '../../src/reset/config/schema.js'; +import type { ServiceResult } from '../../src/reset/service/index.js'; + +const runningService: ServiceResult = { + ok: true, + supported: true, + installed: true, + running: true, + code: 'service-running', +}; + +function autoConfig(): ResetRequestConfig { + const config = createDefaultConfig(); + config.mode = 'auto'; + config.replyText = 'custom reset request'; + config.expectedXHandle = 'example'; + config.consent = { + disclaimerVersion: '2026-08-28-v1', + acceptedAt: '2026-08-28T12:00:00.000Z', + automaticPostingAccepted: true, + }; + return config; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('one-command installer', () => { + it('runs real setup preparation and enables the customized auto config only after startup', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const saved: ResetRequestConfig[] = []; + await runInstall( + { + mode: 'auto', + replyText: 'Please reset my Codex limit', + expectedXHandle: 'example', + acceptDisclaimer: true, + confirmation: AUTO_CONFIRMATION, + }, + { + platform: 'darwin', + load: async () => createDefaultConfig(), + prepare: async (options) => + await prepareSetup(options, { + load: async () => createDefaultConfig(), + readRateLimits: async () => ({ ok: true, value: { rateLimits: { limitId: 'codex' } } }), + createProvider: () => ({ + getCurrentAccount: async () => ({ ok: true, id: '7', handle: 'example' }), + findTargetPost: async () => ({ + status: 'found', + post: { + id: '100', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: '2026-08-28T12:00:00.000Z', + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { + source: 'timeline', + isPinned: false, + isRetweet: false, + isReply: false, + }, + }, + }), + }), + createPrompt: () => ({ + question: async () => { + throw new Error('The non-interactive install test should not prompt'); + }, + close: () => undefined, + }), + resolveCodexHome: () => '/home/example/.codex', + now: () => new Date('2026-08-28T12:00:00.000Z'), + }), + save: async (config) => { + saved.push(structuredClone(config)); + return config; + }, + manage: async () => runningService, + inspect: async () => runningService, + }, + ); + + expect(saved.at(-1)).toMatchObject({ + mode: 'auto', + replyText: 'Please reset my Codex limit', + expectedXHandle: 'example', + consent: { automaticPostingAccepted: true }, + }); + }); + + it('keeps posting disabled until the installed service passes its startup check', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const saved: ResetRequestConfig[] = []; + const actions: string[] = []; + await runInstall( + { mode: 'auto', replyText: 'custom reset request' }, + { + platform: 'darwin', + load: async () => createDefaultConfig(), + prepare: async () => autoConfig(), + save: async (config) => { + saved.push(structuredClone(config)); + return config; + }, + manage: async (action) => { + actions.push(action); + return runningService; + }, + inspect: async () => runningService, + }, + ); + expect(saved.map((config) => [config.mode, config.consent.automaticPostingAccepted])).toEqual([ + ['dry-run', false], + ['dry-run', false], + ['auto', true], + ]); + expect(actions).toEqual(['install']); + }); + + it('leaves automatic posting disabled when service startup fails', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const saved: ResetRequestConfig[] = []; + const actions: string[] = []; + await expect( + runInstall( + { mode: 'auto' }, + { + platform: 'linux', + load: async () => createDefaultConfig(), + prepare: async () => autoConfig(), + save: async (config) => { + saved.push(structuredClone(config)); + return config; + }, + manage: async (action) => { + actions.push(action); + return runningService; + }, + inspect: async () => ({ ...runningService, running: false, code: 'service-installed-stopped' }), + }, + ), + ).rejects.toThrow(/automatic replies remain disabled/); + expect(saved).toHaveLength(2); + expect(saved).toEqual([ + expect.objectContaining({ + mode: 'dry-run', + consent: expect.objectContaining({ automaticPostingAccepted: false }), + }), + expect.objectContaining({ + mode: 'dry-run', + consent: expect.objectContaining({ automaticPostingAccepted: false }), + }), + ]); + expect(actions).toEqual(['install', 'stop']); + }); + + it('stops a partially installed service when the manager reports failure', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const actions: string[] = []; + await expect( + runInstall( + { mode: 'auto' }, + { + platform: 'linux', + load: async () => createDefaultConfig(), + prepare: async () => autoConfig(), + save: async (config) => config, + manage: async (action) => { + actions.push(action); + return action === 'install' + ? { ...runningService, ok: false, running: false, code: 'service-command-failed' } + : runningService; + }, + }, + ), + ).rejects.toThrow(/automatic replies remain disabled/); + expect(actions).toEqual(['install', 'stop']); + }); + + it('keeps the already-disabled watcher untouched when setup preparation cannot complete', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const actions: string[] = []; + await expect( + runInstall( + { mode: 'auto' }, + { + platform: 'linux', + load: async () => autoConfig(), + prepare: async () => { + throw new Error('synthetic preflight failure'); + }, + save: async (config) => config, + manage: async (action) => { + actions.push(action); + return runningService; + }, + }, + ), + ).rejects.toThrow(/synthetic preflight failure/); + expect(actions).toEqual([]); + }); + + it('surfaces a failed service rollback instead of silently swallowing it', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const actions: string[] = []; + await expect( + runInstall( + { mode: 'auto' }, + { + platform: 'linux', + load: async () => createDefaultConfig(), + prepare: async () => autoConfig(), + save: async (config) => config, + manage: async (action) => { + actions.push(action); + return { ...runningService, ok: false, running: false, code: 'service-command-failed' }; + }, + }, + ), + ).rejects.toThrow(/rollback also failed/); + expect(actions).toEqual(['install', 'stop']); + }); + + it('stops the service if enabling the final automatic config fails', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const actions: string[] = []; + await expect( + runInstall( + { mode: 'auto' }, + { + platform: 'darwin', + load: async () => createDefaultConfig(), + prepare: async () => autoConfig(), + save: async (config) => { + if (config.mode === 'auto') { + throw new Error('synthetic final save failure'); + } + return config; + }, + manage: async (action) => { + actions.push(action); + return runningService; + }, + inspect: async () => runningService, + }, + ), + ).rejects.toThrow(/synthetic final save failure/); + expect(actions).toEqual(['install', 'stop']); + }); + + it('rejects unsupported platforms before configuration is prepared', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const prepare = vi.fn(async () => autoConfig()); + await expect(runInstall({ mode: 'auto' }, { platform: 'freebsd', prepare })).rejects.toThrow(/unsupported/); + expect(prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/one-shot-reply.test.ts b/tests/unit/one-shot-reply.test.ts new file mode 100644 index 0000000..87fb7b2 --- /dev/null +++ b/tests/unit/one-shot-reply.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../../src/lib/twitter-client.js'; + +function client(timeoutMs?: number): TwitterClient { + return new TwitterClient({ + cookies: { + authToken: 'test-auth', + ct0: 'test-ct0', + cookieHeader: 'auth_token=test-auth; ct0=test-ct0', + source: 'test', + }, + timeoutMs, + }); +} + +const originalNodeEnvironment = process.env.NODE_ENV; + +beforeEach(() => { + process.env.NODE_ENV = 'test'; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + if (originalNodeEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnvironment; + } +}); + +describe('Bird one-shot reply mutation', () => { + it.each([ + [ + 'direct result', + { data: { create_tweet: { tweet_results: { result: { rest_id: '9001' } } } } }, + '9001', + ], + [ + 'visibility wrapper', + { data: { create_tweet: { tweet_results: { result: { tweet: { rest_id: '9002' } } } } } }, + '9002', + ], + ])('accepts a numeric ID at the exact %s path', async (_label, responseBody, tweetId) => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(responseBody), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + expect(await client().replySingleAttempt('reset', '100')).toEqual({ status: 'sent', tweetId }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + [401, 'write-http-rejected'], + [403, 'write-http-rejected'], + [404, 'write-query-rejected'], + [422, 'write-http-rejected'], + [429, 'write-http-rejected'], + ])('classifies an explicit HTTP %i rejection without retry', async (status, safeCode) => { + const fetchMock = vi.fn(async () => new Response('rejected-canary', { status })); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'definitive-failure', + safeCode, + httpStatus: status, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([500, 502, 503])('classifies HTTP %i as unknown without retry', async (status) => { + const fetchMock = vi.fn(async () => new Response('server-canary', { status })); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode: 'write-server-ambiguous', + httpStatus: status, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('classifies a transport failure as unknown without retry', async () => { + const fetchMock = vi.fn(async () => { + throw new Error('connection reset canary'); + }); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode: 'write-transport-ambiguous', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ['missing ID', JSON.stringify({ data: { create_tweet: { tweet_results: { result: {} } } } }), 'write-id-missing'], + ['malformed JSON', '{not-json', 'write-response-unparseable'], + ])('classifies a 2xx %s as unknown', async (_label, body, safeCode) => { + const fetchMock = vi.fn(async () => new Response(body, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode, + httpStatus: 200, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('does not invoke the legacy fallback for GraphQL code 226', async () => { + const fetchMock = vi.fn(async (_input: string | URL | Request) => + new Response(JSON.stringify({ errors: [{ code: 226, message: 'automation canary' }] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'definitive-failure', + safeCode: 'write-automation-restricted', + httpStatus: 200, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0][0])).toContain('/CreateTweet'); + expect(String(fetchMock.mock.calls[0][0])).not.toContain('statuses/update'); + }); + + it('accepts an explicit created ID before considering partial GraphQL errors', async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + data: { create_tweet: { tweet_results: { result: { rest_id: '9001' } } } }, + errors: [{ code: 131, message: 'partial resolver error' }], + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ status: 'sent', tweetId: '9001' }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('keeps an unrecognized GraphQL error ambiguous and never falls back', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ errors: [{ code: 131, message: 'internal error' }] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode: 'write-graphql-ambiguous', + httpStatus: 200, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('does not follow a redirect that could replay the POST', async () => { + const fetchMock = vi.fn(async (_url, init) => { + expect(init?.redirect).toBe('manual'); + return new Response(null, { status: 307, headers: { location: 'https://x.com/other-write' } }); + }); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode: 'write-redirect-ambiguous', + httpStatus: 307, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('rejects invalid input before any mutation request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('', 'not-an-id')).toEqual({ + status: 'definitive-failure', + safeCode: 'invalid-write-input', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('persists the mutation marker immediately before the sole write request', async () => { + const order: string[] = []; + const fetchMock = vi.fn(async () => { + order.push('mutation'); + return new Response( + JSON.stringify({ data: { create_tweet: { tweet_results: { result: { rest_id: '9001' } } } } }), + { status: 200 }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + expect( + await client().replySingleAttempt('reset', '100', { + onMutationStart: async () => { + order.push('persisted'); + return { ok: true }; + }, + }), + ).toMatchObject({ status: 'sent' }); + expect(order).toEqual(['persisted', 'mutation']); + }); + + it('does not start the mutation when persistence of the write marker fails', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + expect( + await client().replySingleAttempt('reset', '100', { + onMutationStart: async () => { + throw new Error('synthetic persistence failure'); + }, + }), + ).toEqual({ status: 'definitive-failure', safeCode: 'write-state-persistence-failed' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not start the mutation when final write authorization is revoked', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + expect( + await client().replySingleAttempt('reset', '100', { + onMutationStart: async () => ({ ok: false, safeCode: 'write-authorization-revoked' }), + }), + ).toEqual({ status: 'definitive-failure', safeCode: 'write-authorization-revoked' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('waits for an in-flight persistence hook after timeout and still performs no request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const gate: { resolve?: (value: { ok: true }) => void } = {}; + let hookStarted = false; + let settled = false; + const outcome = client(20) + .replySingleAttempt('reset', '100', { + onMutationStart: async () => { + hookStarted = true; + return await new Promise<{ ok: true }>((resolve) => { + gate.resolve = resolve; + }); + }, + }) + .finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(hookStarted).toBe(true)); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(settled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + + gate.resolve?.({ ok: true }); + expect(await outcome).toEqual({ status: 'unknown', safeCode: 'write-transport-ambiguous' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('bounds a stalled mutation response body and leaves the result unknown', async () => { + const fetchMock = vi.fn(async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"data":')); + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + expect(await client(20).replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode: 'write-body-timeout', + httpStatus: 200, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('rejects an oversized mutation response body without unbounded buffering', async () => { + const fetchMock = vi.fn(async () => new Response('x'.repeat(1_048_577), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + expect(await client().replySingleAttempt('reset', '100')).toEqual({ + status: 'unknown', + safeCode: 'write-response-too-large', + httpStatus: 200, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/rate-limit-confirmation.test.ts b/tests/unit/rate-limit-confirmation.test.ts new file mode 100644 index 0000000..03807be --- /dev/null +++ b/tests/unit/rate-limit-confirmation.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { + confirmRateLimit, + parseRateLimitsResponse, + type RateLimitSnapshot, +} from '../../src/reset/codex/rate-limit-confirmation.js'; + +const now = new Date('2026-08-28T00:00:00.000Z'); +const future = Math.floor(now.getTime() / 1_000) + 3_600; +const past = Math.floor(now.getTime() / 1_000) - 1; + +function snapshot(input: Partial = {}): RateLimitSnapshot { + return { + limitId: null, + primary: null, + secondary: null, + rateLimitReachedType: null, + ...input, + }; +} + +describe('Codex rate-limit confirmation', () => { + it('rejects a reached-type response without a saturated reset window', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot({ rateLimitReachedType: 'workspace_member_usage_limit_reached' }), + }); + expect(confirmRateLimit(response, now)).toMatchObject({ confirmed: false, safeCode: 'not-reached' }); + }); + + it('retains a future reset boundary for reached-type window identity', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot({ + rateLimitReachedType: 'rate_limit_reached', + primary: { usedPercent: 10, resetsAt: future - 1_800 }, + secondary: { usedPercent: 100, resetsAt: future }, + }), + }); + expect(confirmRateLimit(response, now)).toMatchObject({ + confirmed: true, + reason: 'window', + matchedWindow: 'secondary', + resetsAt: future, + }); + }); + + it('uses the primary reset boundary when both visible windows are saturated', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot({ + rateLimitReachedType: 'rate_limit_reached', + primary: { usedPercent: 100, resetsAt: future }, + secondary: { usedPercent: 100, resetsAt: future + 3_600 }, + }), + }); + expect(confirmRateLimit(response, now)).toMatchObject({ + confirmed: true, + reason: 'window', + matchedWindow: 'primary', + resetsAt: future, + }); + }); + + it.each([ + ['primary', { primary: { usedPercent: 100, resetsAt: future } }], + ['secondary', { secondary: { usedPercent: 101, resetsAt: future } }], + ] as const)('confirms a saturated future %s window', (matchedWindow, fields) => { + const response = parseRateLimitsResponse({ rateLimits: snapshot(fields) }); + expect(confirmRateLimit(response, now)).toMatchObject({ + confirmed: true, + reason: 'window', + matchedWindow, + resetsAt: future, + }); + }); + + it.each([ + { usedPercent: 99, resetsAt: future }, + { usedPercent: 100, resetsAt: past }, + { usedPercent: 100, resetsAt: Math.floor(now.getTime() / 1_000) }, + { usedPercent: 100, resetsAt: null }, + ])('does not confirm an incomplete or expired window: %j', (primary) => { + const response = parseRateLimitsResponse({ rateLimits: snapshot({ primary }) }); + expect(confirmRateLimit(response, now)).toMatchObject({ confirmed: false, safeCode: 'not-reached' }); + }); + + it('prefers a unique Codex bucket even when another bucket is reached', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot({ rateLimitReachedType: 'legacy-reached' }), + rateLimitsByLimitId: { + codex: snapshot({ limitId: 'codex' }), + other: snapshot({ limitId: 'other', rateLimitReachedType: 'rate_limit_reached' }), + }, + }); + expect(confirmRateLimit(response, now)).toEqual({ + confirmed: false, + safeCode: 'not-reached', + bucketKey: 'codex', + limitId: 'codex', + }); + }); + + it('does not select a reached non-Codex bucket from an ambiguous mapping', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot(), + rateLimitsByLimitId: { + alpha: snapshot(), + beta: snapshot({ + rateLimitReachedType: 'future-reached-type', + primary: { usedPercent: 100, resetsAt: future }, + }), + }, + }); + expect(confirmRateLimit(response, now)).toEqual({ confirmed: false, safeCode: 'ambiguous-buckets' }); + }); + + it('fails closed for multiple reached buckets without a Codex bucket', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot({ rateLimitReachedType: 'legacy-reached' }), + rateLimitsByLimitId: { + alpha: snapshot({ rateLimitReachedType: 'reached-a' }), + beta: snapshot({ rateLimitReachedType: 'reached-b' }), + }, + }); + expect(confirmRateLimit(response, now)).toEqual({ confirmed: false, safeCode: 'ambiguous-buckets' }); + }); + + it('uses the sole mapped bucket and ignores forward-compatible fields', () => { + const response = parseRateLimitsResponse({ + rateLimits: snapshot(), + rateLimitsByLimitId: { + future: { + ...snapshot({ rateLimitReachedType: 'rate_limit_reached' }), + rateLimitResetCredits: 50, + }, + }, + futureTopLevelField: true, + }); + expect(confirmRateLimit(response, now)).toMatchObject({ confirmed: false, safeCode: 'not-reached' }); + }); + + it.each([ + {}, + { rateLimits: null }, + { rateLimits: snapshot(), rateLimitsByLimitId: { broken: 'not-an-object' } }, + { rateLimits: snapshot({ primary: { usedPercent: '100', resetsAt: future } as never }) }, + ])('rejects malformed response data instead of guessing: %j', (value) => { + expect(() => parseRateLimitsResponse(value)).toThrow(); + }); +}); diff --git a/tests/unit/release-safeguards.test.ts b/tests/unit/release-safeguards.test.ts new file mode 100644 index 0000000..a4e4ebc --- /dev/null +++ b/tests/unit/release-safeguards.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { replaceGeneratedDirectories } from '../../scripts/generate-codex-schemas.js'; +import { inspectProductionSource } from '../../scripts/verify-no-polling.js'; +import { scanSecretPayload } from '../../scripts/verify-no-secrets.js'; + +describe('release safeguards', () => { + const temporaryRoots: string[] = []; + + afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(async (directory) => await rm(directory, { recursive: true }))); + }); + + it('finds X credentials in JSON-shaped and binary payloads regardless of filename', () => { + const token = 'a'.repeat(40); + const json = scanSecretPayload('Dockerfile', Buffer.from(`{"auth_token":"${token}"}`)); + const binary = scanSecretPayload( + 'deploy.sh', + Buffer.concat([Buffer.from([0, 1, 2]), Buffer.from(`auth_token=${token}`), Buffer.from([0])]), + ); + + expect(json.map(({ code }) => code)).toContain('x-auth-literal'); + expect(binary.map(({ code }) => code)).toContain('x-auth-cookie'); + }); + + it('does not treat documented credential placeholders as live secrets', () => { + const findings = scanSecretPayload( + 'README.md', + Buffer.from('{"auth_token":"","ct0":""}'), + ); + + expect(findings).toEqual([]); + }); + + it('rejects polling aliases, computed calls, and every usePolling property form in JavaScript', () => { + const source = ` + import cron from 'node-cron'; + const repeat = setInterval; + const computed = globalThis['setInterval']; + const usePolling = true; + const opts = { usePolling }; + opts.usePolling = true; + const other = { ['usePolling']: false }; + cron.schedule('* * * * *', task); + repeat(task, 1000); + computed(task, 1000); + `; + const inspected = inspectProductionSource(path.resolve('src/reset/polling-bypass.js'), source); + const codes = new Set(inspected.findings.map(({ code }) => code)); + + expect(codes).toContain('periodic-interval-reference'); + expect(codes).toContain('polling-option-enabled'); + expect(codes).toContain('cron-runtime-reference'); + expect(codes).toContain('cron-runtime-string'); + }); + + it('allows explicit false polling options', () => { + const source = ` + const usePolling = false; + watch(target, { usePolling: false }); + watch(other, { ['usePolling']: usePolling }); + options.usePolling = false; + `; + const inspected = inspectProductionSource(path.resolve('src/reset/native-watch.js'), source); + + expect(inspected.findings).toEqual([]); + }); + + it('resolves shorthand polling options in their lexical scope', () => { + const source = ` + const usePolling = true; + function unrelated() { + const usePolling = false; + return usePolling; + } + watch(target, { usePolling }); + `; + const inspected = inspectProductionSource(path.resolve('src/reset/scoped-polling.js'), source); + + expect(inspected.findings.map(({ code }) => code)).toContain('polling-option-enabled'); + }); + + it('resolves shorthand polling options in a shared switch-case scope', () => { + const source = ` + switch (mode) { + case 'native': + const usePolling = true; + watch(target, { usePolling }); + break; + } + `; + const inspected = inspectProductionSource(path.resolve('src/reset/switch-polling.js'), source); + + expect(inspected.findings.map(({ code }) => code)).toContain('polling-option-enabled'); + }); + + it('unwraps transparent TypeScript expressions around polling booleans', () => { + const source = ` + const usePolling = true as const; + watch(target, { usePolling }); + watch(other, { usePolling: true satisfies boolean }); + `; + const inspected = inspectProductionSource(path.resolve('src/reset/wrapped-polling.ts'), source); + + expect(inspected.findings.filter(({ code }) => code === 'polling-option-enabled')).toHaveLength(2); + }); + + it('preflights both generated schema targets before replacing either one', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'codex-reset-request-transaction-test-')); + temporaryRoots.push(root); + const typescriptStage = path.join(root, 'typescript-stage'); + const jsonStage = path.join(root, 'json-stage'); + const typescriptTarget = path.join(root, 'typescript-target'); + const jsonTarget = path.join(root, 'json-target'); + const marker = '.codex-reset-request-generated'; + const markerValue = 'codex-reset-request-schema-v1\n'; + await Promise.all( + [typescriptStage, jsonStage, typescriptTarget, jsonTarget].map(async (directory) => { + await mkdir(directory); + }), + ); + await Promise.all([ + writeFile(path.join(typescriptStage, marker), markerValue), + writeFile(path.join(jsonStage, marker), markerValue), + writeFile(path.join(typescriptTarget, marker), markerValue), + writeFile(path.join(typescriptTarget, 'version.txt'), 'old-typescript'), + writeFile(path.join(jsonTarget, 'version.txt'), 'unowned-json'), + ]); + + await expect( + replaceGeneratedDirectories([ + { stage: typescriptStage, target: typescriptTarget }, + { stage: jsonStage, target: jsonTarget }, + ]), + ).rejects.toThrow('schema-target-not-owned'); + await expect(readFile(path.join(typescriptTarget, 'version.txt'), 'utf8')).resolves.toBe('old-typescript'); + }); + + it('restores both generated schema targets when the second install fails', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'codex-reset-request-rollback-test-')); + temporaryRoots.push(root); + const typescriptStage = path.join(root, 'typescript-stage'); + const jsonStage = path.join(typescriptStage, 'json-stage'); + const typescriptTarget = path.join(root, 'typescript-target'); + const jsonTarget = path.join(root, 'json-target'); + const marker = '.codex-reset-request-generated'; + const markerValue = 'codex-reset-request-schema-v1\n'; + await mkdir(jsonStage, { recursive: true }); + await Promise.all([mkdir(typescriptTarget), mkdir(jsonTarget)]); + await Promise.all([ + writeFile(path.join(typescriptStage, marker), markerValue), + writeFile(path.join(jsonStage, marker), markerValue), + writeFile(path.join(typescriptTarget, marker), markerValue), + writeFile(path.join(jsonTarget, marker), markerValue), + writeFile(path.join(typescriptTarget, 'version.txt'), 'old-typescript'), + writeFile(path.join(jsonTarget, 'version.txt'), 'old-json'), + ]); + + await expect( + replaceGeneratedDirectories([ + { stage: typescriptStage, target: typescriptTarget }, + { stage: jsonStage, target: jsonTarget }, + ]), + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(path.join(typescriptTarget, 'version.txt'), 'utf8')).resolves.toBe('old-typescript'); + await expect(readFile(path.join(jsonTarget, 'version.txt'), 'utf8')).resolves.toBe('old-json'); + }); +}); diff --git a/tests/unit/reply-verifier.test.ts b/tests/unit/reply-verifier.test.ts new file mode 100644 index 0000000..5baa09c --- /dev/null +++ b/tests/unit/reply-verifier.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { TweetData } from '../../src/lib/twitter-client-types.js'; +import { verifyReplyCandidates } from '../../src/reset/x/reply-verifier.js'; + +const attemptStartedAt = new Date('2026-08-28T10:00:00.000Z'); +const input = { + currentAccountId: '88', + currentAccountHandle: 'example', + targetPostId: '100', + replyText: 'reset', + attemptStartedAt, +}; + +function reply(fields: Partial = {}): TweetData { + return { + id: '9001', + text: 'reset', + author: { username: 'example', name: 'Example' }, + authorId: '88', + createdAt: '2026-08-28T10:01:00.000Z', + inReplyToStatusId: '100', + ...fields, + }; +} + +describe('read-after-write verification', () => { + it('verifies exactly one structural match', () => { + expect(verifyReplyCandidates([reply()], input)).toEqual({ + status: 'verified', + tweetId: '9001', + url: 'https://x.com/example/status/9001', + }); + }); + + it('leaves multiple matches unknown', () => { + expect(verifyReplyCandidates([reply(), reply({ id: '9002' })], input)).toEqual({ + status: 'not-verified', + safeCode: 'multiple-matches', + }); + }); + + it.each([ + { text: 'Reset' }, + { inReplyToStatusId: '101' }, + { authorId: '89' }, + { createdAt: '2026-08-28T09:59:59.000Z' }, + { createdAt: '2026-08-28T10:16:00.000Z' }, + { id: 'not-numeric' }, + ])('rejects a nonmatching candidate: %j', (fields) => { + expect(verifyReplyCandidates([reply(fields)], input)).toEqual({ + status: 'not-verified', + safeCode: 'no-match', + }); + }); +}); diff --git a/tests/unit/rollout-classifier.test.ts b/tests/unit/rollout-classifier.test.ts new file mode 100644 index 0000000..267866d --- /dev/null +++ b/tests/unit/rollout-classifier.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { extractUsageLimitCandidate } from '../../src/reset/codex/rollout-classifier.js'; +import type { RolloutObservationContext } from '../../src/reset/codex/rollout-types.js'; + +const context: RolloutObservationContext = { + safeFileName: 'rollout-safe.jsonl', + fileIdentity: '1:2', + byteOffset: 100, + observedAt: new Date('2026-08-28T00:00:00.000Z'), +}; + +function classify(record: unknown) { + return extractUsageLimitCandidate(record, context); +} + +describe('strict Codex rollout classifier', () => { + it.each(['UsageLimitExceeded', 'usageLimitExceeded', 'usage_limit_exceeded'])( + 'accepts structured usage metadata at the allowlisted path: %s', + (value) => { + const candidate = classify({ + type: 'event_msg', + payload: { type: 'error', codex_error_info: value, message: 'redacted' }, + }); + expect(candidate).toMatchObject({ + tier: 'structured', + normalizedRecordType: 'event_msg:error', + normalizedErrorType: 'usage_limit_exceeded', + byteOffset: 100, + }); + expect(candidate?.eventFingerprint).toMatch(/^[a-f0-9]{64}$/); + }, + ); + + it.each([ + "You've hit your usage limit", + 'You’ve hit your usage limit', + 'Usage limit reached', + 'Usage limit has been exceeded', + ])('accepts a conservative error-message fallback: %s', (message) => { + expect(classify({ type: 'event_msg', payload: { type: 'stream_error', message } })).toMatchObject({ + tier: 'text-fallback', + normalizedRecordType: 'event_msg:stream_error', + }); + }); + + it.each([ + { type: 'response_item', payload: { type: 'user_message', content: 'UsageLimitExceeded' } }, + { type: 'response_item', payload: { type: 'assistant_message', content: 'usage_limit_exceeded' } }, + { type: 'event_msg', payload: { type: 'tool_output', output: "You've hit your usage limit" } }, + { type: 'event_msg', payload: { type: 'command_output', stdout: 'Usage limit exceeded' } }, + { type: 'event_msg', payload: { type: 'log', message: "You've hit your usage limit" } }, + ])('ignores non-server-error records even when their content matches', (record) => { + expect(classify(record)).toBeNull(); + }); + + it.each(['429', 'HTTP 429', 'rate limit', 'too many requests', 'quota', 'limit'])( + 'does not treat a generic signal as account usage exhaustion: %s', + (message) => { + expect(classify({ type: 'event_msg', payload: { type: 'error', message } })).toBeNull(); + }, + ); + + it('does not recursively scan nested metadata or arbitrary strings', () => { + expect( + classify({ + type: 'event_msg', + payload: { + type: 'error', + message: 'request failed', + nested: { codex_error_info: 'usage_limit_exceeded', text: 'Usage limit exceeded' }, + }, + }), + ).toBeNull(); + }); +}); diff --git a/tests/unit/service-management.test.ts b/tests/unit/service-management.test.ts new file mode 100644 index 0000000..f188ad5 --- /dev/null +++ b/tests/unit/service-management.test.ts @@ -0,0 +1,518 @@ +import type { Stats } from 'node:fs'; +import { homedir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BoundedCommandResult } from '../../src/reset/utils/process.js'; + +const resolveCredentialsMock = vi.hoisted(() => vi.fn()); +vi.mock('../../src/lib/cookies.js', () => ({ resolveBrowserFirstCredentials: resolveCredentialsMock })); + +import { serviceDoctorCheck } from '../../src/reset/commands/doctor.js'; +import { getAppPaths } from '../../src/reset/config/paths.js'; +import { createResetRequestProgram } from '../../src/reset/program.js'; +import { manageService } from '../../src/reset/service/index.js'; +import { LAUNCHD_LABEL, launchAgentPath, renderLaunchAgent } from '../../src/reset/service/launchd.js'; +import { renderSystemdUnit, SYSTEMD_UNIT_NAME, systemdUnitPath } from '../../src/reset/service/systemd.js'; + +function regularStats(): Stats { + return { isFile: () => true, isSymbolicLink: () => false } as Stats; +} + +function symlinkStats(): Stats { + return { isFile: () => false, isSymbolicLink: () => true } as Stats; +} + +async function missingStats(): Promise { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); +} + +function commandResult( + ok = true, + stdout = '', + exitCode: number | null = ok ? 0 : 1, + safeCode?: BoundedCommandResult['safeCode'], +): BoundedCommandResult { + return safeCode ? { ok, exitCode, stdout, safeCode } : { ok, exitCode, stdout }; +} + +function launchdMissing(): BoundedCommandResult { + return commandResult(false, '', 113); +} + +function launchdLoaded(running = true): BoundedCommandResult { + return commandResult(true, running ? 'state = running\npid = 123' : 'state = waiting'); +} + +function systemdInactive(): BoundedCommandResult { + return commandResult(false, 'inactive', 3); +} + +function systemdNotFound(): BoundedCommandResult { + return commandResult(false, 'not-found', 1); +} + +function posixRuntime(platform: 'darwin' | 'linux') { + const homeDirectory = platform === 'darwin' ? '/Users/tester' : '/home/tester'; + return { + platform, + homeDirectory, + nodePath: '/usr/local/bin/node', + cliPath: `${homeDirectory}/codex-reset-request/dist/reset/cli.js`, + codexHome: `${homeDirectory}/.codex`, + paths: getAppPaths({ platform, homeDirectory, env: {} }), + lstat: async () => regularStats(), + realpath: async (value: string) => value, + } as const; +} + +afterEach(() => { + resolveCredentialsMock.mockReset(); +}); + +describe('service definitions', () => { + it('renders an owner-qualified launchd agent with absolute pinned paths and restart-on-failure only', () => { + const paths = getAppPaths({ platform: 'darwin', homeDirectory: '/Users/A & B', env: {} }); + const definition = renderLaunchAgent({ + nodePath: '/opt/node 22/bin/node', + cliPath: '/opt/Codex & Reset/dist/reset/cli.js', + codexHome: '/Users/A & B/.codex', + paths, + environmentPath: '/opt/homebrew/bin:/usr/bin:/bin', + }); + expect(LAUNCHD_LABEL).toBe('io.github.ncihxaonn.codex-reset-request'); + expect(definition).toContain(`${LAUNCHD_LABEL}`); + expect(definition).toContain('RunAtLoad'); + expect(definition).toContain('KeepAlive'); + expect(definition).toContain('SuccessfulExit'); + expect(definition).toContain(''); + expect(definition).toContain('/opt/node 22/bin/node'); + expect(definition).toContain('/opt/Codex & Reset/dist/reset/cli.js'); + expect(definition).toContain('CRR_CONFIG_DIR'); + expect(definition).toContain('CRR_CODEX_HOME'); + expect(definition).toContain('/Users/A & B/.codex'); + expect(definition).toContain(paths.stateDir.replaceAll('&', '&')); + expect(definition).not.toMatch(/StartInterval|CalendarInterval|pnpm|npm|shell/i); + }); + + it('renders a systemd user service without a timer or shell expansion', () => { + const paths = getAppPaths({ + platform: 'linux', + homeDirectory: '/home/test user', + env: { XDG_CONFIG_HOME: '/home/test user/.config' }, + }); + const definition = renderSystemdUnit({ + nodePath: '/opt/node % build/node', + cliPath: '/home/test user/app/$release/cli.js', + codexHome: '/home/test user/.codex', + paths, + environmentPath: '/usr/local/bin:/usr/bin:/bin', + }); + expect(definition).toContain('Type=simple'); + expect(definition).toContain('ExecStart=:"/opt/node %% build/node" "/home/test user/app/$release/cli.js" watch'); + expect(definition).toContain('Restart=on-failure'); + expect(definition).toContain('RestartSec=5'); + expect(definition).toContain(`Environment="CRR_LOG_DIR=${paths.logDir}"`); + expect(definition).toContain('Environment="CRR_CODEX_HOME=/home/test user/.codex"'); + expect(definition).not.toMatch(/\.timer|OnCalendar|OnUnitActiveSec|setInterval|while\s*\(/i); + }); + + it('rejects control characters in systemd values', () => { + const paths = getAppPaths({ platform: 'linux', homeDirectory: '/home/test', env: {} }); + expect(() => + renderSystemdUnit({ + nodePath: '/usr/bin/node\nmalicious', + cliPath: '/opt/app/cli.js', + codexHome: '/home/test/.codex', + paths, + environmentPath: '/usr/bin', + }), + ).toThrow(/single-line/); + }); +}); + +describe('service installation', () => { + it('atomically defines and bootstraps the macOS user agent with argv arrays', async () => { + const runtime = posixRuntime('darwin'); + const commands: Array<[string, string[]]> = []; + const written = { filePath: '', value: '' }; + const result = await manageService('install', { + ...runtime, + uid: 501, + env: { PATH: '/opt/homebrew/bin:/usr/bin:/bin' }, + ensureDirectories: async () => undefined, + validateBackgroundCredentials: async () => undefined, + writeDefinition: async (filePath, value) => { + written.filePath = filePath; + written.value = value; + }, + runCommand: async (binary, args) => { + commands.push([binary, args]); + return args[0] === 'print' ? launchdMissing() : commandResult(); + }, + }); + expect(result).toMatchObject({ ok: true, installed: true, running: true }); + expect(written).toMatchObject({ filePath: launchAgentPath(runtime.homeDirectory) }); + expect(written.value).toContain(runtime.nodePath); + expect(written.value).toContain(`${runtime.codexHome}`); + expect(commands).toEqual([ + ['launchctl', ['print', `gui/501/${LAUNCHD_LABEL}`]], + ['launchctl', ['bootstrap', 'gui/501', launchAgentPath(runtime.homeDirectory)]], + ]); + }); + + it('reloads, enables, and starts a new Linux user service without a timer', async () => { + const runtime = posixRuntime('linux'); + const commands: Array<[string, string[]]> = []; + let definition = ''; + const result = await manageService('install', { + ...runtime, + env: { + PATH: '/usr/local/bin:/usr/bin:/bin', + CRR_CODEX_HOME: runtime.codexHome, + }, + ensureDirectories: async () => undefined, + validateBackgroundCredentials: async () => undefined, + writeDefinition: async (_filePath, value) => { + definition = value; + }, + runCommand: async (binary, args) => { + commands.push([binary, args]); + if (args[1] === 'is-active') return systemdInactive(); + if (args[1] === 'is-enabled') return systemdNotFound(); + return commandResult(); + }, + }); + expect(result).toMatchObject({ ok: true, code: 'service-installed-running' }); + expect(definition).toContain('Restart=on-failure'); + expect(definition).toContain(`CRR_CODEX_HOME=${runtime.codexHome}`); + expect(commands).toEqual([ + ['systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'daemon-reload']], + ['systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT_NAME]], + ]); + }); + + it('restarts an already-active Linux service after replacing its definition', async () => { + const runtime = posixRuntime('linux'); + const commands: Array<[string, string[]]> = []; + const result = await manageService('install', { + ...runtime, + validateBackgroundCredentials: async () => undefined, + ensureDirectories: async () => undefined, + writeDefinition: async () => undefined, + runCommand: async (binary, args) => { + commands.push([binary, args]); + return commandResult(true, args[1] === 'is-active' ? 'active' : ''); + }, + }); + expect(result.ok).toBe(true); + expect(commands).toEqual([ + ['systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'daemon-reload']], + ['systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'restart', SYSTEMD_UNIT_NAME]], + ]); + }); + + it('rejects environment-only X credentials without persisting them', async () => { + const runtime = posixRuntime('darwin'); + resolveCredentialsMock.mockResolvedValue({ + cookies: { authToken: 'secret', ct0: 'secret', cookieHeader: 'secret', source: 'env AUTH_TOKEN' }, + warnings: [], + }); + const writeDefinition = vi.fn(); + await expect( + manageService('install', { + ...runtime, + uid: 501, + writeDefinition, + runCommand: async () => launchdMissing(), + }), + ).rejects.toThrow(/Environment-only X credentials/); + expect(writeDefinition).not.toHaveBeenCalled(); + }); +}); + +describe('service lifecycle and drift handling', () => { + it.each(['start', 'stop', 'restart'] as const)('uses exact systemctl argv for %s', async (action) => { + const commands: Array<[string, string[]]> = []; + const result = await manageService(action, { + platform: 'linux', + homeDirectory: '/home/test', + lstat: async () => regularStats(), + runCommand: async (binary, args) => { + commands.push([binary, args]); + return commandResult(true, args[1] === 'is-active' ? 'active' : ''); + }, + }); + expect(result.ok).toBe(true); + expect(commands).toEqual([ + ['systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', action, SYSTEMD_UNIT_NAME]], + ]); + }); + + it('stops launchd by unloading the KeepAlive job', async () => { + const commands: Array<[string, string[]]> = []; + const target = `gui/501/${LAUNCHD_LABEL}`; + const result = await manageService('stop', { + platform: 'darwin', + homeDirectory: '/Users/test', + uid: 501, + lstat: async () => regularStats(), + runCommand: async (binary, args) => { + commands.push([binary, args]); + return args[0] === 'print' ? launchdLoaded() : commandResult(); + }, + }); + expect(result).toMatchObject({ ok: true, running: false, code: 'service-stopped' }); + expect(commands).toEqual([ + ['launchctl', ['print', target]], + ['launchctl', ['bootout', target]], + ]); + }); + + it.each([ + ['start', false, ['bootstrap', 'gui/501', launchAgentPath('/Users/test')]], + ['start', true, ['kickstart', `gui/501/${LAUNCHD_LABEL}`]], + ['restart', false, ['bootstrap', 'gui/501', launchAgentPath('/Users/test')]], + ['restart', true, ['kickstart', '-k', `gui/501/${LAUNCHD_LABEL}`]], + ] as const)('%s recovers the expected launchd loaded=%s state', async (action, loaded, expectedArgs) => { + const commands: Array<[string, string[]]> = []; + const result = await manageService(action, { + platform: 'darwin', + homeDirectory: '/Users/test', + uid: 501, + lstat: async () => regularStats(), + runCommand: async (binary, args) => { + commands.push([binary, args]); + return args[0] === 'print' ? (loaded ? launchdLoaded() : launchdMissing()) : commandResult(); + }, + }); + expect(result.ok).toBe(true); + expect(commands[1]).toEqual(['launchctl', [...expectedArgs]]); + }); + + it('distinguishes loaded-but-inactive launchd state from running', async () => { + const result = await manageService('status', { + platform: 'darwin', + homeDirectory: '/Users/test', + uid: 501, + lstat: async () => regularStats(), + runCommand: async () => launchdLoaded(false), + }); + expect(result).toMatchObject({ ok: true, installed: true, running: false, code: 'service-installed-stopped' }); + }); + + it('reports a running manager job whose definition disappeared', async () => { + const result = await manageService('status', { + platform: 'darwin', + homeDirectory: '/Users/test', + uid: 501, + lstat: missingStats, + runCommand: async () => launchdLoaded(), + }); + expect(result).toMatchObject({ + ok: true, + installed: false, + running: true, + code: 'service-running-definition-missing', + }); + }); + + it('unloads a running launchd job even when its definition disappeared', async () => { + const commands: Array<[string, string[]]> = []; + const remove = vi.fn(); + const target = `gui/501/${LAUNCHD_LABEL}`; + const result = await manageService('uninstall', { + platform: 'darwin', + homeDirectory: '/Users/test', + uid: 501, + lstat: missingStats, + unlink: remove, + runCommand: async (binary, args) => { + commands.push([binary, args]); + return args[0] === 'print' ? launchdLoaded() : commandResult(); + }, + }); + expect(result).toMatchObject({ ok: true, installed: false, running: false, code: 'service-uninstalled' }); + expect(commands).toEqual([ + ['launchctl', ['print', target]], + ['launchctl', ['bootout', target]], + ]); + expect(remove).not.toHaveBeenCalled(); + }); + + it('stops an active Linux unit even when its definition disappeared', async () => { + const commands: Array<[string, string[]]> = []; + const result = await manageService('stop', { + platform: 'linux', + homeDirectory: '/home/test', + lstat: missingStats, + runCommand: async (binary, args) => { + commands.push([binary, args]); + return commandResult(true, args[1] === 'is-active' ? 'active' : ''); + }, + }); + expect(result).toMatchObject({ ok: true, installed: false, running: false, code: 'service-stopped' }); + expect(commands).toEqual([ + ['systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'stop', SYSTEMD_UNIT_NAME]], + ]); + }); + + it('stops a Linux unit that is still activating', async () => { + const commands: Array<[string, string[]]> = []; + const result = await manageService('stop', { + platform: 'linux', + homeDirectory: '/home/test', + lstat: async () => regularStats(), + runCommand: async (binary, args) => { + commands.push([binary, args]); + if (args[1] === 'is-active') return commandResult(false, 'activating', 3); + if (args[1] === 'is-enabled') return commandResult(true, 'enabled'); + return commandResult(); + }, + }); + expect(result).toMatchObject({ ok: true, running: false, code: 'service-stopped' }); + expect(commands).toEqual([ + ['systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'is-enabled', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'stop', SYSTEMD_UNIT_NAME]], + ]); + }); + + it('disables before deleting the exact Linux unit and then reloads', async () => { + const commands: Array<[string, string[]]> = []; + const removed: string[] = []; + const unitPath = systemdUnitPath('/home/test', {}); + const result = await manageService('uninstall', { + platform: 'linux', + homeDirectory: '/home/test', + env: {}, + lstat: async () => regularStats(), + unlink: async (filePath) => { + removed.push(filePath); + }, + runCommand: async (binary, args) => { + commands.push([binary, args]); + return commandResult(true, args[1] === 'is-active' ? 'active' : ''); + }, + }); + expect(result).toMatchObject({ ok: true, code: 'service-uninstalled' }); + expect(removed).toEqual([unitPath]); + expect(commands).toEqual([ + ['systemctl', ['--user', 'is-active', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT_NAME]], + ['systemctl', ['--user', 'daemon-reload']], + ]); + }); + + it('fails status closed when the service manager cannot be queried', async () => { + const result = await manageService('status', { + platform: 'linux', + homeDirectory: '/home/test', + lstat: async () => regularStats(), + runCommand: async () => commandResult(false, '', null, 'timeout'), + }); + expect(result).toMatchObject({ ok: false, installed: true, running: false, code: 'service-status-unavailable' }); + expect(serviceDoctorCheck(result)).toMatchObject({ status: 'FAIL', code: 'service-status-unavailable' }); + }); +}); + +describe('service safety and CLI surface', () => { + it('returns explicit Windows unsupported results without touching commands or files', async () => { + const runCommand = vi.fn(); + const writeDefinition = vi.fn(); + const remove = vi.fn(); + for (const action of ['install', 'start', 'stop', 'restart', 'uninstall', 'status'] as const) { + expect( + await manageService(action, { + platform: 'win32', + runCommand, + writeDefinition, + unlink: remove, + }), + ).toMatchObject({ ok: false, supported: false, code: 'windows-service-unsupported' }); + } + expect(runCommand).not.toHaveBeenCalled(); + expect(writeDefinition).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect( + serviceDoctorCheck({ + ok: false, + supported: false, + installed: false, + running: false, + code: 'windows-service-unsupported', + }), + ).toMatchObject({ status: 'WARN', code: 'windows-service-unsupported' }); + expect( + serviceDoctorCheck({ + ok: true, + supported: true, + installed: true, + running: true, + code: 'service-running', + definitionPath: `${homedir()}/Library/LaunchAgents/test.plist`, + }).detail, + ).not.toContain(homedir()); + }); + + it('fails closed on unsafe definitions, relative runtime paths, and relative app paths', async () => { + await expect( + manageService('status', { + platform: 'linux', + homeDirectory: '/home/test', + lstat: async () => symlinkStats(), + }), + ).rejects.toThrow(/unsafe/); + await expect( + manageService('install', { + platform: 'linux', + homeDirectory: '/home/test', + nodePath: 'relative-node', + cliPath: '/opt/app/cli.js', + }), + ).rejects.toThrow(/absolute/); + await expect( + manageService('status', { + platform: 'linux', + homeDirectory: '/home/test', + env: { CRR_CONFIG_DIR: 'relative-config' }, + }), + ).rejects.toThrow(/absolute/); + await expect( + manageService('status', { + platform: 'linux', + homeDirectory: '/home/test', + env: { XDG_CONFIG_HOME: 'relative-xdg' }, + }), + ).rejects.toThrow(/absolute/); + }); + + it('registers all required service subcommands', () => { + const service = createResetRequestProgram().commands.find((command) => command.name() === 'service'); + expect(service?.commands.map((command) => command.name())).toEqual([ + 'install', + 'start', + 'stop', + 'restart', + 'uninstall', + 'status', + ]); + }); + + it('registers the one-command installation flow', () => { + const install = createResetRequestProgram().commands.find((command) => command.name() === 'install'); + expect(install?.description()).toContain('automatic replies'); + expect(install?.options.find((option) => option.long === '--mode')?.defaultValue).toBe('auto'); + expect(install?.options.map((option) => option.long)).toEqual([ + '--mode', + '--reply-text', + '--accept-disclaimer', + '--confirmation', + '--expected-x-handle', + ]); + }); +}); diff --git a/tests/unit/setup-command.test.ts b/tests/unit/setup-command.test.ts new file mode 100644 index 0000000..b48ef22 --- /dev/null +++ b/tests/unit/setup-command.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultConfig } from '../../src/reset/config/schema.js'; +import { + AUTO_CONFIRMATION, + prepareSetup, + type SetupDependencies, +} from '../../src/reset/commands/setup.js'; + +const NOW = new Date('2026-08-28T12:00:00.000Z'); + +function dependencies(): SetupDependencies { + return { + load: async () => createDefaultConfig(), + readRateLimits: async () => ({ + ok: true, + value: { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: 100, resetsAt: Math.floor(NOW.getTime() / 1_000) + 3_600 }, + }, + }, + }), + createProvider: () => ({ + getCurrentAccount: async () => ({ ok: true, id: '7', handle: 'example' }), + findTargetPost: async () => ({ + status: 'found', + post: { + id: '100', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: NOW.toISOString(), + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { + source: 'timeline', + isPinned: false, + isRetweet: false, + isReply: false, + }, + }, + }), + }), + createPrompt: () => ({ + question: async () => { + throw new Error('The non-interactive test should not prompt'); + }, + close: () => undefined, + }), + resolveCodexHome: () => '/home/example/.codex', + now: () => NOW, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('setup command', () => { + it('previews and stores customized reply text before recording automatic consent', async () => { + const output: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message?: unknown) => output.push(String(message))); + + const config = await prepareSetup( + { + mode: 'auto', + replyText: 'Please reset my Codex limit', + acceptDisclaimer: true, + confirmation: AUTO_CONFIRMATION, + }, + dependencies(), + ); + + expect(config).toMatchObject({ + mode: 'auto', + replyText: 'Please reset my Codex limit', + codexHome: '/home/example/.codex', + expectedXHandle: 'example', + consent: { automaticPostingAccepted: true, acceptedAt: NOW.toISOString() }, + }); + expect(config).not.toHaveProperty('notifications'); + expect(output.indexOf('Configured reply text: "Please reset my Codex limit"')).toBeLessThan( + output.findIndex((line) => line.includes('unofficial')), + ); + }); + + it('rejects invalid custom reply text before network preflight', async () => { + const deps = dependencies(); + deps.readRateLimits = vi.fn(deps.readRateLimits); + await expect( + prepareSetup( + { mode: 'auto', replyText: ' ', acceptDisclaimer: true, confirmation: AUTO_CONFIRMATION }, + deps, + ), + ).rejects.toThrow(/must not be empty/); + expect(deps.readRateLimits).not.toHaveBeenCalled(); + }); + + it('rejects a non-exact interactive automatic-posting confirmation', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const deps = dependencies(); + const close = vi.fn(); + deps.createPrompt = () => ({ + question: async (message) => (message.includes('Type YES') ? 'YES' : 'I understand the X account risk'), + close, + }); + + await expect(prepareSetup({ mode: 'auto' }, deps)).rejects.toThrow(/did not match exactly/); + expect(close).toHaveBeenCalledOnce(); + }); + + it('compares the expected X handle without case sensitivity', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const config = await prepareSetup( + { + mode: 'dry-run', + expectedXHandle: '@Example', + acceptDisclaimer: true, + }, + dependencies(), + ); + expect(config.expectedXHandle).toBe('example'); + }); +}); diff --git a/tests/unit/target-selector.test.ts b/tests/unit/target-selector.test.ts new file mode 100644 index 0000000..df56346 --- /dev/null +++ b/tests/unit/target-selector.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { TweetData } from '../../src/lib/twitter-client-types.js'; +import { crossCheckTargetPost, selectTargetPost } from '../../src/reset/x/target-selector.js'; + +const now = new Date('2026-08-28T10:00:00.000Z'); +const input = { targetHandle: 'thsottiaux', targetAuthorId: '42', maxPostAgeHours: 72, now }; + +function tweet(fields: Partial = {}): TweetData { + return { + id: '100', + text: 'Codex users can reply reset here', + author: { username: 'thsottiaux', name: 'Tibo' }, + authorId: '42', + createdAt: '2026-08-28T09:00:00.000Z', + isPinned: false, + isRetweet: false, + isReply: false, + isQuote: false, + ...fields, + }; +} + +describe('target-post selector', () => { + it('sorts by creation time and allows an original quote tweet', () => { + const result = selectTargetPost( + [tweet({ id: '100' }), tweet({ id: '101', createdAt: '2026-08-28T09:30:00.000Z', isQuote: true })], + input, + ); + expect(result).toEqual({ + status: 'found', + post: { + id: '101', + authorHandle: 'thsottiaux', + authorId: '42', + createdAt: '2026-08-28T09:30:00.000Z', + url: 'https://x.com/thsottiaux/status/101', + selectionEvidence: { + source: 'timeline', + isPinned: false, + isRetweet: false, + isReply: false, + }, + }, + }); + }); + + it.each([ + ['pinned', { isPinned: true }], + ['retweet', { isRetweet: true }], + ['reply', { isReply: true, inReplyToStatusId: '7' }], + ['wrong author id', { authorId: '99' }], + ['wrong author handle', { author: { username: 'other', name: 'Other' } }], + ['old post', { createdAt: '2026-08-20T09:00:00.000Z' }], + ['future post', { createdAt: '2026-08-29T09:00:00.000Z' }], + ['invalid id', { id: 'not-numeric' }], + ['invalid timestamp', { createdAt: 'not-a-date' }], + ['unrelated post', { text: 'A completely unrelated announcement' }], + ['negated invitation', { text: 'Codex users: do not reply reset to this post' }], + ['qualified negation', { text: 'Codex: do not, under any circumstances, reply with reset' }], + ['post-action negation', { text: 'Codex users should reply with anything but reset' }], + ['contracted post-action negation', { text: "Codex users: reply, but don't use reset" }], + ['alternative negation', { text: 'Codex users: reply with anything other than reset' }], + ['leading no', { text: 'Codex users: no reply with reset' }], + ['trailing no', { text: 'Codex users: reply with reset? No.' }], + ] as const)('rejects a %s', (_label, fields) => { + expect(selectTargetPost([tweet(fields)], input)).toEqual({ + status: 'not-found', + safeCode: 'target-no-eligible-post', + }); + }); + + it.each([{ isPinned: null }, { isRetweet: null }, { isReply: undefined }])( + 'fails closed for uncertain structural metadata: %j', + (fields) => { + expect(selectTargetPost([tweet(fields)], input)).toEqual({ + status: 'not-found', + safeCode: 'target-metadata-ambiguous', + }); + }, + ); + + it('fails closed when timeline and search choose different latest originals', () => { + const selected = selectTargetPost([tweet({ id: '100' })], input); + expect(selected.status).toBe('found'); + if (selected.status === 'found') { + expect(crossCheckTargetPost(selected.post, [tweet({ id: '101' })], input)).toEqual({ + status: 'not-found', + safeCode: 'target-search-mismatch', + }); + } + }); + + it('records agreement when timeline and search match', () => { + const selected = selectTargetPost([tweet({ id: '100' })], input); + expect(selected.status).toBe('found'); + if (selected.status === 'found') { + expect(crossCheckTargetPost(selected.post, [tweet({ id: '100' })], input)).toMatchObject({ + status: 'found', + post: { id: '100', selectionEvidence: { source: 'timeline+search' } }, + }); + } + }); +}); diff --git a/tests/unit/test-command.test.ts b/tests/unit/test-command.test.ts new file mode 100644 index 0000000..1a6f0a3 --- /dev/null +++ b/tests/unit/test-command.test.ts @@ -0,0 +1,314 @@ +import { access } from 'node:fs/promises'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + assertLiveXTestEnabled, + parseOwnedPostUrl, + runSyntheticTriggerTest, + runXReadTest, + runXReplyTest, + type TestCommandDependencies, +} from '../../src/reset/commands/test.js'; +import { createDefaultConfig, type ResetRequestConfig } from '../../src/reset/config/schema.js'; +import { createResetRequestProgram } from '../../src/reset/program.js'; +import { acquireSingleInstanceLock } from '../../src/reset/state/lock.js'; +import { StateStore } from '../../src/reset/state/store.js'; +import type { XReplyProvider } from '../../src/reset/x/provider.js'; +import { createTemporaryHome, type TemporaryHome } from '../helpers/temporary-home.js'; + +const homes: TemporaryHome[] = []; + +function config(overrides: Partial = {}): ResetRequestConfig { + return { + ...createDefaultConfig(), + expectedXHandle: 'alice', + maxAttemptsPer24Hours: 3, + ...overrides, + }; +} + +function postReader(options: { postId?: string; authorId?: string; handle?: string } = {}) { + const postId = options.postId ?? '123'; + const authorId = options.authorId ?? '42'; + const handle = options.handle ?? 'alice'; + return { + getCurrentUser: vi.fn(async () => ({ + success: true, + user: { id: '42', username: 'alice', name: 'Alice' }, + })), + getTweet: vi.fn(async () => ({ + success: true, + tweet: { + id: postId, + text: 'private test content that must not be printed', + authorId, + author: { username: handle, name: 'Alice' }, + createdAt: '2026-08-28T00:00:00.000Z', + }, + })), + }; +} + +function replyProvider( + result: 'sent' | 'unknown' = 'sent', + hooks: { + beforeStart?(input: Parameters[0]): Promise; + afterStart?(input: Parameters[0]): Promise; + } = {}, +): XReplyProvider { + return { + doctor: vi.fn(), + getCurrentAccount: vi.fn(), + findTargetPost: vi.fn(), + replyOnce: vi.fn(async (input) => { + await hooks.beforeStart?.(input); + const start = await input.onMutationStart?.(); + if (start && !start.ok) { + return { status: 'definitive-failure' as const, safeCode: start.safeCode }; + } + await hooks.afterStart?.(input); + return result === 'sent' + ? { + status: 'sent' as const, + tweetId: '9001', + url: 'https://x.com/alice/status/9001', + verifiedBy: 'mutation-response' as const, + } + : { + status: 'unknown' as const, + safeCode: 'write-timeout', + targetPostUrl: 'https://x.com/alice/status/123', + }; + }), + verifyReply: vi.fn(async () => ({ status: 'not-verified' as const, safeCode: 'no-match' as const })), + }; +} + +async function fixture(provider = replyProvider(), overrides: Partial = {}) { + const home = await createTemporaryHome(); + homes.push(home); + const currentConfig = config(); + const reader = postReader(); + const dependencies: TestCommandDependencies = { + env: { CRR_LIVE_X: '1' }, + paths: home.paths, + now: () => new Date('2026-08-28T01:00:00.000Z'), + loadConfiguration: vi.fn(async () => currentConfig), + createPostReader: vi.fn(async () => reader), + createReplyProvider: vi.fn(() => provider), + ...overrides, + }; + return { home, currentConfig, reader, provider, dependencies }; +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => home.cleanup())); +}); + +describe('test command safety gates', () => { + it('runs the trigger as a synthetic zero-side-effect classifier rehearsal', () => { + const result = runSyntheticTriggerTest(new Date('2026-08-28T00:00:00.000Z')); + expect(result).toMatchObject({ + code: 'synthetic-trigger-detected', + synthetic: true, + mode: 'dry-run', + networkRequests: 0, + mutationAttempts: 0, + stateWritten: false, + }); + }); + + it('requires the environment gate, write flag, and a non-CI process before any live work', async () => { + expect(() => assertLiveXTestEnabled({}, false, false)).toThrow(/CRR_LIVE_X=1/); + expect(() => assertLiveXTestEnabled({ CRR_LIVE_X: '1' }, true, false)).toThrow(/--live/); + expect(() => assertLiveXTestEnabled({ CRR_LIVE_X: '1', CI: 'true' }, true, true)).toThrow(/disabled in CI/); + + const loadConfiguration = vi.fn(); + await expect(runXReadTest({ env: { CI: 'true', CRR_LIVE_X: '1' }, loadConfiguration })).rejects.toThrow( + /disabled in CI/, + ); + await expect( + runXReplyTest('https://x.com/alice/status/123', false, { + env: { CRR_LIVE_X: '1' }, + loadConfiguration, + }), + ).rejects.toThrow(/--live/); + expect(loadConfiguration).not.toHaveBeenCalled(); + }); + + it.each([ + '123', + 'http://x.com/alice/status/123', + 'https://x.com.evil.example/alice/status/123', + 'https://alice:secret@x.com/alice/status/123', + 'https://x.com:444/alice/status/123', + 'https://x.com/i/web/status/123', + 'https://x.com/alice/status/not-numeric', + ])('rejects a non-canonical or unsafe post URL: %s', (value) => { + expect(() => parseOwnedPostUrl(value)).toThrow(); + }); + + it('canonicalizes a strict user status URL and allows a harmless share query', () => { + expect(parseOwnedPostUrl('https://twitter.com/Alice/status/123/?s=20')).toEqual({ + handle: 'alice', + postId: '123', + canonicalUrl: 'https://x.com/alice/status/123', + }); + }); +}); + +describe('live X diagnostics', () => { + it('performs a gated read of the fixed target and returns safe metadata only', async () => { + const findTargetPost = vi.fn(async (input: { targetHandle: string }) => ({ + status: 'found' as const, + post: { + id: '100', + authorHandle: input.targetHandle, + authorId: '7', + createdAt: '2026-08-28T00:00:00.000Z', + url: 'https://x.com/thsottiaux/status/100', + selectionEvidence: { + source: 'timeline' as const, + isPinned: false as const, + isRetweet: false as const, + isReply: false as const, + }, + }, + })); + const result = await runXReadTest({ + env: { CRR_LIVE_X: '1' }, + loadConfiguration: async () => config(), + createTargetReader: () => ({ + doctor: vi.fn(), + getCurrentAccount: vi.fn(async () => ({ ok: true as const, id: '42', handle: 'alice' })), + findTargetPost, + }), + }); + expect(findTargetPost).toHaveBeenCalledWith({ targetHandle: 'thsottiaux', maxPostAgeHours: 72 }); + expect(result).toEqual({ + code: 'x-read-ok', + currentAccount: '@alice', + targetHandle: '@thsottiaux', + targetPostUrl: 'https://x.com/thsottiaux/status/100', + }); + expect(JSON.stringify(result)).not.toMatch(/private test content|auth_token|ct0|cookie/i); + }); + + it('rejects Tibo and fetched ownership mismatches before creating a write provider', async () => { + const loadConfiguration = vi.fn(); + await expect( + runXReplyTest('https://x.com/thsottiaux/status/123', true, { + env: { CRR_LIVE_X: '1' }, + loadConfiguration, + }), + ).rejects.toThrow(/never reply/); + expect(loadConfiguration).not.toHaveBeenCalled(); + + const createReplyProvider = vi.fn(); + const setup = await fixture(replyProvider(), { + createPostReader: async () => postReader({ authorId: '99', handle: 'mallory' }), + createReplyProvider, + }); + await expect(runXReplyTest('https://x.com/alice/status/123', true, setup.dependencies)).rejects.toThrow( + /not verifiably owned/, + ); + expect(createReplyProvider).not.toHaveBeenCalled(); + }); + + it('persists the mutation marker before exactly one write and deduplicates a rerun', async () => { + let setup: Awaited>; + const provider = replyProvider('sent', { + beforeStart: async () => { + const before = (await new StateStore(setup.home.paths).load()).actions[0]; + expect(before).toMatchObject({ status: 'attempting', targetPostId: '123' }); + expect(before.mutationStartedAt).toBeUndefined(); + }, + afterStart: async () => { + expect((await new StateStore(setup.home.paths).load()).actions[0].mutationStartedAt).toBeDefined(); + }, + }); + setup = await fixture(provider); + + const first = await runXReplyTest('https://x.com/alice/status/123?s=20', true, setup.dependencies); + const second = await runXReplyTest('https://x.com/alice/status/123', true, setup.dependencies); + + expect(first).toMatchObject({ status: 'sent', replyTweetId: '9001' }); + expect(second).toEqual(first); + expect(provider.replyOnce).toHaveBeenCalledOnce(); + expect(provider.verifyReply).not.toHaveBeenCalled(); + await expect(access(setup.home.paths.daemonLockFile)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps an ambiguous result unknown and never writes again', async () => { + const provider = replyProvider('unknown'); + const setup = await fixture(provider); + expect(await runXReplyTest('https://x.com/alice/status/123', true, setup.dependencies)).toMatchObject({ + status: 'unknown', + }); + expect(await runXReplyTest('https://x.com/alice/status/123', true, setup.dependencies)).toMatchObject({ + status: 'unknown', + }); + expect(provider.replyOnce).toHaveBeenCalledOnce(); + expect(provider.verifyReply).toHaveBeenCalledOnce(); + }); + + it('revokes the transport when write-relevant configuration changes at the mutation boundary', async () => { + const transport = vi.fn(); + const provider = replyProvider('sent', { afterStart: transport }); + const home = await createTemporaryHome(); + homes.push(home); + const initial = config(); + let reads = 0; + const dependencies: TestCommandDependencies = { + env: { CRR_LIVE_X: '1' }, + paths: home.paths, + now: () => new Date('2026-08-28T01:00:00.000Z'), + loadConfiguration: async () => { + reads += 1; + return reads === 1 ? structuredClone(initial) : config({ maxAttemptsPer24Hours: 0 }); + }, + createPostReader: async () => postReader(), + createReplyProvider: () => provider, + }; + + const result = await runXReplyTest('https://x.com/alice/status/123', true, dependencies); + expect(result).toMatchObject({ status: 'definitive-failure', safeCode: 'write-authorization-revoked' }); + expect(transport).not.toHaveBeenCalled(); + expect((await new StateStore(home.paths).load()).actions[0].mutationStartedAt).toBeUndefined(); + }); + + it('honors the singleton lock and rolling write guard before any new mutation', async () => { + const locked = await fixture(); + const held = await acquireSingleInstanceLock(locked.home.paths.daemonLockFile); + try { + await expect(runXReplyTest('https://x.com/alice/status/123', true, locked.dependencies)).rejects.toThrow( + /already running|already held/, + ); + expect(locked.dependencies.createPostReader).not.toHaveBeenCalled(); + } finally { + await held.release(); + } + + const firstProvider = replyProvider(); + const limited = await fixture(firstProvider, { + loadConfiguration: async () => config({ maxAttemptsPer24Hours: 1 }), + }); + await runXReplyTest('https://x.com/alice/status/123', true, limited.dependencies); + const secondProvider = replyProvider(); + limited.dependencies.createPostReader = async () => postReader({ postId: '124' }); + limited.dependencies.createReplyProvider = () => secondProvider; + await expect(runXReplyTest('https://x.com/alice/status/124', true, limited.dependencies)).rejects.toThrow( + /rolling-24-hour-limit/, + ); + expect(secondProvider.replyOnce).not.toHaveBeenCalled(); + }); +}); + +describe('test CLI surface', () => { + it('registers only trigger, x-read, and the doubly gated x-reply surface', () => { + const test = createResetRequestProgram().commands.find((command) => command.name() === 'test'); + expect(test?.commands.map((command) => command.name())).toEqual(['trigger', 'x-read', 'x-reply']); + const reply = test?.commands.find((command) => command.name() === 'x-reply'); + expect(reply?.options.map((option) => option.long)).toEqual(['--url', '--live']); + expect(reply?.options.some((option) => /auth|token|ct0|cookie/i.test(option.long ?? ''))).toBe(false); + }); +}); diff --git a/tsconfig.oxlint.json b/tsconfig.oxlint.json new file mode 100644 index 0000000..fc25c45 --- /dev/null +++ b/tsconfig.oxlint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist"] +}