From 8ced520e8da740fa659f6c43ce3d41098945e611 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 12:36:15 +0800 Subject: [PATCH 1/7] refactor(config): centralize env and provider protocols --- README.md | 2 +- README_CN.md | 2 +- build.sh | 6 +- cmd/aiscan/cli_test.go | 8 +- cmd/aiscan/setup.go | 16 +-- cmd/aiscan/web_full.go | 3 +- core/config/config_gen.go | 10 +- core/config/datadir.go | 7 +- core/config/env.go | 113 +++++++++++++++++--- core/config/loader_test.go | 101 ++++++++++++++++- core/config/options.go | 9 +- docs/agent.md | 10 +- docs/mechanisms.md | 2 + docs/reference.md | 69 ++++++------ pkg/commands/factory.go | 17 +-- pkg/runner/app.go | 15 +-- pkg/runner/application_builder.go | 60 ++++++----- pkg/runner/application_config.go | 34 +++--- pkg/runner/provider_config.go | 16 ++- pkg/runner/provider_config_test.go | 4 +- pkg/runner/remote_repl_test.go | 7 +- pkg/runner/runner.go | 3 +- pkg/runner/scanner.go | 14 ++- pkg/tui/console.go | 8 +- pkg/tui/output.go | 11 +- pkg/tui/render.go | 5 +- pkg/web/agents.go | 3 +- pkg/web/config_profiles_test.go | 4 +- pkg/web/config_reload_test.go | 2 +- pkg/web/types.go | 1 + pkg/webagent/remote.go | 4 + pkg/webagent/remote_test.go | 2 +- pkg/webproto/config.go | 23 +++- pkg/webproto/config_test.go | 18 ++++ tools/ioa/commands_test.go | 4 +- tools/playwright/browser.go | 15 ++- tools/playwright/browser_test.go | 7 ++ tools/playwright/register.go | 2 +- tools/scan/engine/set.go | 1 + tools/scan/engine/set_uncover_recon.go | 8 ++ tools/scan/engine/uncover.go | 38 ++++++- tools/scan/engine/uncover_test.go | 27 +++++ tools/search/tavily.go | 3 - tools/search/tavily_test.go | 8 ++ web/frontend/e2e/start-server.mjs | 2 +- web/frontend/src/components/ConfigPanel.tsx | 17 ++- web/frontend/src/i18n/locales/en/config.ts | 2 +- web/frontend/src/i18n/locales/zh/config.ts | 2 +- 48 files changed, 553 insertions(+), 192 deletions(-) create mode 100644 pkg/webproto/config_test.go diff --git a/README.md b/README.md index af3e91b6..d2bcf6e1 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \ export OPENAI_API_KEY="sk-..." # CLI arguments -aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat +aiscan agent --provider openai --base-url https://api.deepseek.com/v1 --api-key sk-... --model deepseek-chat ``` Config file `aiscan.yaml`: diff --git a/README_CN.md b/README_CN.md index 034be639..8a60eb42 100644 --- a/README_CN.md +++ b/README_CN.md @@ -186,7 +186,7 @@ aiscan agent --ioa-url http://127.0.0.1:8765 --space pentest-project \ export OPENAI_API_KEY="sk-..." # CLI 参数 -aiscan agent --provider deepseek --api-key sk-... --model deepseek-chat +aiscan agent --provider openai --base-url https://api.deepseek.com/v1 --api-key sk-... --model deepseek-chat ``` 配置文件 `aiscan.yaml`: diff --git a/build.sh b/build.sh index ac6e5b66..c07c2f89 100755 --- a/build.sh +++ b/build.sh @@ -7,7 +7,7 @@ # ./build.sh -o linux/amd64 # 快速编译单一平台 # ./build.sh -o "linux/amd64 darwin/arm64" # 编译指定平台 # ./build.sh --config prod.yaml # 使用指定配置文件 -# ./build.sh --llm-model deepseek-chat # CLI 覆盖配置文件中的值 +# ./build.sh --llm-provider openai --llm-model deepseek-chat # OpenAI-compatible # ./build.sh --embed # 嵌入扫描资源(不加 emptytemplates/noembed tag) # ./build.sh --ioa # 同时编译 ioa server 二进制 @@ -109,7 +109,7 @@ aiscan 构建脚本 --profile PROFILE 构建配置: agent (~28MB), mini (默认, ~77MB), full (~123MB) LLM 覆盖(优先级高于 aiscan.yaml): - --llm-provider NAME + --llm-provider TYPE openai (OpenAI-compatible) or anthropic --llm-base-url URL --llm-api-key KEY --llm-model NAME @@ -137,7 +137,7 @@ Web Search: ./build.sh -o linux/amd64 # 快速编译单平台 ./build.sh --config prod.yaml -o linux/amd64 # 使用生产配置编译 ./build.sh --cyberhub-url http://10.0.0.1:9000 --cyberhub-key mykey - ./build.sh --llm-provider deepseek --llm-model deepseek-chat + ./build.sh --llm-provider openai --llm-base-url https://api.deepseek.com/v1 --llm-model deepseek-chat ./build.sh --embed # 嵌入资源的完整构建 ./build.sh -g # 打印 ldflags(用于自定义构建命令) ./build.sh --profile agent -o linux/amd64 # agent 构建 (仅 agent REPL + Arsenal, 无内置扫描器) diff --git a/cmd/aiscan/cli_test.go b/cmd/aiscan/cli_test.go index 00c8b653..acecada8 100644 --- a/cmd/aiscan/cli_test.go +++ b/cmd/aiscan/cli_test.go @@ -194,8 +194,8 @@ func TestParseCLIAgentAcceptsLLMFlags(t *testing.T) { t.Fatalf("llm options = %#v", opt.LLMOptions) } pcfg := runner.ProviderConfig(&opt) - if pcfg.Provider != "" { - t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider) + if pcfg.Provider != "openai" { + t.Fatalf("provider = %q, want openai protocol", pcfg.Provider) } resolved, err := agent.ResolveProvider(&pcfg) if err != nil { @@ -304,8 +304,8 @@ func TestParseCLIScanExtractsLLMFlags(t *testing.T) { t.Fatalf("llm options = %#v", opt.LLMOptions) } pcfg := runner.ProviderConfig(&opt) - if pcfg.Provider != "" { - t.Fatalf("provider should be unresolved before agent.ResolveProvider, got %q", pcfg.Provider) + if pcfg.Provider != "openai" { + t.Fatalf("provider = %q, want openai protocol", pcfg.Provider) } resolved, err := agent.ResolveProvider(&pcfg) if err != nil { diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 9485a8b0..97983f4b 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -63,6 +63,7 @@ func initEngines(ctx context.Context, sc runner.ScannerConfig, logger telemetry. HunterAPIKey: sc.HunterAPIKey, IngressProxy: sc.ReconProxy, Limit: sc.ReconLimit, + ProviderKeys: sc.ReconProviderKeys, } engineSet.SetupUncover(recon, logger) return engineSet @@ -97,13 +98,14 @@ func registerScannerCommands(cmdReg *commands.CommandRegistry, engineSet *engine workDir, _ := os.Getwd() deps := &commands.Deps{ - WorkDir: workDir, - BashTimeout: toolCfg.BashTimeout, - SkillStore: skillStore, - ScannerProxy: scanCfg.Proxy, - Logger: logger, - TavilyKeys: toolCfg.TavilyKeys, - DataBus: dataBus, + WorkDir: workDir, + BashTimeout: toolCfg.BashTimeout, + SkillStore: skillStore, + ScannerProxy: scanCfg.Proxy, + Logger: logger, + TavilyKeys: toolCfg.TavilyKeys, + PlaywrightSession: toolCfg.PlaywrightSession, + DataBus: dataBus, } commands.Provide(deps, scan.OptsKey, scanOpts) if engineSet != nil { diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index bb300a0b..4ab0556a 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -38,7 +38,7 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom } defer store.Close() - application, err := initWebApp(ctx, option, logger) + application, err := initWebApp(ctx, explicitOption, logger) if err != nil { return fmt.Errorf("init aiscan: %s", err) } @@ -269,6 +269,7 @@ func (s *webConfigStore) PrepareDistributeConfig(ctx context.Context, incoming w } current = parseDistributeConfig(data) } + webproto.MigrateLLMConfig(&incoming.LLM, webproto.LLMProviderConfig{}) // Preserve existing secrets when incoming value is empty. preserveLLMProfileSecrets(&incoming.LLM, current.LLM) diff --git a/core/config/config_gen.go b/core/config/config_gen.go index 045646b7..e68a8382 100644 --- a/core/config/config_gen.go +++ b/core/config/config_gen.go @@ -9,7 +9,7 @@ import ( const configFileHeader = `# aiscan 配置文件 # # 运行时: aiscan 自动加载 ./aiscan.yaml 或 <二进制所在目录>/aiscan.yaml -# 优先级: CLI 参数 > 环境变量 > 配置文件 > 默认值 +# 优先级: CLI > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 默认值 # 生成: aiscan --init # # 仅填写需要的字段,留空或删除的字段不会覆盖其他来源的值 @@ -17,16 +17,18 @@ const configFileHeader = `# aiscan 配置文件 # LLM 配置支持两种格式: # 格式一 — 单 provider 简写(兼容旧配置): # llm: -# provider: deepseek +# provider: openai +# base_url: https://api.deepseek.com/v1 # api_key: sk-... # model: deepseek-chat # -# 格式二 — providers 配置列表(通过 active_profile 显式选择): +# 格式二 — LLM profile 列表(字段名 providers,通过 active_profile 选择): # llm: # active_profile: deepseek # providers: # - id: deepseek -# provider: deepseek +# provider: openai +# base_url: https://api.deepseek.com/v1 # api_key: sk-... # model: deepseek-chat # - id: openai diff --git a/core/config/datadir.go b/core/config/datadir.go index 2da39bd1..641e779d 100644 --- a/core/config/datadir.go +++ b/core/config/datadir.go @@ -3,7 +3,6 @@ package config import ( "os" "path/filepath" - "strings" "sync" ) @@ -24,14 +23,12 @@ func SetDataDir(dir string) { } // DataDir returns the resolved .aiscan data directory. -// Priority: AISCAN_DATA_DIR env > config/CLI --data-dir > /.aiscan +// Priority is resolved centrally before this function is called: +// CLI > AISCAN_DATA_DIR > config > /.aiscan. func DataDir() string { dataDirOnce.Do(func() { dataDirMu.Lock() defer dataDirMu.Unlock() - if v := strings.TrimSpace(os.Getenv("AISCAN_DATA_DIR")); v != "" { - resolvedDataDir = v - } if resolvedDataDir == "" { if exe, err := os.Executable(); err == nil { resolvedDataDir = filepath.Join(filepath.Dir(exe), dataDirName) diff --git a/core/config/env.go b/core/config/env.go index 9517cab9..04d756f7 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -17,6 +17,7 @@ func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider return configPath, err } applyEnvironment(option, explicit, os.LookupEnv, inferProvider) + normalizeProviderOptions(option, inferProvider) ApplyDefaults(option) if _, err := ResolveOutputPolicy(option); err != nil { return configPath, err @@ -31,6 +32,7 @@ func applyEnvironment(option *Option, explicit Option, lookup envLookup, inferPr applyLLMEnvironment(option, explicit, lookup, inferProvider) applyScannerEnvironment(option, explicit, lookup) applyReconEnvironment(option, explicit, lookup) + applyRuntimeEnvironment(option, explicit, lookup) } func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { @@ -109,17 +111,17 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe func applyScannerEnvironment(option *Option, explicit Option, lookup envLookup) { if strings.TrimSpace(explicit.CyberhubURL) == "" { - if v := firstEnv(lookup, "CYBERHUB_URL", "AISCAN_CYBERHUB_URL"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_URL", "CYBERHUB_URL"); v != "" { option.CyberhubURL = v } } if strings.TrimSpace(explicit.CyberhubKey) == "" { - if v := firstEnv(lookup, "CYBERHUB_KEY", "AISCAN_CYBERHUB_KEY"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_KEY", "CYBERHUB_KEY"); v != "" { option.CyberhubKey = v } } if strings.TrimSpace(explicit.CyberhubMode) == "" { - if v := firstEnv(lookup, "CYBERHUB_MODE", "AISCAN_CYBERHUB_MODE"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_MODE", "CYBERHUB_MODE"); v != "" { option.CyberhubMode = v } } @@ -161,26 +163,75 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { option.ReconProxy = v } } + applyReconProviderEnvironment(option, lookup) +} + +func applyRuntimeEnvironment(option *Option, explicit Option, lookup envLookup) { + if strings.TrimSpace(explicit.DataDir) == "" { + if v := firstEnv(lookup, "AISCAN_DATA_DIR"); v != "" { + option.DataDir = v + } + } + if strings.TrimSpace(explicit.RenderMode) == "" { + option.RenderMode = firstEnv(lookup, "AISCAN_RENDER") + } + if strings.TrimSpace(explicit.REPLMode) == "" { + option.REPLMode = firstEnv(lookup, "AISCAN_REPL") + } + if strings.TrimSpace(explicit.PlaywrightSession) == "" { + option.PlaywrightSession = firstEnv(lookup, "PLAYWRIGHT_CLI_SESSION") + } +} + +var reconProviderEnvNames = []string{ + "SHODAN_API_KEY", + "QUAKE_TOKEN", + "NETLAS_API_KEY", + "CRIMINALIP_API_KEY", + "PUBLICWWW_API_KEY", + "HUNTERHOW_API_KEY", + "ZOOMEYE_API_KEY", + "DRIFTNET_API_KEY", + "DAYDAYMAP_API_KEY", + "CENSYS_API_TOKEN", + "CENSYS_ORGANIZATION_ID", + "GOOGLE_API_KEY", + "GOOGLE_API_CX", + "ODIN_API_KEY", + "BINARYEDGE_API_KEY", + "ONYPHE_API_KEY", + "GREYNOISE_API_KEY", + "NERDYDATA_API_KEY", +} + +func applyReconProviderEnvironment(option *Option, lookup envLookup) { + for _, name := range reconProviderEnvNames { + if value := firstEnv(lookup, name); value != "" { + if option.ReconProviderKeys == nil { + option.ReconProviderKeys = make(map[string]string) + } + option.ReconProviderKeys[name] = value + } + } } func selectedEnvProvider(option *Option, lookup envLookup, inferProvider func(string) string) string { if v := strings.ToLower(strings.TrimSpace(option.Provider)); v != "" { - return v + return normalizeProviderName(v) } if option.BaseURL != "" && inferProvider != nil { return inferProvider(option.BaseURL) } - if firstEnv(lookup, "ANTHROPIC_API_KEY") != "" { - return "anthropic" - } - if firstEnv(lookup, "OPENAI_API_KEY") != "" { - return "openai" + for _, providerName := range []string{"anthropic", "openai"} { + if providerAPIKeyEnv(providerName, lookup) != "" { + return providerName + } } return "" } func providerBaseURLEnv(providerName string, lookup envLookup) string { - providerName = strings.ToLower(strings.TrimSpace(providerName)) + providerName = canonicalEnvProvider(providerName) if providerName == "" { return "" } @@ -193,7 +244,7 @@ func providerBaseURLEnv(providerName string, lookup envLookup) string { } func providerModelEnv(providerName string, lookup envLookup) string { - providerName = strings.ToLower(strings.TrimSpace(providerName)) + providerName = canonicalEnvProvider(providerName) if providerName == "" { return "" } @@ -201,13 +252,41 @@ func providerModelEnv(providerName string, lookup envLookup) string { } func providerAPIKeyEnv(providerName string, lookup envLookup) string { - providerName = strings.ToLower(strings.TrimSpace(providerName)) - switch providerName { - case "anthropic": - return firstEnv(lookup, "ANTHROPIC_API_KEY") - default: - return firstEnv(lookup, "OPENAI_API_KEY") + providerName = canonicalEnvProvider(providerName) + if providerName == "" { + return "" + } + return firstEnv(lookup, providerEnvName(providerName, "API_KEY")) +} + +func canonicalEnvProvider(providerName string) string { + if strings.TrimSpace(providerName) == "" { + return "" + } + return normalizeProviderName(providerName) +} + +func normalizeProviderOptions(option *Option, inferProvider func(string) string) { + if strings.TrimSpace(option.Provider) != "" { + option.Provider = normalizeProviderName(option.Provider) + } else if strings.TrimSpace(option.BaseURL) != "" && inferProvider != nil { + option.Provider = normalizeProviderName(inferProvider(option.BaseURL)) + } + for i := range option.Providers { + providerName := strings.TrimSpace(option.Providers[i].Provider) + if providerName != "" { + option.Providers[i].Provider = normalizeProviderName(providerName) + } else if inferProvider != nil { + option.Providers[i].Provider = normalizeProviderName(inferProvider(option.Providers[i].BaseURL)) + } + } +} + +func normalizeProviderName(name string) string { + if strings.EqualFold(strings.TrimSpace(name), "anthropic") { + return "anthropic" } + return "openai" } func providerEnvName(providerName, suffix string) string { diff --git a/core/config/loader_test.go b/core/config/loader_test.go index b3b4af69..f4a996b6 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -519,7 +519,7 @@ cyberhub: } checks := []struct{ field, got, want string }{ - {"Provider", option.Provider, "deepseek"}, + {"Provider", option.Provider, "openai"}, {"BaseURL", option.BaseURL, "https://env.example/v1"}, {"APIKey", option.APIKey, "env-key"}, {"Model", option.Model, "env-model"}, @@ -612,6 +612,103 @@ llm: }) } +func TestResolveRuntimeConfigNormalizesLegacyProviderToOpenAI(t *testing.T) { + t.Setenv("AISCAN_PROVIDER", "") + t.Setenv("AISCAN_LLM_PROVIDER", "") + t.Setenv("AISCAN_API_KEY", "") + t.Setenv("AISCAN_LLM_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "openai-compatible-key") + t.Setenv("DEEPSEEK_API_KEY", "ignored-vendor-key") + + withDefaults(t, func() { + dir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + t.Fatal(err) + } + if option.Provider != "openai" || option.APIKey != "openai-compatible-key" { + t.Fatalf("legacy provider was not normalized: %#v", option.LLMOptions) + } + }) +} + +func TestApplyEnvironmentIgnoresVendorSpecificLLMVariables(t *testing.T) { + values := map[string]string{ + "DEEPSEEK_API_KEY": "vendor-key", + "DEEPSEEK_BASE_URL": "https://vendor.example/v1", + "DEEPSEEK_MODEL": "vendor-model", + } + lookup := func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } + + option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} + applyEnvironment(&option, option, lookup, testProviderInference) + normalizeProviderOptions(&option, testProviderInference) + if option.Provider != "openai" || option.APIKey != "" || option.BaseURL != "" || option.Model != "" { + t.Fatalf("vendor-specific LLM environment should be ignored: %#v", option.LLMOptions) + } +} + +func TestApplyEnvironmentCentralizesRuntimeAndReconValues(t *testing.T) { + values := map[string]string{ + "AISCAN_DATA_DIR": "env-data", + "AISCAN_RENDER": "static", + "AISCAN_REPL": "fast", + "PLAYWRIGHT_CLI_SESSION": "browser-1", + "SHODAN_API_KEY": "shodan-key", + } + lookup := func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } + + option := Option{MiscOptions: MiscOptions{DataDir: "config-data"}} + applyEnvironment(&option, Option{}, lookup, testProviderInference) + if option.DataDir != "env-data" || option.RenderMode != "static" || option.REPLMode != "fast" || option.PlaywrightSession != "browser-1" { + t.Fatalf("runtime environment not resolved: %#v", option) + } + if option.ReconProviderKeys["SHODAN_API_KEY"] != "shodan-key" { + t.Fatalf("recon provider environment not resolved: %#v", option.ReconProviderKeys) + } + + cli := Option{MiscOptions: MiscOptions{DataDir: "cli-data"}} + applyEnvironment(&cli, cli, lookup, testProviderInference) + if cli.DataDir != "cli-data" { + t.Fatalf("CLI data dir should win over env: got %q", cli.DataDir) + } +} + +func TestResolveRuntimeConfigTavilyPriority(t *testing.T) { + dir := t.TempDir() + writeTestConfig(t, dir, "search:\n tavily_keys: config-key\n") + t.Setenv("TAVILY_API_KEY", "env-key") + + withDefaults(t, func() { + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(origDir) + + option := Option{ReconOptions: ReconOptions{TavilyKey: "cli-key"}} + if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + t.Fatal(err) + } + if option.TavilyKey != "cli-key" || option.SearchConfig.TavilyKeys != "config-key" { + t.Fatalf("Tavily sources were not centralized: cli=%q config=%q", option.TavilyKey, option.SearchConfig.TavilyKeys) + } + }) +} + // A provider-scoped model env (ANTHROPIC_MODEL) is often injected by the // surrounding environment for another tool (a Claude-Code style gateway). It must // NOT override a model the user configured for aiscan itself — otherwise editing @@ -757,7 +854,7 @@ llm: if _, err := ResolveRuntimeConfig(&explicit, false, testProviderInference); err != nil { t.Fatal(err) } - if explicit.Provider != "deepseek" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { + if explicit.Provider != "openai" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { t.Fatalf("explicit CLI LLM values did not override staged config: %+v", explicit.LLMOptions) } } diff --git a/core/config/options.go b/core/config/options.go index 0cb93a71..7a109a89 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -21,6 +21,13 @@ type Option struct { MiscOptions `group:"Miscellaneous Options" config:"misc"` ScanConfig ScanConfigOptions `no-flag:"true" config:"scan"` SearchConfig SearchConfigOptions `no-flag:"true" config:"search"` + + // Runtime-only environment settings. Business packages receive these values + // after ResolveRuntimeConfig instead of reading the process environment. + RenderMode string `no-flag:"true"` + REPLMode string `no-flag:"true"` + PlaywrightSession string `no-flag:"true"` + ReconProviderKeys map[string]string `no-flag:"true"` } type ScanConfigOptions struct { @@ -32,7 +39,7 @@ type SearchConfigOptions struct { } type LLMOptions struct { - Provider string `long:"provider" config:"provider" description:"LLM provider: openai (default), anthropic, deepseek, openrouter, ollama, groq, moonshot, zhipu"` + Provider string `long:"provider" config:"provider" description:"LLM protocol: openai (OpenAI-compatible, default) or anthropic"` BaseURL string `long:"base-url" config:"base_url" description:"LLM API base URL (leave empty to use provider default)"` APIKey string `long:"api-key" config:"api_key" description:"LLM API key (or env: OPENAI_API_KEY, ANTHROPIC_API_KEY, AISCAN_API_KEY)"` Model string `long:"model" config:"model" description:"LLM model name"` diff --git a/docs/agent.md b/docs/agent.md index 577c7c32..98afde55 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -530,13 +530,15 @@ llm: model: gpt-4o api_key: "sk-..." - id: deepseek - provider: deepseek + provider: openai + base_url: "https://api.deepseek.com/v1" model: deepseek-chat api_key: "..." - id: ollama - provider: ollama + provider: openai model: llama3 base_url: "http://localhost:11434/v1" + api_key: "local" ``` `active_profile` 按 `id` 选择当前项;未设置时使用列表第一项。完整格式参见 [参考手册](reference.md)。 @@ -549,8 +551,8 @@ REPL 中使用 `/provider` 命令查看当前和其他可用配置: aiscan> /provider Provider profiles: 1. openai / gpt-4o # active - 2. deepseek / deepseek-chat # configured - 3. ollama / llama3 # configured + 2. openai / deepseek-chat # deepseek profile + 3. openai / llama3 # ollama profile ``` 切换通过 Web 设置页,或 REPL 的 `/provider set --provider ... --model ...` 显式完成。 diff --git a/docs/mechanisms.md b/docs/mechanisms.md index 24b0503f..879703cf 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -275,3 +275,5 @@ scan、agent joined、session cleared 等产品事件保留独立的 `DomainEven 对 `BaseURL`、`APIKey` 同理。 **文件**: `core/config/env.go` + +所有 AIScan 运行时业务环境变量都由该入口读取一次。DataDir、TUI、Playwright、Tavily 和 Uncover 只消费解析后的配置,不再自行调用 `os.Getenv`。系统级 `PATH`、Go 标准代理环境变量和 Vite 构建期变量仍按各自平台语义处理。 diff --git a/docs/reference.md b/docs/reference.md index 0e834342..9f076ea9 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -35,9 +35,11 @@ aiscan [全局参数] [子命令参数] ### 配置优先级 ``` -CLI 参数 > 环境变量 > 配置文件 > 编译时默认值 +CLI 参数 > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 编译时默认值 ``` +`AISCAN_*`、Cyberhub、FOFA、Hunter、Tavily 等明确属于 AIScan 的环境变量会覆盖配置文件。`OPENAI_*`、`ANTHROPIC_*` 等可能由其他工具注入的 Provider 兼容变量只用于填补配置文件中的空值。 + ### 配置文件 ```bash @@ -52,7 +54,7 @@ aiscan -c /path/to/aiscan.yaml scan -i 192.168.1.0/24 # 指定配置文件 ```yaml # LLM Provider llm: - provider: "" # openai, deepseek, openrouter, ollama, groq, moonshot, anthropic, zhipu + provider: "" # 协议类型:openai(默认,兼容所有 OpenAI API)或 anthropic base_url: "" # API base URL(留空使用 provider 默认值) api_key: "" # API key(建议使用环境变量) model: "" # 模型名称 @@ -60,13 +62,13 @@ llm: max_tokens: 0 # 单次最大输出;0 使用默认值 16384 proxy: "" # 访问 LLM API 的 HTTP proxy - # 多 provider 配置(可选;只手动切换,不自动 fallback) + # 多 LLM profile 配置(可选;只手动切换,不自动 fallback) active_profile: deepseek providers: - id: deepseek name: DeepSeek - provider: deepseek - base_url: https://api.deepseek.com + provider: openai + base_url: https://api.deepseek.com/v1 api_key: "sk-..." model: deepseek-chat context_window: 128000 @@ -140,7 +142,7 @@ misc: | 参数 | 说明 | | --- | --- | -| `--provider` | LLM provider 名称(openai、deepseek、openrouter、ollama 等) | +| `--provider` | LLM 协议类型:`openai`(OpenAI-compatible)或 `anthropic` | | `--base-url` | LLM API base URL | | `--api-key` | LLM API key(也可用环境变量) | | `--model` | 模型名称(默认 `gpt-4o`) | @@ -198,24 +200,18 @@ misc: --- -## LLM Provider +## LLM 协议与 Profile -### 支持的 Provider +### 支持的协议 -| Provider | 默认 Base URL | 默认模型 | API Key 环境变量 | +| 协议 | 用途 | 默认 Base URL | 环境变量 | | --- | --- | --- | --- | -| `openai` | `https://api.openai.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `deepseek` | `https://api.deepseek.com/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `anthropic` | `https://api.anthropic.com/v1` | — | `AISCAN_API_KEY` / `ANTHROPIC_API_KEY` | -| `openrouter` | `https://openrouter.ai/api/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `groq` | `https://api.groq.com/openai/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `moonshot` | `https://api.moonshot.cn/v1` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | -| `ollama` | `http://localhost:11434/v1` | — | 不需要 | -| `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | — | `AISCAN_API_KEY` / `OPENAI_API_KEY` | +| `openai` | OpenAI 及 DeepSeek、OpenRouter、Groq、Moonshot、Ollama 等 OpenAI-compatible API | `https://api.openai.com/v1` | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL` | +| `anthropic` | Anthropic Messages API 及兼容网关 | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL` | -`glm` 和 `bigmodel` 是 `zhipu` 的别名。已知 Provider 在 `base_url` 留空时使用上表地址;显式填写的地址始终优先。只提供 `base_url` 而不提供 Provider 时,Anthropic 官方域名会选择 Anthropic 协议,其他地址默认按 OpenAI 兼容协议处理。 +除 Anthropic 协议外,其余模型服务统一使用 `openai`,通过 `base_url`、`model` 和 `api_key` 指定实际服务。旧配置中的 `deepseek`、`openrouter`、`ollama` 等 provider 名称会自动归一化为 `openai`。 -### 多 Provider 配置 +### 多 LLM Profile 配置 配置文件可通过 `llm.providers` 保存多个 LLM profile,并用 `llm.active_profile` 明确选择当前项;未指定时使用列表第一项。每个 entry 支持 `id`、`name`、`provider`、`base_url`、`api_key`、`model`、`proxy`、`timeout`、`max_tokens` 和 `context_window`。`model` 必填,保存配置或激活 Profile 时都会拒绝空模型。Web 设置页可以选择当前 profile,REPL 可通过 `/provider` 查看配置,并用 `/provider set` 显式应用新配置。 @@ -230,11 +226,11 @@ Agent 只会重试当前 provider。重试耗尽后直接返回错误,不会 export OPENAI_API_KEY="sk-..." aiscan agent -p "检查目标" -i http://target.example -# 指定 provider -aiscan agent --provider deepseek --api-key "sk-..." --model deepseek-chat +# DeepSeek(OpenAI-compatible) +aiscan agent --provider openai --base-url https://api.deepseek.com/v1 --api-key "sk-..." --model deepseek-chat -# Ollama 本地模型 -aiscan agent --provider ollama --model llama3 --base-url http://localhost:11434/v1 +# Ollama(OpenAI-compatible;部分部署可使用任意非空 API key) +aiscan agent --provider openai --model llama3 --base-url http://localhost:11434/v1 --api-key local # 任意 OpenAI 兼容 API aiscan agent --base-url https://my-proxy.example/v1 --api-key "$MY_KEY" --model my-model @@ -424,21 +420,34 @@ scan: | `OPENAI_API_KEY` | OpenAI API key | | `OPENAI_BASE_URL` / `OPENAI_BASEURL` | OpenAI/Codex 风格 API base URL | | `OPENAI_MODEL` | OpenAI/Codex 风格模型名 | -| `DEEPSEEK_API_KEY` | DeepSeek API key | | `ANTHROPIC_API_KEY` | Anthropic API key | | `ANTHROPIC_BASE_URL` / `ANTHROPIC_BASEURL` | Claude Code 风格 API base URL | | `ANTHROPIC_MODEL` | Claude Code 风格模型名 | -| `OPENROUTER_API_KEY` | OpenRouter API key | -| `GROQ_API_KEY` | Groq API key | -| `MOONSHOT_API_KEY` | Moonshot API key | | `AISCAN_API_KEY` | 统一 fallback API key(所有 provider 通用) | | `AISCAN_BASE_URL` / `AISCAN_LLM_BASE_URL` | 统一 LLM API base URL | | `AISCAN_MODEL` / `AISCAN_LLM_MODEL` | 统一模型名 | -| `AISCAN_PROVIDER` / `AISCAN_LLM_PROVIDER` | 统一 provider 名称 | +| `AISCAN_PROVIDER` / `AISCAN_LLM_PROVIDER` | 协议类型:`openai` 或 `anthropic` | | `AISCAN_LLM_PROXY` | LLM API 请求代理 | -| `TAVILY_API_KEY` | Tavily Web Search API key(agent `web_search` 工具) | +| `AISCAN_DATA_DIR` | 数据目录;优先级低于显式 `--data-dir` | +| `AISCAN_PROXY` / `AISCAN_SCANNER_PROXY` | 扫描工具代理 | +| `AISCAN_CYBERHUB_URL` / `CYBERHUB_URL` | Cyberhub URL | +| `AISCAN_CYBERHUB_KEY` / `CYBERHUB_KEY` | Cyberhub API key | +| `AISCAN_CYBERHUB_MODE` / `CYBERHUB_MODE` | Cyberhub 资源模式 | +| `TAVILY_API_KEY` / `TAVILY_API_KEYS` | Tavily Web Search API key,多个 key 可逗号分隔 | | `FOFA_EMAIL` / `FOFA_KEY` | FOFA 凭据 | -| `HUNTER_API_KEY` | Hunter API key | +| `HUNTER_API_KEY` / `HUNTER_TOKEN` | Hunter 凭据 | +| `RECON_PROXY` | 被动测绘出站代理 | +| `SHODAN_API_KEY`、`QUAKE_TOKEN`、`ZOOMEYE_API_KEY`、`NETLAS_API_KEY` | Uncover 数据源凭据 | +| `CENSYS_API_TOKEN` / `CENSYS_ORGANIZATION_ID` | Censys 凭据 | +| `CRIMINALIP_API_KEY`、`PUBLICWWW_API_KEY`、`HUNTERHOW_API_KEY` | Uncover 数据源凭据 | +| `BINARYEDGE_API_KEY`、`ONYPHE_API_KEY`、`GREYNOISE_API_KEY` | Uncover 数据源凭据 | +| `DRIFTNET_API_KEY`、`DAYDAYMAP_API_KEY`、`ODIN_API_KEY`、`NERDYDATA_API_KEY` | Uncover 数据源凭据 | +| `GOOGLE_API_KEY` / `GOOGLE_API_CX` | Google Search 凭据 | +| `AISCAN_RENDER` | 终端渲染模式:interactive、static、forwarded | +| `AISCAN_REPL` | REPL 输入模式:readline 或 fast | +| `PLAYWRIGHT_CLI_SESSION` | Playwright 默认 session | + +运行时业务环境变量只在 `core/config` 解析一次,再通过运行时配置下传。`PATH`、子进程环境继承以及 Go 标准库的 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 属于操作系统级行为,不纳入业务配置优先级。前端开发服务器的 `AISCAN_BACKEND_URL` 是 Vite 构建期配置,也不进入 Go 运行时配置。 --- diff --git a/pkg/commands/factory.go b/pkg/commands/factory.go index 3ba9b0c9..6606ff74 100644 --- a/pkg/commands/factory.go +++ b/pkg/commands/factory.go @@ -35,14 +35,15 @@ type Deps struct { SkillStore SkillSource RunnerMode bool - Provider provider.Provider - ScannerProxy string - Logger telemetry.Logger - NodeName string - NodeMeta map[string]any - TavilyKeys string // comma-separated Tavily API keys (build-time fallback) - DataBus *eventbus.Bus[output.ToolDataEvent] - Hooks *hooks.Registry + Provider provider.Provider + ScannerProxy string + Logger telemetry.Logger + NodeName string + NodeMeta map[string]any + TavilyKeys string // comma-separated Tavily API keys + PlaywrightSession string + DataBus *eventbus.Bus[output.ToolDataEvent] + Hooks *hooks.Registry } // Provide stores a typed dependency, allocating the bag on first use so a diff --git a/pkg/runner/app.go b/pkg/runner/app.go index 95e0d8ef..11d2362b 100644 --- a/pkg/runner/app.go +++ b/pkg/runner/app.go @@ -244,13 +244,14 @@ func initCoreCommands(rc ApplicationConfig, llmProvider agent.Provider, skillSto cmdReg := commands.NewRegistry() workDir, _ := os.Getwd() deps := &commands.Deps{ - WorkDir: workDir, - BashTimeout: rc.Tools.BashTimeout, - SkillStore: skillStore, - Provider: llmProvider, - Logger: logger, - TavilyKeys: rc.Tools.TavilyKeys, - Hooks: hookRegistry, + WorkDir: workDir, + BashTimeout: rc.Tools.BashTimeout, + SkillStore: skillStore, + Provider: llmProvider, + Logger: logger, + TavilyKeys: rc.Tools.TavilyKeys, + PlaywrightSession: rc.Tools.PlaywrightSession, + Hooks: hookRegistry, } plan := capability.Select(capability.Options{ Groups: []string{"core", "arsenal", "search", "browser"}, diff --git a/pkg/runner/application_builder.go b/pkg/runner/application_builder.go index 48562905..945f4e95 100644 --- a/pkg/runner/application_builder.go +++ b/pkg/runner/application_builder.go @@ -25,24 +25,26 @@ func AppConfig(option *cfg.Option, features RuntimeFeatures, logger telemetry.Lo Optional: features.ProviderOptional, }, Scanner: ScannerConfig{ - CyberhubURL: option.CyberhubURL, - CyberhubKey: option.CyberhubKey, - CyberhubMode: option.CyberhubMode, - AIEnabled: features.AIEnabled, - VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), - Proxy: option.Proxy, - FofaEmail: option.FofaEmail, - FofaKey: option.FofaKey, - HunterToken: option.HunterToken, - HunterAPIKey: option.HunterAPIKey, - ReconProxy: option.ReconProxy, - ReconLimit: intOptionValue(option.ReconLimit), + CyberhubURL: option.CyberhubURL, + CyberhubKey: option.CyberhubKey, + CyberhubMode: option.CyberhubMode, + AIEnabled: features.AIEnabled, + VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), + Proxy: option.Proxy, + FofaEmail: option.FofaEmail, + FofaKey: option.FofaKey, + HunterToken: option.HunterToken, + HunterAPIKey: option.HunterAPIKey, + ReconProxy: option.ReconProxy, + ReconLimit: intOptionValue(option.ReconLimit), + ReconProviderKeys: cloneStringMap(option.ReconProviderKeys), }, Tools: ToolConfig{ - Enabled: features.ToolsEnabled, - BashTimeout: 300, - TavilyKeys: resolveTavilyKeys(option.TavilyKey, cfg.ResolveString(option.SearchConfig.TavilyKeys, cfg.DefaultTavilyKeys)), - OptionalTools: option.Tools, + Enabled: features.ToolsEnabled, + BashTimeout: 300, + TavilyKeys: resolveTavilyKeys(option.TavilyKey, option.SearchConfig.TavilyKeys, cfg.DefaultTavilyKeys), + PlaywrightSession: option.PlaywrightSession, + OptionalTools: option.Tools, }, Logger: logger, CLISkillPaths: skillPathsFromOptions(option), @@ -70,14 +72,24 @@ func intOptionValue(p *int) int { return 0 } -func resolveTavilyKeys(flagKey, configKeys string) string { - flagKey = strings.TrimSpace(flagKey) - configKeys = strings.TrimSpace(configKeys) - if flagKey != "" && configKeys != "" { - return flagKey + "," + configKeys +func resolveTavilyKeys(primary string, fallbacks ...string) string { + keys := make([]string, 0, len(fallbacks)+1) + for _, raw := range append([]string{primary}, fallbacks...) { + raw = strings.TrimSpace(raw) + if raw != "" { + keys = append(keys, raw) + } + } + return strings.Join(keys, ",") +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil } - if flagKey != "" { - return flagKey + dst := make(map[string]string, len(src)) + for key, value := range src { + dst[key] = value } - return configKeys + return dst } diff --git a/pkg/runner/application_config.go b/pkg/runner/application_config.go index 8aaade0c..20f499c0 100644 --- a/pkg/runner/application_config.go +++ b/pkg/runner/application_config.go @@ -24,25 +24,27 @@ type ApplicationProviderConfig struct { } type ScannerConfig struct { - CyberhubURL string - CyberhubKey string - CyberhubMode string - AIEnabled bool - VerifyMode string - Proxy string - FofaEmail string - FofaKey string - HunterToken string - HunterAPIKey string - ReconProxy string - ReconLimit int + CyberhubURL string + CyberhubKey string + CyberhubMode string + AIEnabled bool + VerifyMode string + Proxy string + FofaEmail string + FofaKey string + HunterToken string + HunterAPIKey string + ReconProxy string + ReconLimit int + ReconProviderKeys map[string]string } type ToolConfig struct { - Enabled bool - BashTimeout int - TavilyKeys string - OptionalTools []string // optional tool groups to enable (e.g. "search", "browser") + Enabled bool + BashTimeout int + TavilyKeys string + PlaywrightSession string + OptionalTools []string // optional tool groups to enable (e.g. "search", "browser") } type IOAConfig struct { diff --git a/pkg/runner/provider_config.go b/pkg/runner/provider_config.go index de1eff24..0576e435 100644 --- a/pkg/runner/provider_config.go +++ b/pkg/runner/provider_config.go @@ -1,13 +1,15 @@ package runner import ( + "strings" + "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" ) func defaultProviderConfig() agent.ProviderConfig { return agent.ProviderConfig{ - Provider: cfg.DefaultProvider, + Provider: agent.NormalizeProvider(cfg.DefaultProvider), BaseURL: cfg.DefaultBaseURL, APIKey: cfg.DefaultAPIKey, Model: cfg.DefaultModel, @@ -19,8 +21,14 @@ func hasSingleProviderFields(option *cfg.Option) bool { } func entryToProviderConfig(entry cfg.LLMProviderEntry) agent.ProviderConfig { + providerName := strings.TrimSpace(entry.Provider) + if providerName == "" { + providerName = agent.InferProviderFromBaseURL(entry.BaseURL) + } else { + providerName = agent.NormalizeProvider(providerName) + } cfg := agent.ProviderConfig{ - Provider: entry.Provider, + Provider: providerName, BaseURL: entry.BaseURL, APIKey: entry.APIKey, Model: entry.Model, @@ -66,12 +74,12 @@ func ProviderConfig(option *cfg.Option) agent.ProviderConfig { } cfg := defaultProviderConfig() if option.Provider != "" { - cfg.Provider = option.Provider + cfg.Provider = agent.NormalizeProvider(option.Provider) } if option.BaseURL != "" { cfg.BaseURL = option.BaseURL if option.Provider == "" { - cfg.Provider = "" + cfg.Provider = agent.InferProviderFromBaseURL(option.BaseURL) } } if option.APIKey != "" { diff --git a/pkg/runner/provider_config_test.go b/pkg/runner/provider_config_test.go index 9476676b..42f1e788 100644 --- a/pkg/runner/provider_config_test.go +++ b/pkg/runner/provider_config_test.go @@ -19,7 +19,7 @@ func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { t.Fatalf("primary profile = %+v", primary) } fallbacks := FallbackProviderConfigs(&option) - if len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" || fallbacks[0].APIKey != "dk-111" { + if len(fallbacks) != 1 || fallbacks[0].Provider != "openai" || fallbacks[0].APIKey != "dk-111" { t.Fatalf("fallback profiles = %+v", fallbacks) } } @@ -33,7 +33,7 @@ func TestProviderConfigExplicitFieldsWin(t *testing.T) { if primary.Provider != "anthropic" || primary.APIKey != "cli-key" || primary.Model != "cli-model" { t.Fatalf("explicit provider = %+v", primary) } - if fallbacks := FallbackProviderConfigs(&option); len(fallbacks) != 1 || fallbacks[0].Provider != "deepseek" { + if fallbacks := FallbackProviderConfigs(&option); len(fallbacks) != 1 || fallbacks[0].Provider != "openai" { t.Fatalf("fallback profiles = %+v", fallbacks) } } diff --git a/pkg/runner/remote_repl_test.go b/pkg/runner/remote_repl_test.go index 7a8ca656..fd0024cc 100644 --- a/pkg/runner/remote_repl_test.go +++ b/pkg/runner/remote_repl_test.go @@ -15,9 +15,7 @@ func TestRuntimeOwnsPersistentMainREPLWithoutProvider(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - t.Setenv("AISCAN_REPL", "fast") - - option := &cfg.Option{} + option := &cfg.Option{REPLMode: "fast"} rt, err := NewAgentRuntime(ctx, option, telemetry.NopLogger(), &RuntimeConfig{ ProviderOptional: true, NoOutput: true, @@ -137,8 +135,7 @@ func TestEphemeralLocalREPLDoesNotCreateBufferedPTYConsole(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - t.Setenv("AISCAN_REPL", "fast") - rt, err := NewAgentRuntime(ctx, &cfg.Option{}, telemetry.NopLogger(), &RuntimeConfig{ + rt, err := NewAgentRuntime(ctx, &cfg.Option{REPLMode: "fast"}, telemetry.NopLogger(), &RuntimeConfig{ ProviderOptional: true, NoOutput: true, REPLMode: REPLEphemeral, diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 20d15b00..9dc16ea9 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -514,7 +514,8 @@ func runInteractiveMode(ctx context.Context, option *cfg.Option, logger telemetr // --------------------------------------------------------------------------- func RunDirectScannerMode(ctx context.Context, option *cfg.Option, rest []string, logger telemetry.Logger) error { - features, scannerArgs, err := DirectScannerRuntimeFeatures(rest) + defaultVerify := cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify) + features, scannerArgs, err := DirectScannerRuntimeFeaturesWithDefault(rest, defaultVerify) if err != nil { return err } diff --git a/pkg/runner/scanner.go b/pkg/runner/scanner.go index 69862489..291b8dbe 100644 --- a/pkg/runner/scanner.go +++ b/pkg/runner/scanner.go @@ -8,13 +8,17 @@ import ( ) func DirectScannerRuntimeFeatures(rest []string) (RuntimeFeatures, []string, error) { + return DirectScannerRuntimeFeaturesWithDefault(rest, config.DefaultVerify) +} + +func DirectScannerRuntimeFeaturesWithDefault(rest []string, defaultVerify string) (RuntimeFeatures, []string, error) { if len(rest) == 0 { return RuntimeFeatures{}, nil, fmt.Errorf("missing scanner command") } if rest[0] != "scan" { return RuntimeFeatures{}, rest, nil } - verifyMode, explicit := scannerVerifyMode(rest[1:]) + verifyMode, explicit := scannerVerifyMode(rest[1:], defaultVerify) sniperEnabled := HasScannerFlag(rest[1:], "--sniper") deepEnabled := HasScannerFlag(rest[1:], "--deep") aiSkillRequested := sniperEnabled || deepEnabled @@ -104,7 +108,7 @@ func isDirectScannerJSONOutput(rest []string) bool { return false } -func scannerVerifyMode(args []string) (string, bool) { +func scannerVerifyMode(args []string, defaultVerify string) (string, bool) { for i := 0; i < len(args); i++ { arg := args[i] key, value, hasValue := strings.Cut(arg, "=") @@ -119,7 +123,7 @@ func scannerVerifyMode(args []string) (string, bool) { } return "", true } - return defaultVerifyMode(), false + return defaultVerifyMode(defaultVerify), false } func replaceOrAppendScannerFlag(args []string, flag, value string) []string { @@ -144,8 +148,8 @@ func replaceOrAppendScannerFlag(args []string, flag, value string) []string { return append(out, flag+"="+value) } -func defaultVerifyMode() string { - value := strings.ToLower(strings.TrimSpace(config.DefaultVerify)) +func defaultVerifyMode(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "off" } diff --git a/pkg/tui/console.go b/pkg/tui/console.go index 85c764bd..f304f35c 100644 --- a/pkg/tui/console.go +++ b/pkg/tui/console.go @@ -127,7 +127,7 @@ func NewAgentConsoleWithTerminal(ctx context.Context, option *cfg.Option, appInf stdout: stdout, stderr: stderr, } - if isTerminal && isLocalAgentTerminal(t) && resolveRenderMode() == ModeInteractive { + if isTerminal && isLocalAgentTerminal(t) && resolveRenderMode(renderModeValue(option)) == ModeInteractive { bridge := newReadlineConsoleBridge(c.Shell(), t.Out, func() bool { return repl.readlineActive.Load() }) @@ -503,7 +503,11 @@ func (r *AgentConsole) fastInputEnabled() bool { if r != nil && r.terminal != nil && r.terminal.Control != nil { isTerminal = r.terminal.Control.IsTerminal() } - return fastInputEnabledForMode(os.Getenv("AISCAN_REPL"), isTerminal) + mode := "" + if r != nil && r.option != nil { + mode = r.option.REPLMode + } + return fastInputEnabledForMode(mode, isTerminal) } func fastInputEnabledForMode(mode string, _ bool) bool { diff --git a/pkg/tui/output.go b/pkg/tui/output.go index f605d79a..133ff521 100644 --- a/pkg/tui/output.go +++ b/pkg/tui/output.go @@ -82,7 +82,7 @@ func NewAgentOutput(option *cfg.Option) *AgentOutput { return newAgentOutput(option, os.Stdout, os.Stderr, term.IsTerminal(int(os.Stdout.Fd())), term.IsTerminal(int(os.Stderr.Fd())), - resolveRenderMode()) + resolveRenderMode(renderModeValue(option))) } func NewStaticAgentOutput(option *cfg.Option) *AgentOutput { @@ -93,7 +93,14 @@ func NewStaticAgentOutput(option *cfg.Option) *AgentOutput { } func NewAgentOutputWithWriters(option *cfg.Option, stdout, stderr io.Writer, terminal bool) *AgentOutput { - return newAgentOutputWithWriters(option, stdout, stderr, terminal, resolveRenderMode()) + return newAgentOutputWithWriters(option, stdout, stderr, terminal, resolveRenderMode(renderModeValue(option))) +} + +func renderModeValue(option *cfg.Option) string { + if option == nil { + return "" + } + return option.RenderMode } func NewStaticAgentOutputWithWriters(option *cfg.Option, stdout, stderr io.Writer, terminal bool) *AgentOutput { diff --git a/pkg/tui/render.go b/pkg/tui/render.go index 46d19dcb..0921b88c 100644 --- a/pkg/tui/render.go +++ b/pkg/tui/render.go @@ -3,7 +3,6 @@ package tui import ( "fmt" "io" - "os" "strings" "sync" "time" @@ -24,8 +23,8 @@ const ( ModeForwarded ) -func resolveRenderMode() RenderMode { - switch strings.ToLower(strings.TrimSpace(os.Getenv("AISCAN_RENDER"))) { +func resolveRenderMode(value string) RenderMode { + switch strings.ToLower(strings.TrimSpace(value)) { case "static", "plain", "noninteractive", "non-interactive", "off": return ModeStatic case "forwarded", "forward", "remote", "pipe": diff --git a/pkg/web/agents.go b/pkg/web/agents.go index 95d14491..978992cb 100644 --- a/pkg/web/agents.go +++ b/pkg/web/agents.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "time" + agentprovider "github.com/chainreactors/aiscan/agent/provider" "github.com/chainreactors/aiscan/core/aop" "github.com/chainreactors/aiscan/core/output" "github.com/chainreactors/aiscan/pkg/webproto" @@ -926,7 +927,7 @@ func (p *AgentPool) handleAgentMessage(a *remoteAgent, msg webproto.Message) { if len(msg.Payload) > 0 && json.Unmarshal(msg.Payload, &result) == nil { a.mu.Lock() if result.OK { - a.status.Provider = result.Provider + a.status.Provider = agentprovider.NormalizeProvider(result.Provider) a.status.Model = result.Model a.status.ConfigError = "" } else { diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index 7a492acf..ecfeead9 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -25,10 +25,10 @@ func TestActivateLLMProfileSelectsByID(t *testing.T) { if store.cfg.LLM.ActiveProfile != "fast" || store.cfg.LLM.Providers[0].ID != "primary" { t.Fatalf("active profile not switched by id: %+v", store.cfg.LLM) } - if active := store.cfg.LLM.Active(); active.Model != "deepseek-fast" || active.APIKey != "key-2" { + if active := store.cfg.LLM.Active(); active.Provider != "openai" || active.Model != "deepseek-fast" || active.APIKey != "key-2" { t.Fatalf("Active() did not resolve the selected profile: %+v", active) } - if status.LLM.ActiveProfile != "fast" || status.LLM.Model != "deepseek-fast" { + if status.LLM.ActiveProfile != "fast" || status.LLM.Provider != "openai" || status.LLM.Model != "deepseek-fast" { t.Fatalf("status not synchronized: %+v", status.LLM) } } diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index 5fd0b76a..9b62c509 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -106,7 +106,7 @@ func TestHandleConfigReloadResultUpdatesAgentStatus(t *testing.T) { }) pool.handleAgentMessage(a, WSMessage{Type: "config.result", Payload: payload}) got := a.info().Status - if got.Provider != "deepseek" || got.Model != "deepseek-v4-pro" || got.ConfigError != "" { + if got.Provider != "openai" || got.Model != "deepseek-v4-pro" || got.ConfigError != "" { t.Fatalf("unexpected config result status: %+v", got) } diff --git a/pkg/web/types.go b/pkg/web/types.go index 58618590..76756e0e 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -138,6 +138,7 @@ func ConfigStatusFromDistribute(d *webproto.DistributeConfig, path string, loade cs.LLM.ContextWindow = active.ContextWindow cs.LLM.ActiveProfile = d.LLM.ActiveProfile for _, profile := range d.LLM.Providers { + profile = webproto.NormalizeLLMProvider(profile) cs.LLM.Profiles = append(cs.LLM.Profiles, LLMProfileStatus{ ID: profile.ID, Name: profile.Name, Provider: profile.Provider, BaseURL: profile.BaseURL, APIKeyConfigured: profile.APIKey != "", diff --git a/pkg/webagent/remote.go b/pkg/webagent/remote.go index d6d28f88..9c2504b2 100644 --- a/pkg/webagent/remote.go +++ b/pkg/webagent/remote.go @@ -38,6 +38,7 @@ func fetchRemoteConfig(webURL string) (*cfg.Option, error) { if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { return nil, fmt.Errorf("decode remote config: %w", err) } + webproto.MigrateLLMConfig(&dc.LLM, webproto.LLMProviderConfig{}) return distributeToOption(&dc), nil } @@ -67,6 +68,9 @@ func distributeToOption(d *webproto.DistributeConfig) *cfg.Option { ScanConfig: cfg.ScanConfigOptions{ Verify: d.Scan.Verify, }, + SearchConfig: cfg.SearchConfigOptions{ + TavilyKeys: d.Search.TavilyKeys, + }, } opt.FofaEmail = d.Recon.FofaEmail opt.FofaKey = d.Recon.FofaKey diff --git a/pkg/webagent/remote_test.go b/pkg/webagent/remote_test.go index 2b0f38f6..c5cee377 100644 --- a/pkg/webagent/remote_test.go +++ b/pkg/webagent/remote_test.go @@ -37,7 +37,7 @@ func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { t.Fatalf("unexpected remote option: %+v", option.LLMOptions) } primary := option.Providers[0] - if primary.Provider != "deepseek" || primary.Model != "deepseek-chat" { + if primary.Provider != "openai" || primary.Model != "deepseek-chat" { t.Fatalf("unexpected primary profile: %+v", primary) } if primary.MaxTokens != 8192 || primary.ContextWindow != 128000 { diff --git a/pkg/webproto/config.go b/pkg/webproto/config.go index 08b91980..64522052 100644 --- a/pkg/webproto/config.go +++ b/pkg/webproto/config.go @@ -1,6 +1,11 @@ package webproto -import "fmt" +import ( + "fmt" + "strings" + + agentprovider "github.com/chainreactors/aiscan/agent/provider" +) // LLMProviderConfig is one named LLM profile. The profile selected by // ActiveProfile is the runtime primary provider; the remaining entries are @@ -32,10 +37,10 @@ func (c LLMConfig) Active() LLMProviderConfig { } for _, p := range c.Providers { if p.ID == c.ActiveProfile { - return p + return NormalizeLLMProvider(p) } } - return c.Providers[0] + return NormalizeLLMProvider(c.Providers[0]) } // MigrateLLMConfig normalizes a freshly loaded config exactly once: a legacy @@ -53,6 +58,7 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { llm.Providers = []LLMProviderConfig{flat} } for i := range llm.Providers { + llm.Providers[i] = NormalizeLLMProvider(llm.Providers[i]) if llm.Providers[i].ID == "" { llm.Providers[i].ID = fmt.Sprintf("profile-%d", i+1) } @@ -67,6 +73,17 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { llm.ActiveProfile = active.ID } +// NormalizeLLMProvider collapses vendor labels to the two supported wire +// protocols while preserving the profile's endpoint, model, and identity. +func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { + if strings.TrimSpace(profile.Provider) != "" { + profile.Provider = agentprovider.NormalizeProvider(profile.Provider) + } else { + profile.Provider = agentprovider.InferFromBaseURL(profile.BaseURL) + } + return profile +} + // DistributeConfig is the configuration payload sent from the web server // to agents. All secret fields are included so agents can use them. // Also used by the settings UI (with secrets masked at the handler level). diff --git a/pkg/webproto/config_test.go b/pkg/webproto/config_test.go new file mode 100644 index 00000000..ddb7b963 --- /dev/null +++ b/pkg/webproto/config_test.go @@ -0,0 +1,18 @@ +package webproto + +import "testing" + +func TestMigrateLLMConfigNormalizesProviderProtocol(t *testing.T) { + config := LLMConfig{Providers: []LLMProviderConfig{ + {ID: "deepseek", Provider: "deepseek", BaseURL: "https://api.deepseek.com/v1"}, + {ID: "claude", Provider: "anthropic"}, + }} + MigrateLLMConfig(&config, LLMProviderConfig{}) + + if config.Providers[0].Provider != "openai" { + t.Fatalf("OpenAI-compatible provider = %q", config.Providers[0].Provider) + } + if config.Providers[1].Provider != "anthropic" { + t.Fatalf("Anthropic provider = %q", config.Providers[1].Provider) + } +} diff --git a/tools/ioa/commands_test.go b/tools/ioa/commands_test.go index f01b33d6..6844dade 100644 --- a/tools/ioa/commands_test.go +++ b/tools/ioa/commands_test.go @@ -493,10 +493,10 @@ func TestDefaultSpaceSkipsJoin(t *testing.T) { func TestLLMIOAToolUsage(t *testing.T) { apiKey := os.Getenv("LIVE_TEST_API_KEY") if apiKey == "" { - apiKey = os.Getenv("DEEPSEEK_API_KEY") + apiKey = os.Getenv("OPENAI_API_KEY") } if apiKey == "" { - t.Skip("set LIVE_TEST_API_KEY or DEEPSEEK_API_KEY to run live LLM IOA test") + t.Skip("set LIVE_TEST_API_KEY or OPENAI_API_KEY to run live LLM IOA test") } baseURL := envOr("LIVE_TEST_BASE_URL", "https://api.deepseek.com") model := envOr("LIVE_TEST_MODEL", "deepseek-v4-pro") diff --git a/tools/playwright/browser.go b/tools/playwright/browser.go index 44fd2074..4365f4b7 100644 --- a/tools/playwright/browser.go +++ b/tools/playwright/browser.go @@ -46,8 +46,9 @@ type Command struct { proxyURL string // Browser mode: headed (GUI) vs headless, optional CDP endpoint. - headed bool - cdpURL string + headed bool + cdpURL string + defaultSession string } // New creates a playwright pseudo-command. @@ -55,6 +56,11 @@ func New(workDir string) *Command { return &Command{workDir: workDir} } +func (c *Command) WithDefaultSession(session string) *Command { + c.defaultSession = strings.TrimSpace(session) + return c +} + // SetProxy updates the proxy URL for new browser launches. func (c *Command) SetProxy(proxyURLStr string) { c.proxyMu.Lock() @@ -245,8 +251,9 @@ func (c *Command) Run(ctx context.Context, execution *commands.Execution) (_ any return nil, fmt.Errorf("playwright: subcommand required\n\n%s", c.Usage()) } - // Extract global -s flag (playwright-cli alignment) and PLAYWRIGHT_CLI_SESSION env var. - globalSession := os.Getenv("PLAYWRIGHT_CLI_SESSION") + // Extract global -s flag. The environment-derived default is resolved once + // by core/config and injected when the command is constructed. + globalSession := c.defaultSession var cleanArgs []string for i := 0; i < len(args); i++ { if args[i] == "-s" && i+1 < len(args) { diff --git a/tools/playwright/browser_test.go b/tools/playwright/browser_test.go index 61a54fe8..7eb7da47 100644 --- a/tools/playwright/browser_test.go +++ b/tools/playwright/browser_test.go @@ -227,6 +227,13 @@ func TestNameAndUsage(t *testing.T) { } } +func TestWithDefaultSession(t *testing.T) { + command := New(".").WithDefaultSession(" session-1 ") + if command.defaultSession != "session-1" { + t.Fatalf("default session = %q", command.defaultSession) + } +} + func TestFormatTextOutput_Truncation(t *testing.T) { long := strings.Repeat("a", maxOutputLen+100) out := formatTextOutput("https://example.com", long) diff --git a/tools/playwright/register.go b/tools/playwright/register.go index a1d58326..72d6dbae 100644 --- a/tools/playwright/register.go +++ b/tools/playwright/register.go @@ -12,7 +12,7 @@ func init() { commands.RegisterFactory(commands.Factory{ Capability: "browser", Build: func(deps *commands.Deps, reg *commands.CommandRegistry) { - impl := New(deps.WorkDir) + impl := New(deps.WorkDir).WithDefaultSession(deps.PlaywrightSession) reg.Register(commands.Command{Name: impl.Name(), Usage: impl.Usage(), Run: impl.Run, Close: impl.Close}, "browser") }, }) diff --git a/tools/scan/engine/set.go b/tools/scan/engine/set.go index 66587bcf..ea52784b 100644 --- a/tools/scan/engine/set.go +++ b/tools/scan/engine/set.go @@ -33,6 +33,7 @@ type ReconOptions struct { HunterAPIKey string // 华顺信安后台 API 管理生成的 api-key (推荐, 64 位 hex) Limit int IngressProxy string // 给 uncover 的全局出站代理 (http://, https://, socks5://, socks5h://) + ProviderKeys map[string]string } type Set struct { diff --git a/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go index 530f9c89..b050229a 100644 --- a/tools/scan/engine/set_uncover_recon.go +++ b/tools/scan/engine/set_uncover_recon.go @@ -43,5 +43,13 @@ func mergeReconOptions(base, next ReconOptions) ReconOptions { if next.Limit != 0 { base.Limit = next.Limit } + if len(next.ProviderKeys) > 0 { + if base.ProviderKeys == nil { + base.ProviderKeys = make(map[string]string, len(next.ProviderKeys)) + } + for key, value := range next.ProviderKeys { + base.ProviderKeys[key] = value + } + } return base } diff --git a/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go index f8b85b63..978d82ab 100644 --- a/tools/scan/engine/uncover.go +++ b/tools/scan/engine/uncover.go @@ -31,8 +31,8 @@ type UncoverEngine struct { avail []string } -// NewUncoverEngine builds an engine from ReconOptions credentials merged with -// environment-provided keys (for sources like shodan, censys, etc.). +// NewUncoverEngine builds an engine from fully resolved ReconOptions. It does +// not read process environment variables; core/config owns that boundary. func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine { if logger == nil { logger = telemetry.NopLogger() @@ -55,7 +55,7 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine p.Hunter = append(p.Hunter, opts.HunterToken) } - p.LoadProviderKeysFromEnv() + applyProviderKeys(p, opts.ProviderKeys) keys := p.GetKeys() // uncover's GetKeys only populates the FofaEmail/FofaKey pair when the stored @@ -87,6 +87,38 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine return e } +func applyProviderKeys(p *sources.Provider, values map[string]string) { + appendValue := func(dst *[]string, name string) { + if value := strings.TrimSpace(values[name]); value != "" { + *dst = append(*dst, value) + } + } + appendPair := func(dst *[]string, first, second string) { + left := strings.TrimSpace(values[first]) + right := strings.TrimSpace(values[second]) + if left != "" && right != "" { + *dst = append(*dst, left+":"+right) + } + } + + appendValue(&p.Shodan, "SHODAN_API_KEY") + appendValue(&p.Quake, "QUAKE_TOKEN") + appendValue(&p.Netlas, "NETLAS_API_KEY") + appendValue(&p.CriminalIP, "CRIMINALIP_API_KEY") + appendValue(&p.Publicwww, "PUBLICWWW_API_KEY") + appendValue(&p.HunterHow, "HUNTERHOW_API_KEY") + appendValue(&p.ZoomEye, "ZOOMEYE_API_KEY") + appendValue(&p.Driftnet, "DRIFTNET_API_KEY") + appendValue(&p.Daydaymap, "DAYDAYMAP_API_KEY") + appendValue(&p.Odin, "ODIN_API_KEY") + appendValue(&p.BinaryEdge, "BINARYEDGE_API_KEY") + appendValue(&p.Onyphe, "ONYPHE_API_KEY") + appendValue(&p.GreyNoise, "GREYNOISE_API_KEY") + appendValue(&p.NerdyData, "NERDYDATA_API_KEY") + appendPair(&p.Censys, "CENSYS_API_TOKEN", "CENSYS_ORGANIZATION_ID") + appendPair(&p.Google, "GOOGLE_API_KEY", "GOOGLE_API_CX") +} + func (e *UncoverEngine) detectSources() []string { type check struct { name string diff --git a/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go index e151fd93..0194a8d7 100644 --- a/tools/scan/engine/uncover_test.go +++ b/tools/scan/engine/uncover_test.go @@ -49,6 +49,33 @@ func TestNewUncoverEngineFofaLegacyEmailKey(t *testing.T) { } } +func TestNewUncoverEngineDoesNotRereadCredentialEnvironment(t *testing.T) { + t.Setenv("FOFA_EMAIL", "env@example.com") + t.Setenv("FOFA_KEY", "env-key") + t.Setenv("HUNTER_API_KEY", "env-hunter") + + eng := NewUncoverEngine(ReconOptions{ + FofaEmail: "cli@example.com", + FofaKey: "cli-key", + HunterAPIKey: "cli-hunter", + }, nil) + if eng.keys.FofaEmail != "cli@example.com" || eng.keys.FofaKey != "cli-key" { + t.Fatalf("FOFA environment bypassed resolved config: %#v", eng.keys) + } + if eng.keys.HunterToken != "cli-hunter" { + t.Fatalf("Hunter environment bypassed resolved config: %#v", eng.keys) + } +} + +func TestNewUncoverEngineUsesInjectedProviderKeys(t *testing.T) { + eng := NewUncoverEngine(ReconOptions{ProviderKeys: map[string]string{ + "SHODAN_API_KEY": "shodan-key", + }}, nil) + if eng.keys.Shodan != "shodan-key" || !sourceAvailable(eng, "shodan") { + t.Fatalf("injected provider key not applied: %#v", eng.keys) + } +} + func sourceAvailable(e *UncoverEngine, name string) bool { for _, s := range e.Sources() { if s == name { diff --git a/tools/search/tavily.go b/tools/search/tavily.go index 82c7b4b8..a339a6e0 100644 --- a/tools/search/tavily.go +++ b/tools/search/tavily.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "os" "regexp" "strconv" "strings" @@ -70,8 +69,6 @@ func NewTavilySearch(builtinKeys string) *TavilySearch { } } - addKeys(os.Getenv("TAVILY_API_KEY")) - addKeys(os.Getenv("TAVILY_API_KEYS")) addKeys(builtinKeys) if len(keys) > 0 { diff --git a/tools/search/tavily_test.go b/tools/search/tavily_test.go index 2a5d84ff..5d844944 100644 --- a/tools/search/tavily_test.go +++ b/tools/search/tavily_test.go @@ -18,6 +18,14 @@ func TestParseTavilyArgsBasicQuery(t *testing.T) { } } +func TestNewTavilySearchUsesInjectedKeysOnly(t *testing.T) { + t.Setenv("TAVILY_API_KEY", "ambient-key") + search := NewTavilySearch("resolved-key") + if search.apiKey != "resolved-key" || len(search.apiKeys) != 1 { + t.Fatalf("ambient environment bypassed resolved config: %#v", search.apiKeys) + } +} + func TestParseTavilyArgsWithNum(t *testing.T) { query, num, err := parseTavilyArgs([]string{"nginx", "--num", "8"}) if err != nil { diff --git a/web/frontend/e2e/start-server.mjs b/web/frontend/e2e/start-server.mjs index 2d4fcf57..ba975dde 100644 --- a/web/frontend/e2e/start-server.mjs +++ b/web/frontend/e2e/start-server.mjs @@ -67,7 +67,7 @@ await writeFile(configPath, `llm: providers: - id: e2e name: E2E DeepSeek - provider: deepseek + provider: openai base_url: http://${host}:${llmAddress.port}/v1 api_key: test-key model: deepseek-chat diff --git a/web/frontend/src/components/ConfigPanel.tsx b/web/frontend/src/components/ConfigPanel.tsx index 87d7c777..b3f00ea1 100644 --- a/web/frontend/src/components/ConfigPanel.tsx +++ b/web/frontend/src/components/ConfigPanel.tsx @@ -27,14 +27,8 @@ const TABS: { key: TabKey; label: string }[] = [ ] const LLM_PROVIDERS = [ - { value: 'deepseek', label: 'DeepSeek' }, - { value: 'openai', label: 'OpenAI' }, - { value: 'openrouter', label: 'OpenRouter' }, - { value: 'ollama', label: 'Ollama' }, - { value: 'groq', label: 'Groq' }, - { value: 'moonshot', label: 'Moonshot' }, + { value: 'openai', label: 'OpenAI-compatible' }, { value: 'anthropic', label: 'Anthropic' }, - { value: 'zhipu', label: 'Zhipu GLM' }, ] function emptyForm(): DistributeConfig { @@ -54,6 +48,7 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { const profiles: LLMProviderProfile[] = cs.llm.profiles?.length ? cs.llm.profiles.map(profile => ({ ...profile, + provider: normalizeProvider(profile.provider), api_key: '', context_window: positiveInteger(profile.context_window), max_tokens: positiveInteger(profile.max_tokens), @@ -61,7 +56,7 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { : [{ id: cs.llm.active_profile || 'default', name: cs.llm.model || cs.llm.provider || 'Default', - provider: cs.llm.provider, + provider: normalizeProvider(cs.llm.provider), base_url: cs.llm.base_url, api_key: '', model: cs.llm.model, @@ -87,6 +82,10 @@ function blankLLMProfile(id = `llm-${Date.now()}`): LLMProviderProfile { return { id, name: 'New LLM', provider: 'openai', base_url: '', api_key: '', model: '', proxy: '' } } +function normalizeProvider(provider: string): 'openai' | 'anthropic' { + return provider.trim().toLowerCase() === 'anthropic' ? 'anthropic' : 'openai' +} + function positiveInteger(value: number | undefined): number | undefined { return Number.isSafeInteger(value) && Number(value) > 0 ? value : undefined } @@ -478,7 +477,7 @@ function LLMTab({
updateProfile('api_key', e.target.value)} - placeholder={configuredProfile?.api_key_configured ? t('configuredKeep') : t('requiredUnlessOllama')} /> + placeholder={configuredProfile?.api_key_configured ? t('configuredKeep') : t('apiKeyRequired')} />
diff --git a/web/frontend/src/i18n/locales/en/config.ts b/web/frontend/src/i18n/locales/en/config.ts index b144b1a9..645490a5 100644 --- a/web/frontend/src/i18n/locales/en/config.ts +++ b/web/frontend/src/i18n/locales/en/config.ts @@ -59,7 +59,7 @@ export default { autoSaveSessions: 'Auto-save sessions', // placeholder hints configuredKeep: 'configured; leave blank to keep', - requiredUnlessOllama: 'required unless ollama', + apiKeyRequired: 'API key required', modelRequired: 'Model is required', modelRequiredProfile: 'Profile “{{name}}” requires a model', providerDefault: 'leave empty for provider default', diff --git a/web/frontend/src/i18n/locales/zh/config.ts b/web/frontend/src/i18n/locales/zh/config.ts index 5de3a2cd..229b725d 100644 --- a/web/frontend/src/i18n/locales/zh/config.ts +++ b/web/frontend/src/i18n/locales/zh/config.ts @@ -59,7 +59,7 @@ export default { autoSaveSessions: '自动保存会话', // placeholder hints configuredKeep: '已配置;留空则保持不变', - requiredUnlessOllama: '必填(ollama 除外)', + apiKeyRequired: '需要 API Key', modelRequired: '模型不能为空', modelRequiredProfile: '配置 “{{name}}” 的模型不能为空', providerDefault: '留空则使用 Provider 默认值', From d8119495db156662378a9f67620ecd7565b8c2ad Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 13:18:38 +0800 Subject: [PATCH 2/7] refactor(config): enforce strict provider protocols --- agent/provider/provider.go | 84 ++++++-------- agent/provider/provider_test.go | 36 ++---- agent/types.go | 1 + cmd/aiscan/setup.go | 2 +- core/config/config_gen.go | 4 +- core/config/env.go | 117 +++++++++++--------- core/config/loader_test.go | 107 ++++++++++-------- core/config/options.go | 8 +- docs/mechanisms.md | 6 +- docs/reference.md | 28 ++--- pkg/runner/application_builder.go | 26 ++--- pkg/runner/application_config.go | 26 ++--- pkg/runner/provider_config_test.go | 4 +- pkg/runner/runtime_config.go | 9 +- pkg/web/config_profiles_test.go | 2 +- pkg/web/config_reload_test.go | 2 +- pkg/web/validation.go | 5 + pkg/web/validation_test.go | 17 ++- pkg/webagent/remote_test.go | 2 +- pkg/webproto/config.go | 4 +- pkg/webproto/config_test.go | 10 +- test-skips.json | 2 +- tools/scan/engine/set.go | 2 +- tools/scan/engine/set_uncover_recon.go | 10 +- tools/scan/engine/uncover.go | 4 +- tools/scan/engine/uncover_test.go | 4 +- tools/search/cyberhub.go | 2 +- web/frontend/src/components/ConfigPanel.tsx | 61 +++++++--- web/frontend/src/i18n/locales/en/config.ts | 5 +- web/frontend/src/i18n/locales/zh/config.ts | 9 +- 30 files changed, 327 insertions(+), 272 deletions(-) diff --git a/agent/provider/provider.go b/agent/provider/provider.go index 96ddf9b4..ca422441 100644 --- a/agent/provider/provider.go +++ b/agent/provider/provider.go @@ -42,33 +42,27 @@ type ProviderConfig struct { ContextWindow int `yaml:"context_window,omitempty" config:"context_window"` } -type providerPreset struct { - Protocol string - BaseURL string - APIKeyRequired bool -} +const ( + ProviderOpenAI = "openai" + ProviderAnthropic = "anthropic" +) -var providerPresets = map[string]providerPreset{ - "openai": {Protocol: "openai", BaseURL: "https://api.openai.com/v1", APIKeyRequired: true}, - "anthropic": {Protocol: "anthropic", BaseURL: "https://api.anthropic.com/v1", APIKeyRequired: true}, - "deepseek": {Protocol: "openai", BaseURL: "https://api.deepseek.com/v1", APIKeyRequired: true}, - "openrouter": {Protocol: "openai", BaseURL: "https://openrouter.ai/api/v1", APIKeyRequired: true}, - "groq": {Protocol: "openai", BaseURL: "https://api.groq.com/openai/v1", APIKeyRequired: true}, - "moonshot": {Protocol: "openai", BaseURL: "https://api.moonshot.cn/v1", APIKeyRequired: true}, - "ollama": {Protocol: "openai", BaseURL: "http://localhost:11434/v1"}, - "zhipu": {Protocol: "openai", BaseURL: "https://open.bigmodel.cn/api/paas/v4", APIKeyRequired: true}, +var providerBaseURLs = map[string]string{ + ProviderOpenAI: "https://api.openai.com/v1", + ProviderAnthropic: "https://api.anthropic.com/v1", } -var providerAliases = map[string]string{ - "bigmodel": "zhipu", - "glm": "zhipu", +func NormalizeProvider(name string) string { + return strings.ToLower(strings.TrimSpace(name)) } -func NormalizeProvider(name string) string { - if strings.EqualFold(name, "anthropic") { - return "anthropic" +func IsSupportedProvider(name string) bool { + switch NormalizeProvider(name) { + case ProviderOpenAI, ProviderAnthropic: + return true + default: + return false } - return "openai" } func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) { @@ -80,33 +74,19 @@ func Resolve(cfg *ProviderConfig) (*ProviderConfig, error) { return nil, fmt.Errorf("context_window must be zero or positive") } - providerName := strings.ToLower(strings.TrimSpace(resolved.Provider)) - if alias, ok := providerAliases[providerName]; ok { - providerName = alias - } - + providerName := NormalizeProvider(resolved.Provider) if providerName == "" { - if resolved.BaseURL != "" { - providerName = InferFromBaseURL(resolved.BaseURL) - } else { - providerName = "openai" - } + providerName = InferFromBaseURL(resolved.BaseURL) } - - preset, knownProvider := providerPresets[providerName] - if knownProvider { - if strings.TrimSpace(resolved.BaseURL) == "" { - resolved.BaseURL = preset.BaseURL - } - resolved.Provider = preset.Protocol - } else { - if strings.TrimSpace(resolved.BaseURL) == "" { - return nil, fmt.Errorf("unknown provider %q: set base_url for a custom OpenAI-compatible endpoint", providerName) - } - resolved.Provider = NormalizeProvider(providerName) + if !IsSupportedProvider(providerName) { + return nil, fmt.Errorf("unsupported provider %q: use openai or anthropic", providerName) + } + if strings.TrimSpace(resolved.BaseURL) == "" { + resolved.BaseURL = providerBaseURLs[providerName] } + resolved.Provider = providerName - if strings.TrimSpace(resolved.APIKey) == "" && (!knownProvider || preset.APIKeyRequired) { + if strings.TrimSpace(resolved.APIKey) == "" { return nil, fmt.Errorf("no API key: set --api-key, llm.api_key, or AISCAN_API_KEY") } @@ -131,9 +111,7 @@ func NewProvider(cfg *ProviderConfig) (Provider, error) { } // inferImageSupport guesses whether a provider+model combination accepts -// image content parts based on the provider type and model name heuristics. -// Defaults to true for known provider types (anthropic/openai) and falls -// back to model-name heuristics for unknown providers. +// image content parts based on the protocol and model name heuristics. func inferImageSupport(provider, model string) bool { p := strings.ToLower(strings.TrimSpace(provider)) m := strings.ToLower(strings.TrimSpace(model)) @@ -163,16 +141,20 @@ func inferImageSupport(provider, model string) bool { // silent failure. func InferFromBaseURL(baseURL string) string { if strings.Contains(strings.ToLower(baseURL), "anthropic.com") { - return "anthropic" + return ProviderAnthropic } - return "openai" + return ProviderOpenAI } func NewProviderFromResolved(cfg *ProviderConfig) (Provider, error) { - if strings.ToLower(cfg.Provider) == "anthropic" { + switch NormalizeProvider(cfg.Provider) { + case ProviderAnthropic: return NewAnthropicProvider(cfg) + case ProviderOpenAI: + return NewOpenAIProvider(cfg) + default: + return nil, fmt.Errorf("unsupported provider %q: use openai or anthropic", cfg.Provider) } - return NewOpenAIProvider(cfg) } // Model capability registry extracted from pi's models.generated.ts. diff --git a/agent/provider/provider_test.go b/agent/provider/provider_test.go index 9c0a271f..c168fc8c 100644 --- a/agent/provider/provider_test.go +++ b/agent/provider/provider_test.go @@ -22,14 +22,6 @@ func TestResolveProviderPresets(t *testing.T) { }{ {name: "openai", provider: "openai", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.openai.com/v1"}, {name: "anthropic", provider: "anthropic", apiKey: "key", wantProtocol: "anthropic", wantBaseURL: "https://api.anthropic.com/v1"}, - {name: "deepseek", provider: "deepseek", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.deepseek.com/v1"}, - {name: "openrouter", provider: "openrouter", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://openrouter.ai/api/v1"}, - {name: "groq", provider: "groq", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.groq.com/openai/v1"}, - {name: "moonshot", provider: "moonshot", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://api.moonshot.cn/v1"}, - {name: "ollama", provider: "ollama", wantProtocol: "openai", wantBaseURL: "http://localhost:11434/v1"}, - {name: "zhipu", provider: "zhipu", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, - {name: "glm alias", provider: "glm", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, - {name: "bigmodel alias", provider: "bigmodel", apiKey: "key", wantProtocol: "openai", wantBaseURL: "https://open.bigmodel.cn/api/paas/v4"}, } for _, tt := range tests { @@ -45,30 +37,18 @@ func TestResolveProviderPresets(t *testing.T) { } } -func TestResolvePreservesExplicitDeepSeekBaseURL(t *testing.T) { - resolved, err := Resolve(&ProviderConfig{ - Provider: "deepseek", - BaseURL: "https://gateway.example/v1", - APIKey: "key", - }) - if err != nil { - t.Fatal(err) - } - if resolved.BaseURL != "https://gateway.example/v1" || resolved.Provider != "openai" { - t.Fatalf("Resolve() = %+v", resolved) - } -} - -func TestResolveUnknownProviderRequiresBaseURL(t *testing.T) { - _, err := Resolve(&ProviderConfig{Provider: "custom", APIKey: "key"}) - if err == nil || !strings.Contains(err.Error(), "base_url") { - t.Fatalf("Resolve() error = %v, want base_url guidance", err) +func TestResolveRejectsUnsupportedProvider(t *testing.T) { + for _, name := range []string{"deepseek", "openrouter", "ollama", "custom"} { + _, err := Resolve(&ProviderConfig{Provider: name, BaseURL: "https://gateway.example/v1", APIKey: "key"}) + if err == nil || !strings.Contains(err.Error(), "use openai or anthropic") { + t.Fatalf("Resolve(%q) error = %v", name, err) + } } } func TestResolveUsesBaseURL(t *testing.T) { cfg, err := Resolve(&ProviderConfig{ - Provider: "ollama", + Provider: "openai", BaseURL: "http://localhost:11434/v1", APIKey: "test-key", }) @@ -93,7 +73,7 @@ func TestResolveRejectsNegativeModelLimits(t *testing.T) { func TestResolvePreservesExplicitBaseURL(t *testing.T) { cfg, err := Resolve(&ProviderConfig{ - Provider: "ollama", + Provider: "openai", BaseURL: "http://base-url.example/v1", APIKey: "test-key", }) diff --git a/agent/types.go b/agent/types.go index 957c0164..a8f862f8 100644 --- a/agent/types.go +++ b/agent/types.go @@ -57,6 +57,7 @@ var ( ResolveProvider = provider.Resolve InferProviderFromBaseURL = provider.InferFromBaseURL NormalizeProvider = provider.NormalizeProvider + IsSupportedProvider = provider.IsSupportedProvider ErrCallTimeout = provider.ErrCallTimeout ErrStreamStalled = provider.ErrStreamStalled diff --git a/cmd/aiscan/setup.go b/cmd/aiscan/setup.go index 97983f4b..b109508e 100644 --- a/cmd/aiscan/setup.go +++ b/cmd/aiscan/setup.go @@ -63,7 +63,7 @@ func initEngines(ctx context.Context, sc runner.ScannerConfig, logger telemetry. HunterAPIKey: sc.HunterAPIKey, IngressProxy: sc.ReconProxy, Limit: sc.ReconLimit, - ProviderKeys: sc.ReconProviderKeys, + Credentials: sc.UncoverCredentials, } engineSet.SetupUncover(recon, logger) return engineSet diff --git a/core/config/config_gen.go b/core/config/config_gen.go index e68a8382..b97e036a 100644 --- a/core/config/config_gen.go +++ b/core/config/config_gen.go @@ -9,13 +9,13 @@ import ( const configFileHeader = `# aiscan 配置文件 # # 运行时: aiscan 自动加载 ./aiscan.yaml 或 <二进制所在目录>/aiscan.yaml -# 优先级: CLI > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 默认值 +# 优先级: CLI > AIScan/集成环境变量 > 配置文件 > 协议环境变量 > 默认值 # 生成: aiscan --init # # 仅填写需要的字段,留空或删除的字段不会覆盖其他来源的值 # # LLM 配置支持两种格式: -# 格式一 — 单 provider 简写(兼容旧配置): +# 格式一 — 单 provider 简写: # llm: # provider: openai # base_url: https://api.deepseek.com/v1 diff --git a/core/config/env.go b/core/config/env.go index 04d756f7..1ef24bcb 100644 --- a/core/config/env.go +++ b/core/config/env.go @@ -1,23 +1,24 @@ package config import ( + "fmt" "os" "strings" ) type envLookup func(string) (string, bool) -// ResolveRuntimeConfig resolves parsed configuration with environment and -// defaults. Provider inference is supplied by the integration layer so config -// remains independent of concrete LLM implementations. -func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider func(string) string) (string, error) { +// ResolveRuntimeConfig resolves parsed configuration with environment and defaults. +func ResolveRuntimeConfig(option *Option, applyProcessState bool) (string, error) { explicit := *option configPath, err := LoadAndApplyConfig(option) if err != nil { return configPath, err } - applyEnvironment(option, explicit, os.LookupEnv, inferProvider) - normalizeProviderOptions(option, inferProvider) + applyEnvironment(option, explicit, os.LookupEnv) + if err := normalizeProviderOptions(option); err != nil { + return configPath, err + } ApplyDefaults(option) if _, err := ResolveOutputPolicy(option); err != nil { return configPath, err @@ -28,20 +29,20 @@ func ResolveRuntimeConfig(option *Option, applyProcessState bool, inferProvider return configPath, nil } -func applyEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { - applyLLMEnvironment(option, explicit, lookup, inferProvider) +func applyEnvironment(option *Option, explicit Option, lookup envLookup) { + applyLLMEnvironment(option, explicit, lookup) applyScannerEnvironment(option, explicit, lookup) applyReconEnvironment(option, explicit, lookup) applyRuntimeEnvironment(option, explicit, lookup) } -func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, inferProvider func(string) string) { +func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup) { providerExplicit := strings.TrimSpace(explicit.Provider) != "" - if v := firstEnv(lookup, "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER"); v != "" && !providerExplicit { + if v := firstEnv(lookup, "AISCAN_PROVIDER"); v != "" && !providerExplicit { option.Provider = v } - selectedProvider := selectedEnvProvider(option, lookup, inferProvider) + selectedProvider := selectedEnvProvider(option, lookup) if option.Provider == "" && selectedProvider != "" && !providerExplicit { option.Provider = selectedProvider } @@ -49,7 +50,7 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe // AISCAN_BASE_URL is aiscan's own namespace: an intentional override that wins // over a base URL set in the config file (CLI --base-url wins via the explicit gate). if strings.TrimSpace(explicit.BaseURL) == "" { - if v := firstEnv(lookup, "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL"); v != "" { + if v := firstEnv(lookup, "AISCAN_BASE_URL"); v != "" { option.BaseURL = v } } @@ -66,11 +67,11 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe } } - // AISCAN_MODEL / AISCAN_LLM_MODEL are aiscan's *own* namespace: an intentional + // AISCAN_MODEL is aiscan's own namespace: an intentional // override that still wins over a model set in the config file (CLI --model // wins over it via the explicit gate). if strings.TrimSpace(explicit.Model) == "" { - if v := firstEnv(lookup, "AISCAN_MODEL", "AISCAN_LLM_MODEL"); v != "" { + if v := firstEnv(lookup, "AISCAN_MODEL"); v != "" { option.Model = v } } @@ -88,7 +89,7 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe // AISCAN_API_KEY is aiscan's own namespace: an intentional override that wins // over a key set in the config file (CLI --api-key wins via the explicit gate). if strings.TrimSpace(explicit.APIKey) == "" { - if v := firstEnv(lookup, "AISCAN_API_KEY", "AISCAN_LLM_API_KEY"); v != "" { + if v := firstEnv(lookup, "AISCAN_API_KEY"); v != "" { option.APIKey = v } } @@ -111,22 +112,22 @@ func applyLLMEnvironment(option *Option, explicit Option, lookup envLookup, infe func applyScannerEnvironment(option *Option, explicit Option, lookup envLookup) { if strings.TrimSpace(explicit.CyberhubURL) == "" { - if v := firstEnv(lookup, "AISCAN_CYBERHUB_URL", "CYBERHUB_URL"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_URL"); v != "" { option.CyberhubURL = v } } if strings.TrimSpace(explicit.CyberhubKey) == "" { - if v := firstEnv(lookup, "AISCAN_CYBERHUB_KEY", "CYBERHUB_KEY"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_KEY"); v != "" { option.CyberhubKey = v } } if strings.TrimSpace(explicit.CyberhubMode) == "" { - if v := firstEnv(lookup, "AISCAN_CYBERHUB_MODE", "CYBERHUB_MODE"); v != "" { + if v := firstEnv(lookup, "AISCAN_CYBERHUB_MODE"); v != "" { option.CyberhubMode = v } } if strings.TrimSpace(explicit.Proxy) == "" { - if v := firstEnv(lookup, "AISCAN_PROXY", "AISCAN_SCANNER_PROXY"); v != "" { + if v := firstEnv(lookup, "AISCAN_PROXY"); v != "" { option.Proxy = v } } @@ -154,7 +155,7 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { } } if strings.TrimSpace(explicit.TavilyKey) == "" { - if v := firstEnv(lookup, "TAVILY_API_KEY", "TAVILY_API_KEYS"); v != "" { + if v := firstEnv(lookup, "TAVILY_API_KEY"); v != "" { option.TavilyKey = v } } @@ -163,7 +164,7 @@ func applyReconEnvironment(option *Option, explicit Option, lookup envLookup) { option.ReconProxy = v } } - applyReconProviderEnvironment(option, lookup) + applyUncoverEnvironment(option, lookup) } func applyRuntimeEnvironment(option *Option, explicit Option, lookup envLookup) { @@ -183,7 +184,7 @@ func applyRuntimeEnvironment(option *Option, explicit Option, lookup envLookup) } } -var reconProviderEnvNames = []string{ +var uncoverCredentialEnvNames = []string{ "SHODAN_API_KEY", "QUAKE_TOKEN", "NETLAS_API_KEY", @@ -204,23 +205,23 @@ var reconProviderEnvNames = []string{ "NERDYDATA_API_KEY", } -func applyReconProviderEnvironment(option *Option, lookup envLookup) { - for _, name := range reconProviderEnvNames { +func applyUncoverEnvironment(option *Option, lookup envLookup) { + for _, name := range uncoverCredentialEnvNames { if value := firstEnv(lookup, name); value != "" { - if option.ReconProviderKeys == nil { - option.ReconProviderKeys = make(map[string]string) + if option.UncoverCredentials == nil { + option.UncoverCredentials = make(map[string]string) } - option.ReconProviderKeys[name] = value + option.UncoverCredentials[name] = value } } } -func selectedEnvProvider(option *Option, lookup envLookup, inferProvider func(string) string) string { +func selectedEnvProvider(option *Option, lookup envLookup) string { if v := strings.ToLower(strings.TrimSpace(option.Provider)); v != "" { return normalizeProviderName(v) } - if option.BaseURL != "" && inferProvider != nil { - return inferProvider(option.BaseURL) + if option.BaseURL != "" { + return inferProviderName(option.BaseURL) } for _, providerName := range []string{"anthropic", "openai"} { if providerAPIKeyEnv(providerName, lookup) != "" { @@ -235,12 +236,7 @@ func providerBaseURLEnv(providerName string, lookup envLookup) string { if providerName == "" { return "" } - if providerName == "openai" { - if v := firstEnv(lookup, "OPENAI_BASE_URL", "OPENAI_BASEURL", "OPENAI_API_BASE_URL", "OPENAI_API_BASE"); v != "" { - return v - } - } - return firstEnv(lookup, providerEnvName(providerName, "BASE_URL"), providerEnvName(providerName, "BASEURL")) + return firstEnv(lookup, providerEnvName(providerName, "BASE_URL")) } func providerModelEnv(providerName string, lookup envLookup) string { @@ -260,38 +256,59 @@ func providerAPIKeyEnv(providerName string, lookup envLookup) string { } func canonicalEnvProvider(providerName string) string { - if strings.TrimSpace(providerName) == "" { + providerName = normalizeProviderName(providerName) + if !isSupportedProviderName(providerName) { return "" } - return normalizeProviderName(providerName) + return providerName } -func normalizeProviderOptions(option *Option, inferProvider func(string) string) { - if strings.TrimSpace(option.Provider) != "" { - option.Provider = normalizeProviderName(option.Provider) - } else if strings.TrimSpace(option.BaseURL) != "" && inferProvider != nil { - option.Provider = normalizeProviderName(inferProvider(option.BaseURL)) +func normalizeProviderOptions(option *Option) error { + if strings.TrimSpace(option.Provider) != "" || strings.TrimSpace(option.BaseURL) != "" { + providerName, err := resolveProviderName(option.Provider, option.BaseURL) + if err != nil { + return err + } + option.Provider = providerName } for i := range option.Providers { - providerName := strings.TrimSpace(option.Providers[i].Provider) - if providerName != "" { - option.Providers[i].Provider = normalizeProviderName(providerName) - } else if inferProvider != nil { - option.Providers[i].Provider = normalizeProviderName(inferProvider(option.Providers[i].BaseURL)) + providerName, err := resolveProviderName(option.Providers[i].Provider, option.Providers[i].BaseURL) + if err != nil { + return fmt.Errorf("LLM profile %q: %w", option.Providers[i].ID, err) } + option.Providers[i].Provider = providerName } + return nil } func normalizeProviderName(name string) string { - if strings.EqualFold(strings.TrimSpace(name), "anthropic") { + return strings.ToLower(strings.TrimSpace(name)) +} + +func isSupportedProviderName(name string) bool { + return name == "openai" || name == "anthropic" +} + +func inferProviderName(baseURL string) string { + if strings.Contains(strings.ToLower(baseURL), "anthropic.com") { return "anthropic" } return "openai" } +func resolveProviderName(name, baseURL string) (string, error) { + name = normalizeProviderName(name) + if name == "" { + name = inferProviderName(baseURL) + } + if !isSupportedProviderName(name) { + return "", fmt.Errorf("unsupported provider %q: use openai or anthropic", name) + } + return name, nil +} + func providerEnvName(providerName, suffix string) string { providerName = strings.ToUpper(strings.TrimSpace(providerName)) - providerName = strings.ReplaceAll(providerName, "-", "_") return providerName + "_" + suffix } diff --git a/core/config/loader_test.go b/core/config/loader_test.go index f4a996b6..90fa4c81 100644 --- a/core/config/loader_test.go +++ b/core/config/loader_test.go @@ -75,7 +75,7 @@ func TestLoadConfig(t *testing.T) { dir := t.TempDir() writeTestConfig(t, dir, ` llm: - provider: deepseek + provider: openai model: deepseek-chat base_url: https://api.deepseek.com/v1 max_tokens: 32768 @@ -98,7 +98,7 @@ ioa: } checks := []struct{ field, got, want string }{ - {"Provider", opt.Provider, "deepseek"}, + {"Provider", opt.Provider, "openai"}, {"Model", opt.Model, "deepseek-chat"}, {"BaseURL", opt.BaseURL, "https://api.deepseek.com/v1"}, {"CyberhubURL", opt.CyberhubURL, "http://hub:9000"}, @@ -494,7 +494,7 @@ func TestResolveRuntimeConfigEnvOverridesConfig(t *testing.T) { dir := t.TempDir() writeTestConfig(t, dir, ` llm: - provider: deepseek + provider: openai base_url: https://config.example/v1 api_key: config-key model: config-model @@ -506,7 +506,7 @@ cyberhub: t.Setenv("AISCAN_BASE_URL", "https://env.example/v1") t.Setenv("AISCAN_API_KEY", "env-key") t.Setenv("AISCAN_LLM_PROXY", "http://env-proxy:7890") - t.Setenv("CYBERHUB_URL", "http://env-hub:9000") + t.Setenv("AISCAN_CYBERHUB_URL", "http://env-hub:9000") withDefaults(t, func() { origDir, _ := os.Getwd() @@ -514,7 +514,7 @@ cyberhub: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } @@ -553,7 +553,7 @@ llm: option.Model = "cli-model" option.BaseURL = "https://cli.example/v1" option.APIKey = "cli-key" - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Model != "cli-model" || option.BaseURL != "https://cli.example/v1" || option.APIKey != "cli-key" { @@ -562,7 +562,7 @@ llm: }) } -func TestResolveRuntimeConfigSupportsOpenAIEnvAliases(t *testing.T) { +func TestResolveRuntimeConfigUsesOpenAIEnvironment(t *testing.T) { t.Setenv("OPENAI_BASE_URL", "https://openai-proxy.example/v1") t.Setenv("OPENAI_MODEL", "gpt-env") t.Setenv("OPENAI_API_KEY", "openai-key") @@ -578,16 +578,16 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Provider != "openai" || option.BaseURL != "https://openai-proxy.example/v1" || option.Model != "gpt-env" || option.APIKey != "openai-key" { - t.Fatalf("OpenAI env aliases not applied: %#v", option.LLMOptions) + t.Fatalf("OpenAI environment not applied: %#v", option.LLMOptions) } }) } -func TestResolveRuntimeConfigSupportsAnthropicEnvAliases(t *testing.T) { +func TestResolveRuntimeConfigUsesAnthropicEnvironment(t *testing.T) { t.Setenv("ANTHROPIC_BASE_URL", "https://anthropic-proxy.example/v1") t.Setenv("ANTHROPIC_MODEL", "claude-env") t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") @@ -603,23 +603,20 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Provider != "anthropic" || option.BaseURL != "https://anthropic-proxy.example/v1" || option.Model != "claude-env" || option.APIKey != "anthropic-key" { - t.Fatalf("Anthropic env aliases not applied: %#v", option.LLMOptions) + t.Fatalf("Anthropic environment not applied: %#v", option.LLMOptions) } }) } -func TestResolveRuntimeConfigNormalizesLegacyProviderToOpenAI(t *testing.T) { +func TestResolveRuntimeConfigRejectsUnsupportedProvider(t *testing.T) { t.Setenv("AISCAN_PROVIDER", "") - t.Setenv("AISCAN_LLM_PROVIDER", "") t.Setenv("AISCAN_API_KEY", "") - t.Setenv("AISCAN_LLM_API_KEY", "") t.Setenv("ANTHROPIC_API_KEY", "") t.Setenv("OPENAI_API_KEY", "openai-compatible-key") - t.Setenv("DEEPSEEK_API_KEY", "ignored-vendor-key") withDefaults(t, func() { dir := t.TempDir() @@ -630,11 +627,8 @@ func TestResolveRuntimeConfigNormalizesLegacyProviderToOpenAI(t *testing.T) { defer os.Chdir(origDir) option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { - t.Fatal(err) - } - if option.Provider != "openai" || option.APIKey != "openai-compatible-key" { - t.Fatalf("legacy provider was not normalized: %#v", option.LLMOptions) + if _, err := ResolveRuntimeConfig(&option, true); err == nil || !strings.Contains(err.Error(), "use openai or anthropic") { + t.Fatalf("ResolveRuntimeConfig() error = %v", err) } }) } @@ -650,15 +644,42 @@ func TestApplyEnvironmentIgnoresVendorSpecificLLMVariables(t *testing.T) { return value, ok } - option := Option{LLMOptions: LLMOptions{Provider: "deepseek"}} - applyEnvironment(&option, option, lookup, testProviderInference) - normalizeProviderOptions(&option, testProviderInference) + option := Option{LLMOptions: LLMOptions{Provider: "openai"}} + applyEnvironment(&option, option, lookup) + if err := normalizeProviderOptions(&option); err != nil { + t.Fatal(err) + } if option.Provider != "openai" || option.APIKey != "" || option.BaseURL != "" || option.Model != "" { t.Fatalf("vendor-specific LLM environment should be ignored: %#v", option.LLMOptions) } } -func TestApplyEnvironmentCentralizesRuntimeAndReconValues(t *testing.T) { +func TestApplyEnvironmentIgnoresLegacyAliases(t *testing.T) { + values := map[string]string{ + "AISCAN_LLM_PROVIDER": "anthropic", + "AISCAN_LLM_BASE_URL": "https://legacy.example/v1", + "AISCAN_LLM_MODEL": "legacy-model", + "AISCAN_LLM_API_KEY": "legacy-key", + "OPENAI_BASEURL": "https://legacy-openai.example/v1", + "CYBERHUB_URL": "https://legacy-cyberhub.example", + "TAVILY_API_KEYS": "legacy-tavily-key", + } + lookup := func(name string) (string, bool) { + value, ok := values[name] + return value, ok + } + + option := Option{} + applyEnvironment(&option, Option{}, lookup) + if option.Provider != "" || option.BaseURL != "" || option.Model != "" || option.APIKey != "" { + t.Fatalf("legacy LLM aliases were applied: %#v", option.LLMOptions) + } + if option.CyberhubURL != "" || option.TavilyKey != "" { + t.Fatalf("legacy integration aliases were applied: cyberhub=%q tavily=%q", option.CyberhubURL, option.TavilyKey) + } +} + +func TestApplyEnvironmentCentralizesRuntimeAndUncoverValues(t *testing.T) { values := map[string]string{ "AISCAN_DATA_DIR": "env-data", "AISCAN_RENDER": "static", @@ -672,16 +693,16 @@ func TestApplyEnvironmentCentralizesRuntimeAndReconValues(t *testing.T) { } option := Option{MiscOptions: MiscOptions{DataDir: "config-data"}} - applyEnvironment(&option, Option{}, lookup, testProviderInference) + applyEnvironment(&option, Option{}, lookup) if option.DataDir != "env-data" || option.RenderMode != "static" || option.REPLMode != "fast" || option.PlaywrightSession != "browser-1" { t.Fatalf("runtime environment not resolved: %#v", option) } - if option.ReconProviderKeys["SHODAN_API_KEY"] != "shodan-key" { - t.Fatalf("recon provider environment not resolved: %#v", option.ReconProviderKeys) + if option.UncoverCredentials["SHODAN_API_KEY"] != "shodan-key" { + t.Fatalf("uncover credentials not resolved: %#v", option.UncoverCredentials) } cli := Option{MiscOptions: MiscOptions{DataDir: "cli-data"}} - applyEnvironment(&cli, cli, lookup, testProviderInference) + applyEnvironment(&cli, cli, lookup) if cli.DataDir != "cli-data" { t.Fatalf("CLI data dir should win over env: got %q", cli.DataDir) } @@ -700,7 +721,7 @@ func TestResolveRuntimeConfigTavilyPriority(t *testing.T) { defer os.Chdir(origDir) option := Option{ReconOptions: ReconOptions{TavilyKey: "cli-key"}} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.TavilyKey != "cli-key" || option.SearchConfig.TavilyKeys != "config-key" { @@ -732,7 +753,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Model != "kimi-for-coding" { @@ -749,7 +770,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.Model != "claude-opus-4-8" { @@ -781,7 +802,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.BaseURL != "https://kiro.example/v1" { @@ -802,7 +823,7 @@ llm: defer os.Chdir(origDir) option := Option{} - if _, err := ResolveRuntimeConfig(&option, true, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&option, true); err != nil { t.Fatal(err) } if option.BaseURL != "https://borrowed.example/v1" { @@ -816,9 +837,8 @@ llm: func TestResolveRuntimeConfigCandidateUsesStagedProfileAndExplicitCLIOverrides(t *testing.T) { for _, key := range []string{ - "AISCAN_PROVIDER", "AISCAN_LLM_PROVIDER", "AISCAN_MODEL", "AISCAN_LLM_MODEL", - "AISCAN_BASE_URL", "AISCAN_BASEURL", "AISCAN_LLM_BASE_URL", "AISCAN_LLM_BASEURL", - "AISCAN_API_KEY", "AISCAN_LLM_API_KEY", "OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY", + "AISCAN_PROVIDER", "AISCAN_MODEL", "AISCAN_BASE_URL", "AISCAN_API_KEY", + "OPENAI_MODEL", "OPENAI_BASE_URL", "OPENAI_API_KEY", } { t.Setenv(key, "") } @@ -840,7 +860,7 @@ llm: path := filepath.Join(dir, "aiscan.yaml") staged := Option{MiscOptions: MiscOptions{ConfigFile: path}} - if _, err := ResolveRuntimeConfig(&staged, false, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&staged, false); err != nil { t.Fatal(err) } if staged.ActiveProfile != "staged" || len(staged.Providers) != 2 || staged.Providers[1].Model != "staged-model" { @@ -849,9 +869,9 @@ llm: explicit := Option{ MiscOptions: MiscOptions{ConfigFile: path}, - LLMOptions: LLMOptions{Provider: "deepseek", Model: "cli-model", APIKey: "cli-key"}, + LLMOptions: LLMOptions{Provider: "openai", Model: "cli-model", APIKey: "cli-key"}, } - if _, err := ResolveRuntimeConfig(&explicit, false, testProviderInference); err != nil { + if _, err := ResolveRuntimeConfig(&explicit, false); err != nil { t.Fatal(err) } if explicit.Provider != "openai" || explicit.Model != "cli-model" || explicit.APIKey != "cli-key" { @@ -859,13 +879,6 @@ llm: } } -func testProviderInference(baseURL string) string { - if strings.Contains(strings.ToLower(baseURL), "anthropic") { - return "anthropic" - } - return "openai" -} - func withDefaults(t *testing.T, fn func()) { t.Helper() saved := []*string{ diff --git a/core/config/options.go b/core/config/options.go index 7a109a89..7611bc9f 100644 --- a/core/config/options.go +++ b/core/config/options.go @@ -24,10 +24,10 @@ type Option struct { // Runtime-only environment settings. Business packages receive these values // after ResolveRuntimeConfig instead of reading the process environment. - RenderMode string `no-flag:"true"` - REPLMode string `no-flag:"true"` - PlaywrightSession string `no-flag:"true"` - ReconProviderKeys map[string]string `no-flag:"true"` + RenderMode string `no-flag:"true"` + REPLMode string `no-flag:"true"` + PlaywrightSession string `no-flag:"true"` + UncoverCredentials map[string]string `no-flag:"true"` } type ScanConfigOptions struct { diff --git a/docs/mechanisms.md b/docs/mechanisms.md index 879703cf..06081c29 100644 --- a/docs/mechanisms.md +++ b/docs/mechanisms.md @@ -135,9 +135,9 @@ eval/compact 徽章仍可由 hub 从 AOP extension 派生为 Web 平台控制事 两个协议 provider 都实现 `ListModels(ctx) ([]string, error)`,通过 `GET {base}/models` 返回 model ID 列表。编译期 `capability_parity_test.go` 守卫能力对齐。 -### Provider presets +### Provider 协议 -品牌 preset 在协议归一化前解析,为 OpenAI、Anthropic、DeepSeek、OpenRouter、Groq、Moonshot、Ollama 和 Zhipu GLM 提供默认 Base URL。`glm`、`bigmodel` 映射到 `zhipu`;显式 Base URL 不会被覆盖。Ollama preset 不要求 API Key。 +运行时只接受 `openai` 和 `anthropic`。两者分别提供官方默认 Base URL;其他模型服务必须显式使用 `openai` 协议并填写 `base_url`。不识别品牌名称,也不做别名映射。 ### hint404 协议提示 @@ -145,7 +145,7 @@ chat endpoint 返回 404 时包裹 actionable 建议(如"设置 `llm.provider= ### InferFromBaseURL -这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。品牌默认地址由 preset 解析,不依赖域名猜测。 +这里只推断传输协议:检测 `anthropic.com` 域名选择 `anthropic`,其他自定义地址默认使用 `openai` 兼容协议。 **文件**: `agent/provider/anthropic.go`, `agent/provider/openai.go`, `agent/provider/http.go`, `agent/provider/provider.go` diff --git a/docs/reference.md b/docs/reference.md index 9f076ea9..b278a5a6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -35,10 +35,10 @@ aiscan [全局参数] [子命令参数] ### 配置优先级 ``` -CLI 参数 > AIScan/集成环境变量 > 配置文件 > Provider 兼容环境变量 > 编译时默认值 +CLI 参数 > AIScan/集成环境变量 > 配置文件 > 协议环境变量 > 编译时默认值 ``` -`AISCAN_*`、Cyberhub、FOFA、Hunter、Tavily 等明确属于 AIScan 的环境变量会覆盖配置文件。`OPENAI_*`、`ANTHROPIC_*` 等可能由其他工具注入的 Provider 兼容变量只用于填补配置文件中的空值。 +`AISCAN_*`、FOFA、Hunter、Tavily 等明确属于 AIScan 的环境变量会覆盖配置文件。`OPENAI_*`、`ANTHROPIC_*` 只用于填补配置文件中的空值。 ### 配置文件 @@ -209,7 +209,7 @@ misc: | `openai` | OpenAI 及 DeepSeek、OpenRouter、Groq、Moonshot、Ollama 等 OpenAI-compatible API | `https://api.openai.com/v1` | `OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_MODEL` | | `anthropic` | Anthropic Messages API 及兼容网关 | `https://api.anthropic.com/v1` | `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL` | -除 Anthropic 协议外,其余模型服务统一使用 `openai`,通过 `base_url`、`model` 和 `api_key` 指定实际服务。旧配置中的 `deepseek`、`openrouter`、`ollama` 等 provider 名称会自动归一化为 `openai`。 +除 Anthropic 协议外,其余模型服务统一使用 `openai`,通过 `base_url`、`model` 和 `api_key` 指定实际服务。其他 provider 名称会直接报错。 ### 多 LLM Profile 配置 @@ -376,7 +376,7 @@ aiscan passive -s hunter 'domain.suffix="example.com"' Cyberhub 提供外部指纹库和 POC 模板,可以扩充或替换内置资源。 ```bash -aiscan scan -i http://target.example --cyberhub-url http://127.0.0.1:9000 --cyberhub-key "$CYBERHUB_KEY" +aiscan scan -i http://target.example --cyberhub-url http://127.0.0.1:9000 --cyberhub-key "$AISCAN_CYBERHUB_KEY" ``` 资源模式:`merge`(默认,合并内置和远程)或 `override`(远程覆盖内置)。 @@ -418,22 +418,22 @@ scan: | 变量 | 说明 | | --- | --- | | `OPENAI_API_KEY` | OpenAI API key | -| `OPENAI_BASE_URL` / `OPENAI_BASEURL` | OpenAI/Codex 风格 API base URL | +| `OPENAI_BASE_URL` | OpenAI-compatible API base URL | | `OPENAI_MODEL` | OpenAI/Codex 风格模型名 | | `ANTHROPIC_API_KEY` | Anthropic API key | -| `ANTHROPIC_BASE_URL` / `ANTHROPIC_BASEURL` | Claude Code 风格 API base URL | +| `ANTHROPIC_BASE_URL` | Anthropic-compatible API base URL | | `ANTHROPIC_MODEL` | Claude Code 风格模型名 | | `AISCAN_API_KEY` | 统一 fallback API key(所有 provider 通用) | -| `AISCAN_BASE_URL` / `AISCAN_LLM_BASE_URL` | 统一 LLM API base URL | -| `AISCAN_MODEL` / `AISCAN_LLM_MODEL` | 统一模型名 | -| `AISCAN_PROVIDER` / `AISCAN_LLM_PROVIDER` | 协议类型:`openai` 或 `anthropic` | +| `AISCAN_BASE_URL` | 统一 LLM API base URL | +| `AISCAN_MODEL` | 统一模型名 | +| `AISCAN_PROVIDER` | 协议类型:`openai` 或 `anthropic` | | `AISCAN_LLM_PROXY` | LLM API 请求代理 | | `AISCAN_DATA_DIR` | 数据目录;优先级低于显式 `--data-dir` | -| `AISCAN_PROXY` / `AISCAN_SCANNER_PROXY` | 扫描工具代理 | -| `AISCAN_CYBERHUB_URL` / `CYBERHUB_URL` | Cyberhub URL | -| `AISCAN_CYBERHUB_KEY` / `CYBERHUB_KEY` | Cyberhub API key | -| `AISCAN_CYBERHUB_MODE` / `CYBERHUB_MODE` | Cyberhub 资源模式 | -| `TAVILY_API_KEY` / `TAVILY_API_KEYS` | Tavily Web Search API key,多个 key 可逗号分隔 | +| `AISCAN_PROXY` | 扫描工具代理 | +| `AISCAN_CYBERHUB_URL` | Cyberhub URL | +| `AISCAN_CYBERHUB_KEY` | Cyberhub API key | +| `AISCAN_CYBERHUB_MODE` | Cyberhub 资源模式 | +| `TAVILY_API_KEY` | Tavily Web Search API key,多个 key 可逗号分隔 | | `FOFA_EMAIL` / `FOFA_KEY` | FOFA 凭据 | | `HUNTER_API_KEY` / `HUNTER_TOKEN` | Hunter 凭据 | | `RECON_PROXY` | 被动测绘出站代理 | diff --git a/pkg/runner/application_builder.go b/pkg/runner/application_builder.go index 945f4e95..af6d8a9d 100644 --- a/pkg/runner/application_builder.go +++ b/pkg/runner/application_builder.go @@ -25,19 +25,19 @@ func AppConfig(option *cfg.Option, features RuntimeFeatures, logger telemetry.Lo Optional: features.ProviderOptional, }, Scanner: ScannerConfig{ - CyberhubURL: option.CyberhubURL, - CyberhubKey: option.CyberhubKey, - CyberhubMode: option.CyberhubMode, - AIEnabled: features.AIEnabled, - VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), - Proxy: option.Proxy, - FofaEmail: option.FofaEmail, - FofaKey: option.FofaKey, - HunterToken: option.HunterToken, - HunterAPIKey: option.HunterAPIKey, - ReconProxy: option.ReconProxy, - ReconLimit: intOptionValue(option.ReconLimit), - ReconProviderKeys: cloneStringMap(option.ReconProviderKeys), + CyberhubURL: option.CyberhubURL, + CyberhubKey: option.CyberhubKey, + CyberhubMode: option.CyberhubMode, + AIEnabled: features.AIEnabled, + VerifyMode: cfg.ResolveString(option.ScanConfig.Verify, cfg.DefaultVerify), + Proxy: option.Proxy, + FofaEmail: option.FofaEmail, + FofaKey: option.FofaKey, + HunterToken: option.HunterToken, + HunterAPIKey: option.HunterAPIKey, + ReconProxy: option.ReconProxy, + ReconLimit: intOptionValue(option.ReconLimit), + UncoverCredentials: cloneStringMap(option.UncoverCredentials), }, Tools: ToolConfig{ Enabled: features.ToolsEnabled, diff --git a/pkg/runner/application_config.go b/pkg/runner/application_config.go index 20f499c0..096eb196 100644 --- a/pkg/runner/application_config.go +++ b/pkg/runner/application_config.go @@ -24,19 +24,19 @@ type ApplicationProviderConfig struct { } type ScannerConfig struct { - CyberhubURL string - CyberhubKey string - CyberhubMode string - AIEnabled bool - VerifyMode string - Proxy string - FofaEmail string - FofaKey string - HunterToken string - HunterAPIKey string - ReconProxy string - ReconLimit int - ReconProviderKeys map[string]string + CyberhubURL string + CyberhubKey string + CyberhubMode string + AIEnabled bool + VerifyMode string + Proxy string + FofaEmail string + FofaKey string + HunterToken string + HunterAPIKey string + ReconProxy string + ReconLimit int + UncoverCredentials map[string]string } type ToolConfig struct { diff --git a/pkg/runner/provider_config_test.go b/pkg/runner/provider_config_test.go index 42f1e788..1a1fca1a 100644 --- a/pkg/runner/provider_config_test.go +++ b/pkg/runner/provider_config_test.go @@ -10,7 +10,7 @@ func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { option := cfg.Option{LLMOptions: cfg.LLMOptions{ ActiveProfile: "openai", Providers: []cfg.LLMProviderEntry{ - {ID: "deepseek", Provider: "deepseek", APIKey: "dk-111", Model: "deepseek-chat", MaxTokens: 8192}, + {ID: "deepseek", Provider: "openai", APIKey: "dk-111", Model: "deepseek-chat", MaxTokens: 8192}, {ID: "openai", Provider: "openai", APIKey: "sk-222", Model: "gpt-4o", MaxTokens: 32768}, }, }} @@ -27,7 +27,7 @@ func TestProviderConfigSelectsActiveProfileAndFallbacks(t *testing.T) { func TestProviderConfigExplicitFieldsWin(t *testing.T) { option := cfg.Option{LLMOptions: cfg.LLMOptions{ Provider: "anthropic", APIKey: "cli-key", Model: "cli-model", - Providers: []cfg.LLMProviderEntry{{Provider: "deepseek", APIKey: "fallback-key", Model: "deepseek-chat"}}, + Providers: []cfg.LLMProviderEntry{{Provider: "openai", APIKey: "fallback-key", Model: "deepseek-chat"}}, }} primary := ProviderConfig(&option) if primary.Provider != "anthropic" || primary.APIKey != "cli-key" || primary.Model != "cli-model" { diff --git a/pkg/runner/runtime_config.go b/pkg/runner/runtime_config.go index 3ef69b83..a12d31e7 100644 --- a/pkg/runner/runtime_config.go +++ b/pkg/runner/runtime_config.go @@ -1,18 +1,15 @@ package runner -import ( - "github.com/chainreactors/aiscan/agent" - cfg "github.com/chainreactors/aiscan/core/config" -) +import cfg "github.com/chainreactors/aiscan/core/config" // ResolveRuntimeConfig resolves the process configuration and applies process // state such as the data directory. func ResolveRuntimeConfig(option *cfg.Option) (string, error) { - return cfg.ResolveRuntimeConfig(option, true, agent.InferProviderFromBaseURL) + return cfg.ResolveRuntimeConfig(option, true) } // ResolveRuntimeConfigCandidate resolves a staged Web configuration without // mutating process-wide state before the candidate is committed. func ResolveRuntimeConfigCandidate(option *cfg.Option) (string, error) { - return cfg.ResolveRuntimeConfig(option, false, agent.InferProviderFromBaseURL) + return cfg.ResolveRuntimeConfig(option, false) } diff --git a/pkg/web/config_profiles_test.go b/pkg/web/config_profiles_test.go index ecfeead9..1faa0c64 100644 --- a/pkg/web/config_profiles_test.go +++ b/pkg/web/config_profiles_test.go @@ -12,7 +12,7 @@ func TestActivateLLMProfileSelectsByID(t *testing.T) { store.cfg.LLM.ActiveProfile = "primary" store.cfg.LLM.Providers = []webproto.LLMProviderConfig{ {ID: "primary", Name: "Primary", Provider: "openai", Model: "gpt-primary", APIKey: "key-1"}, - {ID: "fast", Name: "Fast", Provider: "deepseek", Model: "deepseek-fast", APIKey: "key-2"}, + {ID: "fast", Name: "Fast", Provider: "openai", Model: "deepseek-fast", APIKey: "key-2"}, } service := NewService(ServiceConfig{ConfigStore: store}) diff --git a/pkg/web/config_reload_test.go b/pkg/web/config_reload_test.go index 9b62c509..fe100730 100644 --- a/pkg/web/config_reload_test.go +++ b/pkg/web/config_reload_test.go @@ -102,7 +102,7 @@ func TestHandleConfigReloadResultUpdatesAgentStatus(t *testing.T) { pool.register(a) payload, _ := json.Marshal(webproto.ConfigReloadResult{ - OK: true, Provider: "deepseek", Model: "deepseek-v4-pro", + OK: true, Provider: "openai", Model: "deepseek-v4-pro", }) pool.handleAgentMessage(a, WSMessage{Type: "config.result", Payload: payload}) got := a.info().Status diff --git a/pkg/web/validation.go b/pkg/web/validation.go index 250ae8fc..487f2d28 100644 --- a/pkg/web/validation.go +++ b/pkg/web/validation.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" + agentprovider "github.com/chainreactors/aiscan/agent/provider" "github.com/chainreactors/aiscan/pkg/webproto" ) @@ -13,6 +14,10 @@ import ( // incomplete profiles before an invalid configuration can be persisted. func ValidateLLMConfig(cfg webproto.LLMConfig) error { for i, profile := range cfg.Providers { + profile = webproto.NormalizeLLMProvider(profile) + if !agentprovider.IsSupportedProvider(profile.Provider) { + return fmt.Errorf("LLM provider %q is unsupported: use openai or anthropic", profile.Provider) + } if strings.TrimSpace(profile.Model) == "" { name := strings.TrimSpace(profile.Name) if name == "" { diff --git a/pkg/web/validation_test.go b/pkg/web/validation_test.go index 62570eb2..676b1f45 100644 --- a/pkg/web/validation_test.go +++ b/pkg/web/validation_test.go @@ -1,6 +1,21 @@ package web -import "testing" +import ( + "strings" + "testing" + + "github.com/chainreactors/aiscan/pkg/webproto" +) + +func TestValidateLLMConfigRejectsUnsupportedProvider(t *testing.T) { + cfg := webproto.LLMConfig{Providers: []webproto.LLMProviderConfig{{ + Provider: "deepseek", + Model: "deepseek-chat", + }}} + if err := ValidateLLMConfig(cfg); err == nil || !strings.Contains(err.Error(), "use openai or anthropic") { + t.Fatalf("ValidateLLMConfig() error = %v", err) + } +} func TestValidateTarget(t *testing.T) { tests := []struct { diff --git a/pkg/webagent/remote_test.go b/pkg/webagent/remote_test.go index c5cee377..27a3bb07 100644 --- a/pkg/webagent/remote_test.go +++ b/pkg/webagent/remote_test.go @@ -20,7 +20,7 @@ func TestFetchRemoteConfigUsesBearerTokenFromURL(t *testing.T) { var cfg webproto.DistributeConfig cfg.LLM.ActiveProfile = "p1" cfg.LLM.Providers = []webproto.LLMProviderConfig{ - {ID: "p1", Provider: "deepseek", Model: "deepseek-chat", MaxTokens: 8192, ContextWindow: 128000}, + {ID: "p1", Provider: "openai", Model: "deepseek-chat", MaxTokens: 8192, ContextWindow: 128000}, {ID: "p2", Provider: "openai", Model: "gpt-5"}, } _ = json.NewEncoder(w).Encode(cfg) diff --git a/pkg/webproto/config.go b/pkg/webproto/config.go index 64522052..0827fd54 100644 --- a/pkg/webproto/config.go +++ b/pkg/webproto/config.go @@ -73,8 +73,8 @@ func MigrateLLMConfig(llm *LLMConfig, flat LLMProviderConfig) { llm.ActiveProfile = active.ID } -// NormalizeLLMProvider collapses vendor labels to the two supported wire -// protocols while preserving the profile's endpoint, model, and identity. +// NormalizeLLMProvider canonicalizes protocol casing and infers the protocol +// from base_url when omitted. Validation rejects unsupported protocols. func NormalizeLLMProvider(profile LLMProviderConfig) LLMProviderConfig { if strings.TrimSpace(profile.Provider) != "" { profile.Provider = agentprovider.NormalizeProvider(profile.Provider) diff --git a/pkg/webproto/config_test.go b/pkg/webproto/config_test.go index ddb7b963..f9263b4a 100644 --- a/pkg/webproto/config_test.go +++ b/pkg/webproto/config_test.go @@ -2,10 +2,11 @@ package webproto import "testing" -func TestMigrateLLMConfigNormalizesProviderProtocol(t *testing.T) { +func TestMigrateLLMConfigCanonicalizesProviderProtocol(t *testing.T) { config := LLMConfig{Providers: []LLMProviderConfig{ - {ID: "deepseek", Provider: "deepseek", BaseURL: "https://api.deepseek.com/v1"}, - {ID: "claude", Provider: "anthropic"}, + {ID: "openai", Provider: " OPENAI ", BaseURL: "https://api.deepseek.com/v1"}, + {ID: "claude", Provider: "ANTHROPIC"}, + {ID: "invalid", Provider: "deepseek"}, }} MigrateLLMConfig(&config, LLMProviderConfig{}) @@ -15,4 +16,7 @@ func TestMigrateLLMConfigNormalizesProviderProtocol(t *testing.T) { if config.Providers[1].Provider != "anthropic" { t.Fatalf("Anthropic provider = %q", config.Providers[1].Provider) } + if config.Providers[2].Provider != "deepseek" { + t.Fatalf("unsupported provider must not be rewritten, got %q", config.Providers[2].Provider) + } } diff --git a/test-skips.json b/test-skips.json index aaa353fd..f7ca5eca 100644 --- a/test-skips.json +++ b/test-skips.json @@ -141,7 +141,7 @@ }, { "path": "tools/ioa/commands_test.go", - "format": "set LIVE_TEST_API_KEY or DEEPSEEK_API_KEY to run live LLM IOA test", + "format": "set LIVE_TEST_API_KEY or OPENAI_API_KEY to run live LLM IOA test", "count": 1, "category": "live_llm", "reason": "The IOA integration calls a live model endpoint and requires an explicit credential." diff --git a/tools/scan/engine/set.go b/tools/scan/engine/set.go index ea52784b..1f854915 100644 --- a/tools/scan/engine/set.go +++ b/tools/scan/engine/set.go @@ -33,7 +33,7 @@ type ReconOptions struct { HunterAPIKey string // 华顺信安后台 API 管理生成的 api-key (推荐, 64 位 hex) Limit int IngressProxy string // 给 uncover 的全局出站代理 (http://, https://, socks5://, socks5h://) - ProviderKeys map[string]string + Credentials map[string]string } type Set struct { diff --git a/tools/scan/engine/set_uncover_recon.go b/tools/scan/engine/set_uncover_recon.go index b050229a..444b3ba0 100644 --- a/tools/scan/engine/set_uncover_recon.go +++ b/tools/scan/engine/set_uncover_recon.go @@ -43,12 +43,12 @@ func mergeReconOptions(base, next ReconOptions) ReconOptions { if next.Limit != 0 { base.Limit = next.Limit } - if len(next.ProviderKeys) > 0 { - if base.ProviderKeys == nil { - base.ProviderKeys = make(map[string]string, len(next.ProviderKeys)) + if len(next.Credentials) > 0 { + if base.Credentials == nil { + base.Credentials = make(map[string]string, len(next.Credentials)) } - for key, value := range next.ProviderKeys { - base.ProviderKeys[key] = value + for key, value := range next.Credentials { + base.Credentials[key] = value } } return base diff --git a/tools/scan/engine/uncover.go b/tools/scan/engine/uncover.go index 978d82ab..92e4b91a 100644 --- a/tools/scan/engine/uncover.go +++ b/tools/scan/engine/uncover.go @@ -55,7 +55,7 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine p.Hunter = append(p.Hunter, opts.HunterToken) } - applyProviderKeys(p, opts.ProviderKeys) + applyCredentials(p, opts.Credentials) keys := p.GetKeys() // uncover's GetKeys only populates the FofaEmail/FofaKey pair when the stored @@ -87,7 +87,7 @@ func NewUncoverEngine(opts ReconOptions, logger telemetry.Logger) *UncoverEngine return e } -func applyProviderKeys(p *sources.Provider, values map[string]string) { +func applyCredentials(p *sources.Provider, values map[string]string) { appendValue := func(dst *[]string, name string) { if value := strings.TrimSpace(values[name]); value != "" { *dst = append(*dst, value) diff --git a/tools/scan/engine/uncover_test.go b/tools/scan/engine/uncover_test.go index 0194a8d7..6f3c3a32 100644 --- a/tools/scan/engine/uncover_test.go +++ b/tools/scan/engine/uncover_test.go @@ -67,8 +67,8 @@ func TestNewUncoverEngineDoesNotRereadCredentialEnvironment(t *testing.T) { } } -func TestNewUncoverEngineUsesInjectedProviderKeys(t *testing.T) { - eng := NewUncoverEngine(ReconOptions{ProviderKeys: map[string]string{ +func TestNewUncoverEngineUsesInjectedCredentials(t *testing.T) { + eng := NewUncoverEngine(ReconOptions{Credentials: map[string]string{ "SHODAN_API_KEY": "shodan-key", }}, nil) if eng.keys.Shodan != "shodan-key" || !sourceAvailable(eng, "shodan") { diff --git a/tools/search/cyberhub.go b/tools/search/cyberhub.go index 89ae97d7..8fb5510f 100644 --- a/tools/search/cyberhub.go +++ b/tools/search/cyberhub.go @@ -101,7 +101,7 @@ func (c *CyberhubSearch) Usage() string { return cyberhubUsage() } func (c *CyberhubSearch) Run(_ context.Context, execution *commands.Execution) (any, error) { args := execution.Args if c.index == nil { - return nil, fmt.Errorf("search cyberhub: not available — cyberhub resources not loaded. Configure via --cyberhub-url and --cyberhub-key flags, env (CYBERHUB_URL, CYBERHUB_KEY), or config file (cyberhub.url, cyberhub.key). Do not retry until configured") + return nil, fmt.Errorf("search cyberhub: not available — cyberhub resources not loaded. Configure via --cyberhub-url and --cyberhub-key flags, env (AISCAN_CYBERHUB_URL, AISCAN_CYBERHUB_KEY), or config file (cyberhub.url, cyberhub.key). Do not retry until configured") } var opts cyberhubFlags diff --git a/web/frontend/src/components/ConfigPanel.tsx b/web/frontend/src/components/ConfigPanel.tsx index b3f00ea1..20efbf48 100644 --- a/web/frontend/src/components/ConfigPanel.tsx +++ b/web/frontend/src/components/ConfigPanel.tsx @@ -26,9 +26,19 @@ const TABS: { key: TabKey; label: string }[] = [ { key: 'agent', label: 'Agent' }, ] -const LLM_PROVIDERS = [ - { value: 'openai', label: 'OpenAI-compatible' }, - { value: 'anthropic', label: 'Anthropic' }, +type LLMProtocol = 'openai' | 'anthropic' + +const LLM_PROVIDER_PRESETS: { value: string; label: string; protocol: LLMProtocol; baseUrl: string }[] = [ + { value: 'openai', label: 'OpenAI', protocol: 'openai', baseUrl: 'https://api.openai.com/v1' }, + { value: 'anthropic', label: 'Anthropic', protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1' }, + { value: 'deepseek', label: 'DeepSeek', protocol: 'openai', baseUrl: 'https://api.deepseek.com/v1' }, + { value: 'openrouter', label: 'OpenRouter', protocol: 'openai', baseUrl: 'https://openrouter.ai/api/v1' }, + { value: 'groq', label: 'Groq', protocol: 'openai', baseUrl: 'https://api.groq.com/openai/v1' }, + { value: 'moonshot', label: 'Moonshot', protocol: 'openai', baseUrl: 'https://api.moonshot.cn/v1' }, + { value: 'ollama', label: 'Ollama', protocol: 'openai', baseUrl: 'http://localhost:11434/v1' }, + { value: 'zhipu', label: 'Zhipu GLM', protocol: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, + { value: 'custom-openai', label: '', protocol: 'openai', baseUrl: '' }, + { value: 'custom-anthropic', label: '', protocol: 'anthropic', baseUrl: '' }, ] function emptyForm(): DistributeConfig { @@ -48,7 +58,6 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { const profiles: LLMProviderProfile[] = cs.llm.profiles?.length ? cs.llm.profiles.map(profile => ({ ...profile, - provider: normalizeProvider(profile.provider), api_key: '', context_window: positiveInteger(profile.context_window), max_tokens: positiveInteger(profile.max_tokens), @@ -56,7 +65,7 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { : [{ id: cs.llm.active_profile || 'default', name: cs.llm.model || cs.llm.provider || 'Default', - provider: normalizeProvider(cs.llm.provider), + provider: cs.llm.provider, base_url: cs.llm.base_url, api_key: '', model: cs.llm.model, @@ -79,11 +88,15 @@ function statusToForm(cs: ConfigStatus): DistributeConfig { } function blankLLMProfile(id = `llm-${Date.now()}`): LLMProviderProfile { - return { id, name: 'New LLM', provider: 'openai', base_url: '', api_key: '', model: '', proxy: '' } + return { id, name: 'New LLM', provider: 'openai', base_url: 'https://api.openai.com/v1', api_key: '', model: '', proxy: '' } } -function normalizeProvider(provider: string): 'openai' | 'anthropic' { - return provider.trim().toLowerCase() === 'anthropic' ? 'anthropic' : 'openai' +function providerPresetValue(profile: LLMProviderProfile): string { + const baseURL = profile.base_url.trim().replace(/\/+$/, '') + if (!baseURL) return `custom-${profile.provider}` + return LLM_PROVIDER_PRESETS.find(preset => + preset.protocol === profile.provider && preset.baseUrl.replace(/\/+$/, '') === baseURL + )?.value || `custom-${profile.provider}` } function positiveInteger(value: number | undefined): number | undefined { @@ -298,17 +311,35 @@ function LLMTab({ const profile = profiles.find(item => item.id === selectedProfileID) || profiles[0] const configuredProfile = cs?.llm.profiles?.find(item => item.id === profile?.id) - const updateProfile = (key: K, value: LLMProviderProfile[K]) => { + const patchProfile = (patch: Partial) => { if (!profile) return setForm(current => ({ ...current, llm: { ...current.llm, - providers: current.llm.providers.map(item => item.id === profile.id ? { ...item, [key]: value } : item), + providers: current.llm.providers.map(item => item.id === profile.id ? { ...item, ...patch } : item), }, })) } + const updateProfile = (key: K, value: LLMProviderProfile[K]) => { + patchProfile({ [key]: value } as Partial) + } + + const resetProviderState = () => { + setModels([]) + setModelsError(null) + setModelsNotice(null) + setResult(null) + } + + const selectProviderPreset = (value: string) => { + const selected = LLM_PROVIDER_PRESETS.find(item => item.value === value) + if (!selected) return + patchProfile({ provider: selected.protocol, base_url: selected.baseUrl }) + resetProviderState() + } + const addProfile = () => { const next = blankLLMProfile() setForm(current => ({ ...current, llm: { ...current.llm, providers: [...current.llm.providers, next] } })) @@ -425,11 +456,15 @@ function LLMTab({ updateProfile('name', e.target.value)} placeholder={t('profileNameHint')} /> - - - {LLM_PROVIDERS.map((provider) => {provider.label})} + {LLM_PROVIDER_PRESETS.map((preset) => ( + + {preset.label || t(preset.protocol === 'openai' ? 'customOpenAI' : 'customAnthropic')} · {preset.protocol} + + ))} diff --git a/web/frontend/src/i18n/locales/en/config.ts b/web/frontend/src/i18n/locales/en/config.ts index 645490a5..e079a23b 100644 --- a/web/frontend/src/i18n/locales/en/config.ts +++ b/web/frontend/src/i18n/locales/en/config.ts @@ -15,6 +15,9 @@ export default { selectProvider: 'Select provider', // field labels provider: 'Provider', + providerPresetHint: 'Sets protocol and Base URL automatically', + customOpenAI: 'Custom OpenAI-compatible', + customAnthropic: 'Custom Anthropic-compatible', model: 'Model', profileName: 'Profile name', profileNameHint: 'e.g. DeepSeek production', @@ -62,7 +65,7 @@ export default { apiKeyRequired: 'API key required', modelRequired: 'Model is required', modelRequiredProfile: 'Profile “{{name}}” requires a model', - providerDefault: 'leave empty for provider default', + providerDefault: 'filled by preset or enter a custom endpoint', modelDefault: 'leave empty for model default', cyberhubApiKey: 'cyberhub API key', fofaApiKey: 'FOFA API key', diff --git a/web/frontend/src/i18n/locales/zh/config.ts b/web/frontend/src/i18n/locales/zh/config.ts index 229b725d..3b24c9c3 100644 --- a/web/frontend/src/i18n/locales/zh/config.ts +++ b/web/frontend/src/i18n/locales/zh/config.ts @@ -12,9 +12,12 @@ export default { save: '保存', failedLoad: '加载配置失败', failedSave: '保存配置失败', - selectProvider: '选择 Provider', + selectProvider: '选择服务商', // field labels - provider: 'Provider', + provider: '服务商', + providerPresetHint: '自动设置协议与 Base URL', + customOpenAI: '自定义 OpenAI-compatible', + customAnthropic: '自定义 Anthropic-compatible', model: '模型', profileName: '配置名称', profileNameHint: '例如:DeepSeek 生产环境', @@ -62,7 +65,7 @@ export default { apiKeyRequired: '需要 API Key', modelRequired: '模型不能为空', modelRequiredProfile: '配置 “{{name}}” 的模型不能为空', - providerDefault: '留空则使用 Provider 默认值', + providerDefault: '由预设填充,也可输入自定义地址', modelDefault: '留空则使用模型默认值', cyberhubApiKey: 'Cyberhub API Key', fofaApiKey: 'FOFA API Key', From b3611750f1de5a2e2574ecc97c045b1ad244bfca Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 22:38:34 +0800 Subject: [PATCH 3/7] fix(agent): clamp retry backoff before shifting --- agent/retry.go | 13 +++++++++---- agent/retry_test.go | 7 +++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/agent/retry.go b/agent/retry.go index bf61c0ca..7aeff4fd 100644 --- a/agent/retry.go +++ b/agent/retry.go @@ -96,11 +96,16 @@ func isRetryableByMessage(err error) bool { // backward compatibility with external callers such as runner and webagent // reconnect logic. func RetryDelay(attempt int) time.Duration { - delay := time.Second << uint(attempt) - if delay > 10*time.Second { - delay = 10 * time.Second + if attempt < 0 { + attempt = 0 } - return delay + // Clamp before shifting. A large attempt previously overflowed the duration + // shift to zero, turning a persistent authentication failure into a tight + // reconnect loop that could saturate the control plane. + if attempt >= 4 { + return 10 * time.Second + } + return time.Second << uint(attempt) } // retryDelayFor computes the backoff for an LLM call retry. It honors a diff --git a/agent/retry_test.go b/agent/retry_test.go index 5ceea4c5..c2728fbe 100644 --- a/agent/retry_test.go +++ b/agent/retry_test.go @@ -424,6 +424,13 @@ func TestRetryDelayBackoffSequence(t *testing.T) { t.Errorf("attempt %d: RetryDelay = %s, want %s", i, got, w) } } + + if got := RetryDelay(-1); got != time.Second { + t.Errorf("negative attempt: RetryDelay = %s, want 1s", got) + } + if got := RetryDelay(64); got != 10*time.Second { + t.Errorf("large attempt: RetryDelay = %s, want 10s", got) + } } func TestComputeRetryDelaySequence(t *testing.T) { From b93601a8f69c3a3fb55f76994e85528a99ed1131 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 22:38:34 +0800 Subject: [PATCH 4/7] fix(web): initialize app from resolved configuration --- cmd/aiscan/web_full.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/aiscan/web_full.go b/cmd/aiscan/web_full.go index 4ab0556a..14b800a4 100644 --- a/cmd/aiscan/web_full.go +++ b/cmd/aiscan/web_full.go @@ -38,7 +38,10 @@ func runWeb(ctx context.Context, option, explicitOption *cfg.Option, opts webCom } defer store.Close() - application, err := initWebApp(ctx, explicitOption, logger) + // The initial app must use the fully resolved option, including values loaded + // from the config file and environment. explicitOption is only the seed for + // later staged reloads, where the candidate config is resolved independently. + application, err := initWebApp(ctx, option, logger) if err != nil { return fmt.Errorf("init aiscan: %s", err) } From 3d27cd5f90763c3333e113262471456f191c4d82 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 23:08:35 +0800 Subject: [PATCH 5/7] test(tui): isolate bang output newline contract --- pkg/tui/console_test.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/tui/console_test.go b/pkg/tui/console_test.go index 52d8cf57..a5ba5c25 100644 --- a/pkg/tui/console_test.go +++ b/pkg/tui/console_test.go @@ -9,13 +9,13 @@ import ( "os" "path/filepath" "reflect" - "runtime" "strings" "testing" "time" "github.com/chainreactors/aiscan/agent" cfg "github.com/chainreactors/aiscan/core/config" + "github.com/chainreactors/aiscan/core/tool" "github.com/chainreactors/aiscan/pkg/commands" "github.com/chainreactors/tui/readline/inputrc" rlterm "github.com/chainreactors/tui/readline/terminal" @@ -86,15 +86,21 @@ func TestAgentConsoleArgsForLineBangCommand(t *testing.T) { } } +type consoleTextTool struct { + output string +} + +func (t *consoleTextTool) Name() string { return "bash" } +func (t *consoleTextTool) Description() string { return "console output test tool" } +func (t *consoleTextTool) Definition() tool.Definition { return tool.Definition{} } +func (t *consoleTextTool) Execute(context.Context, string) (tool.Result, error) { + return tool.TextResult(t.output), nil +} + func TestAgentConsoleBangCommandTerminatesOutputLine(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell assertion is unix-only") - } var stdout, stderr bytes.Buffer registry := commands.NewRegistry() - bash := commands.NewBashTool(t.TempDir(), 5) - defer bash.Close() - registry.RegisterTool(bash) + registry.RegisterTool(&consoleTextTool{output: "DIRECT_OK"}) repl := NewAgentConsoleWithWriters(context.Background(), &cfg.Option{}, AppInfo{Commands: registry}, nil, &stdout, &stderr) if _, err := repl.ExecuteLineAndWait("!printf DIRECT_OK"); err != nil { From ea6c0e05d175e36576c839f86224abea6a997517 Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 23:14:54 +0800 Subject: [PATCH 6/7] test(tui): retire obsolete platform skip --- test-skips.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/test-skips.json b/test-skips.json index f7ca5eca..cf012a39 100644 --- a/test-skips.json +++ b/test-skips.json @@ -97,13 +97,6 @@ "category": "capability", "reason": "Two mixed HTTP/headless templates reference variables unavailable to the headless-only compile fixture." }, - { - "path": "pkg/tui/console_test.go", - "format": "shell assertion is unix-only", - "count": 1, - "category": "platform", - "reason": "The assertion targets POSIX shell rendering." - }, { "path": "pkg/web/agents_test.go", "format": "chromium not found, skipping browser e2e test", From ee91aa6ae12de54f25020b65ef656cb6390a71cb Mon Sep 17 00:00:00 2001 From: M09Ic Date: Wed, 29 Jul 2026 23:41:06 +0800 Subject: [PATCH 7/7] fix(scan): avoid per-run global logger mutation --- tools/scan/engine/spray.go | 11 +++++------ tools/scan/engine/spray_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tools/scan/engine/spray.go b/tools/scan/engine/spray.go index b340266c..34edee59 100644 --- a/tools/scan/engine/spray.go +++ b/tools/scan/engine/spray.go @@ -48,9 +48,6 @@ func SprayCheckStream(ctx context.Context, eng *spray.Engine, opts SprayCheckOpt return nil, fmt.Errorf("spray engine is not available") } sprayExecutionMu.Lock() - if opts.Debug { - telemetry.EnableLogsDebug() - } runCtx, cancel := sprayInvocationContext(ctx, opts) sprayCtx := spray.NewContext(). WithContext(runCtx). @@ -139,6 +136,11 @@ func defaultSprayInvocationTimeout(opts SprayCheckOptions) time.Duration { func buildSprayOption(opts SprayCheckOptions) *spray.Option { sprayOpt := spray.NewDefaultOption() coreOpt := sprayOpt.Option + // The SDK configures its shared logger once when the engine is initialized, + // and scan --debug configures it before pipeline workers start. Keeping the + // per-run Quiet flag enabled makes upstream NewRunner call SetQuiet while + // other engines are logging, which races on the shared logger. + coreOpt.Quiet = false coreOpt.Threads = opts.Threads coreOpt.Timeout = opts.Timeout coreOpt.Host = opts.Host @@ -159,9 +161,6 @@ func buildSprayOption(opts SprayCheckOptions) *spray.Option { coreOpt.CrawlDepth = opts.CrawlDepth } coreOpt.Debug = opts.Debug - if opts.Debug { - coreOpt.Quiet = false - } if opts.Proxy != "" { coreOpt.Proxies = []string{opts.Proxy} } diff --git a/tools/scan/engine/spray_test.go b/tools/scan/engine/spray_test.go index ab619462..63af5354 100644 --- a/tools/scan/engine/spray_test.go +++ b/tools/scan/engine/spray_test.go @@ -38,6 +38,16 @@ func TestBuildSprayOptionAppliesDebugAndRuntimeOptions(t *testing.T) { } } +func TestBuildSprayOptionAvoidsPerRunGlobalLoggerMutation(t *testing.T) { + opt := buildSprayOption(SprayCheckOptions{}) + if opt.Quiet { + t.Fatal("quiet = true; upstream NewRunner would mutate the shared logger") + } + if !opt.NoBar { + t.Fatal("no bar = false; SDK runs must not install a global progress writer") + } +} + func TestDefaultSprayInvocationTimeoutBoundsCrawl(t *testing.T) { got := defaultSprayInvocationTimeout(SprayCheckOptions{Timeout: 5, Crawl: true, CrawlDepth: 2}) if got != 80*time.Second {