mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
Scope --insecure TLS to explicit base URL, drop global transport override
Builds on s3rj1k's --insecure flag (#35) but limits insecure TLS to the resolved inference client for an explicit --base-url, instead of mutating http.DefaultTransport process-wide. Built-in providers, auth, and model discovery keep normal certificate verification. Documents the flag in the CLI reference. Co-authored-by: s3rj1k <evasive.gyron@gmail.com>
This commit is contained in:
parent
e0ca3fdd3e
commit
ab7fb37046
5 changed files with 150 additions and 90 deletions
|
|
@ -141,6 +141,7 @@ zot --help
|
|||
| `--model <id>` | Pick the model (see `--list-models`). |
|
||||
| `--api-key <key>` | Override the API key. |
|
||||
| `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
|
||||
| `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
|
||||
| `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
|
||||
| `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
|
||||
| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type Resolved struct {
|
|||
AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
|
||||
AccountID string // ChatGPT account id (for openai oauth), "" otherwise
|
||||
BaseURL string
|
||||
InsecureTLS bool
|
||||
CWD string
|
||||
Reasoning string
|
||||
Temperature *float32
|
||||
|
|
@ -409,6 +410,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
model = fm.ID
|
||||
}
|
||||
|
||||
explicitBaseURL := args.BaseURL != ""
|
||||
|
||||
// If the model defines a base URL (e.g. local ollama) and the
|
||||
// user didn't pass --base-url, use the model's URL. For ollama,
|
||||
// keep http://localhost:11434 as a fallback only after the model
|
||||
|
|
@ -420,10 +423,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
args.BaseURL = "http://localhost:11434"
|
||||
}
|
||||
|
||||
provider.InsecureSkipVerify = (args.InsecureTLS || cfg.Insecure) && args.BaseURL != ""
|
||||
if provider.InsecureSkipVerify {
|
||||
provider.ApplyInsecureTLS()
|
||||
}
|
||||
// Insecure TLS is intentionally scoped to explicit custom endpoints.
|
||||
// Built-in provider base URLs, auth calls, and model discovery keep normal
|
||||
// certificate verification even when --insecure is present.
|
||||
insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
|
||||
|
||||
// If the model has a base URL, credentials are optional (local
|
||||
// models like ollama don't need real API keys).
|
||||
|
|
@ -512,6 +515,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
|
|||
AuthMethod: method,
|
||||
AccountID: accountID,
|
||||
BaseURL: args.BaseURL,
|
||||
InsecureTLS: insecureTLS,
|
||||
CWD: args.CWD,
|
||||
Reasoning: reasoning,
|
||||
Temperature: temperature,
|
||||
|
|
@ -639,92 +643,100 @@ func (r Resolved) NewClient() provider.Client {
|
|||
if !r.HasCredential() {
|
||||
panic("NewClient called without credential; check HasCredential first")
|
||||
}
|
||||
wrap := r.withHTTPClient
|
||||
switch r.Provider {
|
||||
case "ollama":
|
||||
return provider.NewOpenAI(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
|
||||
case "kimi":
|
||||
// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
|
||||
// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
|
||||
inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
|
||||
inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
|
||||
if r.AuthMethod == "oauth" {
|
||||
return r.wrapWithRefresh(inner)
|
||||
}
|
||||
return inner
|
||||
case "moonshotai":
|
||||
return provider.NewMoonshot(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
|
||||
case "moonshotai-cn":
|
||||
return provider.NewMoonshotCN(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
|
||||
case "deepseek":
|
||||
return provider.NewDeepSeek(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
|
||||
case "openai":
|
||||
return provider.NewOpenAI(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
|
||||
case "openai-codex":
|
||||
inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
|
||||
inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
|
||||
return r.wrapWithRefresh(inner)
|
||||
case "openai-responses":
|
||||
// Public OpenAI Responses API (api.openai.com/v1/responses) via
|
||||
// API key. Separate provider from `openai` (Chat Completions) and
|
||||
// from `openai-codex` (ChatGPT subscription OAuth).
|
||||
return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
|
||||
case "google":
|
||||
return provider.NewGemini(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewGemini(r.Credential, r.BaseURL))
|
||||
case "cerebras":
|
||||
return provider.NewCerebras(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
|
||||
case "groq":
|
||||
return provider.NewGroq(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewGroq(r.Credential, r.BaseURL))
|
||||
case "xai":
|
||||
return provider.NewXAI(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewXAI(r.Credential, r.BaseURL))
|
||||
case "together":
|
||||
return provider.NewTogether(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewTogether(r.Credential, r.BaseURL))
|
||||
case "huggingface":
|
||||
return provider.NewHuggingFace(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
|
||||
case "openrouter":
|
||||
return provider.NewOpenRouter(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
|
||||
case "zai":
|
||||
return provider.NewZAI(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewZAI(r.Credential, r.BaseURL))
|
||||
case "xiaomi":
|
||||
return provider.NewXiaomi(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
|
||||
case "xiaomi-token-plan-ams":
|
||||
return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
|
||||
case "xiaomi-token-plan-cn":
|
||||
return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
|
||||
case "xiaomi-token-plan-sgp":
|
||||
return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
|
||||
case "opencode":
|
||||
return provider.NewOpenCode(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
|
||||
case "opencode-go":
|
||||
return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
|
||||
case "minimax":
|
||||
return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
|
||||
case "minimax-cn":
|
||||
return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
|
||||
case "fireworks":
|
||||
return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
|
||||
case "vercel-ai-gateway":
|
||||
return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
|
||||
case "mistral":
|
||||
return provider.NewMistral(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewMistral(r.Credential, r.BaseURL))
|
||||
case "amazon-bedrock":
|
||||
return provider.NewBedrock(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
|
||||
case "google-vertex":
|
||||
return provider.NewGoogleVertex(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
|
||||
case "azure-openai-responses":
|
||||
return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
|
||||
case "github-copilot":
|
||||
return provider.NewGithubCopilot(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
|
||||
case "cloudflare-workers-ai":
|
||||
return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
|
||||
case "cloudflare-ai-gateway":
|
||||
return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
|
||||
default:
|
||||
if r.AuthMethod == "oauth" {
|
||||
inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
|
||||
inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
|
||||
return r.wrapWithRefresh(inner)
|
||||
}
|
||||
return provider.NewAnthropic(r.Credential, r.BaseURL)
|
||||
return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
|
||||
}
|
||||
}
|
||||
|
||||
func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
|
||||
if !r.InsecureTLS {
|
||||
return c
|
||||
}
|
||||
return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
|
||||
}
|
||||
|
||||
// wrapWithRefresh wraps an OAuth client so the access token is
|
||||
// refreshed automatically before each API call. Without this, long
|
||||
// sessions (hours) silently fail when the 1-hour token expires.
|
||||
|
|
@ -748,12 +760,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
|
|||
factory := func(token string) provider.Client {
|
||||
switch provName {
|
||||
case "openai-codex":
|
||||
return provider.NewOpenAICodex(token, accountID, baseURL)
|
||||
return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
|
||||
case "kimi":
|
||||
// anthropic-messages on api.kimi.com/coding.
|
||||
return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
|
||||
return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
|
||||
default:
|
||||
return provider.NewAnthropicOAuth(token, baseURL)
|
||||
return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -212,37 +213,35 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveInsecureOnlyWithCustomBaseURL(t *testing.T) {
|
||||
orig := provider.InsecureSkipVerify
|
||||
t.Cleanup(func() { provider.InsecureSkipVerify = orig })
|
||||
func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
|
||||
orig := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
t.Setenv("ZOT_HOME", t.TempDir())
|
||||
t.Setenv("OPENAI_API_KEY", "test-key")
|
||||
|
||||
// no --base-url: must stay false even with --insecure.
|
||||
provider.InsecureSkipVerify = false
|
||||
_, err := Resolve(Args{Provider: "openai", InsecureTLS: true}, false)
|
||||
resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if provider.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify must not be set without a custom base URL")
|
||||
if resolved.InsecureTLS {
|
||||
t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
|
||||
}
|
||||
assertDefaultTransportStillSecure(t)
|
||||
|
||||
// --base-url + --insecure: must be true.
|
||||
provider.InsecureSkipVerify = false
|
||||
_, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
|
||||
resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !provider.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify must be set with --insecure and --base-url")
|
||||
if !resolved.InsecureTLS {
|
||||
t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
|
||||
}
|
||||
assertDefaultTransportStillSecure(t)
|
||||
}
|
||||
|
||||
func TestResolveInsecureFromConfig(t *testing.T) {
|
||||
orig := provider.InsecureSkipVerify
|
||||
t.Cleanup(func() { provider.InsecureSkipVerify = orig })
|
||||
func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
|
||||
orig := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
t.Setenv("ZOT_HOME", t.TempDir())
|
||||
t.Setenv("OPENAI_API_KEY", "test-key")
|
||||
|
|
@ -250,23 +249,32 @@ func TestResolveInsecureFromConfig(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// no --base-url: must stay false.
|
||||
provider.InsecureSkipVerify = false
|
||||
_, err := Resolve(Args{Provider: "openai"}, false)
|
||||
resolved, err := Resolve(Args{Provider: "openai"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if provider.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify must not be set without a custom base URL")
|
||||
if resolved.InsecureTLS {
|
||||
t.Fatal("InsecureTLS must not be set without a custom base URL")
|
||||
}
|
||||
assertDefaultTransportStillSecure(t)
|
||||
|
||||
// --base-url: must be true.
|
||||
provider.InsecureSkipVerify = false
|
||||
_, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
|
||||
resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if !provider.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify must be set when config insecure=true and --base-url is provided")
|
||||
if !resolved.InsecureTLS {
|
||||
t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
|
||||
}
|
||||
assertDefaultTransportStillSecure(t)
|
||||
}
|
||||
|
||||
func assertDefaultTransportStillSecure(t *testing.T) {
|
||||
t.Helper()
|
||||
tr, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("http.DefaultTransport must not be made insecure")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,46 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
// InsecureSkipVerify disables TLS cert verification for inference connections.
|
||||
var InsecureSkipVerify bool
|
||||
|
||||
// ApplyInsecureTLS replaces http.DefaultTransport to skip TLS cert verification.
|
||||
func ApplyInsecureTLS() {
|
||||
http.DefaultTransport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
|
||||
// only this client skips TLS certificate verification. The process-wide
|
||||
// default transport is left untouched so auth, discovery, and other providers
|
||||
// keep normal certificate validation.
|
||||
func NewHTTPClient(insecureTLS bool) *http.Client {
|
||||
if !insecureTLS {
|
||||
return &http.Client{Timeout: 0}
|
||||
}
|
||||
tr, ok := http.DefaultTransport.(*http.Transport)
|
||||
if ok {
|
||||
tr = tr.Clone()
|
||||
} else {
|
||||
tr = &http.Transport{}
|
||||
}
|
||||
if tr.TLSClientConfig != nil {
|
||||
tr.TLSClientConfig = tr.TLSClientConfig.Clone()
|
||||
} else {
|
||||
tr.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
|
||||
return &http.Client{Timeout: 0, Transport: tr}
|
||||
}
|
||||
|
||||
// WithHTTPClient scopes an HTTP client to a concrete provider client.
|
||||
// Unsupported clients are returned unchanged.
|
||||
func WithHTTPClient(c Client, httpClient *http.Client) Client {
|
||||
if httpClient == nil {
|
||||
return c
|
||||
}
|
||||
switch v := c.(type) {
|
||||
case *openaiClient:
|
||||
v.http = httpClient
|
||||
case *anthropicClient:
|
||||
v.http = httpClient
|
||||
case *geminiClient:
|
||||
v.http = httpClient
|
||||
case *bedrockClient:
|
||||
v.http = httpClient
|
||||
case *renamedClient:
|
||||
v.inner = WithHTTPClient(v.inner, httpClient)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,22 +6,24 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyInsecureTLSSetsDefaultTransport(t *testing.T) {
|
||||
func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
|
||||
orig := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
ApplyInsecureTLS()
|
||||
|
||||
tr, ok := http.DefaultTransport.(*http.Transport)
|
||||
client := NewHTTPClient(true)
|
||||
if http.DefaultTransport != orig {
|
||||
t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
|
||||
}
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", http.DefaultTransport)
|
||||
t.Fatalf("expected *http.Transport, got %T", client.Transport)
|
||||
}
|
||||
if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected InsecureSkipVerify=true in TLS config")
|
||||
t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsecureClientReachesTLSServer(t *testing.T) {
|
||||
func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
|
@ -30,20 +32,23 @@ func TestInsecureClientReachesTLSServer(t *testing.T) {
|
|||
orig := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = orig })
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
if _, err := client.Get(srv.URL); err == nil {
|
||||
t.Fatal("expected TLS error with default transport, got nil")
|
||||
secureClient := NewHTTPClient(false)
|
||||
if _, err := secureClient.Get(srv.URL); err == nil {
|
||||
t.Fatal("expected TLS error with secure client, got nil")
|
||||
}
|
||||
|
||||
ApplyInsecureTLS()
|
||||
|
||||
resp, err := client.Get(srv.URL)
|
||||
insecureClient := NewHTTPClient(true)
|
||||
resp, err := insecureClient.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed after ApplyInsecureTLS: %v", err)
|
||||
t.Fatalf("request failed with scoped insecure client: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
defaultClient := &http.Client{}
|
||||
if _, err := defaultClient.Get(srv.URL); err == nil {
|
||||
t.Fatal("default client must still reject self-signed TLS")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue