fix(local): honor server LLM for keyless missions
This commit is contained in:
1 parent
600f43b8b1
commit
4b81bf6436
5 files changed
+91
-33
No files matched your search
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
|
||||
const uiSource = readFileSync(join(process.cwd(), 'docs/index.html'), 'utf8');
|
||||
|
||||
function sourceBlock(startMarker: string, endMarker: string): string {
|
||||
const start = serverSource.indexOf(startMarker);
|
||||
@@ -17,6 +18,34 @@ function routeBlock(startMarker: string, endMarker: string): string {
|
||||
}
|
||||
|
||||
describe('local API authorization hardening invariants', () => {
|
||||
it('server mission/general routes default to the configured LLM instead of hardcoded OpenRouter', () => {
|
||||
const resolver = routeBlock('function resolveGeneralLLMConfig(', '/**\n * Bring a planned mission UP for real');
|
||||
const missionRoute = routeBlock("app.post('/api/mission/start'", "app.get('/api/mission/report'");
|
||||
const generalRoutes = routeBlock("app.post('/api/general/plan'", '// =============================================================================\n// THE ADMIRAL');
|
||||
|
||||
expect(resolver).toContain('config.getLLMConfig()');
|
||||
expect(resolver).toContain('provider || defaultConfig.provider');
|
||||
expect(missionRoute).toContain('resolveGeneralLLMConfig(provider, model, apiKey)');
|
||||
expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/provider\s*=\s*['"]openrouter['"]/);
|
||||
expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/model\s*=\s*['"]anthropic\/claude-sonnet-4['"]/);
|
||||
});
|
||||
|
||||
it('health and LLM status count keyless local backends as configured', () => {
|
||||
const health = routeBlock('function healthPayload()', '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS');
|
||||
const llmStatus = routeBlock("app.get('/api/llm/status'", '// =============================================================================\n// TOOL EXECUTION ENDPOINTS');
|
||||
|
||||
expect(health).toContain('providerRunsKeyless(llmConfig.provider)');
|
||||
expect(llmStatus).toContain('providerRunsKeyless(llmConfig.provider)');
|
||||
});
|
||||
|
||||
it('War Room no-key/no-agent backend dispatch defers to the server-configured LLM', () => {
|
||||
expect(uiSource).toContain("serverLLM: null");
|
||||
expect(uiSource).toContain('this.serverLLM = data.llm');
|
||||
expect(uiSource).toContain('No browser API key — using the server-configured LLM backend');
|
||||
expect(uiSource).toContain('LLM backend: server default — no browser API key sent');
|
||||
expect(uiSource).not.toMatch(/let provider = ['"]openrouter['"];\s*\n\s*let model = state\.settings\.selectedModel/);
|
||||
});
|
||||
|
||||
it('/api/events does not grant wildcard CORS and rejects foreign browser origins before opening SSE', () => {
|
||||
const route = routeBlock("app.get('/api/events'", '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS');
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ describe('local OpenAI-compatible provider hardening', () => {
|
||||
expect(localCase).toMatch(/ZHIPUAI_API_KEY/);
|
||||
});
|
||||
|
||||
it('getLLMConfig treats UI local placeholders as unset model names', () => {
|
||||
const localCase = block(configSource, "case 'local':", 'break;');
|
||||
|
||||
expect(localCase).toContain('local-model');
|
||||
expect(localCase).toContain('local/ollama');
|
||||
expect(localCase).toContain('TEMPEST_LOCAL_MODEL');
|
||||
});
|
||||
|
||||
it('getApiKey resolves a local key from env without persisting it', () => {
|
||||
// The local key is read in-memory from env, matching the env-key hardening
|
||||
// invariant (env keys are never written to the store).
|
||||
|
||||
+4
-3
@@ -726,9 +726,10 @@ class ConfigManager {
|
||||
// Some OpenAI-compatible servers require a real bearer (Zhipu, Together, etc.) —
|
||||
// TEMPEST_LOCAL_API_KEY (or a provider-specific env like ZAI_API_KEY) provides it.
|
||||
baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api';
|
||||
// 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so
|
||||
// TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority.
|
||||
actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
|
||||
// Placeholder ids from AVAILABLE_MODELS / the static UI are not real served model tags.
|
||||
// Treat them as unset so TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag
|
||||
// passed in still takes priority.
|
||||
actualModel = (model && !['local-model', 'local/ollama'].includes(model) ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
|
||||
apiKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim();
|
||||
break;
|
||||
default:
|
||||
|
||||
+29
-22
@@ -4322,7 +4322,7 @@ function healthPayload(): Record<string, unknown> {
|
||||
version: '0.2.1',
|
||||
apiVersion: 'v1',
|
||||
llm: {
|
||||
configured: Boolean(llmConfig.apiKey) || llmConfig.provider === 'codex',
|
||||
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
|
||||
connected: Boolean(llm) || llmConfig.provider === 'codex',
|
||||
provider: llmConfig.provider,
|
||||
model: llmConfig.model,
|
||||
@@ -5953,7 +5953,8 @@ app.get('/api/llm/status', (_req: Request, res: Response) => {
|
||||
connected: !!llm,
|
||||
provider: llmConfig.provider,
|
||||
model: llmConfig.model,
|
||||
hasApiKey: !!llmConfig.apiKey
|
||||
hasApiKey: !!llmConfig.apiKey,
|
||||
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6045,8 +6046,8 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
targets = [],
|
||||
operators = [],
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
// OPTIONAL white-box source: an absolute path to a LOCAL repo you own. When
|
||||
// present, we ingest + security-rank it and hand the packed source to the
|
||||
// command via setWhiteboxSource BEFORE start(), so operators reason over the
|
||||
@@ -6054,18 +6055,19 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
repoPath,
|
||||
} = req.body;
|
||||
|
||||
// Use provided apiKey or fall back to server-configured one. Local-agent backends
|
||||
// (Claude Code / Codex / Hermes) need NO key — the agent uses its own login.
|
||||
// Use the request-selected backend, or fall back to the server's configured default.
|
||||
// Local/local-agent backends need no hosted API key.
|
||||
// SECURITY NOTE: apiKey is read from the request body (Authorization header is
|
||||
// preferred). Kept body-accepted for the same-origin UI; only reachable from
|
||||
// the local operator (loopback bind + origin guard). Header move is out of scope.
|
||||
const effectiveKey = apiKey || config.getLLMConfig().apiKey;
|
||||
if (providerNeedsApiKey(provider) && !effectiveKey) {
|
||||
const missionLLMConfig = resolveGeneralLLMConfig(provider, model, apiKey);
|
||||
const effectiveKey = missionLLMConfig.apiKey;
|
||||
if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) {
|
||||
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
|
||||
return;
|
||||
}
|
||||
if (provider === 'local-agent') {
|
||||
const localAgent = await requireLiveLocalAgent(model);
|
||||
if (missionLLMConfig.provider === 'local-agent') {
|
||||
const localAgent = await requireLiveLocalAgent(missionLLMConfig.model);
|
||||
if (!localAgent.ok) {
|
||||
res.status(503).json({ error: localAgent.error });
|
||||
return;
|
||||
@@ -6101,7 +6103,7 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
|
||||
}
|
||||
|
||||
try {
|
||||
const cmd = createTempestCommandInstance(name, effectiveKey, provider, model);
|
||||
const cmd = createTempestCommandInstance(name, effectiveKey, missionLLMConfig.provider, missionLLMConfig.model);
|
||||
|
||||
// Add targets
|
||||
for (const t of targets) {
|
||||
@@ -6560,6 +6562,10 @@ function providerNeedsApiKey(provider: string): boolean {
|
||||
return !['codex', 'mock', 'local', 'local-agent'].includes(provider);
|
||||
}
|
||||
|
||||
function providerRunsKeyless(provider: string): boolean {
|
||||
return !providerNeedsApiKey(provider);
|
||||
}
|
||||
|
||||
function readPositiveTimeoutEnv(name: string): number | undefined {
|
||||
const raw = process.env[name];
|
||||
if (raw == null || raw.trim() === '') return undefined;
|
||||
@@ -6578,7 +6584,7 @@ function readGeneralTimeoutEnv(): number | undefined {
|
||||
// the key in the body, so we accept it to avoid breaking it. Moving to a header
|
||||
// needs a coordinated UI change and is out of scope. The body key is only ever
|
||||
// reachable from the local operator (loopback bind + origin guard).
|
||||
function resolveGeneralLLMConfig(provider: string, model: string | undefined, apiKey: string | undefined): {
|
||||
function resolveGeneralLLMConfig(provider: string | undefined, model: string | undefined, apiKey: string | undefined): {
|
||||
provider: any;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
@@ -6586,7 +6592,8 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
|
||||
temperature: number;
|
||||
timeout: number;
|
||||
} {
|
||||
const selectedProvider = provider || 'openrouter';
|
||||
const defaultConfig = config.getLLMConfig();
|
||||
const selectedProvider = provider || defaultConfig.provider;
|
||||
// Local-agent backends (Claude Code / Codex / Hermes via the connector) need NO API key — the
|
||||
// agent uses its own login. The agent id (codex|claude|hermes) travels in `model`.
|
||||
if (selectedProvider === 'local-agent') {
|
||||
@@ -6779,8 +6786,8 @@ app.post('/api/general/plan', async (req: Request, res: Response): Promise<void>
|
||||
urgency,
|
||||
opsecPreference,
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
} = req.body;
|
||||
|
||||
if (!objective) {
|
||||
@@ -6853,8 +6860,8 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise<vo
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
} = req.body;
|
||||
|
||||
let generalConfig;
|
||||
@@ -6966,8 +6973,8 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
|
||||
urgency,
|
||||
opsecPreference,
|
||||
apiKey,
|
||||
provider = 'openrouter',
|
||||
model = 'anthropic/claude-sonnet-4',
|
||||
provider,
|
||||
model,
|
||||
} = req.body;
|
||||
|
||||
if (!objective) {
|
||||
@@ -7225,7 +7232,7 @@ import { Admiral, briefToDirective, type ChatMsg, type MissionBrief } from './ad
|
||||
*/
|
||||
app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { messages, provider = 'openrouter', model, apiKey } = req.body as {
|
||||
const { messages, provider, model, apiKey } = req.body as {
|
||||
messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string;
|
||||
};
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
@@ -7255,7 +7262,7 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<v
|
||||
*/
|
||||
app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { operatorPrompt, archetype, failureSignal, provider = 'openrouter', model, apiKey } = req.body as {
|
||||
const { operatorPrompt, archetype, failureSignal, provider, model, apiKey } = req.body as {
|
||||
operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string;
|
||||
};
|
||||
let prompt = typeof operatorPrompt === 'string' ? operatorPrompt : '';
|
||||
@@ -7290,7 +7297,7 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<vo
|
||||
*/
|
||||
app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { brief, confirmed, provider = 'openrouter', model, apiKey } = req.body as {
|
||||
const { brief, confirmed, provider, model, apiKey } = req.body as {
|
||||
brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string;
|
||||
};
|
||||
if (!brief || !brief.objective || !brief.target) {
|
||||
|
||||
Reference in new issue
Block a user