blob: ff89c618c83143c58bd550b4f0e409bbce35e479 [file]
/**
* Pure WebSearch contracts shared by the explicit UI query and agent-tool
* paths. One configured provider handles a query; failures are returned as
* closed reasons rather than silently rotating providers.
*
* The active execution owner controls credentials, provider calls, and the
* incognito gate. Renderer results contain normalized title, URL, and snippet
* fields and never expose cleartext credentials or raw provider errors.
*/
/** Closed enum of search execution sources. */
export const WEB_SEARCH_PROVIDERS = ['model', 'tavily'] as const;
export type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number];
export const WEB_SEARCH_CREDENTIAL_PROVIDERS = ['tavily'] as const;
export type WebSearchCredentialProvider = (typeof WEB_SEARCH_CREDENTIAL_PROVIDERS)[number];
/** Renderer-safe result row. No raw HTML, no provider tag soup. */
export interface WebSearchResultRow {
readonly provider: WebSearchProvider;
readonly title: string;
readonly url: string;
readonly snippet: string;
/** Hostname extracted from `url` so the renderer doesn't reparse. */
readonly source: string;
}
export type WebSearchErrorReason =
| 'invalid_query'
| 'incognito_active'
| 'not_configured'
| 'invalid_credentials'
| 'rate_limited'
| 'network_error'
| 'timeout'
| 'unsupported_provider'
| 'experimental_disabled';
/** Discriminated response: success = array, error = typed object. */
export type WebSearchResponse =
| {
readonly ok: true;
readonly provider?: WebSearchProvider;
readonly results: ReadonlyArray<WebSearchResultRow>;
}
| { readonly ok: false; readonly reason: WebSearchErrorReason; readonly message: string };
export const WEB_SEARCH_QUERY_MAX_CHARS = 200;
export const WEB_SEARCH_DEFAULT_LIMIT = 5;
export const WEB_SEARCH_MAX_LIMIT = 10;
export const WEB_SEARCH_CREDENTIAL_STATUSES = [
'untested',
'valid',
'invalid_credentials',
'rate_limited',
'network_error',
'timeout',
'not_configured',
] as const;
export type WebSearchCredentialStatus = (typeof WEB_SEARCH_CREDENTIAL_STATUSES)[number];
export const WEB_SEARCH_CREDENTIAL_SOURCES = ['none', 'saved', 'env'] as const;
export type WebSearchCredentialSource = (typeof WEB_SEARCH_CREDENTIAL_SOURCES)[number];
/**
* Settings-layer placeholder for a stored API key. The renderer may
* see this when the settings store mirrors back the current value;
* an update that comes back with exactly this token MUST preserve
* the existing token instead of overwriting it. Same pattern as the
* existing bot token / proxy password mask in Maka.
*/
export const MASKED_TOKEN_SENTINEL = '••••••';
/** Returns `null` when the raw value isn't a usable query. */
export function normalizeWebSearchQuery(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
if (trimmed.length > WEB_SEARCH_QUERY_MAX_CHARS) {
return trimmed.slice(0, WEB_SEARCH_QUERY_MAX_CHARS);
}
return trimmed;
}
/** Clamps `raw` to `[1, WEB_SEARCH_MAX_LIMIT]`, default `WEB_SEARCH_DEFAULT_LIMIT`. */
export function normalizeWebSearchLimit(raw: unknown): number {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return WEB_SEARCH_DEFAULT_LIMIT;
const rounded = Math.trunc(raw);
if (rounded < 1) return 1;
if (rounded > WEB_SEARCH_MAX_LIMIT) return WEB_SEARCH_MAX_LIMIT;
return rounded;
}
export function isWebSearchProvider(value: unknown): value is WebSearchProvider {
return typeof value === 'string' && (WEB_SEARCH_PROVIDERS as readonly string[]).includes(value);
}
/**
* Settings shape persisted in `settings.json`. The `apiKey` field is
* stored in cleartext on disk (settings store sees the raw value);
* the IPC store boundary returns the masked sentinel to the renderer
* for display. An update where `apiKey === MASKED_TOKEN_SENTINEL`
* means "keep current" — the store preserves it.
*/
export interface WebSearchProviderSettings {
readonly apiKey: string;
/** Renderer-safe credential source. Never carries the secret value. */
readonly credentialSource: WebSearchCredentialSource;
/**
* Monotonic local version for saved credentials. Async test/query results
* carry the version they observed; stale results must not overwrite status
* for a newer key.
*/
readonly credentialVersion: number;
readonly credentialStatus: WebSearchCredentialStatus;
readonly credentialCheckedAt?: string;
}
export interface WebSearchSettings {
readonly enabled: boolean;
readonly defaultProvider: WebSearchProvider;
readonly providers: { readonly tavily: WebSearchProviderSettings };
}
export type WebSearchSettingsPatch = Partial<{
enabled: boolean;
defaultProvider: WebSearchProvider;
providers: Partial<{
tavily: Partial<WebSearchProviderSettings>;
}>;
}>;
export function defaultWebSearchSettings(): WebSearchSettings {
return {
enabled: false,
defaultProvider: 'model',
providers: {
tavily: {
apiKey: '',
credentialSource: 'none',
credentialVersion: 0,
credentialStatus: 'untested',
},
},
};
}
export function mergeWebSearchSettings(
current: WebSearchSettings,
patch: WebSearchSettingsPatch | undefined,
): WebSearchSettings {
if (!patch) return current;
const tavilyPatch = patch.providers?.tavily;
const candidateProvider = patch.defaultProvider;
const nextProvider: WebSearchProvider = isWebSearchProvider(candidateProvider)
? candidateProvider
: current.defaultProvider;
// Mask-sentinel preservation lives here so the IPC boundary does
// not have to special-case the round-tripped masked value.
const nextApiKey =
tavilyPatch && typeof tavilyPatch.apiKey === 'string'
? reconcileMaskedToken(current.providers.tavily.apiKey, tavilyPatch.apiKey)
: current.providers.tavily.apiKey;
const currentCredentialVersion = normalizeCredentialVersion(
current.providers.tavily.credentialVersion,
);
const explicitCredentialCheckedAt =
tavilyPatch &&
typeof tavilyPatch.credentialCheckedAt === 'string' &&
tavilyPatch.credentialCheckedAt.length <= 64
? tavilyPatch.credentialCheckedAt
: undefined;
const apiKeyChanged =
tavilyPatch &&
typeof tavilyPatch.apiKey === 'string' &&
tavilyPatch.apiKey !== MASKED_TOKEN_SENTINEL &&
nextApiKey !== current.providers.tavily.apiKey;
const nextCredentialVersion = apiKeyChanged
? currentCredentialVersion + 1
: currentCredentialVersion;
const patchCredentialVersion = tavilyPatch
? normalizeOptionalCredentialVersion(tavilyPatch.credentialVersion)
: undefined;
const hasExplicitCredentialStatus =
tavilyPatch &&
isWebSearchCredentialStatus(tavilyPatch.credentialStatus) &&
(patchCredentialVersion === undefined || patchCredentialVersion === currentCredentialVersion);
const credentialStatus = hasExplicitCredentialStatus
? tavilyPatch.credentialStatus
: apiKeyChanged
? 'untested'
: current.providers.tavily.credentialStatus;
const credentialCheckedAt = hasExplicitCredentialStatus
? explicitCredentialCheckedAt
: apiKeyChanged
? undefined
: current.providers.tavily.credentialCheckedAt;
return {
enabled: typeof patch.enabled === 'boolean' ? patch.enabled : current.enabled,
defaultProvider: nextProvider,
providers: {
tavily: {
apiKey: nextApiKey,
credentialSource: webSearchCredentialSourceFromStoredKey(nextApiKey),
credentialVersion: nextCredentialVersion,
credentialStatus,
...(credentialCheckedAt ? { credentialCheckedAt } : {}),
},
},
};
}
export function normalizeWebSearchSettings(settings: WebSearchSettings): WebSearchSettings {
const enabled = settings.enabled === true;
const defaultProvider = isWebSearchProvider(settings.defaultProvider)
? settings.defaultProvider
: 'model';
// Cap apiKey length defensively. Tavily keys are < 64 chars; anything
// longer is almost certainly garbage that would break log redaction.
const rawApiKey = settings.providers?.tavily?.apiKey;
const apiKey = typeof rawApiKey === 'string' && rawApiKey.length <= 256 ? rawApiKey : '';
const rawCredentialStatus = settings.providers?.tavily?.credentialStatus;
const credentialStatus = isWebSearchCredentialStatus(rawCredentialStatus)
? rawCredentialStatus
: 'untested';
const rawCredentialCheckedAt = settings.providers?.tavily?.credentialCheckedAt;
const credentialCheckedAt =
typeof rawCredentialCheckedAt === 'string' && rawCredentialCheckedAt.length <= 64
? rawCredentialCheckedAt
: undefined;
const credentialVersion = normalizeCredentialVersion(
settings.providers?.tavily?.credentialVersion,
);
return {
enabled,
defaultProvider,
providers: {
tavily: {
apiKey,
credentialSource: webSearchCredentialSourceFromStoredKey(apiKey),
credentialVersion,
credentialStatus,
...(credentialCheckedAt ? { credentialCheckedAt } : {}),
},
},
};
}
/**
* Helper for the IPC store boundary: given a (possibly stale)
* persisted token and the renderer-sent update token, choose which
* to persist. Renderer sending exactly the mask means "keep current".
*/
export function reconcileMaskedToken(persisted: string, candidate: string): string {
if (candidate === MASKED_TOKEN_SENTINEL) return persisted;
return candidate;
}
/** Returns the rendered representation (masked when non-empty). */
export function maskedTokenForDisplay(persisted: string): string {
return persisted.length === 0 ? '' : MASKED_TOKEN_SENTINEL;
}
export function isWebSearchCredentialStatus(value: unknown): value is WebSearchCredentialStatus {
return (
typeof value === 'string' &&
(WEB_SEARCH_CREDENTIAL_STATUSES as readonly string[]).includes(value)
);
}
export function isWebSearchCredentialSource(value: unknown): value is WebSearchCredentialSource {
return (
typeof value === 'string' &&
(WEB_SEARCH_CREDENTIAL_SOURCES as readonly string[]).includes(value)
);
}
export function webSearchCredentialSourceFromStoredKey(apiKey: string): WebSearchCredentialSource {
return apiKey.length > 0 ? 'saved' : 'none';
}
export function webSearchCredentialStatusFromResponse(
response: WebSearchResponse,
): WebSearchCredentialStatus {
if (response.ok) return 'valid';
if (isWebSearchCredentialStatus(response.reason)) return response.reason;
return 'network_error';
}
function normalizeCredentialVersion(value: unknown): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return 0;
return value;
}
function normalizeOptionalCredentialVersion(value: unknown): number | undefined {
if (value === undefined) return undefined;
return normalizeCredentialVersion(value);
}