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
81 changes: 81 additions & 0 deletions apps/desktop/e2e/command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,27 @@ test("a command crosses out a list item on the page, is saved, and the voice com
}
});

test("an exact empty-section command skips the model and still supports one-step undo", async () => {
test.setTimeout(60_000);
const { app, page } = await launch([]);
try {
await modeButton(page, "Command").click();
await say(page, 'create an empty section named "Failure modes"');
await expect(
page.locator(".page-editor h2").filter({ hasText: "Failure modes" }),
).toBeVisible();
await expect(page.getByText("Created empty section “Failure modes”.")).toBeVisible();
expect(mock?.requests).toHaveLength(0);

await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(".page-editor h2").filter({ hasText: "Failure modes" })).toHaveCount(
0,
);
} finally {
await app.close();
}
});

test("a command that throws something away waits for a yes: by button, or by voice", async () => {
test.setTimeout(90_000);
const remove = json({
Expand Down Expand Up @@ -196,6 +217,66 @@ test("an unclear command asks which one, and the answer is sent back to the mode
}
});

test("a long clarification with four long choices stays readable in a narrow window", async () => {
test.setTimeout(90_000);
const ask = json({
kind: "clarify",
question:
"Which of these similarly named connections should be removed from the request-processing diagram?",
options: [
{ label: "The web server connection to the primary Redis cache", ref: "e2" },
{ label: "The web server connection to the fallback Redis cache", ref: "e3" },
{ label: "The API gateway connection to the primary application server", ref: "e4" },
{ label: "The API gateway connection to the secondary application server", ref: "e5" },
],
});
const { app, page } = await launch([reply(ask)]);
try {
await modeButton(page, "Command").click();
await say(page, "remove the connection");
const card = page.locator('.dictation-card[data-card="clarify"]');
await expect(card).toBeVisible({ timeout: 20_000 });
await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
win?.unmaximize();
win?.setSize(820, 760);
});
await expect.poll(() => page.evaluate(() => window.innerWidth)).toBeLessThanOrEqual(820);
await expect(
card.getByRole("group", { name: "Choose an answer" }).getByRole("button"),
).toHaveCount(5);
await expect(card).toContainText("secondary application server");

const layout = await card.evaluate((element) => {
const text = element.querySelector(".card-text")?.getBoundingClientRect();
const actions = element.querySelector(".card-actions")?.getBoundingClientRect();
const buttons = [...element.querySelectorAll("button")].map((button) => {
const box = button.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom };
});
return {
contained: element.scrollWidth <= element.clientWidth,
separated: Boolean(text && actions && text.bottom <= actions.top),
buttons,
};
});
expect(layout.contained).toBe(true);
expect(layout.separated).toBe(true);
for (let i = 0; i < layout.buttons.length; i++) {
for (let j = i + 1; j < layout.buttons.length; j++) {
const a = layout.buttons[i];
const b = layout.buttons[j];
if (!a || !b) continue;
const overlaps =
a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
expect(overlaps).toBe(false);
}
}
} finally {
await app.close();
}
});

