fix(security): restrict local connector endpoints
This commit is contained in:
1 parent
fa19e95f19
commit
84b92c859e
12 files changed
+158
-18
No files matched your search
@@ -25,7 +25,7 @@ export const LOCAL_CONNECTORS: ConnectorDefinition[] = [
|
|||||||
defaultModel: "llama3.2",
|
defaultModel: "llama3.2",
|
||||||
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
|
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
|
||||||
notes:
|
notes:
|
||||||
"Ollama exposes /v1/chat/completions and /v1/models. The default port is 11434; override if you launched `ollama serve` on a different address.",
|
"Ollama exposes /v1/chat/completions and /v1/models. The default port is 11434; override with a loopback or private-network address.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lm-studio",
|
id: "lm-studio",
|
||||||
@@ -36,6 +36,6 @@ export const LOCAL_CONNECTORS: ConnectorDefinition[] = [
|
|||||||
defaultModel: "local-model",
|
defaultModel: "local-model",
|
||||||
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
|
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
|
||||||
notes:
|
notes:
|
||||||
"Enable LM Studio's local server from the Developer tab; the default port is 1234. Model ids match whatever you have loaded.",
|
"Enable LM Studio's local server from the Developer tab; the default port is 1234. Use a loopback or private-network address.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
||||||
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
||||||
|
import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials";
|
||||||
import { getConnectorDefinition, isPiKnownProvider } from "@main/connectors";
|
import { getConnectorDefinition, isPiKnownProvider } from "@main/connectors";
|
||||||
import { UpstreamError } from "../errors/index.js";
|
import { UpstreamError } from "../errors/index.js";
|
||||||
import { CredentialStore } from "./credential-store";
|
import { CredentialStore } from "./credential-store";
|
||||||
@@ -150,6 +151,13 @@ export class CredentialResolver {
|
|||||||
if (stored?.kind !== "local") {
|
if (stored?.kind !== "local") {
|
||||||
throw new CredentialError("CREDENTIAL_MISSING", connectorId);
|
throw new CredentialError("CREDENTIAL_MISSING", connectorId);
|
||||||
}
|
}
|
||||||
|
if (!isLocalConnectorBaseUrl(stored.value.baseUrl)) {
|
||||||
|
throw new CredentialError(
|
||||||
|
"CREDENTIAL_INVALID",
|
||||||
|
connectorId,
|
||||||
|
"Local connector baseUrl must use localhost, loopback, or a private IP host.",
|
||||||
|
);
|
||||||
|
}
|
||||||
// Ollama and LM Studio ignore the bearer value but the OpenAI SDK
|
// Ollama and LM Studio ignore the bearer value but the OpenAI SDK
|
||||||
// refuses an empty string, so we hand it the connector id as a
|
// refuses an empty string, so we hand it the connector id as a
|
||||||
// stable non-empty placeholder. The actual transport is keyed off
|
// stable non-empty placeholder. The actual transport is keyed off
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import log from "electron-log/main.js";
|
import log from "electron-log/main.js";
|
||||||
import { getModels, type Api, type Model } from "@mariozechner/pi-ai";
|
import { getModels, type Api, type Model } from "@mariozechner/pi-ai";
|
||||||
|
import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials";
|
||||||
import { isPiKnownProvider } from "./connectors";
|
import { isPiKnownProvider } from "./connectors";
|
||||||
|
|
||||||
// Fireworks' OpenAI-compatible root. The OpenAI SDK appends
|
// Fireworks' OpenAI-compatible root. The OpenAI SDK appends
|
||||||
@@ -125,6 +126,10 @@ export function resolveModel(
|
|||||||
log.warn(`[model-resolver] ${provider} requires a baseUrl from credentials; none provided.`);
|
log.warn(`[model-resolver] ${provider} requires a baseUrl from credentials; none provided.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!isLocalConnectorBaseUrl(options.baseUrl)) {
|
||||||
|
log.warn(`[model-resolver] ${provider} rejected non-local baseUrl.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return buildLocalModel(provider, modelId, options.baseUrl);
|
return buildLocalModel(provider, modelId, options.baseUrl);
|
||||||
}
|
}
|
||||||
if (!isPiKnownProvider(provider)) {
|
if (!isPiKnownProvider(provider)) {
|
||||||
@@ -221,6 +226,10 @@ export function resolveModels(provider: string): Array<{ id: string; name: strin
|
|||||||
export async function fetchLocalConnectorModels(
|
export async function fetchLocalConnectorModels(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
): Promise<Array<{ id: string; name: string }>> {
|
): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
if (!isLocalConnectorBaseUrl(baseUrl)) {
|
||||||
|
log.warn("[model-resolver] rejected non-local model catalog baseUrl.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: "GET" });
|
const response = await fetch(url, { method: "GET" });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FloppyDisk } from "../../ui/icons";
|
import { FloppyDisk } from "../../ui/icons";
|
||||||
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
import type { ConnectorCatalogEntry } from "@shared/connector-catalog";
|
||||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
import { isLocalConnectorBaseUrl, type CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||||
import type { TranslationFn } from "../../../i18n";
|
import type { TranslationFn } from "../../../i18n";
|
||||||
import {
|
import {
|
||||||
InlineFeedback,
|
InlineFeedback,
|
||||||
@@ -23,8 +23,8 @@ interface LocalProviderCardProps {
|
|||||||
* Onboarding card for local OpenAI-compatible servers (Ollama, LM Studio).
|
* Onboarding card for local OpenAI-compatible servers (Ollama, LM Studio).
|
||||||
*
|
*
|
||||||
* The catalog supplies a sensible default URL per entry; the user can
|
* The catalog supplies a sensible default URL per entry; the user can
|
||||||
* override it to point at a non-default port, a remote machine, or a
|
* override it to point at a private LAN server or a non-default port.
|
||||||
* reverse proxy. The card never asks for an API key — local servers
|
* The card never asks for an API key — local servers
|
||||||
* accept any bearer value, so the credential resolver supplies the
|
* accept any bearer value, so the credential resolver supplies the
|
||||||
* connector id as a non-empty placeholder for the OpenAI SDK.
|
* connector id as a non-empty placeholder for the OpenAI SDK.
|
||||||
*/
|
*/
|
||||||
@@ -65,9 +65,7 @@ export function LocalProviderCard({
|
|||||||
setFeedback(t("onboarding.providers.baseUrlRequired"));
|
setFeedback(t("onboarding.providers.baseUrlRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (!isLocalConnectorBaseUrl(trimmed)) {
|
||||||
new URL(trimmed);
|
|
||||||
} catch {
|
|
||||||
setFeedback(t("onboarding.providers.baseUrlInvalid"));
|
setFeedback(t("onboarding.providers.baseUrlInvalid"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { isLocalConnectorBaseUrl } from "@shared/contracts/schemas-credentials";
|
||||||
import { ActionButton, Input } from "../../ui";
|
import { ActionButton, Input } from "../../ui";
|
||||||
import { useT } from "../../../i18n";
|
import { useT } from "../../../i18n";
|
||||||
|
|
||||||
@@ -22,9 +23,7 @@ export function LocalForm({ connectorId, defaultBaseUrl, onSaved }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!desktop || !canSave) return;
|
if (!desktop || !canSave) return;
|
||||||
try {
|
if (!isLocalConnectorBaseUrl(trimmed)) {
|
||||||
new URL(trimmed);
|
|
||||||
} catch {
|
|
||||||
setError(t("onboarding.providers.baseUrlInvalid"));
|
setError(t("onboarding.providers.baseUrlInvalid"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const en: Record<string, string> = {
|
|||||||
"onboarding.providers.apiKeyReplacePlaceholder": "Leave empty to keep the current key",
|
"onboarding.providers.apiKeyReplacePlaceholder": "Leave empty to keep the current key",
|
||||||
"onboarding.providers.baseUrlLabel": "Base URL",
|
"onboarding.providers.baseUrlLabel": "Base URL",
|
||||||
"onboarding.providers.baseUrlRequired": "Enter the local server URL before saving.",
|
"onboarding.providers.baseUrlRequired": "Enter the local server URL before saving.",
|
||||||
"onboarding.providers.baseUrlInvalid": "Enter a valid HTTP or HTTPS URL.",
|
"onboarding.providers.baseUrlInvalid": "Enter a loopback or private-network HTTP(S) URL.",
|
||||||
"onboarding.providers.save": "Save",
|
"onboarding.providers.save": "Save",
|
||||||
"onboarding.providers.saving": "Saving…",
|
"onboarding.providers.saving": "Saving…",
|
||||||
"onboarding.providers.saved": "Saved.",
|
"onboarding.providers.saved": "Saved.",
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export const zh: Record<string, string> = {
|
|||||||
"onboarding.providers.apiKeyReplacePlaceholder": "保持现有 Key 请留空",
|
"onboarding.providers.apiKeyReplacePlaceholder": "保持现有 Key 请留空",
|
||||||
"onboarding.providers.baseUrlLabel": "服务地址",
|
"onboarding.providers.baseUrlLabel": "服务地址",
|
||||||
"onboarding.providers.baseUrlRequired": "请填入本地服务地址后再保存。",
|
"onboarding.providers.baseUrlRequired": "请填入本地服务地址后再保存。",
|
||||||
"onboarding.providers.baseUrlInvalid": "请输入合法的 HTTP 或 HTTPS 地址。",
|
"onboarding.providers.baseUrlInvalid": "请输入本机或私有网络 HTTP(S) 地址。",
|
||||||
"onboarding.providers.save": "保存",
|
"onboarding.providers.save": "保存",
|
||||||
"onboarding.providers.saving": "保存中…",
|
"onboarding.providers.saving": "保存中…",
|
||||||
"onboarding.providers.saved": "已保存。",
|
"onboarding.providers.saved": "已保存。",
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ const LOCAL_ENTRIES: ConnectorCatalogEntry[] = [
|
|||||||
description: "Local Ollama server — open-weights models on your own machine via the OpenAI-compatible endpoint.",
|
description: "Local Ollama server — open-weights models on your own machine via the OpenAI-compatible endpoint.",
|
||||||
defaultModel: "llama3.2",
|
defaultModel: "llama3.2",
|
||||||
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
|
auth: { type: "local", defaultBaseUrl: "http://localhost:11434/v1" },
|
||||||
notes: "Default port 11434. Override if you launched `ollama serve` elsewhere.",
|
notes: "Default port 11434. Override with a loopback or private-network address.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lm-studio",
|
id: "lm-studio",
|
||||||
@@ -261,7 +261,7 @@ const LOCAL_ENTRIES: ConnectorCatalogEntry[] = [
|
|||||||
description: "Local LM Studio server — host any loaded model via the OpenAI-compatible endpoint.",
|
description: "Local LM Studio server — host any loaded model via the OpenAI-compatible endpoint.",
|
||||||
defaultModel: "local-model",
|
defaultModel: "local-model",
|
||||||
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
|
auth: { type: "local", defaultBaseUrl: "http://localhost:1234/v1" },
|
||||||
notes: "Enable LM Studio's local server from the Developer tab. Default port 1234.",
|
notes: "Enable LM Studio's local server from the Developer tab. Use a loopback or private-network address.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,65 @@ export const gcpAdcCredentialSchema = z.object({
|
|||||||
credentialsPath: z.string().optional(),
|
credentialsPath: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizeUrlHostname(hostname: string): string {
|
||||||
|
const trimmed = hostname.trim().toLowerCase();
|
||||||
|
return trimmed.startsWith("[") && trimmed.endsWith("]")
|
||||||
|
? trimmed.slice(1, -1)
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4(hostname: string): [number, number, number, number] | null {
|
||||||
|
const parts = hostname.split(".");
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const octets = parts.map((part) => {
|
||||||
|
if (!/^\d+$/.test(part)) return null;
|
||||||
|
const value = Number(part);
|
||||||
|
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : null;
|
||||||
|
});
|
||||||
|
if (octets.some((part) => part === null)) return null;
|
||||||
|
return octets as [number, number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv4(hostname: string): boolean {
|
||||||
|
const octets = parseIpv4(hostname);
|
||||||
|
if (!octets) return false;
|
||||||
|
const [first, second] = octets;
|
||||||
|
return first === 10
|
||||||
|
|| first === 127
|
||||||
|
|| (first === 172 && second >= 16 && second <= 31)
|
||||||
|
|| (first === 192 && second === 168)
|
||||||
|
|| (first === 169 && second === 254);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv6(hostname: string): boolean {
|
||||||
|
if (hostname === "::1") return true;
|
||||||
|
return /^f[cd][0-9a-f]{2}:/i.test(hostname) || /^fe80:/i.test(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalConnectorBaseUrl(value: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||||
|
if (url.username || url.password) return false;
|
||||||
|
const hostname = normalizeUrlHostname(url.hostname);
|
||||||
|
if (hostname === "localhost") return true;
|
||||||
|
return isPrivateIpv4(hostname) || isPrivateIpv6(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localBaseUrlSchema = z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.refine(
|
||||||
|
isLocalConnectorBaseUrl,
|
||||||
|
"Local connector baseUrl must use HTTP(S) with localhost, loopback, or a private IP host.",
|
||||||
|
);
|
||||||
|
|
||||||
export const localCredentialSchema = z.object({
|
export const localCredentialSchema = z.object({
|
||||||
baseUrl: z.string().url(),
|
baseUrl: localBaseUrlSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const credentialPayloadSchema = z.discriminatedUnion("kind", [
|
export const credentialPayloadSchema = z.discriminatedUnion("kind", [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { getProviders } from "@mariozechner/pi-ai";
|
import { getProviders } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
CONNECTORS,
|
CONNECTORS,
|
||||||
getConnectorKnownProvider,
|
getConnectorKnownProvider,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
isLocalModelResolverProvider,
|
isLocalModelResolverProvider,
|
||||||
isPiKnownProvider,
|
isPiKnownProvider,
|
||||||
} from "../../src/main/connectors";
|
} from "../../src/main/connectors";
|
||||||
import { FIREWORKS_FIREPASS_MODEL_ID, resolveModel, resolveModels } from "../../src/main/model-resolver";
|
import { FIREWORKS_FIREPASS_MODEL_ID, fetchLocalConnectorModels, resolveModel, resolveModels } from "../../src/main/model-resolver";
|
||||||
|
|
||||||
describe("connector provider guard", () => {
|
describe("connector provider guard", () => {
|
||||||
it("classifies catalog providers before they enter pi-ai APIs", () => {
|
it("classifies catalog providers before they enter pi-ai APIs", () => {
|
||||||
@@ -38,6 +38,18 @@ describe("connector provider guard", () => {
|
|||||||
expect(resolveModels("not-a-pi-provider")).toEqual([]);
|
expect(resolveModels("not-a-pi-provider")).toEqual([]);
|
||||||
expect(resolveModel("not-a-pi-provider", "model-id")).toBeNull();
|
expect(resolveModel("not-a-pi-provider", "model-id")).toBeNull();
|
||||||
expect(resolveModel("fireworks-firepass", "ignored")?.id).toBe(FIREWORKS_FIREPASS_MODEL_ID);
|
expect(resolveModel("fireworks-firepass", "ignored")?.id).toBe(FIREWORKS_FIREPASS_MODEL_ID);
|
||||||
|
expect(resolveModel("ollama", "llama3.2", { baseUrl: "http://127.0.0.1:11434/v1" })?.baseUrl).toBe("http://127.0.0.1:11434/v1");
|
||||||
|
expect(resolveModel("ollama", "llama3.2", { baseUrl: "https://example.com/v1" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects remote URLs before local model catalog fetch", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch should stay unused"));
|
||||||
|
try {
|
||||||
|
await expect(fetchLocalConnectorModels("https://example.com/v1")).resolves.toEqual([]);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps provider calls free of erased never casts", () => {
|
it("keeps provider calls free of erased never casts", () => {
|
||||||
|
|||||||
@@ -157,4 +157,26 @@ describe("CredentialResolver", () => {
|
|||||||
GOOGLE_APPLICATION_CREDENTIALS: "/etc/gcp/service-account.json",
|
GOOGLE_APPLICATION_CREDENTIALS: "/etc/gcp/service-account.json",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects remote baseUrl values for local connectors", async () => {
|
||||||
|
store.save("ollama", {
|
||||||
|
kind: "local",
|
||||||
|
value: { baseUrl: "https://example.com/v1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await resolver.resolve("ollama");
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(CredentialError);
|
||||||
|
expect(thrown).toMatchObject({
|
||||||
|
kind: "upstream",
|
||||||
|
code: "CREDENTIAL_INVALID",
|
||||||
|
connectorId: "ollama",
|
||||||
|
context: { connectorId: "ollama" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
azureCredentialSchema,
|
azureCredentialSchema,
|
||||||
credentialPayloadSchema,
|
credentialPayloadSchema,
|
||||||
gcpAdcCredentialSchema,
|
gcpAdcCredentialSchema,
|
||||||
|
isLocalConnectorBaseUrl,
|
||||||
|
localCredentialSchema,
|
||||||
removeCredentialInputSchema,
|
removeCredentialInputSchema,
|
||||||
saveCredentialInputSchema,
|
saveCredentialInputSchema,
|
||||||
testCredentialInputSchema,
|
testCredentialInputSchema,
|
||||||
@@ -59,6 +61,39 @@ describe("credentials Zod schemas", () => {
|
|||||||
expect(gcpAdcCredentialSchema.safeParse({ project: "p", location: "us-central1" }).success).toBe(true);
|
expect(gcpAdcCredentialSchema.safeParse({ project: "p", location: "us-central1" }).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("localCredentialSchema accepts loopback and private-network baseUrl values", () => {
|
||||||
|
for (const baseUrl of [
|
||||||
|
"http://localhost:11434/v1",
|
||||||
|
"http://127.0.0.1:11434/v1",
|
||||||
|
"http://10.0.0.20:11434/v1",
|
||||||
|
"https://172.16.0.10:11434/v1",
|
||||||
|
"http://172.31.255.255:11434/v1",
|
||||||
|
"http://192.168.1.20:11434/v1",
|
||||||
|
"http://169.254.1.20:11434/v1",
|
||||||
|
"http://[::1]:11434/v1",
|
||||||
|
"http://[fc00::1]:11434/v1",
|
||||||
|
"http://[fd12::1]:11434/v1",
|
||||||
|
"http://[fe80::1]:11434/v1",
|
||||||
|
]) {
|
||||||
|
expect(localCredentialSchema.safeParse({ baseUrl }).success).toBe(true);
|
||||||
|
expect(isLocalConnectorBaseUrl(baseUrl)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("localCredentialSchema rejects remote OpenAI-compatible endpoints", () => {
|
||||||
|
for (const baseUrl of [
|
||||||
|
"https://api.openai.com/v1",
|
||||||
|
"https://example.com/v1",
|
||||||
|
"http://172.32.0.1:11434/v1",
|
||||||
|
"http://100.64.0.1:11434/v1",
|
||||||
|
"ftp://localhost:11434/v1",
|
||||||
|
"http://user:pass@127.0.0.1:11434/v1",
|
||||||
|
]) {
|
||||||
|
expect(localCredentialSchema.safeParse({ baseUrl }).success).toBe(false);
|
||||||
|
expect(isLocalConnectorBaseUrl(baseUrl)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("credentialPayloadSchema discriminates on kind", () => {
|
it("credentialPayloadSchema discriminates on kind", () => {
|
||||||
expect(
|
expect(
|
||||||
credentialPayloadSchema.safeParse({
|
credentialPayloadSchema.safeParse({
|
||||||
|
|||||||
Reference in new issue
Block a user