feat: multi-provider model support, SARIF output, and exploit-mode fixes (#402)

* feat(worker): record token, cache, and turn usage per agent

* feat: replace model tiers with a single SHANNON_AI_MODEL across five providers

* feat(cli): rebuild the setup wizard for provider and model selection

* docs: document single-model selection and supported providers

* feat(worker): use chat completions for OpenAI behind a custom base URL

* feat: add SHANNON_AI_OPENAI_FORMAT to pick the wire API for OpenAI gateways

* refactor(cli): drop endpoint path hints from the gateway format picker

* feat(worker): enable pi in-session provider retry with retry-after backoff

* refactor(worker): hand provider error classification to pi and drop the Anthropic ladders

* refactor: remove the subscription retry preset and pipeline config section

* fix(worker): validate Bedrock credentials with the same live probe as other providers

* feat(worker): render the report from structured findings instead of agent-written markdown

* fix(worker): dispose the credential probe session on every path

* fix(worker): refuse to replace the assembled report with an empty one

* refactor(worker): catch post-processing throws across the whole finalization block

* revert(worker): drop the report zero-findings guard

* docs(worker): correct the retry split and Bedrock credential claims

* docs: regenerate llms-full.txt from current sources

* feat(cli): build and run the npx flow from a clone

* refactor(cli): flatten the setup summary output

* feat(cli): reject runs with more than one provider configured

* fix(worker): say a rejected bash call never ran

* chore(cli): drop grok-4.3 and gpt-5.6-luna from the setup suggestions

* feat(worker): capture structured finding locations for SARIF output

* fix(worker): enumerate queue confidence so the report inherits it verbatim

* feat(worker): give the reporting phase a mode-specific output schema

* feat(worker): emit a SARIF 2.1.0 log for exploitative runs

* fix(worker): correct SARIF locations and defer fingerprinting to the upload action

* fix(worker): drop the confidence suffix from the analysis-mode summary list

* feat(worker): give exploit findings a dedicated code location field

* feat(worker): carry structured code locations from the vuln queue to the report

* fix(worker): join code locations from the vuln queue instead of re-asking agents

* fix(worker): spell out the finding_id to category mapping in the tool schema

* feat: drop Google/Gemini as a supported AI provider

* fix(worker): stop asking the report agent for code locations

* docs: correct the provider list and drop the removed rate-limit settings

* docs: add provider cyber safeguards and suggested models per provider

* docs: document the SARIF output and the report rating thresholds
This commit is contained in:
ezl-keygraph
2026-07-30 19:31:52 +05:30
committed by GitHub
parent 30a12114ae
commit 1ce250d6a5
69 changed files with 3225 additions and 1471 deletions
+6 -9
View File
@@ -1,19 +1,16 @@
/**
* `shannon build` command — build the worker Docker image locally.
* Only available in local mode (running from cloned repository).
* `shannon build` command — build the worker Docker image from the repository.
* Requires a clone (Dockerfile in the working directory).
*/
import { buildImage } from '../docker.js';
import { isLocal } from '../mode.js';
import { buildImage, canBuildImage } from '../docker.js';
export function build(noCache: boolean): void {
if (!isLocal()) {
export function build(noCache: boolean, version: string): void {
if (!canBuildImage()) {
console.error('ERROR: Build is only available when running from the Shannon repository');
console.error(' (Dockerfile not found in current directory)');
console.error('');
console.error('For npx usage, run: shannon update');
process.exit(1);
}
buildImage(noCache);
buildImage(noCache, version);
}
+182 -155
View File
@@ -1,56 +1,110 @@
/**
* `npx @keygraph/shannon setup` — interactive TUI wizard for one-time credential configuration.
*
* Walks the user through selecting a provider and entering credentials,
* then persists everything to ~/.shannon/config.toml with 0o600 permissions.
* Walks the user through selecting a provider, entering credentials, and naming
* the model that runs the whole scan, then persists everything to
* ~/.shannon/config.toml with 0o600 permissions.
*/
import os from 'node:os';
import path from 'node:path';
import * as p from '@clack/prompts';
import { type ShannonConfig, saveConfig } from '../config/writer.js';
import { type OpenAiFormat, type ProviderId, SUPPORTED_PROVIDERS } from '../model-spec.js';
import { requireInteractive } from '../tty.js';
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
type Provider = 'anthropic' | 'custom_base_url' | 'bedrock';
const CUSTOM_MODEL = '__custom__';
const CUSTOM_BASE_URL = '__custom_base_url__';
/**
* Wire formats reachable through the gateway route. The format picks the provider
* that supplies the credential, and for OpenAI it also picks which of the two
* OpenAI APIs Shannon calls.
*/
const GATEWAY_DIALECTS: readonly {
value: string;
label: string;
provider: 'anthropic' | 'openai';
format?: OpenAiFormat;
}[] = [
{ value: 'anthropic', label: 'Anthropic Messages', provider: 'anthropic' },
{
value: 'openai-chat-completions',
label: 'OpenAI Chat Completions',
provider: 'openai',
format: 'chat-completions',
},
{ value: 'openai-responses', label: 'OpenAI Responses', provider: 'openai', format: 'responses' },
];
/** Suggested models per provider, best-first. Free-text entry accepts any model in the provider's catalogue. */
const MODEL_SUGGESTIONS: Readonly<Record<ProviderId, readonly string[]>> = {
anthropic: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-haiku-4-5-20251001'],
openai: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4'],
xai: ['grok-4.5'],
'amazon-bedrock': ['us.anthropic.claude-sonnet-4-6', 'us.anthropic.claude-opus-4-8', 'us.anthropic.claude-opus-4-7'],
};
/** Placeholder shown in the free-text model ID prompt. */
const MODEL_ID_PLACEHOLDER: Readonly<Record<ProviderId, string>> = {
anthropic: 'claude-sonnet-4-6',
openai: 'gpt-5.6-sol',
xai: 'grok-4.5',
'amazon-bedrock': 'us.anthropic.claude-opus-4-8',
};
export async function setup(): Promise<void> {
requireInteractive('setup', 'For non-interactive use, export credentials as env vars (e.g. ANTHROPIC_API_KEY).');
p.intro('Shannon Setup');
// 1. Select provider
const provider = await p.select({
// 1. Select provider. "Custom Base URL" is a route, not a provider — it asks
// which API dialect the gateway speaks and configures that provider.
const selected = await p.select({
message: 'Select your AI provider',
options: [
{ value: 'anthropic' as const, label: 'Claude Direct', hint: 'recommended' },
{ value: 'custom_base_url' as const, label: 'Custom Base URL', hint: 'proxies, gateways' },
{ value: 'bedrock' as const, label: 'Claude via AWS Bedrock' },
{ value: 'anthropic' as const, label: 'Anthropic', hint: 'Claude models - recommended' },
{ value: 'openai' as const, label: 'OpenAI', hint: 'GPT models' },
{ value: 'xai' as const, label: 'xAI', hint: 'Grok models' },
{ value: 'amazon-bedrock' as const, label: 'AWS Bedrock', hint: 'Claude models via AWS' },
{ value: CUSTOM_BASE_URL as typeof CUSTOM_BASE_URL, label: 'Custom Base URL', hint: 'your own proxy or gateway' },
],
});
if (p.isCancel(provider)) return cancelAndExit();
if (p.isCancel(selected)) return cancelAndExit();
const config = await setupProvider(provider as Provider);
// 2. Credentials — and, on the gateway route, the endpoint and its dialect.
const gateway = selected === CUSTOM_BASE_URL ? await setupGateway() : undefined;
const provider = gateway?.provider ?? (selected as ProviderId);
const config = gateway?.config ?? (await setupProvider(provider));
// 2. Adaptive thinking
await maybePromptAdaptiveThinking(config);
// 3. The model that runs every phase.
const modelId = await promptModel(provider);
config.core = { ...config.core, model: `${provider}:${modelId}` };
if (gateway) config.core = { ...config.core, base_url: gateway.baseUrl };
// 3. Save config
saveConfig(config);
const configPath = path.join(SHANNON_HOME, 'config.toml');
const summary = [`Provider ${provider}`, `Model ${modelId}`];
if (gateway) summary.push(`Endpoint ${gateway.baseUrl}`);
if (gateway?.format) summary.push(`API ${gateway.format}`);
p.log.success(`Configuration saved to ${configPath}`);
p.log.info(summary.join('\n'));
p.outro('Run `npx @keygraph/shannon start` to begin a scan.');
}
async function setupProvider(provider: Provider): Promise<ShannonConfig> {
async function setupProvider(provider: ProviderId): Promise<ShannonConfig> {
switch (provider) {
case 'amazon-bedrock':
return setupBedrock();
case 'anthropic':
return setupAnthropic();
case 'custom_base_url':
return setupCustomBaseUrl();
case 'bedrock':
return setupBedrock();
case 'openai':
return { openai: { api_key: await promptSecret('Enter your OpenAI API key') } };
case 'xai':
return { xai: { api_key: await promptSecret('Enter your xAI API key') } };
}
}
@@ -66,112 +120,13 @@ async function setupAnthropic(): Promise<ShannonConfig> {
});
if (p.isCancel(authMethod)) return cancelAndExit();
const config: ShannonConfig = {};
if (authMethod === 'oauth') {
const token = await promptSecret('Enter your OAuth token');
config.anthropic = { oauth_token: token };
} else {
const apiKey = await promptSecret('Enter your Anthropic API key');
config.anthropic = { api_key: apiKey };
return { anthropic: { oauth_token: token } };
}
const customizeModels = await p.confirm({
message:
'Do you want to change the default models?\n' +
' Small - claude-haiku-4-5-20251001\n' +
' Medium - claude-sonnet-4-6\n' +
' Large - claude-opus-4-8',
initialValue: false,
});
if (p.isCancel(customizeModels)) return cancelAndExit();
if (customizeModels) {
const small = await p.text({
message: 'Small model ID',
initialValue: 'claude-haiku-4-5-20251001',
validate: required('Small model ID is required'),
});
if (p.isCancel(small)) return cancelAndExit();
const medium = await p.text({
message: 'Medium model ID',
initialValue: 'claude-sonnet-4-6',
validate: required('Medium model ID is required'),
});
if (p.isCancel(medium)) return cancelAndExit();
const large = await p.text({
message: 'Large model ID',
initialValue: 'claude-opus-4-8',
validate: required('Large model ID is required'),
});
if (p.isCancel(large)) return cancelAndExit();
config.models = { small, medium, large };
}
return config;
}
async function setupCustomBaseUrl(): Promise<ShannonConfig> {
const baseUrl = await p.text({
message: 'Endpoint URL',
placeholder: 'https://your-proxy.example.com',
validate: (value) => {
if (!value) return 'Endpoint URL is required';
try {
new URL(value);
} catch {
return 'Must be a valid URL';
}
return undefined;
},
});
if (p.isCancel(baseUrl)) return cancelAndExit();
const authToken = await promptSecret('Enter the auth token for the custom endpoint');
const config: ShannonConfig = {
custom_base_url: { base_url: baseUrl, auth_token: authToken },
};
const customizeModels = await p.confirm({
message:
'Do you want to change the default models?\n' +
' Small - claude-haiku-4-5-20251001\n' +
' Medium - claude-sonnet-4-6\n' +
' Large - claude-opus-4-8',
initialValue: false,
});
if (p.isCancel(customizeModels)) return cancelAndExit();
if (customizeModels) {
const small = await p.text({
message: 'Small model ID',
initialValue: 'claude-haiku-4-5-20251001',
validate: required('Small model ID is required'),
});
if (p.isCancel(small)) return cancelAndExit();
const medium = await p.text({
message: 'Medium model ID',
initialValue: 'claude-sonnet-4-6',
validate: required('Medium model ID is required'),
});
if (p.isCancel(medium)) return cancelAndExit();
const large = await p.text({
message: 'Large model ID',
initialValue: 'claude-opus-4-8',
validate: required('Large model ID is required'),
});
if (p.isCancel(large)) return cancelAndExit();
config.models = { small, medium, large };
}
return config;
const apiKey = await promptSecret('Enter your Anthropic API key');
return { anthropic: { api_key: apiKey } };
}
async function setupBedrock(): Promise<ShannonConfig> {
@@ -184,49 +139,121 @@ async function setupBedrock(): Promise<ShannonConfig> {
const token = await promptSecret('Enter your AWS Bearer Token');
const small = await p.text({
message: 'Small model ID',
placeholder: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
validate: required('Small model ID is required'),
});
if (p.isCancel(small)) return cancelAndExit();
return { bedrock: { region, token } };
}
const medium = await p.text({
message: 'Medium model ID',
placeholder: 'us.anthropic.claude-sonnet-4-6',
validate: required('Medium model ID is required'),
});
if (p.isCancel(medium)) return cancelAndExit();
interface GatewaySetup {
provider: ProviderId;
config: ShannonConfig;
baseUrl: string;
format?: OpenAiFormat;
}
const large = await p.text({
message: 'Large model ID',
placeholder: 'us.anthropic.claude-opus-4-8',
validate: required('Large model ID is required'),
/**
* Gateway route: the endpoint decides where requests go, but the format still
* picks a real provider, because that is what supplies the credential and the
* wire protocol.
*/
async function setupGateway(): Promise<GatewaySetup> {
const choice = await p.select({
message: 'API format',
options: GATEWAY_DIALECTS.map(({ value, label }) => ({ value, label })),
});
if (p.isCancel(large)) return cancelAndExit();
if (p.isCancel(choice)) return cancelAndExit();
return {
bedrock: { use: true, region, token },
models: { small, medium, large },
};
const dialect = GATEWAY_DIALECTS.find((entry) => entry.value === choice);
if (!dialect) return cancelAndExit();
const provider = dialect.provider;
const baseUrl = await p.text({
message: 'Endpoint URL',
placeholder: 'https://llm-gateway.example.com',
validate: (value) => {
if (!value) return 'Endpoint URL is required';
try {
new URL(value);
} catch {
return 'Must be a valid URL';
}
return undefined;
},
});
if (p.isCancel(baseUrl)) return cancelAndExit();
const authToken = await promptSecret('Enter the auth token for the endpoint');
const config: ShannonConfig =
provider === 'anthropic'
? { anthropic: { api_key: authToken } }
: { openai: { api_key: authToken, ...(dialect.format && { format: dialect.format }) } };
return { provider, config, baseUrl, ...(dialect.format && { format: dialect.format }) };
}
// === Model Selection ===
/**
* Ask for the one model that runs every phase. Providers with suggestions offer a
* pick list with a free-text escape hatch; the rest go straight to free text.
*/
async function promptModel(provider: ProviderId): Promise<string> {
const suggestions = MODEL_SUGGESTIONS[provider];
if (suggestions.length === 0) {
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
}
const choice = await p.select({
message: 'Model',
options: [
...suggestions.map((model) => ({ value: model, label: model })),
{ value: CUSTOM_MODEL, label: 'Enter a model ID…' },
],
});
if (p.isCancel(choice)) return cancelAndExit();
if (choice === CUSTOM_MODEL) {
return promptModelId(provider, MODEL_ID_PLACEHOLDER[provider]);
}
return choice as string;
}
/**
* A leading `<provider>:` naming a supported provider other than the selected
* one. Bedrock model IDs carry their own colons (`…-v1:0`), so only a genuine
* provider id counts as a prefix.
*/
function conflictingProviderPrefix(provider: ProviderId, value: string): string | undefined {
const separator = value.indexOf(':');
if (separator === -1) return undefined;
const head = value.slice(0, separator);
if (head === provider) return undefined;
return (SUPPORTED_PROVIDERS as readonly string[]).includes(head) ? head : undefined;
}
/**
* Ask for a model ID. The provider is already chosen, so this takes the bare ID
* and the caller pairs it with the provider — pasting a full `<provider>:<model>`
* spec just has its redundant prefix dropped.
*/
async function promptModelId(provider: ProviderId, placeholder: string): Promise<string> {
const modelId = await p.text({
message: 'Model ID',
placeholder,
validate: (value) => {
if (!value) return 'Model ID is required';
const conflicting = conflictingProviderPrefix(provider, value);
if (conflicting) return `That model ID is for ${conflicting}, but you selected ${provider}.`;
return undefined;
},
});
if (p.isCancel(modelId)) return cancelAndExit();
return modelId.startsWith(`${provider}:`) ? modelId.slice(provider.length + 1) : modelId;
}
// === Helpers ===
async function maybePromptAdaptiveThinking(config: ShannonConfig): Promise<void> {
const m = config.models;
const hasAdaptiveModel = !m || [m.small, m.medium, m.large].some((v) => v && /opus-4-[678]/.test(v));
if (!hasAdaptiveModel) return;
const enable = await p.confirm({
message: 'Enable adaptive thinking on Opus 4.6/4.7/4.8? Claude decides when and how deeply to reason.',
initialValue: true,
});
if (p.isCancel(enable)) return cancelAndExit();
config.core = { ...config.core, adaptive_thinking: enable };
}
async function promptSecret(message: string): Promise<string> {
const value = await p.password({
message,
+4 -13
View File
@@ -12,6 +12,7 @@ import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.j
import { buildEnvFlags, loadEnv, validateCredentials } from '../env.js';
import { getWorkspacesDir, initHome } from '../home.js';
import { isLocal } from '../mode.js';
import { resolveModelSpec } from '../model-spec.js';
import { FINAL_REPORT_FILENAME, INTERNAL_DIR, resolveConfig, resolveRepo, resolveRunFile } from '../paths.js';
import { displaySplash } from '../splash.js';
import { stdoutIsTerminal } from '../tty.js';
@@ -261,19 +262,9 @@ function printInfo(
console.log(' Mode: Pipeline Testing');
}
// Surface Fable usage: its safety classifiers route cybersecurity tasks to
// Opus 4.8, so those phases run on Opus 4.8 regardless of the tier setting.
const fableTiers = (
[
['small', process.env.ANTHROPIC_SMALL_MODEL],
['medium', process.env.ANTHROPIC_MEDIUM_MODEL],
['large', process.env.ANTHROPIC_LARGE_MODEL],
] as const
).filter(([, model]) => model && /fable/i.test(model));
if (fableTiers.length > 0) {
const tierList = fableTiers.map(([tier, model]) => `${tier} (${model})`).join(', ');
console.log(` Note: ${tierList} set to a Fable model. Fable's safety classifiers`);
console.log(' route cybersecurity tasks to Opus 4.8, so those phases run on Opus 4.8.');
const spec = resolveModelSpec();
if (typeof spec !== 'string') {
console.log(` Model: ${spec.providerId}:${spec.modelId}`);
}
console.log('');
+52 -68
View File
@@ -9,6 +9,7 @@ import fs from 'node:fs';
import { parse as parseTOML } from 'smol-toml';
import { getConfigFile } from '../home.js';
import { getMode } from '../mode.js';
import { DEFAULT_MODEL_SPEC, type ProviderId, parseModelSpec } from '../model-spec.js';
// === TOML ↔ Env Mapping ===
@@ -23,28 +24,34 @@ interface ConfigMapping {
/** Maps every supported env var to its TOML path (section.key) and expected type. */
const CONFIG_MAP: readonly ConfigMapping[] = [
// Core
{ env: 'CLAUDE_ADAPTIVE_THINKING', toml: 'core.adaptive_thinking', type: 'boolean', boolFormat: 'literal' },
// Core — base_url points any provider at a proxy or gateway
{ env: 'SHANNON_AI_MODEL', toml: 'core.model', type: 'string' },
{ env: 'SHANNON_AI_BASE_URL', toml: 'core.base_url', type: 'string' },
// Anthropic
{ env: 'ANTHROPIC_API_KEY', toml: 'anthropic.api_key', type: 'string' },
{ env: 'CLAUDE_CODE_OAUTH_TOKEN', toml: 'anthropic.oauth_token', type: 'string' },
// OpenAI — format picks the wire API a gateway serves
{ env: 'OPENAI_API_KEY', toml: 'openai.api_key', type: 'string' },
{ env: 'SHANNON_AI_OPENAI_FORMAT', toml: 'openai.format', type: 'string' },
// xAI
{ env: 'XAI_API_KEY', toml: 'xai.api_key', type: 'string' },
// Bedrock
{ env: 'CLAUDE_CODE_USE_BEDROCK', toml: 'bedrock.use', type: 'boolean' },
{ env: 'AWS_REGION', toml: 'bedrock.region', type: 'string' },
{ env: 'AWS_BEARER_TOKEN_BEDROCK', toml: 'bedrock.token', type: 'string' },
// Custom Base URL
{ env: 'ANTHROPIC_BASE_URL', toml: 'custom_base_url.base_url', type: 'string' },
{ env: 'ANTHROPIC_AUTH_TOKEN', toml: 'custom_base_url.auth_token', type: 'string' },
// Model tiers
{ env: 'ANTHROPIC_SMALL_MODEL', toml: 'models.small', type: 'string' },
{ env: 'ANTHROPIC_MEDIUM_MODEL', toml: 'models.medium', type: 'string' },
{ env: 'ANTHROPIC_LARGE_MODEL', toml: 'models.large', type: 'string' },
] as const;
/** TOML section holding each provider's credentials, keyed by provider id. */
const PROVIDER_SECTIONS: Readonly<Record<ProviderId, string>> = {
anthropic: 'anthropic',
openai: 'openai',
xai: 'xai',
'amazon-bedrock': 'bedrock',
};
// === TOML Parsing ===
type TOMLValue = string | number | boolean;
@@ -118,52 +125,33 @@ function buildSchema(): Map<string, Map<string, TOMLType>> {
return schema;
}
/** Check that a provider section has all required fields and dependencies. */
function validateProviderFields(config: TOMLConfig, provider: string, errors: string[]): void {
const section = config[provider] as Record<string, unknown> | undefined;
if (!section) return;
const keys = Object.keys(section);
/**
* Check that the section backing the selected provider carries a usable
* credential. `core.model` names the provider, so only that section is required;
* other providers' sections are ignored and never forwarded.
*/
function validateProviderFields(config: TOMLConfig, providerId: ProviderId, errors: string[]): void {
const sectionName = PROVIDER_SECTIONS[providerId];
const section = config[sectionName] as Record<string, unknown> | undefined;
const keys = section ? Object.keys(section) : [];
switch (provider) {
case 'anthropic':
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
errors.push('[anthropic] requires either api_key or oauth_token');
}
break;
case 'custom_base_url': {
const required = ['base_url', 'auth_token'];
const missing = required.filter((k) => !keys.includes(k));
if (missing.length > 0) {
errors.push(`[custom_base_url] missing required keys: ${missing.join(', ')}`);
}
break;
if (providerId === 'amazon-bedrock') {
const missing = ['region', 'token'].filter((k) => !keys.includes(k));
if (missing.length > 0) {
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
}
case 'bedrock': {
const required = ['use', 'region', 'token'];
const missing = required.filter((k) => !keys.includes(k));
if (missing.length > 0) {
errors.push(`[bedrock] missing required keys: ${missing.join(', ')}`);
}
validateModelTiers(config, 'bedrock', errors);
break;
}
}
}
/** Bedrock requires a [models] section with all three tiers. */
function validateModelTiers(config: TOMLConfig, provider: string, errors: string[]): void {
const models = config.models as Record<string, unknown> | undefined;
if (!models || typeof models !== 'object') {
errors.push(`[${provider}] requires a [models] section with small, medium, and large`);
return;
}
const required = ['small', 'medium', 'large'];
const missing = required.filter((k) => !Object.keys(models).includes(k));
if (missing.length > 0) {
errors.push(`[models] missing required keys for ${provider}: ${missing.join(', ')}`);
if (providerId === 'anthropic') {
if (!keys.includes('api_key') && !keys.includes('oauth_token')) {
errors.push('[anthropic] requires either api_key or oauth_token');
}
return;
}
if (!keys.includes('api_key')) {
errors.push(`[${sectionName}] requires api_key`);
}
}
@@ -211,23 +199,19 @@ function validateConfig(config: TOMLConfig): string[] {
}
}
// 4. Only one provider section allowed (ignore empty sections)
const PROVIDER_SECTIONS = ['anthropic', 'custom_base_url', 'bedrock'] as const;
const present = PROVIDER_SECTIONS.filter((s) => {
const section = config[s];
return section && typeof section === 'object' && Object.keys(section).length > 0;
});
if (present.length > 1) {
errors.push(
`Multiple providers configured: [${present.join('], [')}]. Only one provider section is allowed at a time`,
);
// 4. core.model must parse and name a supported provider
const modelValue = config.core?.model;
if (modelValue !== undefined && typeof modelValue !== 'string') {
return errors;
}
const spec = parseModelSpec(modelValue || DEFAULT_MODEL_SPEC);
if (typeof spec === 'string') {
errors.push(`[core].model — ${spec}`);
return errors;
}
// 5. Required fields per provider
const singleProvider = present.length === 1 ? present[0] : undefined;
if (singleProvider) {
validateProviderFields(config, singleProvider, errors);
}
// 5. The selected provider's section must carry a credential
validateProviderFields(config, spec.providerId, errors);
return errors;
}
+4 -4
View File
@@ -8,11 +8,11 @@ import { getConfigFile } from '../home.js';
// === Types ===
export interface ShannonConfig {
core?: { adaptive_thinking?: boolean };
core?: { model?: string; base_url?: string };
anthropic?: { api_key?: string; oauth_token?: string };
custom_base_url?: { base_url?: string; auth_token?: string };
bedrock?: { use?: boolean; region?: string; token?: string };
models?: { small?: string; medium?: string; large?: string };
openai?: { api_key?: string; format?: string };
xai?: { api_key?: string };
bedrock?: { region?: string; token?: string };
}
// === File Operations ===
+22 -9
View File
@@ -12,7 +12,7 @@ import os from 'node:os';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { getMode } from './mode.js';
import { getMode, isDevMode } from './mode.js';
import { INTERNAL_DIR } from './paths.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -24,6 +24,17 @@ export function getWorkerImage(version: string): string {
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
}
/** True when the working directory supplies a Dockerfile and build context. */
export function canBuildImage(): boolean {
if (getMode() === 'local') return true;
if (!isDevMode()) return false;
const hasDockerfile = fs.existsSync(path.resolve('Dockerfile'));
const hasCompose = fs.existsSync(path.resolve('docker-compose.yml'));
return hasDockerfile && hasCompose;
}
function getComposeFile(): string {
return getMode() === 'local'
? path.resolve('docker-compose.yml')
@@ -96,29 +107,31 @@ export async function ensureInfra(): Promise<void> {
}
/**
* Build the worker image locally (local mode only).
* Build the worker image from the repository, tagged with the name this mode
* resolves at run time.
*/
export function buildImage(noCache: boolean): void {
console.log(`Building ${DEV_IMAGE}...`);
export function buildImage(noCache: boolean, version: string): void {
const image = getWorkerImage(version);
console.log(`Building ${image}...`);
const args = ['build'];
if (noCache) args.push('--no-cache');
args.push('-t', DEV_IMAGE, '.');
args.push('-t', image, '.');
execFileSync('docker', args, { stdio: 'inherit' });
console.log(`Build complete: ${DEV_IMAGE}`);
console.log(`Build complete: ${image}`);
}
/**
* Ensure the worker image is available.
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
* Buildable checkout: auto-builds if missing. Otherwise: pulls from Docker Hub.
*/
export function ensureImage(version: string): void {
const image = getWorkerImage(version);
const exists = runQuiet('docker', ['image', 'inspect', image]);
if (exists) return;
if (getMode() === 'local') {
if (canBuildImage()) {
console.log('Shannon image not found, building...');
buildImage(false);
buildImage(false, version);
} else {
console.log(`Pulling ${image}...`);
try {
+66 -71
View File
@@ -8,21 +8,28 @@
import dotenv from 'dotenv';
import { resolveConfig } from './config/resolver.js';
import { getMode } from './mode.js';
import {
PROVIDER_API_KEY_ENV,
PROVIDER_CREDENTIAL_HINT,
PROVIDER_EXTRA_ENV,
type ProviderId,
resolveModelSpec,
SUPPORTED_PROVIDERS,
} from './model-spec.js';
/** Environment variables forwarded to worker containers. */
const FORWARD_VARS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_USE_BEDROCK',
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'ANTHROPIC_SMALL_MODEL',
'ANTHROPIC_MEDIUM_MODEL',
'ANTHROPIC_LARGE_MODEL',
'CLAUDE_ADAPTIVE_THINKING',
] as const;
/**
* Variables forwarded to every worker container regardless of provider. Each is
* forwarded only when set, so an unused one never appears in the container.
*/
const COMMON_FORWARD_VARS = ['SHANNON_AI_MODEL', 'SHANNON_AI_BASE_URL', 'SHANNON_AI_OPENAI_FORMAT'] as const;
/**
* Credential variables for one provider. Only the selected provider's entries are
* forwarded, so a key for an unused provider never enters the scan container.
*/
function providerForwardVars(providerId: ProviderId): readonly string[] {
return [...PROVIDER_API_KEY_ENV[providerId], ...PROVIDER_EXTRA_ENV[providerId]];
}
/**
* Load credentials into process.env.
@@ -39,12 +46,16 @@ export function loadEnv(): void {
}
/**
* Build `-e KEY=VALUE` flags for docker run, only for set variables.
* Build `-e KEY=VALUE` flags for docker run. Forwards the common vars plus only
* the selected provider's credentials.
*/
export function buildEnvFlags(): string[] {
const flags: string[] = ['-e', 'TEMPORAL_ADDRESS=shannon-temporal:7233'];
for (const key of FORWARD_VARS) {
const spec = resolveModelSpec();
const providerVars = typeof spec === 'string' ? [] : providerForwardVars(spec.providerId);
for (const key of [...COMMON_FORWARD_VARS, ...providerVars]) {
const value = process.env[key];
if (value) {
flags.push('-e', `${key}=${value}`);
@@ -57,71 +68,55 @@ export function buildEnvFlags(): string[] {
interface CredentialValidation {
valid: boolean;
error?: string;
mode: 'api-key' | 'oauth' | 'custom-base-url' | 'bedrock';
}
/** Check if a custom Anthropic-compatible base URL is configured. */
function isCustomBaseUrlConfigured(): boolean {
return !!(process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN);
}
/** Detect which providers are configured via environment variables. */
function detectProviders(): string[] {
const providers: string[] = [];
if (process.env.ANTHROPIC_API_KEY) providers.push('Anthropic API key');
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) providers.push('Anthropic OAuth');
if (isCustomBaseUrlConfigured()) providers.push('Custom Base URL');
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') providers.push('AWS Bedrock');
return providers;
}
/**
* Validate that exactly one authentication method is configured.
* Whether the selected provider has a usable credential in the environment. Any
* one API key satisfies a key-based provider; Bedrock instead needs every one of
* its AWS_ vars.
*/
function hasCredential(providerId: ProviderId): boolean {
const apiKeys = PROVIDER_API_KEY_ENV[providerId];
if (apiKeys.length > 0 && !apiKeys.some((name) => Boolean(process.env[name]))) {
return false;
}
return PROVIDER_EXTRA_ENV[providerId].every((name) => Boolean(process.env[name]));
}
/** Every provider that currently has a complete credential in the environment. */
function configuredProviders(): ProviderId[] {
return SUPPORTED_PROVIDERS.filter((providerId) => hasCredential(providerId));
}
/**
* Validate that the model selection parses and its provider has a credential.
* Runs before any Docker work so mistakes fail immediately.
*/
export function validateCredentials(): CredentialValidation {
// Reject multiple providers
const providers = detectProviders();
if (providers.length > 1) {
// 1. Model selection must parse and name a supported provider
const spec = resolveModelSpec();
if (typeof spec === 'string') {
return { valid: false, error: spec };
}
// 2. The selected provider must have a credential
if (!hasCredential(spec.providerId)) {
const hint =
getMode() === 'local'
? `Set ${PROVIDER_CREDENTIAL_HINT[spec.providerId]} in .env or export it.`
: `Export the variables or run 'npx @keygraph/shannon setup'.`;
return {
valid: false,
mode: 'api-key',
error: `Multiple providers detected: ${providers.join(', ')}. Only one provider can be active at a time.`,
error: `No credentials found for provider "${spec.providerId}". ${hint}`,
};
}
if (process.env.ANTHROPIC_API_KEY) {
return { valid: true, mode: 'api-key' };
}
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return { valid: true, mode: 'oauth' };
}
if (isCustomBaseUrlConfigured()) {
return { valid: true, mode: 'custom-base-url' };
}
if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') {
const missing: string[] = [];
if (!process.env.AWS_REGION) missing.push('AWS_REGION');
if (!process.env.AWS_BEARER_TOKEN_BEDROCK) missing.push('AWS_BEARER_TOKEN_BEDROCK');
if (!process.env.ANTHROPIC_SMALL_MODEL) missing.push('ANTHROPIC_SMALL_MODEL');
if (!process.env.ANTHROPIC_MEDIUM_MODEL) missing.push('ANTHROPIC_MEDIUM_MODEL');
if (!process.env.ANTHROPIC_LARGE_MODEL) missing.push('ANTHROPIC_LARGE_MODEL');
if (missing.length > 0) {
return {
valid: false,
mode: 'bedrock',
error: `Bedrock mode requires: ${missing.join(', ')}`,
};
}
return { valid: true, mode: 'bedrock' };
// 3. Exactly one provider may be configured. Several complete credentials make
// the scan's provider depend on SHANNON_AI_MODEL alone, which is too easy to
// misread as "both are in play" and too easy to redirect by editing one line.
if (configuredProviders().length > 1) {
return { valid: false, error: 'Credentials for more than one provider are set.' };
}
const hint =
getMode() === 'local'
? `No credentials found. Set ANTHROPIC_API_KEY in .env or export it.`
: `Authentication not configured. Export variables or run 'npx @keygraph/shannon setup'.`;
return {
valid: false,
mode: 'api-key',
error: hint,
};
return { valid: true };
}
+1 -1
View File
@@ -220,7 +220,7 @@ switch (command) {
setup();
break;
case 'build':
build(args.includes('--no-cache'));
build(args.includes('--no-cache'), getVersion());
break;
case 'uninstall':
if (getMode() === 'local') {
+4
View File
@@ -23,3 +23,7 @@ export function setMode(mode: Mode): void {
export function isLocal(): boolean {
return getMode() === 'local';
}
export function isDevMode(): boolean {
return process.env.SHANNON_DEV === '1';
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Parsing for the single model setting, `SHANNON_AI_MODEL=<provider>:<model-id>`.
*
* Mirrors apps/worker/src/ai/models.ts. The CLI cannot import from the worker
* package (it ships as a standalone bundle), so the provider list and the parse
* rule are duplicated here deliberately and must stay in sync.
*/
/** Providers Shannon can currently reach. Each is a pi-ai provider id. */
export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'xai', 'amazon-bedrock'] as const;
export type ProviderId = (typeof SUPPORTED_PROVIDERS)[number];
/**
* Env vars carrying each provider's API key, in precedence order. Any one of them
* satisfies the provider. Mirrors PROVIDER_API_KEY_ENV in apps/worker/src/ai/models.ts.
*/
export const PROVIDER_API_KEY_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
openai: ['OPENAI_API_KEY'],
xai: ['XAI_API_KEY'],
'amazon-bedrock': ['AWS_BEARER_TOKEN_BEDROCK'],
};
/** Additional env vars a provider requires beyond its API key. All must be set. */
export const PROVIDER_EXTRA_ENV: Readonly<Record<ProviderId, readonly string[]>> = {
anthropic: [],
openai: [],
xai: [],
'amazon-bedrock': ['AWS_REGION'],
};
/** Human-readable credential requirement, used in "nothing configured" errors. */
export const PROVIDER_CREDENTIAL_HINT: Readonly<Record<ProviderId, string>> = {
anthropic: 'ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN)',
openai: 'OPENAI_API_KEY',
xai: 'XAI_API_KEY',
'amazon-bedrock': 'AWS_REGION and AWS_BEARER_TOKEN_BEDROCK',
};
/** Model used when SHANNON_AI_MODEL is unset. */
export const DEFAULT_MODEL_SPEC = 'anthropic:claude-sonnet-4-6';
/**
* Values SHANNON_AI_OPENAI_FORMAT accepts, selecting the wire format an
* OpenAI-compatible gateway serves. Mirrors OPENAI_FORMATS in
* apps/worker/src/ai/models.ts; the worker validates and applies it.
*/
export const OPENAI_FORMATS = ['chat-completions', 'responses'] as const;
export type OpenAiFormat = (typeof OPENAI_FORMATS)[number];
export interface ModelSpec {
providerId: ProviderId;
modelId: string;
}
function isSupportedProvider(value: string): value is ProviderId {
return (SUPPORTED_PROVIDERS as readonly string[]).includes(value);
}
/**
* Parse a `<provider>:<model-id>` spec. Splits on the first colon only, so colons
* inside a model ID survive (`amazon-bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0`).
* Returns an error string rather than throwing, for the CLI's validation flow.
*/
export function parseModelSpec(spec: string): ModelSpec | string {
const trimmed = spec.trim();
const separator = trimmed.indexOf(':');
const malformed = `SHANNON_AI_MODEL must be "<provider>:<model-id>", got "${trimmed}". Example: ${DEFAULT_MODEL_SPEC}`;
if (separator === -1) return malformed;
const providerId = trimmed.slice(0, separator).trim();
const modelId = trimmed.slice(separator + 1).trim();
if (!providerId || !modelId) return malformed;
if (!isSupportedProvider(providerId)) {
return `Unsupported provider "${providerId}" in SHANNON_AI_MODEL. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`;
}
return { providerId, modelId };
}
/** Resolve the run's model spec from the environment, or an error string. */
export function resolveModelSpec(): ModelSpec | string {
return parseModelSpec(process.env.SHANNON_AI_MODEL || DEFAULT_MODEL_SPEC);
}