Skip to content
Merged
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
22 changes: 22 additions & 0 deletions src/main/app-controls/mcp/toolRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,9 @@ describe("Poracode app control tools — settings", () => {
return {
...defaultSharedSettings,
themeMode: "light",
agentSettings: {
cursor: { structuredRuntime: "sdk", sdkApiKey: "lc-safe:cursor-secret-token" },
},
acpRegistryInstalledAgents: { registryAgent: { source: "x" } as never },
agentInstances: {
claudeProfile: {
Expand Down Expand Up @@ -772,7 +775,12 @@ describe("Poracode app control tools — settings", () => {
};
const serialized = JSON.stringify(result);
expect(serialized).not.toContain("super-secret-token");
expect(serialized).not.toContain("cursor-secret-token");
expect(serialized).not.toContain("visible");
expect(
(result.settings as unknown as { agentSettings: Record<string, Record<string, string>> })
.agentSettings.cursor?.sdkApiKey,
).toBe("«redacted»");
const env = result.settings.agentInstances.claudeProfile?.environment;
expect(env).toEqual({
ANTHROPIC_API_KEY: { sensitive: true },
Expand Down Expand Up @@ -848,6 +856,20 @@ describe("Poracode app control tools — settings", () => {
expect(settingsWrite).not.toHaveBeenCalled();
});

it("update_settings refuses sensitive agent settings", async () => {
const { ctx, settingsWrite } = context({ settings: settingsWithSecret() });
await expect(
dispatchTool(
"update_settings",
{
patch: { agentSettings: { cursor: { sdkApiKey: "replacement" } } },
},
ctx,
),
).rejects.toThrow(/cursor\.sdkApiKey/);
expect(settingsWrite).not.toHaveBeenCalled();
});

it("update_settings refuses mcpServers and points to the dedicated tools", async () => {
const { ctx, settingsWrite } = context({ settings: settingsWithSecret() });
await expect(
Expand Down
30 changes: 29 additions & 1 deletion src/main/app-controls/mcp/tools/settings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import type { McpServer, McpTransport } from "@/shared/contracts";
import { isSensitiveAgentSetting, sensitiveAgentSettingKeys } from "@/shared/agentSecrets";
import { normalizeSharedSettings, type SharedSettings } from "@/shared/settings";
import { mergeManagedSharedSettings } from "../../../sharedSettingsFile";
import type { ToolDomain } from "./types";
Expand Down Expand Up @@ -71,6 +72,7 @@ export const settingsTools: ToolDomain = {
update_settings: (args, ctx) => {
const { patch } = updateArgsSchema.parse(args);
const rejected = Object.keys(patch).filter((key) => PROTECTED_SETTINGS_KEYS.has(key));
const rejectedAgentSecrets = findPatchedAgentSecrets(patch.agentSettings);
if (rejected.length > 0) {
const hint = rejected.includes("mcpServers")
? " Manage MCP servers with add_mcp_server, update_mcp_server, and remove_mcp_server."
Expand All @@ -81,6 +83,13 @@ export const settingsTools: ToolDomain = {
)}.${hint}`,
);
}
if (rejectedAgentSecrets.length > 0) {
throw new Error(
`These sensitive agent settings are managed elsewhere and cannot be changed with this tool: ${rejectedAgentSecrets.join(
", ",
)}.`,
);
}
const onDisk = ctx.settings.read();
const candidate = deepMerge(onDisk as Record<string, unknown>, patch);
const normalized = normalizeSharedSettings(candidate);
Expand Down Expand Up @@ -125,6 +134,15 @@ function deepMerge(
* An agent can therefore see what is configured without ever reading a secret.
*/
export function redactSharedSettings(settings: SharedSettings): Record<string, unknown> {
const agentSettings = Object.fromEntries(
Object.entries(settings.agentSettings).map(([agentKind, values]) => {
const next = { ...values };
for (const key of sensitiveAgentSettingKeys(agentKind)) {
if (key in next) next[key] = REDACTED_VALUE;
}
return [agentKind, next];
}),
);
const agentInstances = Object.fromEntries(
Object.entries(settings.agentInstances).map(([id, instance]) => {
if (!instance.environment) return [id, instance];
Expand All @@ -138,7 +156,17 @@ export function redactSharedSettings(settings: SharedSettings): Record<string, u
}),
);
const mcpServers = settings.mcpServers.map(redactMcpServer);
return { ...settings, agentInstances, mcpServers };
return { ...settings, agentSettings, agentInstances, mcpServers };
}

function findPatchedAgentSecrets(value: unknown): string[] {
if (!isPlainObject(value)) return [];
return Object.entries(value).flatMap(([agentKind, settings]) => {
if (!isPlainObject(settings)) return [];
return Object.keys(settings)
.filter((key) => isSensitiveAgentSetting(agentKind, key))
.map((key) => `${agentKind}.${key}`);
});
}

/** Mask the credential-bearing values of one MCP server's transport. */
Expand Down
12 changes: 12 additions & 0 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
setProfileIdentityResponse,
} from "../profile";
import {
applyAgentSecretSetting,
applyClaudeProfileEnvironment,
mergeManagedSharedSettings,
readSharedSettingsFile,
Expand Down Expand Up @@ -351,6 +352,17 @@ export function createLocalIpcHandlers(
options.updatePowerSaveBlocker();
options.onSharedSettingsChanged?.(merged);
},
setAgentSecretSetting: (payload) => {
const settingsPath = options.requirePoracodePaths().settingsPath;
const { settings, storedValue } = applyAgentSecretSetting(
readSharedSettingsFile(settingsPath),
payload,
dirname(settingsPath),
);
writeSharedSettingsFile(settingsPath, settings);
options.onSharedSettingsChanged?.(settings);
return { storedValue };
},
removeCrossagentRoutingOverride: ({ tags }) => {
const settingsPath = options.requirePoracodePaths().settingsPath;
const current = readSharedSettingsFile(settingsPath);
Expand Down
79 changes: 78 additions & 1 deletion src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { allUsageProviderDescriptors } from "@poracode/agents-usage";
import type { AgentInstanceConfig } from "@/shared/contracts";
import { isEncryptedSecret } from "@/shared/secretStorage";
import { decryptSecret, isEncryptedSecret } from "@/shared/secretStorage";
import {
DEFAULT_USAGE_DISABLED_PROVIDER_IDS,
DEFAULT_USAGE_ENABLED_PROVIDER_IDS,
defaultSharedSettings,
type SharedSettings,
} from "@/shared/settings";
import {
applyAgentSecretSetting,
applyClaudeProfileEnvironment,
mergeManagedSharedSettings,
patchSharedSettingsFile,
readSharedSettingsFile,
writeSharedSettingsFile,
} from "./sharedSettingsFile";
Expand Down Expand Up @@ -492,6 +494,81 @@ describe("sharedSettingsFile", () => {
});
});

describe("applyAgentSecretSetting", () => {
it("seals Cursor SDK API keys and lets the supervisor decrypt the stored value", () => {
const dir = makeTempDir();
const { settings, storedValue } = applyAgentSecretSetting(
defaultSharedSettings,
{ agentKind: "cursor", key: "sdkApiKey", value: " cursor-secret " },
dir,
);

expect(storedValue).not.toBeNull();
expect(isEncryptedSecret(storedValue ?? "")).toBe(true);
expect(storedValue).not.toContain("cursor-secret");
expect(settings.agentSettings.cursor?.sdkApiKey).toBe(storedValue);
expect(decryptSecret(dir, storedValue ?? "")).toBe("cursor-secret");
});

it("pins encrypted agent secrets during ordinary renderer settings writes", () => {
const dir = makeTempDir();
const { settings: onDisk, storedValue } = applyAgentSecretSetting(
defaultSharedSettings,
{ agentKind: "cursor", key: "sdkApiKey", value: "cursor-secret" },
dir,
);
const incoming = {
...defaultSharedSettings,
agentSettings: {
cursor: { sdkApiKey: "plaintext-overwrite", structuredRuntime: "sdk" },
},
};

const merged = mergeManagedSharedSettings(onDisk, incoming);
expect(merged.agentSettings.cursor).toEqual({
sdkApiKey: storedValue,
structuredRuntime: "sdk",
});
});

it("clears a saved key only through the dedicated secret path", () => {
const dir = makeTempDir();
const saved = applyAgentSecretSetting(
defaultSharedSettings,
{ agentKind: "cursor", key: "sdkApiKey", value: "cursor-secret" },
dir,
);
const cleared = applyAgentSecretSetting(
saved.settings,
{ agentKind: "cursor", key: "sdkApiKey", value: "" },
dir,
);

expect(cleared.storedValue).toBeNull();
expect(cleared.settings.agentSettings.cursor?.sdkApiKey).toBeUndefined();
});

it("preserves a saved key when a remote patch replaces ordinary agent settings", () => {
const dir = makeTempDir();
const settingsPath = join(dir, "settings.json");
const saved = applyAgentSecretSetting(
defaultSharedSettings,
{ agentKind: "cursor", key: "sdkApiKey", value: "cursor-secret" },
dir,
);
writeSharedSettingsFile(settingsPath, saved.settings);

const patched = patchSharedSettingsFile(settingsPath, {
agentSettings: { cursor: { structuredRuntime: "acp" } },
});

expect(patched.agentSettings.cursor).toEqual({
structuredRuntime: "acp",
sdkApiKey: saved.storedValue,
});
});
});

describe("applyClaudeProfileEnvironment", () => {
function claudeProfileSettings(environment?: AgentInstanceConfig["environment"]): SharedSettings {
const instance: AgentInstanceConfig = {
Expand Down
56 changes: 54 additions & 2 deletions src/main/sharedSettingsFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AgentInstanceEnvVar,
SetClaudeProfileEnvironmentPayload,
} from "@/shared/contracts";
import { isSensitiveAgentSetting, sensitiveAgentSettingKeys } from "@/shared/agentSecrets";
import { encryptSecret } from "@/shared/secretStorage";
import {
defaultSharedSettings,
Expand Down Expand Up @@ -41,10 +42,12 @@ export function patchSharedSettingsFile(
settingsPath: string,
patch: { [K in keyof SharedSettings]?: SharedSettings[K] | undefined },
): SharedSettings {
const next = readSharedSettingsFile(settingsPath);
const onDisk = readSharedSettingsFile(settingsPath);
const incoming = { ...onDisk };
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) (next as Record<string, unknown>)[key] = value;
if (value !== undefined) (incoming as Record<string, unknown>)[key] = value;
}
const next = mergeManagedSharedSettings(onDisk, incoming);
writeSharedSettingsFile(settingsPath, next);
return next;
}
Expand All @@ -66,6 +69,22 @@ export function mergeManagedSharedSettings(
onDisk: SharedSettings,
incoming: SharedSettingsInput,
): SharedSettings {
const agentSettings = { ...incoming.agentSettings };
for (const agentKind of new Set([
...Object.keys(onDisk.agentSettings),
...Object.keys(incoming.agentSettings),
])) {
const sensitiveKeys = sensitiveAgentSettingKeys(agentKind);
if (sensitiveKeys.length === 0) continue;
const values = { ...agentSettings[agentKind] };
for (const key of sensitiveKeys) {
const stored = onDisk.agentSettings[agentKind]?.[key];
if (stored === undefined) delete values[key];
else values[key] = stored;
}
agentSettings[agentKind] = values;
}

const rendererManagedInstances = Object.fromEntries(
Object.entries(incoming.agentInstances)
.filter(([, instance]) => instance.driver !== "acp-generic")
Expand All @@ -88,6 +107,7 @@ export function mergeManagedSharedSettings(
);
return {
...incoming,
agentSettings,
acpRegistryInstalledAgents: onDisk.acpRegistryInstalledAgents,
agentInstances: {
...rendererManagedInstances,
Expand All @@ -99,6 +119,38 @@ export function mergeManagedSharedSettings(
};
}

export function applyAgentSecretSetting(
settings: SharedSettings,
payload: { agentKind: string; key: string; value: string },
baseDir: string,
): { settings: SharedSettings; storedValue: string | null } {
if (!isSensitiveAgentSetting(payload.agentKind, payload.key)) {
throw new Error(`Unsupported sensitive agent setting: ${payload.agentKind}.${payload.key}`);
}

const current = settings.agentSettings[payload.agentKind] ?? {};
const nextValues = { ...current };
const value = payload.value.trim();
let storedValue: string | null = null;
if (value) {
storedValue = encryptSecret(baseDir, value);
nextValues[payload.key] = storedValue;
} else {
delete nextValues[payload.key];
}

return {
settings: {
...settings,
agentSettings: {
...settings.agentSettings,
[payload.agentKind]: nextValues,
},
},
storedValue,
};
}

/**
* Apply a Claude profile's environment edit, sealing any `sensitive` value that
* is not already sealed. `baseDir` is the settings directory (passed through to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface ProviderModelMenuProps {
lockedAgentKind?: string;
presentationMode?: ThreadPresentationMode;
isDisabled?: boolean;
/** Fast mode is on for the current draft, so supported rows mark it filled. */
isFastEnabled?: boolean;
hideLabelOnWrap?: boolean;
forceHideLabel?: boolean;
collapseTier?: number;
Expand Down Expand Up @@ -259,6 +261,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
lockedAgentKind,
presentationMode,
isDisabled,
isFastEnabled = false,
hideLabelOnWrap,
forceHideLabel = false,
collapseTier,
Expand Down Expand Up @@ -472,6 +475,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
modelRowHeight={mobile ? MODEL_MENU_ROW_HEIGHT_MOBILE : MODEL_MENU_ROW_HEIGHT}
mobile={mobile}
mobileExpanded={mobile && expanded}
isFastEnabled={isFastEnabled}
toggleFavorite={(providerKind, modelId, rowPresentationMode) =>
toggleFavoriteModel(
providerKind,
Expand Down Expand Up @@ -520,6 +524,7 @@ function WindowedProviderModelList(props: {
modelRowHeight: number;
mobile: boolean;
mobileExpanded: boolean;
isFastEnabled: boolean;
toggleFavorite: (
providerKind: string,
modelId: string,
Expand All @@ -535,6 +540,7 @@ function WindowedProviderModelList(props: {
modelRowHeight,
mobile,
mobileExpanded,
isFastEnabled,
toggleFavorite,
onSelect,
} = props;
Expand Down Expand Up @@ -818,10 +824,16 @@ function WindowedProviderModelList(props: {
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="min-w-0 truncate">{name}</span>
{item.supportsFast ? (
// Filled while Fast mode is on, outlined when the model
// merely supports it.
<Zap
role="img"
aria-label={t`Supports Fast mode`}
className="size-3 shrink-0 text-muted/60"
aria-label={isFastEnabled ? t`Fast mode` : t`Supports Fast mode`}
className={
isFastEnabled
? "size-3 shrink-0 fill-current text-muted"
: "size-3 shrink-0 text-muted/60"
}
/>
) : null}
{mutedHint ? (
Expand Down
Loading