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
37 changes: 33 additions & 4 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { homedir } from "node:os";
import { dirname } from "node:path";
import { clipboard, dialog, nativeImage, shell, type BrowserWindow } from "electron";
import type { BrowserPanelManager } from "../browser";
import { openMicrophoneSettings } from "../browser/permissions";
Expand Down Expand Up @@ -28,14 +29,19 @@ import {
saveHandoffContextFile,
} from "../attachments/localFiles";
import { createProjectDirectory } from "../projectDirectory";
import { readSharedSettingsFile, writeSharedSettingsFile } from "../sharedSettingsFile";
import {
applyClaudeProfileEnvironment,
readSharedSettingsFile,
writeSharedSettingsFile,
} from "../sharedSettingsFile";
import { readKeybindingsFile } from "../keybindingsFile";
import type { AutoUpdaterController } from "../updates/autoUpdater";
import {
defineMainLocalIpcHandlers,
type MainLocalIpcHandlerMap,
type WindowChromePayload,
} from "@/shared/ipc";
import type { AgentInstanceConfig } from "@/shared/contracts";
import type { LightcodePaths } from "@/shared/lightcodePaths";
import { UsageLoginManager } from "../usageLogin/UsageLoginManager";

Expand Down Expand Up @@ -141,9 +147,21 @@ export function createLocalIpcHandlers(
// doesn't clobber writes made out-of-band by the supervisor.
const onDisk = readSharedSettingsFile(settingsPath);
const rendererManagedInstances = Object.fromEntries(
Object.entries(settings.agentInstances).filter(
([, instance]) => instance.driver !== "acp-generic",
),
Object.entries(settings.agentInstances)
.filter(([, instance]) => instance.driver !== "acp-generic")
.map(([id, instance]): [string, AgentInstanceConfig] => {
// A Claude profile's `environment` is owned by the encrypting
// `setClaudeProfileEnvironment` path. Pin it to disk so the
// renderer's plaintext-capable persist cycle can never write a
// secret in the clear or clear a saved one. Other drivers keep
// their existing renderer-managed behavior.
if (instance.driver !== "claude") return [id, instance];
const onDiskEnv = onDisk.agentInstances[id]?.environment;
const next: AgentInstanceConfig = { ...instance };
if (onDiskEnv) next.environment = onDiskEnv;
else delete next.environment;
return [id, next];
}),
);
const supervisorManagedInstances = Object.fromEntries(
Object.entries(onDisk.agentInstances).filter(
Expand All @@ -162,6 +180,17 @@ export function createLocalIpcHandlers(
options.updatePowerSaveBlocker();
options.onSharedSettingsChanged?.();
},
setClaudeProfileEnvironment: (payload) => {
const settingsPath = options.requireLightcodePaths().settingsPath;
const { settings, instance } = applyClaudeProfileEnvironment(
readSharedSettingsFile(settingsPath),
payload,
dirname(settingsPath),
);
writeSharedSettingsFile(settingsPath, settings);
options.onSharedSettingsChanged?.();
return instance;
},
setWindowChrome: async (payload: WindowChromePayload) => {
const mainWindow = options.getMainWindow();
if (!mainWindow) {
Expand Down
97 changes: 95 additions & 2 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { defaultSharedSettings } from "@/shared/settings";
import { readSharedSettingsFile, writeSharedSettingsFile } from "./sharedSettingsFile";
import type { AgentInstanceConfig } from "@/shared/contracts";
import { isEncryptedSecret } from "@/shared/secretStorage";
import { defaultSharedSettings, type SharedSettings } from "@/shared/settings";
import {
applyClaudeProfileEnvironment,
readSharedSettingsFile,
writeSharedSettingsFile,
} from "./sharedSettingsFile";

const tempDirs: string[] = [];

Expand Down Expand Up @@ -330,3 +336,90 @@ describe("sharedSettingsFile", () => {
});
});
});

describe("applyClaudeProfileEnvironment", () => {
function claudeProfileSettings(environment?: AgentInstanceConfig["environment"]): SharedSettings {
const instance: AgentInstanceConfig = {
id: "glm",
driver: "claude",
displayName: "GLM",
config: { configDir: "~/.lightcode/claude-profiles/glm" },
...(environment ? { environment } : {}),
};
return { ...defaultSharedSettings, agentInstances: { glm: instance } };
}

it("seals sensitive values and stores non-sensitive ones as plaintext", () => {
const { settings, instance } = applyClaudeProfileEnvironment(
claudeProfileSettings(),
{
instanceId: "glm",
environment: {
ANTHROPIC_BASE_URL: { value: "https://api.z.ai/api/anthropic" },
ANTHROPIC_AUTH_TOKEN: { value: "sk-secret-123", sensitive: true },
},
},
makeTempDir(),
);

expect(instance.environment?.ANTHROPIC_BASE_URL).toEqual({
value: "https://api.z.ai/api/anthropic",
});
const token = instance.environment?.ANTHROPIC_AUTH_TOKEN;
expect(token?.sensitive).toBe(true);
expect(isEncryptedSecret(token?.value ?? "")).toBe(true);
expect(token?.value).not.toContain("sk-secret-123");
// The returned instance is the one written into the settings map.
expect(settings.agentInstances.glm).toBe(instance);
});

it("round-trips an already-sealed secret without re-sealing it", () => {
const dir = makeTempDir();
const first = applyClaudeProfileEnvironment(
claudeProfileSettings(),
{ instanceId: "glm", environment: { TOKEN: { value: "plain", sensitive: true } } },
dir,
);
const sealed = first.instance.environment?.TOKEN?.value ?? "";

const second = applyClaudeProfileEnvironment(
claudeProfileSettings(),
{ instanceId: "glm", environment: { TOKEN: { value: sealed, sensitive: true } } },
dir,
);
expect(second.instance.environment?.TOKEN?.value).toBe(sealed);
});

it("drops empty values and removes the environment field when all are empty", () => {
const { instance } = applyClaudeProfileEnvironment(
claudeProfileSettings({ OLD: { value: "x" } }),
{ instanceId: "glm", environment: { OLD: { value: "" }, "": { value: "ignored" } } },
makeTempDir(),
);
expect(instance.environment).toBeUndefined();
});

it("throws for a missing instance or a non-Claude driver", () => {
expect(() =>
applyClaudeProfileEnvironment(
claudeProfileSettings(),
{ instanceId: "nope", environment: {} },
makeTempDir(),
),
).toThrow(/not found/i);

const acpSettings: SharedSettings = {
...defaultSharedSettings,
agentInstances: {
droid: { id: "droid", driver: "acp-generic", config: { binary: "droid" } },
},
};
expect(() =>
applyClaudeProfileEnvironment(
acpSettings,
{ instanceId: "droid", environment: {} },
makeTempDir(),
),
).toThrow(/not found/i);
});
});
49 changes: 49 additions & 0 deletions src/main/sharedSettingsFile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import { writeFileAtomic } from "@/shared/atomicFile";
import type {
AgentInstanceConfig,
AgentInstanceEnvVar,
SetClaudeProfileEnvironmentPayload,
} from "@/shared/contracts";
import { encryptSecret } from "@/shared/secretStorage";
import {
defaultSharedSettings,
normalizeSharedSettings,
Expand All @@ -25,3 +31,46 @@ export function readSharedSettingsFile(settingsPath: string): SharedSettings {
export function writeSharedSettingsFile(settingsPath: string, settings: SharedSettings): void {
writeFileAtomic(settingsPath, serializeSharedSettings(settings), { encoding: "utf8" });
}

/**
* Apply a Claude profile's environment edit, sealing any `sensitive` value that
* is not already sealed. `baseDir` is the settings directory (passed through to
* the cipher). Empty values are dropped (delete semantics); an empty result
* removes the `environment` field entirely. Throws if the instance is missing
* or is not a Claude profile. Returns the next settings plus the updated
* instance (with sealed env) for the renderer store to adopt without re-reading.
*/
export function applyClaudeProfileEnvironment(
settings: SharedSettings,
payload: SetClaudeProfileEnvironmentPayload,
baseDir: string,
): { settings: SharedSettings; instance: AgentInstanceConfig } {
const instance = settings.agentInstances[payload.instanceId];
if (!instance || instance.driver !== "claude") {
throw new Error(`Claude profile not found: ${payload.instanceId}`);
}

const nextEnv: Record<string, AgentInstanceEnvVar> = {};
for (const [name, variable] of Object.entries(payload.environment)) {
const key = name.trim();
if (key.length === 0 || variable.value.length === 0) continue;
nextEnv[key] = variable.sensitive
? { value: encryptSecret(baseDir, variable.value), sensitive: true }
: { value: variable.value };
}

const nextInstance: AgentInstanceConfig = { ...instance };
if (Object.keys(nextEnv).length > 0) {
nextInstance.environment = nextEnv;
} else {
delete nextInstance.environment;
}

return {
settings: {
...settings,
agentInstances: { ...settings.agentInstances, [payload.instanceId]: nextInstance },
},
instance: nextInstance,
};
}
7 changes: 6 additions & 1 deletion src/renderer/views/SettingsOverlay/SettingsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ function renderSection(
);
}
if (activeSection.startsWith("agents:")) {
return <SingleAgentSettings agentKind={activeSection.slice(7)} />;
return (
<SingleAgentSettings
agentKind={activeSection.slice(7)}
onOpenProfile={(kind) => onSectionChange(`agents:${kind}`)}
/>
);
}
return SECTION_VIEWS[activeSection]?.() ?? null;
}
Expand Down
Loading