Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { MicrophoneLeaseRegistry } from "./live-voice/microphone-lease";
import { createLiveCallService } from "./live-voice/runtime";
import { createLiveVoiceWidget } from "./live-voice/widget-window";
import { getActiveRemoteHostsBoot } from "./bootstrap/remote-hosts";
import { assertJevUserEndpoint } from "./jev-endpoint-policy";
import { installLiveMicrophonePermissionHandlers } from "./live-voice/microphone-permissions";
import { MainProcessState } from "./bootstrap/main-state";
import { registerApplicationActivation } from "./bootstrap/app-activation";
Expand Down Expand Up @@ -378,6 +379,7 @@ const createdSessionLaunchRuntime = createSessionLaunchRuntime({
bindingForModel,
effectiveSubagentModelConfig,
normalizeThinkingLevel,
assertJevEndpoint: assertJevUserEndpoint,
});
sessionLaunchRuntime = createdSessionLaunchRuntime;
const {
Expand Down
36 changes: 24 additions & 12 deletions apps/desktop/electron/main/ipc/jev-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
/**
* The Jev key check channel.
*
* One job, separate from the provider channels: Jev owns no provider row, and
* its credential lives under a fixed Host secret reference rather than a
* provider id. The handler only asks TypeSafe; it stores nothing, so a key
* that fails the check never reaches the secret store.
*/
import { IPC } from "@pi-desktop/shared";
/** Jev System One credential check IPC. The handler stores nothing. */
import {
DEFAULT_JEV_CLASSIFIER_BINDING,
IPC,
normalizeJevClassifierBinding,
type JevKeyCheckInput,
} from "@pi-desktop/shared";
import { assertJevUserEndpoint } from "../jev-endpoint-policy";
import { probeJevApiKey } from "../jev-probe";
import type { Logger } from "../logger";
import type { IpcRegistrar } from "./types";
Expand All @@ -17,9 +16,22 @@ export type JevIpcDependencies = {
};

export function registerJevIpc({ registrar, logger }: JevIpcDependencies): void {
registrar.handle(IPC.invoke.jevTest, async (apiKey: unknown) => {
const result = await probeJevApiKey(typeof apiKey === "string" ? apiKey : "");
logger.app("provider", result.ok ? "info" : "warn", "TypeSafe Jev key check", {
registrar.handle(IPC.invoke.jevTest, async (input: unknown) => {
// Older renderer callers sent only the key. Keep that shape bound to the
// official endpoint while all new calls state their destination explicitly.
const legacy = typeof input === "string";
const request = legacy ? { apiKey: input } : (input ?? {}) as Partial<JevKeyCheckInput>;
const binding = legacy
? { ...DEFAULT_JEV_CLASSIFIER_BINDING }
: normalizeJevClassifierBinding(request.binding);
const result = binding
? await probeJevApiKey(
typeof request.apiKey === "string" ? request.apiKey : "",
binding,
{ assertEndpoint: assertJevUserEndpoint },
)
: { ok: false, message: "The System One endpoint or model is invalid." };
logger.app("provider", result.ok ? "info" : "warn", "Jev System One key check", {
data: { ok: result.ok, status: result.status },
});
return result;
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/electron/main/jev-endpoint-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Network-policy guard for a user-configured System One endpoint. */
import { net, session } from "electron";
import { fetchPinnedDirect } from "./public-https-direct";
import { createPublicHttpsClient } from "./public-https-fetch";
import {
allowInsecureUserEndpointsEnabled,
noteInsecureUserEndpoint,
relaxedNetworkPolicyEnabled,
} from "./endpoint-policy";

const client = createPublicHttpsClient({
fetchImpl: (url, init) => net.fetch(url, init),
pinnedFetchImpl: (url, init, address) => fetchPinnedDirect(url, init, address),
routeImpl: (url) => session.defaultSession.resolveProxy(url),
allowFakeIp: () => relaxedNetworkPolicyEnabled(),
allowInsecureUserEndpoints: () => allowInsecureUserEndpointsEnabled(),
onInsecureUserEndpoint: (host) => noteInsecureUserEndpoint(host),
});

/** Apply the same user-entered endpoint policy used by other app-owned fetches. */
export async function assertJevUserEndpoint(url: string): Promise<void> {
await client.assertPublicUrl(url, "user");
}
144 changes: 102 additions & 42 deletions apps/desktop/electron/main/jev-probe.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,43 @@
/**
* TypeSafe reachability check for the Jev classifier key.
* Reachability check for the configured Jev System One credential.
*
* Jev is configured from settings, where a wrong or expired key would
* otherwise be discovered by the Agent mid-task. The check is the smallest
* real System One round trip — one boolean question over a one-field state —
* against the same address and classifier id the Agent's `JevClassify` tool
* uses, so "the key works" means TypeSafe answered this key, not that the
* string looks plausible.
*
* Nothing is written here: the caller stores the key only when this answered.
* The key never reaches a log line or an error message; failure text is
* redacted before it is returned.
* This performs the smallest real classification request against the same
* normalized endpoint and model that Agent runtime will use. It stores nothing,
* never logs the credential, and follows redirects only inside the configured
* origin so Bearer auth cannot escape to another service.
*/
import {
TYPESAFE_JEV_MODEL_ID,
TYPESAFE_SYSTEM_ONE_URL,
DEFAULT_JEV_CLASSIFIER_BINDING,
jevSystemOneUrl,
normalizeJevClassifierBinding,
type JevClassifierBinding,
type JevKeyCheckResult,
} from "@pi-desktop/shared";

export const JEV_PROBE_TIMEOUT_MS = 20_000;

/** Longest provider message echoed back to the settings dialog. */
const MAX_DETAIL_LENGTH = 300;
const MAX_REDIRECTS = 3;

export type JevProbeOptions = {
/** The caller's own cancellation, combined with the timeout. */
signal?: AbortSignal;
/** Test seam; defaults to the global fetch. */
fetchImpl?: typeof fetch;
timeoutMs?: number;
/** Production injects the app's DNS/route/network-policy guard. */
assertEndpoint?: (url: string) => Promise<void>;
};

/**
* The smallest valid System One request: `bool` is PI-Desktop's public name for
* the wire-level `noul` question (see pi-ai's System One transport).
*/
function probeRequestBody() {
function isProbeOptions(value: unknown): value is JevProbeOptions {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
// The legacy `(key, options)` shape allowed an empty object. New bindings
// always identify themselves with at least one binding field, including
// malformed partial bindings that must fail validation rather than fall back.
return !("baseUrl" in record || "modelId" in record);
}

function probeRequestBody(modelId: string) {
return {
model: TYPESAFE_JEV_MODEL_ID,
model: modelId,
state: { probe: true },
questions: {
reachable: {
Expand All @@ -52,50 +52,110 @@ function probeRequestBody() {
};
}

/** A message that never carries the credential it describes. */
function redact(value: string, apiKey: string): string {
if (!apiKey) return value;
return value.split(apiKey).join("[redacted]");
return apiKey ? value.split(apiKey).join("[redacted]") : value;
}

async function postWithSameOriginRedirects(
startUrl: string,
init: RequestInit,
fetchImpl: typeof fetch,
): Promise<Response> {
const allowedOrigin = new URL(startUrl).origin;
let url = startUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
const response = await fetchImpl(url, { ...init, redirect: "manual" });
const location = response.headers?.get?.("location");
if (response.status < 300 || response.status >= 400 || !location) return response;
const next = new URL(location, url);
if (next.origin !== allowedOrigin) {
throw Object.assign(
new Error("System One redirect refused: it left the configured origin"),
{ status: response.status },
);
}
url = next.toString();
}
throw new Error("System One request followed too many redirects");
}

/**
* `bindingOrOptions` keeps the original `(key, options)` test/caller shape
* compatible; new callers pass `(key, binding, options)`.
*/
export async function probeJevApiKey(
apiKey: string,
options: JevProbeOptions = {},
bindingOrOptions: JevClassifierBinding | JevProbeOptions = {
...DEFAULT_JEV_CLASSIFIER_BINDING,
},
explicitOptions: JevProbeOptions = {},
): Promise<JevKeyCheckResult> {
const options = isProbeOptions(bindingOrOptions)
? bindingOrOptions
: explicitOptions;
const binding = isProbeOptions(bindingOrOptions)
? { ...DEFAULT_JEV_CLASSIFIER_BINDING }
: bindingOrOptions;
const key = apiKey.trim();
if (!key) return { ok: false, message: "No TypeSafe API key was provided." };
if (!key) return { ok: false, message: "No System One API key was provided." };
const normalized = normalizeJevClassifierBinding(binding);
if (!normalized) {
return { ok: false, message: "The System One endpoint or model is invalid." };
}
const requestUrl = jevSystemOneUrl(normalized);
const timeoutMs = options.timeoutMs ?? JEV_PROBE_TIMEOUT_MS;
const timeout = AbortSignal.timeout(timeoutMs);
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
const timeoutController = new AbortController();
const timeoutTimer = setTimeout(() => timeoutController.abort(), timeoutMs);
const signal = options.signal
? AbortSignal.any([options.signal, timeoutController.signal])
: timeoutController.signal;
const doFetch = options.fetchImpl ?? globalThis.fetch;
try {
const response = await doFetch(TYPESAFE_SYSTEM_ONE_URL, {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify(probeRequestBody()),
signal,
});
await options.assertEndpoint?.(requestUrl);
const response = await postWithSameOriginRedirects(
requestUrl,
{
method: "POST",
headers: {
authorization: `Bearer ${key}`,
"content-type": "application/json",
},
body: JSON.stringify(probeRequestBody(normalized.modelId)),
signal,
},
doFetch,
);
if (response.ok) return { ok: true, status: response.status };
const body = await response.text().catch(() => "");
const detail = redact(body.slice(0, MAX_DETAIL_LENGTH).trim(), key);
const detail = redact(body.trim(), key).slice(0, MAX_DETAIL_LENGTH);
return {
ok: false,
status: response.status,
message: detail
? `TypeSafe returned ${response.status}: ${detail}`
: `TypeSafe returned ${response.status}`,
? `System One returned ${response.status}: ${detail}`
: `System One returned ${response.status}`,
};
} catch (error) {
if (options.signal?.aborted) {
return { ok: false, message: "The TypeSafe key check was cancelled." };
return { ok: false, message: "The System One key check was cancelled." };
}
const name = (error as { name?: unknown }).name;
if (name === "TimeoutError" || name === "AbortError") {
return {
ok: false,
message: `TypeSafe did not answer within ${Math.round(timeoutMs / 1000)}s.`,
message: `The System One endpoint did not answer within ${Math.round(timeoutMs / 1000)}s.`,
};
}
return { ok: false, message: redact(error instanceof Error ? error.message : String(error), key) };
const status = (error as { status?: unknown }).status;
return {
ok: false,
...(typeof status === "number" ? { status } : {}),
message: redact(
error instanceof Error ? error.message : String(error),
key,
).slice(0, MAX_DETAIL_LENGTH),
};
} finally {
clearTimeout(timeoutTimer);
}
}
23 changes: 21 additions & 2 deletions apps/desktop/electron/main/runtime/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
defaultCommandShellForPlatform,
isCommandShellId,
validateNetworkProxy,
normalizeJevClassifierBinding,
resolveJevClassifierBinding,
validateSpeechSettings,
type CommandShellId,
type ModelBinding,
Expand Down Expand Up @@ -166,14 +168,19 @@ export function createProviderCatalogRuntime({
): T & { defaultCommandShell: CommandShellId } => {
const value = (
settings && typeof settings === "object" ? settings : {}
) as T & { defaultCommandShell?: unknown };
) as T & { defaultCommandShell?: unknown; jevClassifier?: unknown };
const jev = resolveJevClassifierBinding(value.jevClassifier);
return {
...(value as T),
infiniteProviderRetry: (value as T & { infiniteProviderRetry?: unknown })
.infiniteProviderRetry === true,
keepAwakeWhileRunning: (value as T & { keepAwakeWhileRunning?: unknown })
.keepAwakeWhileRunning === true,
jevEnabled: (value as T & { jevEnabled?: unknown }).jevEnabled === true,
jevEnabled:
(value as T & { jevEnabled?: unknown }).jevEnabled === true &&
jev.status !== "invalid",
...(jev.status === "configured" ? { jevClassifier: jev.binding } : {}),
...(jev.status === "invalid" ? { jevClassifier: null } : {}),
defaultCommandShell: isCommandShellId(value.defaultCommandShell)
? value.defaultCommandShell
: defaultCommandShellForPlatform(process.platform),
Expand All @@ -192,6 +199,7 @@ export function createProviderCatalogRuntime({
updatePreference?: unknown;
lastNotifiedUpdateVersion?: unknown;
networkProxy?: unknown;
jevClassifier?: unknown;
};
if (
Object.prototype.hasOwnProperty.call(value, "defaultCommandShell") &&
Expand Down Expand Up @@ -225,6 +233,17 @@ export function createProviderCatalogRuntime({
errorCode: ErrorCodes.INVALID_PARAMS,
});
}
if (Object.prototype.hasOwnProperty.call(value, "jevClassifier")) {
if (value.jevClassifier !== null) {
const binding = normalizeJevClassifierBinding(value.jevClassifier);
if (!binding) {
throw Object.assign(new Error("jevClassifier is invalid"), {
errorCode: ErrorCodes.INVALID_PARAMS,
});
}
value.jevClassifier = binding;
}
}
if (
Object.prototype.hasOwnProperty.call(value, "updatePreference") &&
value.updatePreference !== "automatic" &&
Expand Down
Loading
Loading