Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
28 changes: 28 additions & 0 deletions apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions apps/bot/src/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface InsertJobParams {
task: string
chatId: number
messageId: number
githubToken: string
branch?: string
maxLoops?: number
maxBudgetUsd?: number
Expand All @@ -69,6 +70,7 @@ export async function insertJob(params: InsertJobParams): Promise<Job> {
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,
})
Expand Down
19 changes: 15 additions & 4 deletions apps/worker/src/claude-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,10 +57,12 @@ export async function runClaudeSession(config: ClaudeSessionConfig): Promise<Cla
let sessionId = config.sessionId

try {
// Remove CLAUDECODE env var to prevent "nested session" detection when the worker
// itself is running inside a Claude Code session (e.g. during development).
const env: Record<string, string | undefined> = { ...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<string, string> = {}
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
}
Expand All @@ -68,6 +78,7 @@ export async function runClaudeSession(config: ClaudeSessionConfig): Promise<Cla
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
env,
abortController: config.abortController,
stderr: (data: string) => {
if (data.trim()) {
console.error('[claude-code stderr]', data.trim())
Expand Down
17 changes: 13 additions & 4 deletions apps/worker/src/dev-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
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) {
Expand Down Expand Up @@ -122,6 +125,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
maxTurns,
maxBudgetUsd: maxBudget - totalCost,
anthropicApiKey: config.anthropicApiKey,
abortController: config.abortController,
sessionId,
onToken: (text: string) => {
// Future: stream to Supabase realtime channel
Expand All @@ -133,15 +137,20 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
})
},
}),
new Promise<never>((_resolve, reject) =>
setTimeout(
new Promise<never>((_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)
Expand Down
1 change: 1 addition & 0 deletions apps/worker/src/queue-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ async function processJob(job: Job): Promise<void> {
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) {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"clean": "rm -rf dist .turbo"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}
2 changes: 2 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
76 changes: 76 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.