diff --git a/apps/bot/.env.example b/apps/bot/.env.example new file mode 100644 index 0000000..7950c0e --- /dev/null +++ b/apps/bot/.env.example @@ -0,0 +1,17 @@ +# Wright Telegram Bot — environment variables +# Copy to .env and fill in values: cp .env.example .env + +# Telegram bot token from @BotFather +BOT_TOKEN= + +# Supabase project URL +SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co + +# Supabase service_role key (NOT the anon key) +SUPABASE_KEY= + +# GitHub personal access token (needs repo scope for PR creation) +GITHUB_TOKEN= + +# Comma-separated Telegram user IDs allowed to use the bot (empty = no restriction) +ALLOWED_TELEGRAM_USERS= diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile new file mode 100644 index 0000000..8c1d1e6 --- /dev/null +++ b/apps/bot/Dockerfile @@ -0,0 +1,80 @@ +# ============================================================================= +# Wright Bot -- Production Multi-Stage Dockerfile +# +# Stages: +# 1. deps -- install pnpm + all workspace dependencies +# 2. build -- compile TypeScript (shared + bot) +# 3. runtime -- minimal image with built artifacts +# +# Build context: repo root (run with `docker build -f apps/bot/Dockerfile .`) +# ============================================================================= + +# --------------------------------------------------------------------------- +# Stage 1: Install dependencies +# --------------------------------------------------------------------------- +FROM node:22-slim AS deps + +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +WORKDIR /app + +# Copy only what pnpm needs for dependency resolution (layer cache) +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* turbo.json ./ +COPY packages/shared/package.json packages/shared/ +COPY apps/bot/package.json apps/bot/ + +# Install all deps (including devDependencies needed for build) +RUN pnpm install --frozen-lockfile || pnpm install + +# --------------------------------------------------------------------------- +# Stage 2: Build TypeScript +# --------------------------------------------------------------------------- +FROM deps AS build + +# Copy source files +COPY tsconfig.base.json ./ +COPY packages/shared/tsconfig.json packages/shared/ +COPY packages/shared/src/ packages/shared/src/ +COPY apps/bot/tsconfig.json apps/bot/ +COPY apps/bot/src/ apps/bot/src/ + +# Build shared first, then bot +RUN pnpm --filter @wright/shared build && pnpm --filter @wright/bot build + +# Prune devDependencies for a leaner copy into runtime. +RUN pnpm prune --prod \ + && mkdir -p packages/shared/node_modules apps/bot/node_modules + +# --------------------------------------------------------------------------- +# Stage 3: Runtime +# --------------------------------------------------------------------------- +FROM node:22-slim AS runtime + +WORKDIR /app + +# Copy workspace manifests (needed for pnpm workspace resolution at runtime) +COPY --from=build /app/package.json /app/pnpm-workspace.yaml ./ + +# Copy shared package (built artifacts + package.json) +COPY --from=build /app/packages/shared/package.json packages/shared/ +COPY --from=build /app/packages/shared/dist/ packages/shared/dist/ + +# Copy bot (built artifacts + package.json) +COPY --from=build /app/apps/bot/package.json apps/bot/ +COPY --from=build /app/apps/bot/dist/ apps/bot/dist/ + +# Copy production node_modules from pruned build stage +COPY --from=build /app/node_modules/ node_modules/ +COPY --from=build /app/packages/shared/node_modules/ packages/shared/node_modules/ +COPY --from=build /app/apps/bot/node_modules/ apps/bot/node_modules/ + +# Non-root user +RUN groupadd --gid 1001 wright \ + && useradd --uid 1001 --gid wright --shell /bin/bash --create-home wright \ + && chown -R wright:wright /app + +USER wright + +WORKDIR /app/apps/bot + +CMD ["node", "dist/index.js"] diff --git a/apps/bot/fly.toml b/apps/bot/fly.toml new file mode 100644 index 0000000..23b1967 --- /dev/null +++ b/apps/bot/fly.toml @@ -0,0 +1,16 @@ +# Fly.io configuration for the wright Telegram bot +# Long-polling bot — no HTTP service needed, just a persistent process + +app = "wright-bot" +primary_region = "ord" + +[build] + dockerfile = "Dockerfile" + +[env] + NODE_ENV = "production" + +[[vm]] + memory = "256mb" + cpu_kind = "shared" + cpus = 1 diff --git a/apps/worker/.env.example b/apps/worker/.env.example new file mode 100644 index 0000000..806da9c --- /dev/null +++ b/apps/worker/.env.example @@ -0,0 +1,24 @@ +# Wright Worker — environment variables (for local development) +# Copy to .env and fill in values: cp .env.example .env +# In production (Fly.io), these are set via `fly secrets set`. + +# Supabase project URL +SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co + +# Supabase service_role key (NOT the anon key) +SUPABASE_SERVICE_ROLE_KEY= + +# Anthropic API key for Claude sessions +ANTHROPIC_API_KEY= + +# Optional: Claude model to use (default: claude-sonnet-4-20250514) +# CLAUDE_MODEL=claude-sonnet-4-20250514 + +# Optional: Max turns per Claude session loop (default: 30) +# MAX_TURNS_PER_LOOP=30 + +# Optional: Test timeout in seconds (default: 300) +# TEST_TIMEOUT_SECONDS=300 + +# Optional: Workspace directory for cloned repos (default: /tmp/wright-work) +# WORKSPACE_DIR=/tmp/wright-work diff --git a/apps/worker/src/dev-loop.ts b/apps/worker/src/dev-loop.ts index 40aa19c..04fe7ae 100644 --- a/apps/worker/src/dev-loop.ts +++ b/apps/worker/src/dev-loop.ts @@ -16,8 +16,20 @@ import { detectTestRunner, detectPackageManager, installDependencies, runTests } import { runClaudeSession } from './claude-session.js' import { existsSync, rmSync, mkdirSync } from 'fs' +/** + * Strip Telegram HTML-like formatting delimiters (e.g. ``, ``, ``) from + * raw message text so they don't leak into prompts, PR bodies, or commit messages. + */ +function sanitizeTaskText(text: string): string { + return text.replace(/<\/?[^>]+>/g, '').trim() +} + export async function runDevLoop(config: DevLoopConfig): Promise { const { job } = config + // Sanitize task text early so all downstream uses (prompts, PR body, commit messages) + // receive clean text without Telegram formatting delimiters. + job.task = sanitizeTaskText(job.task) + const maxLoops = job.max_loops const maxBudget = job.max_budget_usd const maxTurns = config.maxTurnsPerLoop || DEFAULT_MAX_TURNS_PER_LOOP @@ -195,7 +207,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise }) const eventType = - lastTestResults.failed === 0 && lastTestResults.total > 0 + lastTestResults.failed === 0 && lastTestResults.errors === 0 ? 'test_pass' : 'test_fail' await emit(supabase, job.id, eventType, loop, { @@ -230,7 +242,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise // 5c. Check pass allTestsPassed = - lastTestResults.failed === 0 && lastTestResults.total > 0 + lastTestResults.failed === 0 && lastTestResults.errors === 0 } // 6. Commit and push @@ -238,7 +250,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise ? `feat: ${job.task.slice(0, 60)}` : `wip: ${job.task.slice(0, 60)} (${lastTestResults.passed}/${lastTestResults.total} tests passing)` - const commitSha = await commitAndPush(workDir, commitMessage) + const commitSha = await commitAndPush(workDir, commitMessage, job.github_token) // 7. Create PR let prUrl: string | undefined @@ -248,6 +260,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise job.task.slice(0, 70), buildPrBody(job.task, lastTestResults, loopsCompleted, totalCost), job.branch, + job.github_token, ) await emit(supabase, job.id, 'pr_created', undefined, { prUrl }) } catch (err) { @@ -304,12 +317,19 @@ ${workDir} - Dependencies are already installed ## Rules -1. Make changes to implement the requested task -2. After making changes, run tests to verify they pass -3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong -4. Keep changes minimal and focused on the task -5. Do not refactor unrelated code -6. Do not add unnecessary dependencies` +1. Make changes to implement the requested task. +2. After making changes, run tests to verify they pass. +3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong. +4. Keep changes minimal and focused ONLY on the files directly required by the task. +5. Do not refactor unrelated code. +6. Do not add unnecessary dependencies. + +## Hard constraints — NEVER violate these +- NEVER modify test files unless the task explicitly asks for test changes. +- NEVER modify package manifests (package.json, pyproject.toml, Cargo.toml, go.mod, setup.py, setup.cfg, etc.) unless the task explicitly requires adding or removing a dependency. +- NEVER modify lock files (uv.lock, pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, poetry.lock, etc.) under any circumstances. +- If the task is about documentation (README, docs/, *.md), ONLY modify documentation files. Do NOT touch source code, tests, or manifests. +- When in doubt about whether a file is in scope, leave it unchanged.` } function buildInitialPrompt( diff --git a/apps/worker/src/github-ops.ts b/apps/worker/src/github-ops.ts index a6f344a..53068ba 100644 --- a/apps/worker/src/github-ops.ts +++ b/apps/worker/src/github-ops.ts @@ -7,6 +7,10 @@ export async function cloneRepo(repoUrl: string, workDir: string, token: string, try { const cloneArgs = branch ? ['--branch', branch] : [] await git.clone(authedUrl, workDir, cloneArgs) + // Strip credentials from the stored remote URL so they aren't + // accessible via .git/config (e.g. by the Claude subprocess). + const repoGit = simpleGit(workDir) + await repoGit.remote(['set-url', 'origin', repoUrl]) } catch (err) { const msg = err instanceof Error ? err.message : String(err) throw new Error(sanitizeGitError(msg, cleanToken)) @@ -31,7 +35,7 @@ export async function createFeatureBranch( return branchName } -export async function commitAndPush(workDir: string, message: string): Promise { +export async function commitAndPush(workDir: string, message: string, githubToken?: string): Promise { const git = simpleGit(workDir) await git.addConfig('user.email', 'wright@openadaptai.noreply.github.com') @@ -47,9 +51,36 @@ export async function commitAndPush(workDir: string, message: string): Promise {}) + } + } + } const log = await git.log({ maxCount: 1 }) return log.latest?.hash || '' @@ -57,18 +88,28 @@ export async function commitAndPush(workDir: string, message: string): Promise { const { execFileSync } = await import('child_process') + // Minimal env — only what `gh` needs. Avoids leaking SUPABASE_SERVICE_ROLE_KEY etc. + const env: Record = { + PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', + HOME: process.env.HOME || '/home/wright', + } + if (githubToken) { + env.GH_TOKEN = githubToken + } const result = execFileSync( 'gh', ['pr', 'create', '--title', title, '--body', body, '--base', baseBranch], - { cwd: workDir, encoding: 'utf-8' }, + { cwd: workDir, encoding: 'utf-8', env }, ) return result.trim() // Returns the PR URL }