From 0b2c4f545aba2bd430d8387cc5d3ccfca654fd1d Mon Sep 17 00:00:00 2001 From: Joseph Magly <1159087+jmagly@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:42:33 -0400 Subject: [PATCH 1/2] fix: clarify local LLM setup --- README.md | 1 + src/config/index.ts | 3 +++ src/setup.ts | 20 +++++--------------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 100acde..274fabf 100755 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Or run it **fully offline** on your own model — no key, no cloud. Defaults to ollama serve && ollama pull llama3 # or an OpenAI-compatible server export TEMPEST_LOCAL_BASE_URL=http://localhost:11434/api # LM Studio: http://localhost:1234/v1 export TEMPEST_LOCAL_MODEL=llama3 +npm run build # only needed from a git clone npx tempest # → "Change default provider" → local ``` diff --git a/src/config/index.ts b/src/config/index.ts index b1b9ea3..c710051 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -802,6 +802,9 @@ class ConfigManager { case 'codex': this.config.set('defaultModel', this.config.get('codex').defaultModel); break; + case 'local': + this.config.set('defaultModel', process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3'); + break; } } diff --git a/src/setup.ts b/src/setup.ts index cabf850..b2802b7 100755 --- a/src/setup.ts +++ b/src/setup.ts @@ -281,13 +281,14 @@ async function runSetup(): Promise { 'Welcome to T3MP3ST Setup', `This wizard will help you configure T3MP3ST for first use. -You'll need an API key from one of these providers: +You can use a local model with no API key, or add a key from one of these providers: +• ${chalk.cyan('Local')} - Ollama / LM Studio / vLLM via TEMPEST_LOCAL_BASE_URL • ${chalk.cyan('OpenRouter')} (recommended) - Access multiple models • ${chalk.cyan('Anthropic')} - Direct Claude access • ${chalk.cyan('OpenAI')} - GPT models The setup will guide you through: -1. Adding your API key(s) +1. Adding API key(s), if you use a hosted provider 2. Selecting your default provider and model 3. Configuring basic settings`, 'cyan' @@ -365,12 +366,11 @@ async function setupApiKeys(): Promise { { type: 'checkbox', name: 'providers', - message: 'Which API keys would you like to configure?', + message: 'Which hosted API keys would you like to configure? Leave empty to use a local model only.', choices: [ { name: `OpenRouter ${hasApiKey('openrouter') ? chalk.green('(configured)') : chalk.yellow('(recommended)')}`, value: 'openrouter', - checked: !hasApiKey('openrouter'), }, { name: `Venice ${hasApiKey('venice') ? chalk.green('(configured)') : ''}`, @@ -409,22 +409,12 @@ async function setupApiKeys(): Promise { async function setupProvider(): Promise { const configuredProviders = []; + configuredProviders.push({ name: 'Local model (Ollama / LM Studio / vLLM, no API key)', value: 'local' }); if (hasApiKey('openrouter')) configuredProviders.push({ name: 'OpenRouter', value: 'openrouter' }); if (hasApiKey('venice')) configuredProviders.push({ name: 'Venice', value: 'venice' }); if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' }); if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' }); - if (configuredProviders.length === 0) { - showWarning('No API keys configured. Please add at least one API key first.'); - return; - } - - if (configuredProviders.length === 1) { - config.setDefaultProvider(configuredProviders[0].value as any); - showSuccess(`Default provider set to: ${configuredProviders[0].name}`); - return; - } - const { provider } = await inquirer.prompt([ { type: 'list', From df37e7c4236db66285b3aded8dd4454f3c0469ed Mon Sep 17 00:00:00 2001 From: Joseph Magly <1159087+jmagly@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:50:16 -0400 Subject: [PATCH 2/2] Address upstream issues 60 and 62 --- .env.example | 1 + src/__tests__/api-key-env-static.test.ts | 2 +- src/__tests__/deepseek-provider.test.ts | 33 +++++ src/__tests__/whitebox-containment.test.ts | 18 ++- src/cli.ts | 4 +- src/config/index.ts | 66 ++++++++- src/llm/index.ts | 11 ++ src/recon/whitebox.ts | 163 ++++++++++++++++----- src/server.ts | 46 +++--- src/setup.ts | 61 +++++++- src/types/index.ts | 2 +- 11 files changed, 328 insertions(+), 79 deletions(-) create mode 100644 src/__tests__/deepseek-provider.test.ts diff --git a/.env.example b/.env.example index a48686b..cb6e353 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VENICE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx GROQ_API_KEY= TOGETHER_API_KEY= HUGGINGFACE_TOKEN= diff --git a/src/__tests__/api-key-env-static.test.ts b/src/__tests__/api-key-env-static.test.ts index f463a3e..e5dd807 100644 --- a/src/__tests__/api-key-env-static.test.ts +++ b/src/__tests__/api-key-env-static.test.ts @@ -52,7 +52,7 @@ describe('API key environment handling hardening', () => { it('exportConfig redacts every supported provider key slot', () => { const block = exportConfigBlock(); - for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai']) { + for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek']) { expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`); } }); diff --git a/src/__tests__/deepseek-provider.test.ts b/src/__tests__/deepseek-provider.test.ts new file mode 100644 index 0000000..6211a25 --- /dev/null +++ b/src/__tests__/deepseek-provider.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { config, AVAILABLE_MODELS } from '../config/index.js'; +import { createDeepSeekBackbone } from '../llm/index.js'; + +const KEY = 'deepseek-key-abcdef123456'; + +describe('DeepSeek provider wiring (#62)', () => { + afterEach(() => { delete process.env.DEEPSEEK_API_KEY; }); + + it('resolves DeepSeek base URL, default model, and key from DEEPSEEK_API_KEY', () => { + process.env.DEEPSEEK_API_KEY = KEY; + const cfg = config.getLLMConfig('deepseek'); + expect(cfg.provider).toBe('deepseek'); + expect(cfg.baseUrl).toBe('https://api.deepseek.com/v1'); + expect(cfg.model).toBe('deepseek-chat'); + expect(cfg.apiKey).toBe(KEY); + }); + + it('routes through the OpenAI-compatible backbone and validates with a key', () => { + process.env.DEEPSEEK_API_KEY = KEY; + const bb = createDeepSeekBackbone(); + expect(bb.getProvider()).toBe('deepseek'); + expect(bb.validateConfig().valid).toBe(true); + }); + + it('surfaces native DeepSeek models and configured provider state', () => { + process.env.DEEPSEEK_API_KEY = KEY; + expect(AVAILABLE_MODELS.deepseek?.map(m => m.id)).toEqual( + expect.arrayContaining(['deepseek-chat', 'deepseek-reasoner']), + ); + expect(config.getConfiguredProviders()).toContain('deepseek'); + }); +}); diff --git a/src/__tests__/whitebox-containment.test.ts b/src/__tests__/whitebox-containment.test.ts index 840bd26..ea629ff 100644 --- a/src/__tests__/whitebox-containment.test.ts +++ b/src/__tests__/whitebox-containment.test.ts @@ -18,7 +18,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync, realpathSyn import { statSync } from 'node:fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { resolveContainedRepoPath, RepoPathError } from '../recon/whitebox.js'; +import { resolveContainedRepoPath, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from '../recon/whitebox.js'; // Mock node:fs (what whitebox.ts imports) so we can force statSync to throw AFTER realpath // succeeds — the TOCTOU race path. Every other export keeps its real implementation, and @@ -98,6 +98,22 @@ describe('resolveContainedRepoPath (B-03 path containment)', () => { expect(() => resolveContainedRepoPath(link)).toThrow(RepoPathError); }); + it('treats GitHub HTTPS repo URLs as remote sources and requires a GitHub token before cloning', () => { + const prev = process.env.GITHUB_TOKEN; + const prevT3 = process.env.T3MP3ST_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.T3MP3ST_GITHUB_TOKEN; + try { + expect(() => resolveRepoSourceForAnalysis('https://github.com/example/private-repo')).toThrow(RepoCloneError); + expect(() => resolveRepoSourceForAnalysis('https://github.com/example/private-repo')).toThrow(/T3MP3ST_GITHUB_TOKEN or GITHUB_TOKEN/); + } finally { + if (prev === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = prev; + if (prevT3 === undefined) delete process.env.T3MP3ST_GITHUB_TOKEN; + else process.env.T3MP3ST_GITHUB_TOKEN = prevT3; + } + }); + it('normalizes a statSync failure (TOCTOU delete after realpath) to RepoPathError', () => { // The dir realpath-resolves (exists) but statSync then throws — simulating a deletion in // the race window between realpath and stat. The wrap must convert it to a RepoPathError diff --git a/src/cli.ts b/src/cli.ts index a8cc256..2c1c18e 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -523,6 +523,7 @@ async function openSettings(): Promise { console.log(chalk.cyan(' Venice:'), hasApiKey('venice') ? chalk.green('configured') : chalk.red('not set')); console.log(chalk.cyan(' Anthropic:'), hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set')); console.log(chalk.cyan(' OpenAI:'), hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set')); + console.log(chalk.cyan(' DeepSeek:'), hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set')); console.log(''); break; @@ -575,7 +576,7 @@ async function openSettings(): Promise { type: 'list', name: 'keyProvider', message: 'Which provider?', - choices: ['openrouter', 'venice', 'anthropic', 'openai'], + choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek'], }, ]); const { apiKey } = await inquirer.prompt([ @@ -642,6 +643,7 @@ program [chalk.cyan('Venice API Key'), hasApiKey('venice') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('Anthropic API Key'), hasApiKey('anthropic') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('OpenAI API Key'), hasApiKey('openai') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], + [chalk.cyan('DeepSeek API Key'), hasApiKey('deepseek') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('Config Path'), config.getConfigPath()], ); diff --git a/src/config/index.ts b/src/config/index.ts index c710051..c735fbe 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -24,6 +24,7 @@ export interface TempestSettings { openai?: string; xai?: string; gemini?: string; + deepseek?: string; local?: string; }; @@ -69,6 +70,12 @@ export interface TempestSettings { defaultModel: string; }; + // DeepSeek — OpenAI-compatible API + deepseek: { + baseUrl: string; + defaultModel: string; + }; + // Codex CLI/account subscription backend codex: { command: string; @@ -140,6 +147,11 @@ const DEFAULT_SETTINGS: TempestSettings = { defaultModel: 'gemini-2.5-flash', }, + deepseek: { + baseUrl: 'https://api.deepseek.com/v1', + defaultModel: 'deepseek-chat', + }, + codex: { command: 'codex', defaultModel: 'codex-default', @@ -466,6 +478,24 @@ export const AVAILABLE_MODELS: Record = { capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'], }, ], + deepseek: [ + { + id: 'deepseek-chat', + name: 'DeepSeek V3 (native)', + provider: 'DeepSeek', + contextWindow: 64000, + maxOutput: 8192, + capabilities: ['reasoning', 'code', 'analysis', 'tools'], + }, + { + id: 'deepseek-reasoner', + name: 'DeepSeek R1 (native)', + provider: 'DeepSeek', + contextWindow: 64000, + maxOutput: 8192, + capabilities: ['reasoning', 'code', 'analysis', 'complex-tasks'], + }, + ], local: [ { id: 'local-model', @@ -591,7 +621,7 @@ class ConfigManager { /** * Set an API key for a provider */ - setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local', key: string): void { + setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local', key: string): void { const apiKeys = this.config.get('apiKeys'); apiKeys[provider] = key; this.config.set('apiKeys', apiKeys); @@ -600,7 +630,7 @@ class ConfigManager { /** * Get an API key for a provider */ - getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): string | undefined { + getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): string | undefined { // First check environment variables (highest priority) // local provider: a self-hosted/OpenAI-compatible server MAY require a bearer // (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars. @@ -616,6 +646,7 @@ class ConfigManager { openai: 'OPENAI_API_KEY', xai: 'XAI_API_KEY', gemini: 'GEMINI_API_KEY', + deepseek: 'DEEPSEEK_API_KEY', }; // Force a fully UNCONFIGURED server (no key from env OR the saved store) — used by @@ -634,7 +665,7 @@ class ConfigManager { /** * Check if a provider has a valid API key configured */ - hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): boolean { + hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): boolean { const key = this.getApiKey(provider); return !!key && key.length > 10; } @@ -642,7 +673,7 @@ class ConfigManager { /** * Remove an API key */ - removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): void { + removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): void { const apiKeys = this.config.get('apiKeys'); delete apiKeys[provider]; this.config.set('apiKeys', apiKeys); @@ -660,6 +691,7 @@ class ConfigManager { if (this.hasApiKey('openai')) providers.push('openai'); if (this.hasApiKey('xai')) providers.push('xai'); if (this.hasApiKey('gemini')) providers.push('gemini'); + if (this.hasApiKey('deepseek')) providers.push('deepseek'); // Codex uses the local Codex CLI/account auth instead of API-key storage. providers.push('codex'); @@ -713,6 +745,12 @@ class ConfigManager { baseUrl = this.config.get('gemini').baseUrl; actualModel = model || this.config.get('gemini').defaultModel; break; + case 'deepseek': + // DeepSeek native API is OpenAI-compatible. + apiKey = this.getApiKey('deepseek'); + baseUrl = this.config.get('deepseek').baseUrl; + actualModel = model || this.config.get('deepseek').defaultModel; + break; case 'codex': actualModel = model || this.config.get('codex').defaultModel; break; @@ -761,7 +799,7 @@ class ConfigManager { const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase(); if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return []; const chain: FallbackEntry[] = []; - const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => { + const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => { if (p === primary || !this.hasApiKey(p)) return; chain.push({ provider: p, @@ -776,6 +814,7 @@ class ConfigManager { add('openai'); add('xai'); add('gemini'); + add('deepseek'); return chain; } @@ -799,6 +838,9 @@ class ConfigManager { case 'openai': this.config.set('defaultModel', this.config.get('openai').defaultModel); break; + case 'deepseek': + this.config.set('defaultModel', this.config.get('deepseek').defaultModel); + break; case 'codex': this.config.set('defaultModel', this.config.get('codex').defaultModel); break; @@ -825,6 +867,9 @@ class ConfigManager { case 'openai': this.config.set('openai', { ...this.config.get('openai'), defaultModel: model }); break; + case 'deepseek': + this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model }); + break; case 'codex': this.config.set('codex', { ...this.config.get('codex'), defaultModel: model }); break; @@ -864,6 +909,7 @@ class ConfigManager { openai: settings.apiKeys.openai ? '***REDACTED***' : undefined, xai: settings.apiKeys.xai ? '***REDACTED***' : undefined, gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined, + deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined, }, }; writeFileSync(filePath, JSON.stringify(safeSettings, null, 2)); @@ -901,6 +947,10 @@ XAI_API_KEY= # Get your key at: https://aistudio.google.com/apikey GEMINI_API_KEY= +# DeepSeek API Key (direct DeepSeek API via its OpenAI-compatible endpoint) +# Get your key at: https://platform.deepseek.com/api_keys +DEEPSEEK_API_KEY= + # Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server) # Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below). # For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1, @@ -923,8 +973,8 @@ TEMPEST_LOCAL_API_KEY= export const config = new ConfigManager(); // Helper functions for quick access -export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.getApiKey(provider); -export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini', key: string) => config.setApiKey(provider, key); -export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.hasApiKey(provider); +export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => config.getApiKey(provider); +export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek', key: string) => config.setApiKey(provider, key); +export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => config.hasApiKey(provider); export const getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model); export const getConfiguredProviders = () => config.getConfiguredProviders(); diff --git a/src/llm/index.ts b/src/llm/index.ts index b29aa30..e5de218 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -1209,6 +1209,8 @@ export class LLMBackbone extends EventEmitter { return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible case 'gemini': return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai) + case 'deepseek': + return new OpenAIAdapter(config); // DeepSeek native API is OpenAI-compatible case 'codex': return new CodexAdapter(config); case 'mock': @@ -1483,6 +1485,12 @@ export function createOpenAIBackbone(apiKey?: string, model?: string): LLMBackbo return new LLMBackbone(llmConfig); } +export function createDeepSeekBackbone(apiKey?: string, model?: string): LLMBackbone { + const llmConfig = config.getLLMConfig('deepseek', model); + if (apiKey) llmConfig.apiKey = apiKey; + return new LLMBackbone(llmConfig); +} + export function createMockBackbone(): LLMBackbone { return new LLMBackbone({ provider: 'mock', @@ -1521,6 +1529,9 @@ export function createBestAvailableBackbone(): LLMBackbone { if (providers.includes('openai')) { return createOpenAIBackbone(); } + if (providers.includes('deepseek')) { + return createDeepSeekBackbone(); + } // Default to mock if no API keys configured console.warn('No API keys configured. Using mock provider.'); diff --git a/src/recon/whitebox.ts b/src/recon/whitebox.ts index 397c956..24a69e0 100644 --- a/src/recon/whitebox.ts +++ b/src/recon/whitebox.ts @@ -23,9 +23,10 @@ import { import { DecompositionOrchestrator } from '../orchestration/index.js'; import type { DecompositionConfig } from '../orchestration/index.js'; import type { LLMProvider } from '../types/index.js'; -import { realpathSync, statSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { homedir, tmpdir } from 'node:os'; +import { join as joinPath, isAbsolute, relative, resolve, sep } from 'node:path'; /** * Default worker-source token budget when building a source blob directly from a @@ -57,6 +58,86 @@ export class RepoPathError extends Error { } } +export class RepoCloneError extends Error { + constructor(message: string) { + super(message); + this.name = 'RepoCloneError'; + } +} + +export interface ResolvedRepoSource { + repoPath: string; + source: 'local' | 'github'; + cleanup(): void; +} + +function isGitHubHttpsRepoUrl(input: string): boolean { + try { + const url = new URL(input); + return url.protocol === 'https:' && + url.hostname.toLowerCase() === 'github.com' && + /^\/[^/]+\/[^/]+(?:\.git)?\/?$/.test(url.pathname); + } catch { + return false; + } +} + +function githubCloneUrlWithToken(repoUrl: string, token: string): string { + const url = new URL(repoUrl); + url.username = 'x-access-token'; + url.password = token; + return url.toString(); +} + +function getGitHubRepoToken(): string | undefined { + return process.env.T3MP3ST_GITHUB_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim(); +} + +export function cloneGitHubRepoForAnalysis(repoUrl: string): ResolvedRepoSource { + if (!isGitHubHttpsRepoUrl(repoUrl)) { + throw new RepoCloneError('repoUrl must be an https://github.com// URL'); + } + + const token = getGitHubRepoToken(); + if (!token) { + throw new RepoCloneError('private GitHub repo scans require T3MP3ST_GITHUB_TOKEN or GITHUB_TOKEN'); + } + + const tempRoot = mkdtempSync(joinPath(tmpdir(), 't3mp3st-repo-')); + const cloneDir = joinPath(tempRoot, 'repo'); + try { + execFileSync('git', [ + '-c', 'credential.helper=', + 'clone', + '--depth', '1', + '--filter=blob:none', + githubCloneUrlWithToken(repoUrl, token), + cloneDir, + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + + return { + repoPath: realpathSync(cloneDir), + source: 'github', + cleanup: () => { rmSync(tempRoot, { recursive: true, force: true }); }, + }; + } catch (error) { + rmSync(tempRoot, { recursive: true, force: true }); + const message = error instanceof Error ? error.message.replaceAll(token, '[REDACTED]') : 'git clone failed'; + throw new RepoCloneError(`GitHub repo clone failed: ${message}`); + } +} + +export function resolveRepoSourceForAnalysis(input: unknown): ResolvedRepoSource { + if (typeof input === 'string' && isGitHubHttpsRepoUrl(input.trim())) { + return cloneGitHubRepoForAnalysis(input.trim()); + } + return { + repoPath: resolveContainedRepoPath(input), + source: 'local', + cleanup: () => { /* local repos are caller-owned; nothing to remove */ }, + }; +} + /** * B-03 — path containment for the caller-supplied repoPath. * @@ -114,27 +195,31 @@ export function resolveContainedRepoPath(input: unknown): string { * suitable for handing to `TempestCommand.setWhiteboxSource` (or any consumer * that wants the highest-priority code first). * - * @param repoPath Absolute path to a LOCAL repo you own. + * @param repoPath Absolute path to a LOCAL repo you own, or a GitHub HTTPS repo URL with token auth. * @param tokenBudget Packing budget in tokens (default 24000). */ export function ingestRepoToSourceContext( repoPath: string, tokenBudget = 24000, ): RepoSourceContext { - const safePath = resolveContainedRepoPath(repoPath); - const result = ingestRepository(createPythonIngestConfig(safePath)); - const packed = packAnalysisUnits(result.analysisUnits, tokenBudget); - return { - sourceContext: packed.text, - stats: result.stats, - includedUnits: packed.includedUnits.length, - droppedUnits: packed.droppedUnits.length, - }; + const source = resolveRepoSourceForAnalysis(repoPath); + try { + const result = ingestRepository(createPythonIngestConfig(source.repoPath)); + const packed = packAnalysisUnits(result.analysisUnits, tokenBudget); + return { + sourceContext: packed.text, + stats: result.stats, + includedUnits: packed.includedUnits.length, + droppedUnits: packed.droppedUnits.length, + }; + } finally { + source.cleanup(); + } } /** Options for a full white-box decomposition analysis run. */ export interface WhiteboxAnalysisOptions { - /** Absolute path to a LOCAL repo you own. */ + /** Absolute path to a LOCAL repo you own, or a GitHub HTTPS repo URL with token auth. */ repoPath: string; /** The offensive objective — only the orchestrator model sees this. */ objective: string; @@ -199,41 +284,45 @@ export async function runWhiteboxAnalysis( opts: WhiteboxAnalysisOptions, ): Promise { const { repoPath, objective, maxRounds } = opts; - const safePath = resolveContainedRepoPath(repoPath); + const source = resolveRepoSourceForAnalysis(repoPath); - // 1) ingest LOCAL source, security-rank the blocks - const result = ingestRepository(createPythonIngestConfig(safePath)); + // 1) ingest source, security-rank the blocks + try { + const result = ingestRepository(createPythonIngestConfig(source.repoPath)); - // 2) pack into a generous source view (orchestrator re-packs per-worker itself) - const packed = packAnalysisUnits(result.analysisUnits, DEFAULT_ANALYSIS_SOURCE_BUDGET); - const sourceContext = packed.text; + // 2) pack into a generous source view (orchestrator re-packs per-worker itself) + const packed = packAnalysisUnits(result.analysisUnits, DEFAULT_ANALYSIS_SOURCE_BUDGET); + const sourceContext = packed.text; // Fail loud ONLY when there is genuinely NO source to analyze: 0 ingestable source files // (non-Python or empty repo). A valid Python repo that has files but no def/class (a // module-level-only script) yields 0 blocks -> empty packed text; that used to run the // orchestrator, so we do NOT regress it into a 500 — gate the throw on the file count, not // on empty packed text, and let a files>0 repo proceed as before. - if (result.stats.files === 0) { - throw new Error( + if (result.stats.files === 0) { + throw new Error( 'white-box analysis has no analyzable source: the repo has 0 ingestable source files ' + - '(non-Python, or empty). Point repoPath at a Python codebase.', - ); - } + '(non-Python, or empty). Point repoPath at a Python codebase.', + ); + } // 3) construct the orchestrator with a sane default config (mirrors decompose.mjs) - const decompConfig = buildDecompositionConfig(); - if (typeof maxRounds === 'number' && maxRounds > 0) { - decompConfig.maxRounds = maxRounds; - } - const orch = new DecompositionOrchestrator(decompConfig); + const decompConfig = buildDecompositionConfig(); + if (typeof maxRounds === 'number' && maxRounds > 0) { + decompConfig.maxRounds = maxRounds; + } + const orch = new DecompositionOrchestrator(decompConfig); // 4) run the decomposition loop (LLM calls happen HERE) - const decomposition = await orch.run(objective, sourceContext); + const decomposition = await orch.run(objective, sourceContext); - return { - ingestStats: result.stats, - entryPoints: result.entryPoints, - unitCount: result.analysisUnits.length, - decomposition, - }; + return { + ingestStats: result.stats, + entryPoints: result.entryPoints, + unitCount: result.analysisUnits.length, + decomposition, + }; + } finally { + source.cleanup(); + } } diff --git a/src/server.ts b/src/server.ts index 01b5fb2..1fd393f 100755 --- a/src/server.ts +++ b/src/server.ts @@ -30,7 +30,7 @@ import { OPERATOR_SYSTEM_PROMPTS, PLINIAN_OPERATOR_DOCTRINE, THE_FIXER_SYSTEM_PR import { createTargetFromUrl, createTargetFromIP } from './target/index.js'; import type { OperatorArchetype } from './types/index.js'; import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js'; -import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveContainedRepoPath, RepoPathError } from './recon/whitebox.js'; +import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from './recon/whitebox.js'; import { redactCredential } from './evidence/index.js'; import dotenv from 'dotenv'; @@ -6001,19 +6001,15 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise | undefined; if (typeof repoPath === 'string' && repoPath.trim()) { try { - containedRepoPath = resolveContainedRepoPath(repoPath); + repoSource = resolveRepoSourceForAnalysis(repoPath); } catch (e) { if (e instanceof RepoPathError) { res.status(400).json({ error: `repoPath rejected: ${e.message}` }); return; } - // Guarantee a response even for an unexpected error — a bare `throw` in this async - // handler becomes an unhandledRejection (Express 4 won't route it), hanging the client. - console.error('[T3MP3ST] repoPath validation error:', e); - res.status(500).json({ error: 'repoPath validation failed' }); + if (e instanceof RepoCloneError) { res.status(400).json({ error: e.message }); return; } + console.error('[T3MP3ST] repo source validation error:', e); + res.status(500).json({ error: 'repo source validation failed' }); return; } } @@ -6055,13 +6051,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise { } } +async function setupDeepSeekKey(): Promise { + console.log(''); + showInfo('DeepSeek provides OpenAI-compatible chat and reasoning models.'); + showInfo('Get your API key at: ' + chalk.underline('https://platform.deepseek.com/api_keys')); + console.log(''); + + const { apiKey } = await inquirer.prompt([ + { + type: 'password', + name: 'apiKey', + message: 'Enter your DeepSeek API key:', + mask: '*', + validate: (input: string) => { + if (!input || input.length < 10) { + return 'Please enter a valid API key'; + } + return true; + }, + }, + ]); + + const spinner = ora('Testing API key...').start(); + + try { + const llm = new LLMBackbone({ + provider: 'deepseek', + model: 'deepseek-chat', + baseUrl: 'https://api.deepseek.com/v1', + apiKey, + maxTokens: 10, + temperature: 0, + }); + + await llm.prompt('Hello', undefined, { maxTokens: 10 }); + spinner.succeed('API key is valid!'); + + setApiKey('deepseek', apiKey); + showSuccess('DeepSeek API key saved successfully!'); + + return true; + } catch (error) { + spinner.fail('API key validation failed'); + showError(`Error: ${error instanceof Error ? error.message : String(error)}`); + return false; + } +} + async function setupOpenAIKey(): Promise { console.log(''); showInfo('OpenAI provides access to GPT models.'); @@ -286,6 +333,7 @@ You can use a local model with no API key, or add a key from one of these provid • ${chalk.cyan('OpenRouter')} (recommended) - Access multiple models • ${chalk.cyan('Anthropic')} - Direct Claude access • ${chalk.cyan('OpenAI')} - GPT models +• ${chalk.cyan('DeepSeek')} - OpenAI-compatible chat/reasoning models The setup will guide you through: 1. Adding API key(s), if you use a hosted provider @@ -298,13 +346,15 @@ The setup will guide you through: const hasOpenRouter = hasApiKey('openrouter'); const hasAnthropic = hasApiKey('anthropic'); const hasOpenAI = hasApiKey('openai'); + const hasDeepSeek = hasApiKey('deepseek'); - if (hasOpenRouter || hasAnthropic || hasOpenAI) { + if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek) { console.log(''); showInfo('Existing API keys detected:'); if (hasOpenRouter) showSuccess(' OpenRouter: configured'); if (hasAnthropic) showSuccess(' Anthropic: configured'); if (hasOpenAI) showSuccess(' OpenAI: configured'); + if (hasDeepSeek) showSuccess(' DeepSeek: configured'); console.log(''); const { action } = await inquirer.prompt([ @@ -384,6 +434,10 @@ async function setupApiKeys(): Promise { name: `OpenAI ${hasApiKey('openai') ? chalk.green('(configured)') : ''}`, value: 'openai', }, + { + name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`, + value: 'deepseek', + }, ], }, ]); @@ -402,6 +456,9 @@ async function setupApiKeys(): Promise { case 'openai': await setupOpenAIKey(); break; + case 'deepseek': + await setupDeepSeekKey(); + break; } } } @@ -414,6 +471,7 @@ async function setupProvider(): Promise { if (hasApiKey('venice')) configuredProviders.push({ name: 'Venice', value: 'venice' }); if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' }); if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' }); + if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' }); const { provider } = await inquirer.prompt([ { @@ -444,6 +502,7 @@ function viewConfiguration(): void { console.log(' Venice: ' + (hasApiKey('venice') ? chalk.green('configured') : chalk.red('not set'))); console.log(' Anthropic: ' + (hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set'))); console.log(' OpenAI: ' + (hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set'))); + console.log(' DeepSeek: ' + (hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set'))); console.log(''); console.log(chalk.cyan(' Config Path: ') + config.getConfigPath()); console.log(''); diff --git a/src/types/index.ts b/src/types/index.ts index e19cfa7..a2d2f64 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,7 +6,7 @@ // LLM CONFIGURATION // ============================================================================= -export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'codex' | 'mock' | 'local' | 'local-agent'; +export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'codex' | 'mock' | 'local' | 'local-agent'; export interface LLMConfig { provider: LLMProvider;