diff --git a/apps/bot/package.json b/apps/bot/package.json index ab62d64..e235cb0 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -12,11 +12,12 @@ "clean": "rm -rf dist .turbo" }, "dependencies": { - "@wright/shared": "workspace:*", "@supabase/supabase-js": "^2.49.0", + "@wright/shared": "workspace:*", "grammy": "^1.35.0" }, "devDependencies": { + "@types/node": "^22.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 0d1b580..6dc8d06 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -27,12 +27,39 @@ if (!BOT_TOKEN) { process.exit(1) } +const GITHUB_TOKEN = process.env.GITHUB_TOKEN +if (!GITHUB_TOKEN) { + console.error('Fatal: GITHUB_TOKEN environment variable is not set.') + process.exit(1) +} + // --------------------------------------------------------------------------- // Bot setup // --------------------------------------------------------------------------- const bot = new Bot(BOT_TOKEN) +// --------------------------------------------------------------------------- +// Authorization middleware — restrict to known Telegram users +// --------------------------------------------------------------------------- + +const ALLOWED_TELEGRAM_USERS = process.env.ALLOWED_TELEGRAM_USERS + ? process.env.ALLOWED_TELEGRAM_USERS.split(',') + .map((id) => parseInt(id.trim(), 10)) + .filter(Number.isFinite) + : [] + +if (ALLOWED_TELEGRAM_USERS.length > 0) { + bot.use(async (ctx, next) => { + const userId = ctx.from?.id + if (!userId || !ALLOWED_TELEGRAM_USERS.includes(userId)) { + await ctx.reply('Unauthorized. Your user ID: ' + (userId ?? 'unknown')) + return + } + await next() + }) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -201,6 +228,7 @@ bot.command('task', async (ctx: Context) => { task: description, chatId: ctx.chat!.id, messageId: ack.message_id, + githubToken: GITHUB_TOKEN, }) await ctx.reply( diff --git a/apps/bot/src/supabase.ts b/apps/bot/src/supabase.ts index 741c113..fe0f41c 100644 --- a/apps/bot/src/supabase.ts +++ b/apps/bot/src/supabase.ts @@ -46,6 +46,7 @@ export interface InsertJobParams { task: string chatId: number messageId: number + githubToken: string branch?: string maxLoops?: number maxBudgetUsd?: number @@ -69,6 +70,7 @@ export async function insertJob(params: InsertJobParams): Promise { max_budget_usd: params.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD, status: JOB_STATUS.QUEUED, total_cost_usd: 0, + github_token: params.githubToken, telegram_chat_id: params.chatId, telegram_message_id: params.messageId, }) diff --git a/apps/worker/src/claude-session.ts b/apps/worker/src/claude-session.ts index 9e17757..b3a4431 100644 --- a/apps/worker/src/claude-session.ts +++ b/apps/worker/src/claude-session.ts @@ -9,10 +9,18 @@ export interface ClaudeSessionConfig { maxBudgetUsd: number anthropicApiKey?: string sessionId?: string + abortController?: AbortController onToken?: (text: string) => void onToolUse?: (toolName: string, input: string) => void } +const ALLOWED_ENV_KEYS = [ + 'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', + 'TMPDIR', 'TMP', 'TEMP', + 'NODE_ENV', 'WORKSPACE_DIR', + 'ANTHROPIC_API_KEY', +] + export interface ClaudeSessionResult { costUsd: number turns: number @@ -49,10 +57,12 @@ export async function runClaudeSession(config: ClaudeSessionConfig): Promise = { ...process.env } - delete env.CLAUDECODE + // Build a minimal env to avoid leaking secrets (SUPABASE_SERVICE_ROLE_KEY, + // BOT_TOKEN, etc.) into the Claude subprocess. + const env: Record = {} + for (const key of ALLOWED_ENV_KEYS) { + if (process.env[key]) env[key] = process.env[key]! + } if (config.anthropicApiKey) { env.ANTHROPIC_API_KEY = config.anthropicApiKey } @@ -68,6 +78,7 @@ export async function runClaudeSession(config: ClaudeSessionConfig): Promise { if (data.trim()) { console.error('[claude-code stderr]', data.trim()) diff --git a/apps/worker/src/dev-loop.ts b/apps/worker/src/dev-loop.ts index e23482a..40aa19c 100644 --- a/apps/worker/src/dev-loop.ts +++ b/apps/worker/src/dev-loop.ts @@ -86,6 +86,9 @@ export async function runDevLoop(config: DevLoopConfig): Promise loop <= maxLoops && totalCost < maxBudget && !allTestsPassed; loop++ ) { + // Check for abort (e.g. SIGTERM) + if (config.abortController?.signal.aborted) break + // Minimum budget guard const remainingBudget = maxBudget - totalCost if (loop > 1 && remainingBudget < MIN_BUDGET_PER_LOOP_USD) { @@ -122,6 +125,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise maxTurns, maxBudgetUsd: maxBudget - totalCost, anthropicApiKey: config.anthropicApiKey, + abortController: config.abortController, sessionId, onToken: (text: string) => { // Future: stream to Supabase realtime channel @@ -133,15 +137,20 @@ export async function runDevLoop(config: DevLoopConfig): Promise }) }, }), - new Promise((_resolve, reject) => - setTimeout( + new Promise((_resolve, reject) => { + const timer = setTimeout( () => reject( new Error('Claude session timed out after 20 minutes'), ), CLAUDE_SESSION_TIMEOUT_MS, - ), - ), + ) + // Clear timeout if abort fires (prevents dangling timer) + config.abortController?.signal.addEventListener('abort', () => { + clearTimeout(timer) + reject(new Error('Claude session aborted')) + }, { once: true }) + }), ]) } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) diff --git a/apps/worker/src/queue-poller.ts b/apps/worker/src/queue-poller.ts index 72f1aed..de9d412 100644 --- a/apps/worker/src/queue-poller.ts +++ b/apps/worker/src/queue-poller.ts @@ -259,6 +259,7 @@ async function processJob(job: Job): Promise { maxTurnsPerLoop: parseInt(process.env.MAX_TURNS_PER_LOOP || '30'), testTimeoutSeconds: parseInt(process.env.TEST_TIMEOUT_SECONDS || '300'), anthropicApiKey: process.env.ANTHROPIC_API_KEY, + abortController, }) if (!shuttingDown) { diff --git a/packages/shared/package.json b/packages/shared/package.json index 3a5ae38..cc68b17 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,7 @@ "clean": "rm -rf dist .turbo" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.7.0" } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2db8e33..4830242 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -104,6 +104,8 @@ export interface DevLoopConfig { testTimeoutSeconds: number /** Anthropic API key (uses env ANTHROPIC_API_KEY if not set) */ anthropicApiKey?: string + /** Abort controller for graceful cancellation (e.g. SIGTERM) */ + abortController?: AbortController } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14ab20..7991308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,10 +20,19 @@ importers: apps/bot: dependencies: + '@supabase/supabase-js': + specifier: ^2.49.0 + version: 2.98.0 '@wright/shared': specifier: workspace:* version: link:../../packages/shared + grammy: + specifier: ^1.35.0 + version: 1.41.0 devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.13 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -64,6 +73,9 @@ importers: packages/shared: devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.13 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -232,6 +244,9 @@ packages: cpu: [x64] os: [win32] + '@grammyjs/types@3.25.0': + resolution: {integrity: sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==} + '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -550,6 +565,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -662,6 +681,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -714,6 +737,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + grammy@1.41.0: + resolution: {integrity: sha512-CAAu74SLT+/QCg40FBhUuYJalVsxxCN3D0c31TzhFBsWWTdXrMXYjGsKngBdfvN6hQ/VzHczluj/ugZVetFNCQ==} + engines: {node: ^12.20.0 || >=14.13.1} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -787,6 +814,15 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -915,6 +951,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1055,6 +1094,12 @@ packages: jsdom: optional: true + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1168,6 +1213,8 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@grammyjs/types@3.25.0': {} + '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 @@ -1447,6 +1494,10 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -1566,6 +1617,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + expect-type@1.3.0: {} express@4.22.1: @@ -1653,6 +1706,16 @@ snapshots: gopd@1.2.0: {} + grammy@1.41.0: + dependencies: + '@grammyjs/types': 3.25.0 + abort-controller: 3.0.0 + debug: 4.4.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + has-symbols@1.1.0: {} hasown@2.0.2: @@ -1705,6 +1768,10 @@ snapshots: negotiator@0.6.3: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + object-inspect@1.13.4: {} obug@2.1.1: {} @@ -1872,6 +1939,8 @@ snapshots: toidentifier@1.0.1: {} + tr46@0.0.3: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -1973,6 +2042,13 @@ snapshots: - tsx - yaml + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0