Address upstream issues 60 and 62

This commit is contained in:
Joseph Magly committed 2026-07-09 11:50:16 -04:00
1 parent 0b2c4f545a
commit df37e7c423
11 files changed
+301 -52

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VENICE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VENICE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GROQ_API_KEY= GROQ_API_KEY=
TOGETHER_API_KEY= TOGETHER_API_KEY=
HUGGINGFACE_TOKEN= HUGGINGFACE_TOKEN=
+1 -1
View File
@@ -52,7 +52,7 @@ describe('API key environment handling hardening', () => {
it('exportConfig redacts every supported provider key slot', () => { it('exportConfig redacts every supported provider key slot', () => {
const block = exportConfigBlock(); 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`); expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`);
} }
}); });
+33
View File
@@ -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');
});
});
+17 -1
View File
@@ -18,7 +18,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync, realpathSyn
import { statSync } from 'node:fs'; import { statSync } from 'node:fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; 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 // 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 // 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); 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', () => { 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 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 // the race window between realpath and stat. The wrap must convert it to a RepoPathError
+3 -1
View File
@@ -523,6 +523,7 @@ async function openSettings(): Promise<void> {
console.log(chalk.cyan(' Venice:'), hasApiKey('venice') ? chalk.green('configured') : chalk.red('not set')); 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(' 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(' 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(''); console.log('');
break; break;
@@ -575,7 +576,7 @@ async function openSettings(): Promise<void> {
type: 'list', type: 'list',
name: 'keyProvider', name: 'keyProvider',
message: 'Which provider?', message: 'Which provider?',
choices: ['openrouter', 'venice', 'anthropic', 'openai'], choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek'],
}, },
]); ]);
const { apiKey } = await inquirer.prompt([ 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('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('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('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()], [chalk.cyan('Config Path'), config.getConfigPath()],
); );
+58 -8
View File
@@ -24,6 +24,7 @@ export interface TempestSettings {
openai?: string; openai?: string;
xai?: string; xai?: string;
gemini?: string; gemini?: string;
deepseek?: string;
local?: string; local?: string;
}; };
@@ -69,6 +70,12 @@ export interface TempestSettings {
defaultModel: string; defaultModel: string;
}; };
// DeepSeek — OpenAI-compatible API
deepseek: {
baseUrl: string;
defaultModel: string;
};
// Codex CLI/account subscription backend // Codex CLI/account subscription backend
codex: { codex: {
command: string; command: string;
@@ -140,6 +147,11 @@ const DEFAULT_SETTINGS: TempestSettings = {
defaultModel: 'gemini-2.5-flash', defaultModel: 'gemini-2.5-flash',
}, },
deepseek: {
baseUrl: 'https://api.deepseek.com/v1',
defaultModel: 'deepseek-chat',
},
codex: { codex: {
command: 'codex', command: 'codex',
defaultModel: 'codex-default', defaultModel: 'codex-default',
@@ -466,6 +478,24 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'], 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: [ local: [
{ {
id: 'local-model', id: 'local-model',
@@ -591,7 +621,7 @@ class ConfigManager {
/** /**
* Set an API key for a provider * 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'); const apiKeys = this.config.get('apiKeys');
apiKeys[provider] = key; apiKeys[provider] = key;
this.config.set('apiKeys', apiKeys); this.config.set('apiKeys', apiKeys);
@@ -600,7 +630,7 @@ class ConfigManager {
/** /**
* Get an API key for a provider * 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) // First check environment variables (highest priority)
// local provider: a self-hosted/OpenAI-compatible server MAY require a bearer // 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. // (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars.
@@ -616,6 +646,7 @@ class ConfigManager {
openai: 'OPENAI_API_KEY', openai: 'OPENAI_API_KEY',
xai: 'XAI_API_KEY', xai: 'XAI_API_KEY',
gemini: 'GEMINI_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 // 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 * 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); const key = this.getApiKey(provider);
return !!key && key.length > 10; return !!key && key.length > 10;
} }
@@ -642,7 +673,7 @@ class ConfigManager {
/** /**
* Remove an API key * 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'); const apiKeys = this.config.get('apiKeys');
delete apiKeys[provider]; delete apiKeys[provider];
this.config.set('apiKeys', apiKeys); this.config.set('apiKeys', apiKeys);
@@ -660,6 +691,7 @@ class ConfigManager {
if (this.hasApiKey('openai')) providers.push('openai'); if (this.hasApiKey('openai')) providers.push('openai');
if (this.hasApiKey('xai')) providers.push('xai'); if (this.hasApiKey('xai')) providers.push('xai');
if (this.hasApiKey('gemini')) providers.push('gemini'); 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. // Codex uses the local Codex CLI/account auth instead of API-key storage.
providers.push('codex'); providers.push('codex');
@@ -713,6 +745,12 @@ class ConfigManager {
baseUrl = this.config.get('gemini').baseUrl; baseUrl = this.config.get('gemini').baseUrl;
actualModel = model || this.config.get('gemini').defaultModel; actualModel = model || this.config.get('gemini').defaultModel;
break; 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': case 'codex':
actualModel = model || this.config.get('codex').defaultModel; actualModel = model || this.config.get('codex').defaultModel;
break; break;
@@ -761,7 +799,7 @@ class ConfigManager {
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase(); const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return []; if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
const chain: FallbackEntry[] = []; 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; if (p === primary || !this.hasApiKey(p)) return;
chain.push({ chain.push({
provider: p, provider: p,
@@ -776,6 +814,7 @@ class ConfigManager {
add('openai'); add('openai');
add('xai'); add('xai');
add('gemini'); add('gemini');
add('deepseek');
return chain; return chain;
} }
@@ -799,6 +838,9 @@ class ConfigManager {
case 'openai': case 'openai':
this.config.set('defaultModel', this.config.get('openai').defaultModel); this.config.set('defaultModel', this.config.get('openai').defaultModel);
break; break;
case 'deepseek':
this.config.set('defaultModel', this.config.get('deepseek').defaultModel);
break;
case 'codex': case 'codex':
this.config.set('defaultModel', this.config.get('codex').defaultModel); this.config.set('defaultModel', this.config.get('codex').defaultModel);
break; break;
@@ -825,6 +867,9 @@ class ConfigManager {
case 'openai': case 'openai':
this.config.set('openai', { ...this.config.get('openai'), defaultModel: model }); this.config.set('openai', { ...this.config.get('openai'), defaultModel: model });
break; break;
case 'deepseek':
this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model });
break;
case 'codex': case 'codex':
this.config.set('codex', { ...this.config.get('codex'), defaultModel: model }); this.config.set('codex', { ...this.config.get('codex'), defaultModel: model });
break; break;
@@ -864,6 +909,7 @@ class ConfigManager {
openai: settings.apiKeys.openai ? '***REDACTED***' : undefined, openai: settings.apiKeys.openai ? '***REDACTED***' : undefined,
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined, xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined, gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
}, },
}; };
writeFileSync(filePath, JSON.stringify(safeSettings, null, 2)); writeFileSync(filePath, JSON.stringify(safeSettings, null, 2));
@@ -901,6 +947,10 @@ XAI_API_KEY=
# Get your key at: https://aistudio.google.com/apikey # Get your key at: https://aistudio.google.com/apikey
GEMINI_API_KEY= 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) # 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). # 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, # 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(); export const config = new ConfigManager();
// Helper functions for quick access // Helper functions for quick access
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.getApiKey(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', key: string) => config.setApiKey(provider, key); 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') => config.hasApiKey(provider); 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 getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model);
export const getConfiguredProviders = () => config.getConfiguredProviders(); export const getConfiguredProviders = () => config.getConfiguredProviders();
+11
View File
@@ -1209,6 +1209,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible
case 'gemini': case 'gemini':
return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai) 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': case 'codex':
return new CodexAdapter(config); return new CodexAdapter(config);
case 'mock': case 'mock':
@@ -1483,6 +1485,12 @@ export function createOpenAIBackbone(apiKey?: string, model?: string): LLMBackbo
return new LLMBackbone(llmConfig); 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 { export function createMockBackbone(): LLMBackbone {
return new LLMBackbone({ return new LLMBackbone({
provider: 'mock', provider: 'mock',
@@ -1521,6 +1529,9 @@ export function createBestAvailableBackbone(): LLMBackbone {
if (providers.includes('openai')) { if (providers.includes('openai')) {
return createOpenAIBackbone(); return createOpenAIBackbone();
} }
if (providers.includes('deepseek')) {
return createDeepSeekBackbone();
}
// Default to mock if no API keys configured // Default to mock if no API keys configured
console.warn('No API keys configured. Using mock provider.'); console.warn('No API keys configured. Using mock provider.');
+99 -10
View File
@@ -23,9 +23,10 @@ import {
import { DecompositionOrchestrator } from '../orchestration/index.js'; import { DecompositionOrchestrator } from '../orchestration/index.js';
import type { DecompositionConfig } from '../orchestration/index.js'; import type { DecompositionConfig } from '../orchestration/index.js';
import type { LLMProvider } from '../types/index.js'; import type { LLMProvider } from '../types/index.js';
import { realpathSync, statSync } from 'node:fs'; import { mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs';
import { homedir } from 'node:os'; import { execFileSync } from 'node:child_process';
import { isAbsolute, relative, resolve, sep } from 'node:path'; 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 * 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/<owner>/<repo> 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. * B-03 — path containment for the caller-supplied repoPath.
* *
@@ -114,15 +195,16 @@ export function resolveContainedRepoPath(input: unknown): string {
* suitable for handing to `TempestCommand.setWhiteboxSource` (or any consumer * suitable for handing to `TempestCommand.setWhiteboxSource` (or any consumer
* that wants the highest-priority code first). * 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). * @param tokenBudget Packing budget in tokens (default 24000).
*/ */
export function ingestRepoToSourceContext( export function ingestRepoToSourceContext(
repoPath: string, repoPath: string,
tokenBudget = 24000, tokenBudget = 24000,
): RepoSourceContext { ): RepoSourceContext {
const safePath = resolveContainedRepoPath(repoPath); const source = resolveRepoSourceForAnalysis(repoPath);
const result = ingestRepository(createPythonIngestConfig(safePath)); try {
const result = ingestRepository(createPythonIngestConfig(source.repoPath));
const packed = packAnalysisUnits(result.analysisUnits, tokenBudget); const packed = packAnalysisUnits(result.analysisUnits, tokenBudget);
return { return {
sourceContext: packed.text, sourceContext: packed.text,
@@ -130,11 +212,14 @@ export function ingestRepoToSourceContext(
includedUnits: packed.includedUnits.length, includedUnits: packed.includedUnits.length,
droppedUnits: packed.droppedUnits.length, droppedUnits: packed.droppedUnits.length,
}; };
} finally {
source.cleanup();
}
} }
/** Options for a full white-box decomposition analysis run. */ /** Options for a full white-box decomposition analysis run. */
export interface WhiteboxAnalysisOptions { 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; repoPath: string;
/** The offensive objective — only the orchestrator model sees this. */ /** The offensive objective — only the orchestrator model sees this. */
objective: string; objective: string;
@@ -199,10 +284,11 @@ export async function runWhiteboxAnalysis(
opts: WhiteboxAnalysisOptions, opts: WhiteboxAnalysisOptions,
): Promise<WhiteboxAnalysisResult> { ): Promise<WhiteboxAnalysisResult> {
const { repoPath, objective, maxRounds } = opts; const { repoPath, objective, maxRounds } = opts;
const safePath = resolveContainedRepoPath(repoPath); const source = resolveRepoSourceForAnalysis(repoPath);
// 1) ingest LOCAL source, security-rank the blocks // 1) ingest source, security-rank the blocks
const result = ingestRepository(createPythonIngestConfig(safePath)); try {
const result = ingestRepository(createPythonIngestConfig(source.repoPath));
// 2) pack into a generous source view (orchestrator re-packs per-worker itself) // 2) pack into a generous source view (orchestrator re-packs per-worker itself)
const packed = packAnalysisUnits(result.analysisUnits, DEFAULT_ANALYSIS_SOURCE_BUDGET); const packed = packAnalysisUnits(result.analysisUnits, DEFAULT_ANALYSIS_SOURCE_BUDGET);
@@ -236,4 +322,7 @@ export async function runWhiteboxAnalysis(
unitCount: result.analysisUnits.length, unitCount: result.analysisUnits.length,
decomposition, decomposition,
}; };
} finally {
source.cleanup();
}
} }
+17 -29
View File
@@ -30,7 +30,7 @@ import { OPERATOR_SYSTEM_PROMPTS, PLINIAN_OPERATOR_DOCTRINE, THE_FIXER_SYSTEM_PR
import { createTargetFromUrl, createTargetFromIP } from './target/index.js'; import { createTargetFromUrl, createTargetFromIP } from './target/index.js';
import type { OperatorArchetype } from './types/index.js'; import type { OperatorArchetype } from './types/index.js';
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/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 { redactCredential } from './evidence/index.js';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
@@ -6001,19 +6001,15 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
if (!guard.allowed) { blockForApproval(res, guard); return; } if (!guard.allowed) { blockForApproval(res, guard); return; }
} }
// B-03: if an OPTIONAL white-box repoPath was supplied, containment-check it HERE let repoSource: ReturnType<typeof resolveRepoSourceForAnalysis> | undefined;
// (canonicalize + confine to the allowed root) before we build/start the command,
// so a bad/escaping path 400s cleanly instead of half-starting a run.
let containedRepoPath: string | undefined;
if (typeof repoPath === 'string' && repoPath.trim()) { if (typeof repoPath === 'string' && repoPath.trim()) {
try { try {
containedRepoPath = resolveContainedRepoPath(repoPath); repoSource = resolveRepoSourceForAnalysis(repoPath);
} catch (e) { } catch (e) {
if (e instanceof RepoPathError) { res.status(400).json({ error: `repoPath rejected: ${e.message}` }); return; } 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 if (e instanceof RepoCloneError) { res.status(400).json({ error: e.message }); return; }
// handler becomes an unhandledRejection (Express 4 won't route it), hanging the client. console.error('[T3MP3ST] repo source validation error:', e);
console.error('[T3MP3ST] repoPath validation error:', e); res.status(500).json({ error: 'repo source validation failed' });
res.status(500).json({ error: 'repoPath validation failed' });
return; return;
} }
} }
@@ -6055,13 +6051,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
// exists on disk, ingest + security-rank it and feed the packed source into // exists on disk, ingest + security-rank it and feed the packed source into
// the command before it starts, so operators analyze real source you own // the command before it starts, so operators analyze real source you own
// rather than probing a black box. Reads LOCAL disk only — no network target. // rather than probing a black box. Reads LOCAL disk only — no network target.
let whitebox: { includedUnits: number; droppedUnits: number; stats: unknown } | undefined; let whitebox: { includedUnits: number; droppedUnits: number; stats: unknown; source: 'local' | 'github' } | undefined;
if (containedRepoPath) { if (repoSource) {
const wb = ingestRepoToSourceContext(containedRepoPath); const wb = ingestRepoToSourceContext(repoSource.repoPath);
// Only feed a NON-empty source (0 ingestable units → don't overwrite the operators' // Only feed a NON-empty source (0 ingestable units → don't overwrite the operators'
// black-box view with an empty blob; the includedUnits:0 in the response signals it). // black-box view with an empty blob; the includedUnits:0 in the response signals it).
if (wb.sourceContext.trim()) cmd.setWhiteboxSource(wb.sourceContext); if (wb.sourceContext.trim()) cmd.setWhiteboxSource(wb.sourceContext);
whitebox = { includedUnits: wb.includedUnits, droppedUnits: wb.droppedUnits, stats: wb.stats }; whitebox = { includedUnits: wb.includedUnits, droppedUnits: wb.droppedUnits, stats: wb.stats, source: repoSource.source };
} }
// Start the command loop (auto-creates mission, auto-dispatches tasks) // Start the command loop (auto-creates mission, auto-dispatches tasks)
@@ -6085,6 +6081,8 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
} catch (error: any) { } catch (error: any) {
console.error('[T3MP3ST] Mission start failed:', error); console.error('[T3MP3ST] Mission start failed:', error);
res.status(500).json({ error: error.message || 'Failed to start mission' }); res.status(500).json({ error: error.message || 'Failed to start mission' });
} finally {
repoSource?.cleanup();
} }
}); });
@@ -6108,29 +6106,19 @@ app.post('/api/whitebox/analyze', async (req: Request, res: Response): Promise<v
res.status(400).json({ error: 'objective required' }); res.status(400).json({ error: 'objective required' });
return; return;
} }
// B-03: containment-check the operator-supplied repoPath (canonicalize + confine
// to the allowed root) BEFORE any disk read; a bad/escaping path 400s, not 500s.
let safeRepoPath: string;
try {
safeRepoPath = resolveContainedRepoPath(repoPath);
} catch (e) {
if (e instanceof RepoPathError) { res.status(400).json({ error: 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' });
return;
}
try { try {
const result = await runWhiteboxAnalysis({ const result = await runWhiteboxAnalysis({
repoPath: safeRepoPath, repoPath: typeof repoPath === 'string' ? repoPath : '',
objective, objective,
maxRounds: typeof maxRounds === 'number' ? maxRounds : undefined, maxRounds: typeof maxRounds === 'number' ? maxRounds : undefined,
}); });
res.json(result); res.json(result);
} catch (error: any) { } catch (error: any) {
console.error('[T3MP3ST] White-box analysis failed:', error); console.error('[T3MP3ST] White-box analysis failed:', error);
if (error instanceof RepoPathError || error instanceof RepoCloneError) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: error?.message || 'White-box analysis failed' }); res.status(500).json({ error: error?.message || 'White-box analysis failed' });
} }
}); });
+60 -1
View File
@@ -194,6 +194,53 @@ async function setupAnthropicKey(): Promise<boolean> {
} }
} }
async function setupDeepSeekKey(): Promise<boolean> {
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<boolean> { async function setupOpenAIKey(): Promise<boolean> {
console.log(''); console.log('');
showInfo('OpenAI provides access to GPT models.'); 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('OpenRouter')} (recommended) - Access multiple models
• ${chalk.cyan('Anthropic')} - Direct Claude access • ${chalk.cyan('Anthropic')} - Direct Claude access
• ${chalk.cyan('OpenAI')} - GPT models • ${chalk.cyan('OpenAI')} - GPT models
• ${chalk.cyan('DeepSeek')} - OpenAI-compatible chat/reasoning models
The setup will guide you through: The setup will guide you through:
1. Adding API key(s), if you use a hosted provider 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 hasOpenRouter = hasApiKey('openrouter');
const hasAnthropic = hasApiKey('anthropic'); const hasAnthropic = hasApiKey('anthropic');
const hasOpenAI = hasApiKey('openai'); const hasOpenAI = hasApiKey('openai');
const hasDeepSeek = hasApiKey('deepseek');
if (hasOpenRouter || hasAnthropic || hasOpenAI) { if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek) {
console.log(''); console.log('');
showInfo('Existing API keys detected:'); showInfo('Existing API keys detected:');
if (hasOpenRouter) showSuccess(' OpenRouter: configured'); if (hasOpenRouter) showSuccess(' OpenRouter: configured');
if (hasAnthropic) showSuccess(' Anthropic: configured'); if (hasAnthropic) showSuccess(' Anthropic: configured');
if (hasOpenAI) showSuccess(' OpenAI: configured'); if (hasOpenAI) showSuccess(' OpenAI: configured');
if (hasDeepSeek) showSuccess(' DeepSeek: configured');
console.log(''); console.log('');
const { action } = await inquirer.prompt([ const { action } = await inquirer.prompt([
@@ -384,6 +434,10 @@ async function setupApiKeys(): Promise<void> {
name: `OpenAI ${hasApiKey('openai') ? chalk.green('(configured)') : ''}`, name: `OpenAI ${hasApiKey('openai') ? chalk.green('(configured)') : ''}`,
value: 'openai', value: 'openai',
}, },
{
name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`,
value: 'deepseek',
},
], ],
}, },
]); ]);
@@ -402,6 +456,9 @@ async function setupApiKeys(): Promise<void> {
case 'openai': case 'openai':
await setupOpenAIKey(); await setupOpenAIKey();
break; break;
case 'deepseek':
await setupDeepSeekKey();
break;
} }
} }
} }
@@ -414,6 +471,7 @@ async function setupProvider(): Promise<void> {
if (hasApiKey('venice')) configuredProviders.push({ name: 'Venice', value: 'venice' }); if (hasApiKey('venice')) configuredProviders.push({ name: 'Venice', value: 'venice' });
if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' }); if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' });
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' }); if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
const { provider } = await inquirer.prompt([ 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(' Venice: ' + (hasApiKey('venice') ? chalk.green('configured') : chalk.red('not set')));
console.log(' Anthropic: ' + (hasApiKey('anthropic') ? 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(' 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('');
console.log(chalk.cyan(' Config Path: ') + config.getConfigPath()); console.log(chalk.cyan(' Config Path: ') + config.getConfigPath());
console.log(''); console.log('');
+1 -1
View File
@@ -6,7 +6,7 @@
// LLM CONFIGURATION // 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 { export interface LLMConfig {
provider: LLMProvider; provider: LLMProvider;