test("'Lucid, ...' in Input mode and holding the right Option key both send one command", async () => {
test.setTimeout(90_000);
const { app, page } = await launch([
Expand Down
73 changes: 72 additions & 1 deletion apps/desktop/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const dialog = (page: Page) => page.getByRole("dialog", { name: "Settings" });
/** A field of the dialog by the start of its label (the labels carry a help line too). */
const field = (page: Page, label: RegExp) => dialog(page).getByRole("textbox", { name: label });
const MODEL = /^Model\b/;
const ADDRESS = /^Address/;
const ADDRESS = /^Base URL/;
const NAME = /^Your name/;
const FOOTER = /^Footer/;
const WORDS = /^One per line/;
Expand Down Expand Up @@ -110,6 +110,77 @@ test("Settings opens from the sidebar and with ⌘, , closes with Esc, and shows
}
});

test("provider profiles expose only relevant controls and keep cloud use explicit", async () => {
test.setTimeout(60_000);
const { app, page } = await launch();
try {
await page.getByRole("button", { name: /Settings/ }).click();
const box = dialog(page);
await expect(box.getByRole("group", { name: "AI providers" })).toContainText(
"never switches providers automatically",
);
await box.getByLabel("Provider to add").selectOption("openai");
await box.getByRole("button", { name: "Add", exact: true }).click();
await expect(field(page, /^API key/)).toBeVisible();
await expect(field(page, ADDRESS)).toBeDisabled();
await expect(box).toContainText("subscriptions do not include API access");
await expect(box).toContainText("selected note context");
await box.getByRole("button", { name: "Set default" }).click();
await box.getByRole("button", { name: "Save", exact: true }).click();
await expect(box).toBeHidden();

await page.keyboard.press("Meta+,");
await expect(box).toContainText("OpenAI API · default");
} finally {
await app.close();
}
});

test("selecting Ollama shows structured-output guidance and hides LM Studio status", async () => {
test.setTimeout(60_000);
const { app, page } = await launch();
try {
await page.getByRole("complementary").getByRole("button", { name: "New page" }).click();
const pill = page.getByRole("group", { name: "Model status" });
await expect(pill).toHaveAttribute("data-state", "ready");

await page.getByRole("button", { name: /Settings/ }).click();
const box = dialog(page);
await box.getByLabel("Provider to add").selectOption("ollama");
await box.getByRole("button", { name: "Add", exact: true }).click();
await expect(box).toContainText("Command Mode requires a current Ollama version");
await field(page, MODEL).fill("qwen3:8b");
await box.getByRole("button", { name: "Set default" }).click();
await box.getByRole("button", { name: "Save", exact: true }).click();
await expect(box).toBeHidden();
await expect(pill).toBeHidden();
} finally {
await app.close();
}
});

test("hosted compatibility presets explain capability limits and custom URLs stay editable", async () => {
test.setTimeout(60_000);
const { app, page } = await launch();
try {
await page.getByRole("button", { name: /Settings/ }).click();
const box = dialog(page);
await box.getByLabel("Provider to add").selectOption("ollama-cloud");
await box.getByRole("button", { name: "Add", exact: true }).click();
await expect(box).toContainText("Ollama Cloud currently lacks JSON Schema output");
await expect(field(page, ADDRESS)).toBeDisabled();

await box.getByLabel("Provider to add").selectOption("custom");
await box.getByRole("button", { name: "Add", exact: true }).click();
await expect(field(page, ADDRESS)).toBeEnabled();
await field(page, ADDRESS).fill("https://llm.example.test/v1");
await expect(box).toContainText("you can enter a model ID manually");
await field(page, MODEL).fill("my-model");
} finally {
await app.close();
}
});

test("saved settings apply at once: the model asked, the PDF footer, the words; and they are in the settings file", async () => {
test.setTimeout(120_000);
const notes = "## Caching\n- Redis keeps hot keys in memory.";
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/electron/ai/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { CredentialVault, type SecretEncryption } from "./credentials";

const dirs: string[] = [];
afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true }))));

const encryption = (available = true): SecretEncryption => ({
secureAvailable: () => available,
encrypt: (value) => Buffer.from(`encrypted:${value}`, "utf8"),
decrypt: (value) => value.toString("utf8").replace(/^encrypted:/, ""),
});

async function vault(available = true) {
const dir = await mkdtemp(join(tmpdir(), "lucid-credentials-"));
dirs.push(dir);
return { dir, vault: new CredentialVault(dir, encryption(available)) };
}

describe("CredentialVault", () => {
it("saves, reads, and deletes an encrypted persistent secret without writing plaintext", async () => {
const { dir, vault: first } = await vault();
const status = await first.save("openai", "sk-private-value", true);
expect(status).toEqual({ present: true, storage: "secure", securePersistenceAvailable: true });
expect(await first.read("openai")).toBe("sk-private-value");
expect(await readFile(join(dir, "credentials.json"), "utf8")).not.toContain("sk-private-value");

const reopened = new CredentialVault(dir, encryption());
expect(await reopened.read("openai")).toBe("sk-private-value");
await reopened.delete("openai");
expect(await reopened.status("openai")).toMatchObject({ present: false, storage: "none" });
});

it("falls back only to memory when secure persistence is unavailable", async () => {
const { dir, vault: current } = await vault(false);
expect(await current.save("anthropic", "session-secret", true)).toEqual({
present: true,
storage: "session",
securePersistenceAvailable: false,
});
expect(await current.read("anthropic")).toBe("session-secret");
await expect(readFile(join(dir, "credentials.json"), "utf8")).rejects.toThrow();
expect(await new CredentialVault(dir, encryption(false)).read("anthropic")).toBeUndefined();
});

it("uses session storage when encryption fails temporarily", async () => {
const dir = await mkdtemp(join(tmpdir(), "lucid-credentials-"));
dirs.push(dir);
const failing: SecretEncryption = {
secureAvailable: () => true,
encrypt: () => {
throw new Error("keychain unavailable");
},
decrypt: () => "",
};
const current = new CredentialVault(dir, failing);
expect(await current.save("openrouter", "temporary-secret", true)).toMatchObject({
present: true,
storage: "session",
});
expect(await current.read("openrouter")).toBe("temporary-secret");
});
});
113 changes: 113 additions & 0 deletions apps/desktop/electron/ai/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";

export interface SecretEncryption {
secureAvailable(): boolean;
encrypt(value: string): Buffer;
decrypt(value: Buffer): string;
}

export type CredentialStorage = "secure" | "session";
export interface CredentialStatus {
present: boolean;
storage: CredentialStorage | "none";
securePersistenceAvailable: boolean;
}

interface StoredSecrets {
version: 1;
secrets: Record<string, string>;
}

const credentialId = (value: string): string => {
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(value)) throw new Error("invalid credential id");
return value;
};
const without = (values: Record<string, string>, id: string): Record<string, string> =>
Object.fromEntries(Object.entries(values).filter(([key]) => key !== id));

/** Main-process-only credentials. Disk values are encrypted bytes encoded as base64, never plaintext. */
export class CredentialVault {
private readonly session = new Map<string, string>();
private readonly file: string;

constructor(
directory: string,
private readonly encryption: SecretEncryption,
) {
this.file = join(directory, "credentials.json");
}

private async stored(): Promise<StoredSecrets> {
try {
const parsed = JSON.parse(await readFile(this.file, "utf8")) as Partial<StoredSecrets>;
return parsed.version === 1 && parsed.secrets && typeof parsed.secrets === "object"
? { version: 1, secrets: { ...parsed.secrets } }
: { version: 1, secrets: {} };
} catch {
return { version: 1, secrets: {} };
}
}

private async write(stored: StoredSecrets): Promise<void> {
await mkdir(dirname(this.file), { recursive: true });
const temporary = `${this.file}.${String(process.pid)}.tmp`;
await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await rename(temporary, this.file);
}

async save(idValue: string, secret: string, persist: boolean): Promise<CredentialStatus> {
const id = credentialId(idValue);
if (!secret || secret.length > 20_000) throw new Error("invalid credential");
const stored = await this.stored();
if (persist && this.encryption.secureAvailable()) {
try {
stored.secrets[id] = this.encryption.encrypt(secret).toString("base64");
await this.write(stored);
this.session.delete(id);
return await this.status(id);
} catch {
// Temporary keychain failures fall through to memory. Never write the secret itself.
}
}
if (id in stored.secrets) {
stored.secrets = without(stored.secrets, id);
await this.write(stored);
}
this.session.set(id, secret);
return this.status(id);
}

/** Deliberately not exposed through preload or IPC. */
async read(idValue: string): Promise<string | undefined> {
const id = credentialId(idValue);
const inSession = this.session.get(id);
if (inSession !== undefined) return inSession;
if (!this.encryption.secureAvailable()) return undefined;
const encrypted = (await this.stored()).secrets[id];
if (!encrypted) return undefined;
return this.encryption.decrypt(Buffer.from(encrypted, "base64"));
}

async delete(idValue: string): Promise<void> {
const id = credentialId(idValue);
this.session.delete(id);
const stored = await this.stored();
if (id in stored.secrets) {
stored.secrets = without(stored.secrets, id);
await this.write(stored);
}
}

async status(idValue: string): Promise<CredentialStatus> {
const id = credentialId(idValue);
const securePersistenceAvailable = this.encryption.secureAvailable();
if (this.session.has(id))
return { present: true, storage: "session", securePersistenceAvailable };
const secure = securePersistenceAvailable && id in (await this.stored()).secrets;
return { present: secure, storage: secure ? "secure" : "none", securePersistenceAvailable };
}
}
Loading
Loading