From 616dcd20a76a96ec73d15c6c3efbd4fef503f925 Mon Sep 17 00:00:00 2001 From: Avi Volah <99116185+AviVolah@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:56:18 +0300 Subject: [PATCH 1/2] Add vault secrets and prompt variable support - Store named secrets in encrypted desktop vaults - Expose vault management in settings and inject variables into provider prompts - Add revert controls for turn checkpoints # Conflicts: # apps/desktop/src/main.ts # packages/contracts/src/settings.ts --- CLAUDE.md | 2 +- apps/desktop/package.json | 4 +- apps/desktop/src/main.ts | 76 +- apps/desktop/src/preload.ts | 20 + apps/desktop/src/secretVault.test.ts | 76 ++ apps/desktop/src/secretVault.ts | 277 +++++++ apps/server/src/cli.ts | 7 +- apps/server/src/codexAppServerManager.test.ts | 10 + apps/server/src/codexAppServerManager.ts | 21 +- .../src/provider/Layers/ClaudeAdapter.ts | 33 +- .../src/provider/Layers/CodexAdapter.ts | 11 +- apps/server/src/provider/vaultVariables.ts | 38 + apps/web/src/components/ChatView.tsx | 21 + .../components/KeybindingsToast.browser.tsx | 1 + .../components/chat/MessagesTimeline.test.tsx | 81 +- .../src/components/chat/MessagesTimeline.tsx | 18 + .../settings/SettingsSidebarNav.tsx | 5 +- .../settings/VaultSettingsPanel.tsx | 701 ++++++++++++++++++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.secrets.tsx | 7 + apps/web/src/wsNativeApi.test.ts | 173 +++-- apps/web/src/wsNativeApi.ts | 92 ++- apps/web/src/wsRpcClient.ts | 19 +- bun.lock | 5 + package.json | 1 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 11 + packages/contracts/src/settings.ts | 3 + packages/contracts/src/vault.ts | 42 ++ 29 files changed, 1663 insertions(+), 114 deletions(-) create mode 100644 apps/desktop/src/secretVault.test.ts create mode 100644 apps/desktop/src/secretVault.ts create mode 100644 apps/server/src/provider/vaultVariables.ts create mode 100644 apps/web/src/components/settings/VaultSettingsPanel.tsx create mode 100644 apps/web/src/routes/settings.secrets.tsx create mode 100644 packages/contracts/src/vault.ts diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dfa3bde2f8..1d9b8855a2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,9 +14,11 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { + "@modelcontextprotocol/server": "^2.0.0-alpha.2", "effect": "catalog:", "electron": "40.6.0", - "electron-updater": "^6.6.2" + "electron-updater": "^6.6.2", + "zod": "^4.3.6" }, "devDependencies": { "@t3tools/contracts": "workspace:*", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..f43f22787c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -11,17 +11,22 @@ import { ipcMain, Menu, nativeImage, + safeStorage, nativeTheme, protocol, shell, } from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; -import type { - DesktopTheme, - DesktopUpdateActionResult, - DesktopUpdateCheckResult, - DesktopUpdateState, +import * as Schema from "effect/Schema"; +import { + VaultSecretDeleteInput, + VaultSecretUpsertInput, + type DesktopTheme, + type DesktopUpdateActionResult, + type DesktopUpdateCheckResult, + type DesktopUpdateState, + type VaultSecretsSnapshot, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; @@ -45,6 +50,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { SecretVault } from "./secretVault"; syncShellEnvironment(); @@ -60,11 +66,18 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const LIST_VAULT_SECRETS_CHANNEL = "desktop:vault:list-secrets"; +const SAVE_VAULT_SECRET_CHANNEL = "desktop:vault:save-secret"; +const DELETE_VAULT_SECRET_CHANNEL = "desktop:vault:delete-secret"; +const VAULT_SECRETS_CHANNEL = "desktop:vault:updated"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); -const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +const SERVER_STATE_DIR_NAME = isDevelopment ? "dev" : "userdata"; +const STATE_DIR = Path.join(BASE_DIR, SERVER_STATE_DIR_NAME); +const SECRET_VAULT_PATH = Path.join(STATE_DIR, "vault.json"); +const LEGACY_SECRET_VAULT_PATH = Path.join(STATE_DIR, "managed-connectors.vault.json"); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const APP_USER_MODEL_ID = "com.t3tools.t3code"; const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; @@ -102,6 +115,7 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); +let secretVault: SecretVault | null = null; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -151,6 +165,28 @@ function backendChildEnv(): NodeJS.ProcessEnv { return env; } +function getSecretVault(): SecretVault { + if (secretVault) { + return secretVault; + } + throw new Error("Secret vault is not ready."); +} + +function getVaultSecretsSnapshot(): VaultSecretsSnapshot { + return getSecretVault().listNamedSecrets(); +} + +function emitVaultSecretsSnapshot( + snapshot: VaultSecretsSnapshot = getVaultSecretsSnapshot(), +): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue; + } + window.webContents.send(VAULT_SECRETS_CHANNEL, snapshot); + } +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -1328,6 +1364,25 @@ function registerIpcHandlers(): void { state: updateState, } satisfies DesktopUpdateCheckResult; }); + + ipcMain.removeHandler(LIST_VAULT_SECRETS_CHANNEL); + ipcMain.handle(LIST_VAULT_SECRETS_CHANNEL, async () => getVaultSecretsSnapshot()); + + ipcMain.removeHandler(SAVE_VAULT_SECRET_CHANNEL); + ipcMain.handle(SAVE_VAULT_SECRET_CHANNEL, async (_event, rawInput: unknown) => { + const input = Schema.decodeUnknownSync(VaultSecretUpsertInput)(rawInput); + const snapshot = getSecretVault().saveNamedSecret(input); + emitVaultSecretsSnapshot(snapshot); + return snapshot; + }); + + ipcMain.removeHandler(DELETE_VAULT_SECRET_CHANNEL); + ipcMain.handle(DELETE_VAULT_SECRET_CHANNEL, async (_event, rawInput: unknown) => { + const input = Schema.decodeUnknownSync(VaultSecretDeleteInput)(rawInput); + const snapshot = getSecretVault().deleteNamedSecret(input); + emitVaultSecretsSnapshot(snapshot); + return snapshot; + }); } function getIconOption(): { icon: string } | Record { @@ -1441,6 +1496,15 @@ async function bootstrap(): Promise { backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); + secretVault = new SecretVault({ + vaultPath: SECRET_VAULT_PATH, + legacyVaultPaths: [LEGACY_SECRET_VAULT_PATH], + safeStorage, + }); + secretVault.onDidChange(() => { + emitVaultSecretsSnapshot(); + }); + registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..c06eeca4b5 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,6 +13,10 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const LIST_VAULT_SECRETS_CHANNEL = "desktop:vault:list-secrets"; +const SAVE_VAULT_SECRET_CHANNEL = "desktop:vault:save-secret"; +const DELETE_VAULT_SECRET_CHANNEL = "desktop:vault:delete-secret"; +const VAULT_SECRETS_CHANNEL = "desktop:vault:updated"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => { @@ -39,6 +43,22 @@ contextBridge.exposeInMainWorld("desktopBridge", { checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), + listVaultSecrets: () => ipcRenderer.invoke(LIST_VAULT_SECRETS_CHANNEL), + saveVaultSecret: (input) => ipcRenderer.invoke(SAVE_VAULT_SECRET_CHANNEL, input), + deleteVaultSecret: (input) => ipcRenderer.invoke(DELETE_VAULT_SECRET_CHANNEL, input), + subscribeVaultSecrets: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, snapshot: unknown) => { + if (typeof snapshot !== "object" || snapshot === null) { + return; + } + listener(snapshot as Parameters[0]); + }; + + ipcRenderer.on(VAULT_SECRETS_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(VAULT_SECRETS_CHANNEL, wrappedListener); + }; + }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; diff --git a/apps/desktop/src/secretVault.test.ts b/apps/desktop/src/secretVault.test.ts new file mode 100644 index 0000000000..8222710990 --- /dev/null +++ b/apps/desktop/src/secretVault.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { SecretVault, type SafeStorageLike } from "./secretVault"; + +const fakeSafeStorage: SafeStorageLike = { + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(`encrypted:${value}`, "utf8"), + decryptString: (value) => { + const decoded = value.toString("utf8"); + if (!decoded.startsWith("encrypted:")) { + throw new Error("Unexpected ciphertext"); + } + return decoded.slice("encrypted:".length); + }, +}; + +describe("SecretVault", () => { + it("persists encrypted named secrets without exposing the raw value in snapshots", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-secret-vault-")); + const vaultPath = path.join(tempDir, "vault.json"); + + try { + const vault = new SecretVault({ + vaultPath, + safeStorage: fakeSafeStorage, + }); + + const snapshot = vault.saveNamedSecret({ + key: "my stripe api key", + value: "sk-live-123", + }); + const persisted = readFileSync(vaultPath, "utf8"); + + expect(snapshot.secrets).toEqual([ + expect.objectContaining({ + key: "my stripe api key", + }), + ]); + expect(persisted).not.toContain("sk-live-123"); + expect(persisted).toContain(Buffer.from("encrypted:sk-live-123", "utf8").toString("base64")); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("deletes named secrets from the vault snapshot", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3-secret-vault-")); + const vaultPath = path.join(tempDir, "vault.json"); + + try { + const vault = new SecretVault({ + vaultPath, + safeStorage: fakeSafeStorage, + }); + + const savedSnapshot = vault.saveNamedSecret({ + key: "my stripe api key", + value: "sk-live-123", + }); + const secretId = savedSnapshot.secrets[0]?.id; + expect(secretId).toBeDefined(); + + const nextSnapshot = vault.deleteNamedSecret({ + id: secretId!, + }); + + expect(nextSnapshot.secrets).toEqual([]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/secretVault.ts b/apps/desktop/src/secretVault.ts new file mode 100644 index 0000000000..f7d1a06452 --- /dev/null +++ b/apps/desktop/src/secretVault.ts @@ -0,0 +1,277 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import type { + VaultSecretDeleteInput, + VaultSecretId, + VaultSecretSummary, + VaultSecretsSnapshot, + VaultSecretUpsertInput, +} from "@t3tools/contracts"; +import { VaultSecretId as VaultSecretIdSchema } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface SafeStorageLike { + isEncryptionAvailable(): boolean; + encryptString(value: string): Buffer; + decryptString(value: Buffer): string; +} + +interface NamedSecretVaultRecord { + key: string; + ciphertext: string; + updatedAt: string; +} + +interface LegacyVaultFileV1 { + version: 1; + materials: Record; +} + +interface LegacyVaultFileV2 { + version: 2; + materials: Record; + namedSecrets: Record; +} + +interface LegacyVaultFileV3 { + version: 3; + materials: Record; + namedSecrets: Record; + verificationStates: Record; +} + +interface VaultFileV4 { + version: 4; + namedSecrets: Record; +} + +type SecretVaultFile = LegacyVaultFileV1 | LegacyVaultFileV2 | LegacyVaultFileV3 | VaultFileV4; + +const SECRET_VAULT_VERSION = 4 as const; + +function createEmptyVaultFile(): VaultFileV4 { + return { + version: SECRET_VAULT_VERSION, + namedSecrets: {}, + }; +} + +function toNamedSecretSummary( + secretId: VaultSecretId, + record: NamedSecretVaultRecord, +): VaultSecretSummary { + return { + id: secretId, + key: record.key, + updatedAt: record.updatedAt, + }; +} + +function normalizeVaultKey(key: string): string { + return key.trim().toLocaleLowerCase(); +} + +function isNamedSecretVaultRecord(value: unknown): value is NamedSecretVaultRecord { + return ( + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "key") === "string" && + typeof Reflect.get(value, "ciphertext") === "string" && + typeof Reflect.get(value, "updatedAt") === "string" + ); +} + +export class SecretVault { + private readonly listeners = new Set<() => void>(); + private readonly namedSecrets = new Map(); + private loaded = false; + + constructor( + private readonly options: { + vaultPath: string; + legacyVaultPaths?: readonly string[]; + safeStorage: SafeStorageLike; + }, + ) {} + + isAvailable(): boolean { + return this.options.safeStorage.isEncryptionAvailable(); + } + + onDidChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + listNamedSecrets(): VaultSecretsSnapshot { + this.ensureLoaded(); + if (!this.isAvailable()) { + return { + enabled: false, + safeStorageAvailable: false, + message: "Secure secret storage is unavailable on this device.", + secrets: [], + }; + } + + return { + enabled: true, + safeStorageAvailable: true, + message: null, + secrets: [...this.namedSecrets.entries()] + .toSorted((left, right) => left[1].key.localeCompare(right[1].key)) + .map(([secretId, record]) => toNamedSecretSummary(secretId, record)), + }; + } + + getNamedSecret(secretId: VaultSecretId): VaultSecretSummary | null { + this.ensureLoaded(); + const record = this.namedSecrets.get(secretId); + if (!record) { + return null; + } + return toNamedSecretSummary(secretId, record); + } + + saveNamedSecret(input: VaultSecretUpsertInput): VaultSecretsSnapshot { + this.ensureAvailable(); + this.ensureLoaded(); + + const existingEntry = input.id ? this.namedSecrets.get(input.id) : undefined; + if (!existingEntry && input.value === undefined) { + throw new Error("A new vault secret requires a value."); + } + + const normalizedKey = normalizeVaultKey(input.key); + for (const [secretId, record] of this.namedSecrets) { + if (input.id && secretId === input.id) { + continue; + } + if (normalizeVaultKey(record.key) === normalizedKey) { + throw new Error(`A vault secret named "${input.key}" already exists.`); + } + } + + const nextSecretId = + input.id ?? + Schema.decodeUnknownSync(VaultSecretIdSchema)(`vault_secret_${crypto.randomUUID()}`); + const nextValue = input.value ?? this.getNamedSecretValue(nextSecretId); + if (nextValue === null) { + throw new Error("Stored vault secret material could not be loaded."); + } + + const nextRecord: NamedSecretVaultRecord = { + key: input.key, + ciphertext: this.options.safeStorage.encryptString(nextValue).toString("base64"), + updatedAt: input.value !== undefined ? new Date().toISOString() : existingEntry!.updatedAt, + }; + + this.namedSecrets.set(nextSecretId, nextRecord); + this.persist(); + this.emitChange(); + return this.listNamedSecrets(); + } + + deleteNamedSecret(input: VaultSecretDeleteInput): VaultSecretsSnapshot { + this.ensureLoaded(); + const changed = this.namedSecrets.delete(input.id); + if (changed) { + this.persist(); + this.emitChange(); + } + return this.listNamedSecrets(); + } + + private getNamedSecretValue(secretId: VaultSecretId): string | null { + this.ensureAvailable(); + this.ensureLoaded(); + + const record = this.namedSecrets.get(secretId); + if (!record) { + return null; + } + + try { + return this.options.safeStorage.decryptString(Buffer.from(record.ciphertext, "base64")); + } catch { + throw new Error("Stored vault secret material could not be decrypted."); + } + } + + private emitChange(): void { + for (const listener of this.listeners) { + listener(); + } + } + + private ensureAvailable(): void { + if (this.isAvailable()) { + return; + } + throw new Error("Secure secret storage is unavailable on this device."); + } + + private ensureLoaded(): void { + if (this.loaded) { + return; + } + this.loaded = true; + + const candidatePaths = [ + this.options.vaultPath, + ...(this.options.legacyVaultPaths ?? []), + ].filter((candidatePath, index, paths) => paths.indexOf(candidatePath) === index); + + const existingVaultPath = candidatePaths.find((candidatePath) => FS.existsSync(candidatePath)); + if (!existingVaultPath) { + return; + } + + try { + const raw = FS.readFileSync(existingVaultPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 && + parsed.version !== 2 && + parsed.version !== 3 && + parsed.version !== SECRET_VAULT_VERSION + ) { + throw new Error("Unsupported vault file version."); + } + + if ( + (parsed.version === 2 || parsed.version === 3 || parsed.version === SECRET_VAULT_VERSION) && + parsed.namedSecrets + ) { + for (const [secretId, record] of Object.entries(parsed.namedSecrets)) { + if (typeof secretId !== "string" || !isNamedSecretVaultRecord(record)) { + continue; + } + + this.namedSecrets.set(secretId as VaultSecretId, { + key: record.key, + ciphertext: record.ciphertext, + updatedAt: record.updatedAt, + }); + } + } + } catch { + throw new Error("Secret vault data is unreadable."); + } + } + + private persist(): void { + const vaultFile = createEmptyVaultFile(); + for (const [secretId, record] of this.namedSecrets) { + vaultFile.namedSecrets[secretId] = record; + } + + FS.mkdirSync(Path.dirname(this.options.vaultPath), { recursive: true }); + const tempPath = `${this.options.vaultPath}.${process.pid}.${Date.now()}.tmp`; + FS.writeFileSync(tempPath, `${JSON.stringify(vaultFile, null, 2)}\n`, "utf8"); + FS.renameSync(tempPath, this.options.vaultPath); + } +} diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 09c17278a5..87bbcd7fdd 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -287,8 +287,7 @@ export const resolveServerConfig = ( () => (mode === "desktop" ? "127.0.0.1" : undefined), ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - - const config: ServerConfigShape = { + const baseConfig = { logLevel, traceMinLevel: env.traceMinLevel, traceTimingEnabled: env.traceTimingEnabled, @@ -326,9 +325,9 @@ export const resolveServerConfig = ( authToken, autoBootstrapProjectFromCwd, logWebSocketEvents, - }; + } satisfies ServerConfigShape; - return config; + return baseConfig; }); const commandFlags = { diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 2f740a7407..172aadabd8 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { + buildCodexAppServerSpawnConfig, buildCodexInitializeParams, CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, @@ -472,6 +473,15 @@ describe("startSession", () => { }); }); +describe("buildCodexAppServerSpawnConfig", () => { + it("returns the default app-server spawn config", () => { + const result = buildCodexAppServerSpawnConfig({}); + + expect(result.args).toEqual(["app-server"]); + expect(result.env).toEqual({}); + }); +}); + describe("sendTurn", () => { it("sends text and image user input items to turn/start", async () => { const { manager, context, requireSession, sendRequest, updateSession } = diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3145038647..b124b1828f 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -127,6 +127,20 @@ export interface CodexAppServerStartSessionInput { readonly runtimeMode: RuntimeMode; } +export function buildCodexAppServerSpawnConfig(input: { readonly homePath?: string }): { + readonly args: string[]; + readonly env: Record; +} { + const args = ["app-server"]; + + const env: Record = {}; + if (input.homePath) { + env.CODEX_HOME = input.homePath; + } + + return { args, env }; +} + export interface CodexThreadTurnSnapshot { id: TurnId; items: unknown[]; @@ -464,11 +478,14 @@ export class CodexAppServerManager extends EventEmitter; -function buildPromptText(input: ProviderSendTurnInput): string { +function buildPromptText(input: ProviderSendTurnInput, promptText: string | undefined): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; const claudeModel = @@ -521,7 +522,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { const trimmedEffort = trimOrNull(rawEffort); const promptEffort = trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; - return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); + return applyClaudePromptEffortPrefix(promptText?.trim() ?? "", promptEffort); } function buildUserMessage(input: { @@ -553,13 +554,15 @@ function buildClaudeImageContentBlock(input: { } const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( - input: ProviderSendTurnInput, + input: ProviderSendTurnInput & { + readonly promptText?: string; + }, dependencies: { readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; }, ) { - const text = buildPromptText(input); + const text = buildPromptText(input, input.promptText); const sdkContent: Array> = []; if (text.length > 0) { @@ -2848,6 +2851,14 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context = yield* requireSession(input.threadId); const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const vaultVariables = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.vaultVariables), + Effect.mapError((error) => toRequestError(input.threadId, "turn/start", error)), + ); + const promptText = injectVaultVariablesIntoPrompt({ + prompt: input.input, + variables: vaultVariables, + }); if (context.turnState) { // Auto-close a stale synthetic turn (from background agent responses @@ -2919,10 +2930,16 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( providerRefs: {}, }); - const message = yield* buildUserMessageEffect(input, { - fileSystem, - attachmentsDir: serverConfig.attachmentsDir, - }); + const message = yield* buildUserMessageEffect( + { + ...input, + ...(promptText !== undefined ? { promptText } : {}), + }, + { + fileSystem, + attachmentsDir: serverConfig.attachmentsDir, + }, + ); yield* Queue.offer(context.promptQueue, { type: "message", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index cee6bca6ed..351a548a74 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -41,6 +41,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { injectVaultVariablesIntoPrompt } from "../vaultVariables.ts"; const PROVIDER = "codex" as const; @@ -1470,12 +1471,20 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( (attachment) => resolveAttachment(input, attachment), { concurrency: 1 }, ); + const vaultVariables = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.vaultVariables), + Effect.mapError((error) => toRequestError(input.threadId, "turn/start", error)), + ); + const prompt = injectVaultVariablesIntoPrompt({ + prompt: input.input, + variables: vaultVariables, + }); return yield* Effect.tryPromise({ try: () => { const managerInput = { threadId: input.threadId, - ...(input.input !== undefined ? { input: input.input } : {}), + ...(prompt !== undefined ? { input: prompt } : {}), ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), diff --git a/apps/server/src/provider/vaultVariables.ts b/apps/server/src/provider/vaultVariables.ts new file mode 100644 index 0000000000..8bcdb071c5 --- /dev/null +++ b/apps/server/src/provider/vaultVariables.ts @@ -0,0 +1,38 @@ +import type { VaultVariable } from "@t3tools/contracts"; + +function normalizePromptValue(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +export function buildVaultVariablesPrompt(variables: readonly VaultVariable[]): string | undefined { + if (variables.length === 0) { + return undefined; + } + + const lines = [ + "Vault variables are reusable, model-visible aliases defined by the user.", + "When the user refers to one of these keys, use its corresponding value.", + "", + ...variables.map((variable) => `- ${variable.key}: ${variable.value}`), + ]; + + return lines.join("\n"); +} + +export function injectVaultVariablesIntoPrompt(input: { + readonly prompt: string | undefined; + readonly variables: readonly VaultVariable[]; +}): string | undefined { + const prompt = normalizePromptValue(input.prompt); + const variablesBlock = buildVaultVariablesPrompt(input.variables); + + if (!variablesBlock) { + return prompt; + } + if (!prompt) { + return variablesBlock; + } + + return `${variablesBlock}\n\nUser request:\n${prompt}`; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1580c8f605..5fc172ad95 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1360,6 +1360,18 @@ export default function ChatView({ threadId }: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const revertTurnCountByTurnId = useMemo(() => { + const byTurnId = new Map(); + for (const summary of turnDiffSummaries) { + const turnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + continue; + } + byTurnId.set(summary.turnId, Math.max(0, turnCount - 1)); + } + return byTurnId; + }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; @@ -3890,6 +3902,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } void onRevertToTurnCount(targetTurnCount); }; + const onRevertTurn = (turnId: TurnId) => { + const targetTurnCount = revertTurnCountByTurnId.get(turnId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCount(targetTurnCount); + }; // Empty state: no active thread if (!activeThread) { @@ -3998,7 +4017,9 @@ export default function ChatView({ threadId }: ChatViewProps) { onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + revertTurnCountByTurnId={revertTurnCountByTurnId} onRevertUserMessage={onRevertUserMessage} + onRevertTurn={onRevertTurn} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 187ecf497a..60662c89e7 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -72,6 +72,7 @@ function createBaseServerConfig(): ServerConfig { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, }, + vaultVariables: [], }, }; } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..27d6af9b4c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,7 +1,11 @@ -import { MessageId } from "@t3tools/contracts"; +import { MessageId, TurnId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +vi.mock("../ChatMarkdown", () => ({ + default: (props: { text: string }) => props.text, +})); + function matchMedia() { return { matches: false, @@ -82,7 +86,9 @@ describe("MessagesTimeline", () => { onToggleWorkGroup={() => {}} onOpenTurnDiff={() => {}} revertTurnCountByUserMessageId={new Map()} + revertTurnCountByTurnId={new Map()} onRevertUserMessage={() => {}} + onRevertTurn={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} @@ -127,7 +133,9 @@ describe("MessagesTimeline", () => { onToggleWorkGroup={() => {}} onOpenTurnDiff={() => {}} revertTurnCountByUserMessageId={new Map()} + revertTurnCountByTurnId={new Map()} onRevertUserMessage={() => {}} + onRevertTurn={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} markdownCwd={undefined} @@ -140,4 +148,75 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders a revert action in the changed files card when a checkpoint is available", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const turnId = TurnId.makeUnsafe("turn-1"); + const assistantMessageId = MessageId.makeUnsafe("message-3"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + revertTurnCountByTurnId={new Map([[turnId, 1]])} + onRevertUserMessage={() => {}} + onRevertTurn={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("Changed files"); + expect(markup).toContain("View diff"); + expect(markup).toContain("Revert"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..2d3a60b91c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -78,7 +78,9 @@ interface MessagesTimelineProps { onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; + revertTurnCountByTurnId: Map; onRevertUserMessage: (messageId: MessageId) => void; + onRevertTurn: (turnId: TurnId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; @@ -113,7 +115,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleWorkGroup, onOpenTurnDiff, revertTurnCountByUserMessageId, + revertTurnCountByTurnId, onRevertUserMessage, + onRevertTurn, isRevertingCheckpoint, onImageExpand, markdownCwd, @@ -464,6 +468,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const changedFileCountLabel = String(checkpointFiles.length); const allDirectoriesExpanded = allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; + const canRevertTurn = revertTurnCountByTurnId.has(turnSummary.turnId); return (
@@ -499,6 +504,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ > View diff + {canRevertTurn && ( + + )}
; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Vault", to: "/settings/secrets", icon: KeyRoundIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/settings/VaultSettingsPanel.tsx b/apps/web/src/components/settings/VaultSettingsPanel.tsx new file mode 100644 index 0000000000..e7bea1c2ae --- /dev/null +++ b/apps/web/src/components/settings/VaultSettingsPanel.tsx @@ -0,0 +1,701 @@ +import { KeyRoundIcon, PlusIcon, Settings2Icon, Trash2Icon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import * as Schema from "effect/Schema"; +import { + VaultSecretId, + VaultVariable, + VaultVariableId, + type VaultSecretsSnapshot, + type VaultVariable as VaultVariableType, +} from "@t3tools/contracts"; + +import { isElectron } from "../../env"; +import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { ensureNativeApi } from "../../nativeApi"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { randomUUID } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; + +interface SecretEditorRow { + id: string; + key: string; + value: string; + persisted: boolean; + updatedAt: string | null; +} + +interface VariableEditorRow { + id: string; + key: string; + value: string; + persisted: boolean; +} + +function createInitialVaultSecretsSnapshot(): VaultSecretsSnapshot { + return { + enabled: isElectron, + safeStorageAvailable: isElectron, + message: isElectron ? "Loading encrypted vault secrets." : null, + secrets: [], + }; +} + +function secretRowsFromSnapshot(snapshot: VaultSecretsSnapshot): SecretEditorRow[] { + return snapshot.secrets.map((secret) => ({ + id: secret.id, + key: secret.key, + value: "", + persisted: true, + updatedAt: secret.updatedAt, + })); +} + +function variableRowsFromSettings(variables: readonly VaultVariableType[]): VariableEditorRow[] { + return variables.map((variable) => ({ + id: variable.id, + key: variable.key, + value: variable.value, + persisted: true, + })); +} + +function formatUpdatedAt(updatedAt: string): string { + return `Updated ${formatRelativeTimeLabel(updatedAt)}`; +} + +function makeSecretRowId(): string { + return `draft-secret:${randomUUID()}`; +} + +function makeVariableRowId(): string { + return `draft-variable:${randomUUID()}`; +} + +function asVaultVariableId(rawId: string) { + return Schema.decodeUnknownSync(VaultVariableId)(rawId); +} + +function asVaultSecretId(rawId: string) { + return Schema.decodeUnknownSync(VaultSecretId)(rawId); +} + +function isMissingDesktopHandlerError(error: unknown): boolean { + return error instanceof Error && error.message.includes("No handler registered for 'desktop:"); +} + +function normalizeKey(input: string): string { + return input.trim().toLocaleLowerCase(); +} + +export function VaultSettingsPanel() { + const vaultVariables = useSettings().vaultVariables; + const { updateSettings } = useUpdateSettings(); + const [vaultSecrets, setVaultSecrets] = useState( + createInitialVaultSecretsSnapshot, + ); + const [secretRows, setSecretRows] = useState([]); + const [variableRows, setVariableRows] = useState([]); + const [savingSecretIds, setSavingSecretIds] = useState>({}); + const [deletingSecretIds, setDeletingSecretIds] = useState>({}); + + useEffect(() => { + setVariableRows((existingRows) => { + const draftRows = existingRows.filter((row) => !row.persisted); + return [...variableRowsFromSettings(vaultVariables), ...draftRows]; + }); + }, [vaultVariables]); + + useEffect(() => { + if (!isElectron) { + return; + } + + let active = true; + const api = ensureNativeApi(); + + const syncSecrets = async () => { + try { + const nextSnapshot = await api.vault.listSecrets(); + if (!active) { + return; + } + setVaultSecrets(nextSnapshot); + } catch (error) { + if (!active) { + return; + } + if (isMissingDesktopHandlerError(error)) { + setVaultSecrets({ + enabled: false, + safeStorageAvailable: true, + message: "Restart the desktop dev app once to load the latest Vault handlers.", + secrets: [], + }); + return; + } + toastManager.add({ + type: "error", + title: "Could not load vault secrets", + description: error instanceof Error ? error.message : "An unknown error occurred.", + }); + } + }; + + void syncSecrets(); + const unsubscribe = api.vault.subscribeSecrets((nextSnapshot) => { + if (!active) { + return; + } + setVaultSecrets(nextSnapshot); + }); + + return () => { + active = false; + unsubscribe(); + }; + }, []); + + useEffect(() => { + setSecretRows((existingRows) => { + const draftRows = existingRows.filter((row) => !row.persisted); + return [...secretRowsFromSnapshot(vaultSecrets), ...draftRows]; + }); + }, [vaultSecrets]); + + const persistedSecretById = useMemo( + () => new Map(vaultSecrets.secrets.map((secret) => [secret.id as string, secret] as const)), + [vaultSecrets.secrets], + ); + const persistedVariableById = useMemo( + () => new Map(vaultVariables.map((variable) => [variable.id as string, variable] as const)), + [vaultVariables], + ); + + const saveVariableRow = (rowId: string) => { + const row = variableRows.find((entry) => entry.id === rowId); + if (!row) { + return; + } + + const key = row.key.trim(); + const value = row.value.trim(); + if (key.length === 0 || value.length === 0) { + toastManager.add({ + type: "warning", + title: "Both fields are required", + description: "Variables need both a key and a value.", + }); + return; + } + + const normalizedKey = normalizeKey(key); + const hasDuplicate = variableRows.some( + (entry) => entry.id !== rowId && normalizeKey(entry.key) === normalizedKey, + ); + if (hasDuplicate) { + toastManager.add({ + type: "error", + title: "Duplicate variable name", + description: `A variable named "${key}" already exists.`, + }); + return; + } + + const nextVariable = Schema.decodeUnknownSync(VaultVariable)({ + id: row.persisted ? row.id : asVaultVariableId(randomUUID()), + key, + value, + }); + const nextVariables = row.persisted + ? vaultVariables.map((variable) => (variable.id === row.id ? nextVariable : variable)) + : [...vaultVariables, nextVariable]; + + updateSettings({ + vaultVariables: nextVariables, + }); + toastManager.add({ + type: "success", + title: row.persisted ? "Variable updated" : "Variable saved", + description: `"${key}" is now available to the model in future turns.`, + }); + }; + + const deleteVariableRow = (rowId: string) => { + const row = variableRows.find((entry) => entry.id === rowId); + if (!row) { + return; + } + + if (!row.persisted) { + setVariableRows((existingRows) => existingRows.filter((entry) => entry.id !== rowId)); + return; + } + + updateSettings({ + vaultVariables: vaultVariables.filter((variable) => variable.id !== rowId), + }); + toastManager.add({ + type: "success", + title: "Variable deleted", + description: `"${row.key}" was removed from model-visible variables.`, + }); + }; + + const saveSecretRow = (rowId: string) => { + const row = secretRows.find((entry) => entry.id === rowId); + if (!row || !isElectron) { + return; + } + + const key = row.key.trim(); + const value = row.value.trim(); + if (key.length === 0) { + toastManager.add({ + type: "warning", + title: "Secret name required", + description: "Give the secret a name before saving it.", + }); + return; + } + if (!row.persisted && value.length === 0) { + toastManager.add({ + type: "warning", + title: "Secret value required", + description: "New secrets need a value before they can be saved.", + }); + return; + } + + const normalizedKey = normalizeKey(key); + const hasDuplicate = secretRows.some( + (entry) => entry.id !== rowId && normalizeKey(entry.key) === normalizedKey, + ); + if (hasDuplicate) { + toastManager.add({ + type: "error", + title: "Duplicate secret name", + description: `A secret named "${key}" already exists.`, + }); + return; + } + + setSavingSecretIds((existing) => ({ + ...existing, + [rowId]: true, + })); + void ensureNativeApi() + .vault.saveSecret({ + ...(row.persisted ? { id: asVaultSecretId(row.id) } : {}), + key, + ...(value.length > 0 ? { value } : {}), + }) + .then((nextSnapshot) => { + setVaultSecrets(nextSnapshot); + setSecretRows((existingRows) => + row.persisted + ? existingRows.map((entry) => + entry.id === rowId + ? { + ...entry, + key, + value: "", + } + : entry, + ) + : existingRows.filter((entry) => entry.id !== rowId), + ); + toastManager.add({ + type: "success", + title: row.persisted ? "Secret updated" : "Secret saved", + description: `"${key}" was saved into the encrypted desktop vault.`, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not save secret", + description: error instanceof Error ? error.message : "An unknown error occurred.", + }); + }) + .finally(() => { + setSavingSecretIds((existing) => ({ + ...existing, + [rowId]: false, + })); + }); + }; + + const deleteSecretRow = (rowId: string) => { + const row = secretRows.find((entry) => entry.id === rowId); + if (!row) { + return; + } + + if (!row.persisted || !isElectron) { + setSecretRows((existingRows) => existingRows.filter((entry) => entry.id !== rowId)); + return; + } + + setDeletingSecretIds((existing) => ({ + ...existing, + [rowId]: true, + })); + void ensureNativeApi() + .vault.deleteSecret({ + id: asVaultSecretId(row.id), + }) + .then((nextSnapshot) => { + setVaultSecrets(nextSnapshot); + toastManager.add({ + type: "success", + title: "Secret deleted", + description: `"${row.key}" was removed from the encrypted desktop vault.`, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not delete secret", + description: error instanceof Error ? error.message : "An unknown error occurred.", + }); + }) + .finally(() => { + setDeletingSecretIds((existing) => ({ + ...existing, + [rowId]: false, + })); + }); + }; + + return ( +
+
+
+
+

+ Vault +

+

+ Store reusable key-value context in one place. Vault secrets stay in encrypted desktop + storage and are not exposed to the model. Vault variables are intentionally exposed to + the model so you can reference them without repeating yourself in every turn. +

+
+
+ +
+
+
+

Secrets

+

+ Safe desktop-only secrets. Use a friendly name as the key and paste the sensitive + value once. +

+
+ {isElectron ? ( + + ) : null} +
+ + {!isElectron ? ( +
+ + + + + + Desktop only + + Encrypted Vault secrets require the desktop app because the values stay in the + Electron main process and never enter normal settings. + + + +
+ ) : ( +
+ {!vaultSecrets.enabled ? ( +
+ {vaultSecrets.message ?? + "Encrypted secret storage is unavailable on this device."} +
+ ) : null} + + {secretRows.length === 0 ? ( + + + + + + No secrets saved + + Example: key `my stripe api key`, value `sk-live-...`. + + + + ) : ( +
+ {secretRows.map((row) => { + const persistedSecret = persistedSecretById.get(row.id); + const isSaving = savingSecretIds[row.id] === true; + const isDeleting = deletingSecretIds[row.id] === true; + const keyChanged = + row.persisted && persistedSecret ? row.key !== persistedSecret.key : true; + const canSave = + row.key.trim().length > 0 && + (!row.persisted || row.value.trim().length > 0 || keyChanged); + + return ( +
+ + + + +
+ +
+ +
+ +
+ +
+ {row.persisted + ? row.updatedAt + ? `${formatUpdatedAt(row.updatedAt)} | Stored value: ********` + : "Stored value: ********" + : "Not saved yet"} +
+
+ ); + })} +
+ )} +
+ )} +
+ +
+
+
+

Variables

+

+ Model-visible key-value context. Use variables for details you want the model to + remember and substitute when you reference the key. +

+
+ +
+ +
+ {variableRows.length === 0 ? ( + + + + + + No variables saved + + Example: key `my work email`, value `you@example.com`. + + + + ) : ( +
+ {variableRows.map((row) => { + const persistedVariable = persistedVariableById.get(row.id); + const keyChanged = + row.persisted && persistedVariable ? row.key !== persistedVariable.key : true; + const valueChanged = + row.persisted && persistedVariable + ? row.value !== persistedVariable.value + : true; + + return ( +
+ + + + +
+ +
+ +
+ +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 77b1b15842..31ef430607 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsSecretsRouteImport } from './routes/settings.secrets' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' @@ -30,6 +31,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsSecretsRoute = SettingsSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => SettingsRoute, +} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -52,12 +58,14 @@ export interface FileRoutesByFullPath { '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/secrets': typeof SettingsSecretsRoute } export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/secrets': typeof SettingsSecretsRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { @@ -67,6 +75,7 @@ export interface FileRoutesById { '/_chat/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/secrets': typeof SettingsSecretsRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { @@ -77,12 +86,14 @@ export interface FileRouteTypes { | '/$threadId' | '/settings/archived' | '/settings/general' + | '/settings/secrets' fileRoutesByTo: FileRoutesByTo to: | '/settings' | '/$threadId' | '/settings/archived' | '/settings/general' + | '/settings/secrets' | '/' id: | '__root__' @@ -91,6 +102,7 @@ export interface FileRouteTypes { | '/_chat/$threadId' | '/settings/archived' | '/settings/general' + | '/settings/secrets' | '/_chat/' fileRoutesById: FileRoutesById } @@ -122,6 +134,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/secrets': { + id: '/settings/secrets' + path: '/secrets' + fullPath: '/settings/secrets' + preLoaderRoute: typeof SettingsSecretsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/general': { id: '/settings/general' path: '/general' @@ -161,11 +180,13 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsGeneralRoute: typeof SettingsGeneralRoute + SettingsSecretsRoute: typeof SettingsSecretsRoute } const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, SettingsGeneralRoute: SettingsGeneralRoute, + SettingsSecretsRoute: SettingsSecretsRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/settings.secrets.tsx b/apps/web/src/routes/settings.secrets.tsx new file mode 100644 index 0000000000..aa0d57bb69 --- /dev/null +++ b/apps/web/src/routes/settings.secrets.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { VaultSettingsPanel } from "../components/settings/VaultSettingsPanel"; + +export const Route = createFileRoute("/settings/secrets")({ + component: VaultSettingsPanel, +}); diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 47f78f967f..9589c16f01 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -32,59 +32,63 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); -const rpcClientMock = { - dispose: vi.fn(), - terminal: { - open: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onEvent: vi.fn((listener: (event: TerminalEvent) => void) => - registerListener(terminalEventListeners, listener), - ), - }, - projects: { - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - git: { - pull: vi.fn(), - status: vi.fn(), - runStackedAction: vi.fn(), - listBranches: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createBranch: vi.fn(), - checkout: vi.fn(), - init: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - upsertKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(), - subscribeLifecycle: vi.fn(), - }, - orchestration: { - getSnapshot: vi.fn(), - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - replayEvents: vi.fn(), - onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => - registerListener(orchestrationEventListeners, listener), - ), - }, -}; +function createRpcClientMock() { + return { + dispose: vi.fn(), + terminal: { + open: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + restart: vi.fn(), + close: vi.fn(), + onEvent: vi.fn((listener: (event: TerminalEvent) => void) => + registerListener(terminalEventListeners, listener), + ), + }, + projects: { + searchEntries: vi.fn(), + writeFile: vi.fn(), + }, + shell: { + openInEditor: vi.fn(), + }, + git: { + pull: vi.fn(), + status: vi.fn(), + runStackedAction: vi.fn(), + listBranches: vi.fn(), + createWorktree: vi.fn(), + removeWorktree: vi.fn(), + createBranch: vi.fn(), + checkout: vi.fn(), + init: vi.fn(), + resolvePullRequest: vi.fn(), + preparePullRequestThread: vi.fn(), + }, + server: { + getConfig: vi.fn(), + refreshProviders: vi.fn(), + upsertKeybinding: vi.fn(), + getSettings: vi.fn(), + updateSettings: vi.fn(), + subscribeConfig: vi.fn(), + subscribeLifecycle: vi.fn(), + }, + orchestration: { + getSnapshot: vi.fn(), + dispatchCommand: vi.fn(), + getTurnDiff: vi.fn(), + getFullThreadDiff: vi.fn(), + replayEvents: vi.fn(), + onDomainEvent: vi.fn((listener: (event: OrchestrationEvent) => void) => + registerListener(orchestrationEventListeners, listener), + ), + }, + }; +} + +let rpcClientMock = createRpcClientMock(); vi.mock("./wsRpcClient", () => { return { @@ -93,6 +97,16 @@ vi.mock("./wsRpcClient", () => { }; }); +const originalRpcClientMock = rpcClientMock; + +beforeEach(() => { + rpcClientMock = createRpcClientMock(); +}); + +afterEach(() => { + rpcClientMock = originalRpcClientMock; +}); + vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); @@ -135,6 +149,16 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg throw new Error("installUpdate not implemented in test"); }, onUpdateState: () => () => undefined, + listVaultSecrets: async () => { + throw new Error("listVaultSecrets not implemented in test"); + }, + saveVaultSecret: async () => { + throw new Error("saveVaultSecret not implemented in test"); + }, + deleteVaultSecret: async () => { + throw new Error("deleteVaultSecret not implemented in test"); + }, + subscribeVaultSecrets: () => () => undefined, ...overrides, }; } @@ -333,6 +357,28 @@ describe("wsNativeApi", () => { }); }); + it("uses the latest RPC client after a backend reconnect without recreating the native API", async () => { + const firstRpcClient = rpcClientMock; + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + + const nextSettings = { + ...DEFAULT_SERVER_SETTINGS, + enableAssistantStreaming: true, + }; + const replacementRpcClient = createRpcClientMock(); + replacementRpcClient.server.updateSettings.mockResolvedValue(nextSettings); + rpcClientMock = replacementRpcClient; + + await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( + nextSettings, + ); + expect(firstRpcClient.server.updateSettings).not.toHaveBeenCalled(); + expect(replacementRpcClient.server.updateSettings).toHaveBeenCalledWith({ + enableAssistantStreaming: true, + }); + }); + it("forwards context menu metadata to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); @@ -345,6 +391,27 @@ describe("wsNativeApi", () => { expect(showContextMenu).toHaveBeenCalledWith(items, undefined); }); + it("forwards vault secret requests to the desktop bridge", async () => { + const listVaultSecrets = vi.fn().mockResolvedValue({ + enabled: true, + safeStorageAvailable: true, + message: null, + secrets: [], + }); + getWindowForTest().desktopBridge = makeDesktopBridge({ listVaultSecrets }); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + + await expect(api.vault.listSecrets()).resolves.toEqual({ + enabled: true, + safeStorageAvailable: true, + message: null, + secrets: [], + }); + expect(listVaultSecrets).toHaveBeenCalledWith(); + }); + it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 31160dfa1c..23ad564960 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -17,7 +17,7 @@ export function createWsNativeApi(): NativeApi { return instance.api; } - const rpcClient = getWsRpcClient(); + const getRpcClient = () => getWsRpcClient(); const api: NativeApi = { dialogs: { @@ -33,20 +33,20 @@ export function createWsNativeApi(): NativeApi { }, }, terminal: { - open: (input) => rpcClient.terminal.open(input as never), - write: (input) => rpcClient.terminal.write(input as never), - resize: (input) => rpcClient.terminal.resize(input as never), - clear: (input) => rpcClient.terminal.clear(input as never), - restart: (input) => rpcClient.terminal.restart(input as never), - close: (input) => rpcClient.terminal.close(input as never), - onEvent: (callback) => rpcClient.terminal.onEvent(callback), + open: (input) => getRpcClient().terminal.open(input as never), + write: (input) => getRpcClient().terminal.write(input as never), + resize: (input) => getRpcClient().terminal.resize(input as never), + clear: (input) => getRpcClient().terminal.clear(input as never), + restart: (input) => getRpcClient().terminal.restart(input as never), + close: (input) => getRpcClient().terminal.close(input as never), + onEvent: (callback) => getRpcClient().terminal.onEvent(callback), }, projects: { - searchEntries: rpcClient.projects.searchEntries, - writeFile: rpcClient.projects.writeFile, + searchEntries: (input) => getRpcClient().projects.searchEntries(input), + writeFile: (input) => getRpcClient().projects.writeFile(input), }, shell: { - openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), + openInEditor: (cwd, editor) => getRpcClient().shell.openInEditor({ cwd, editor }), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -60,16 +60,16 @@ export function createWsNativeApi(): NativeApi { }, }, git: { - pull: rpcClient.git.pull, - status: rpcClient.git.status, - listBranches: rpcClient.git.listBranches, - createWorktree: rpcClient.git.createWorktree, - removeWorktree: rpcClient.git.removeWorktree, - createBranch: rpcClient.git.createBranch, - checkout: rpcClient.git.checkout, - init: rpcClient.git.init, - resolvePullRequest: rpcClient.git.resolvePullRequest, - preparePullRequestThread: rpcClient.git.preparePullRequestThread, + pull: (input) => getRpcClient().git.pull(input), + status: (input) => getRpcClient().git.status(input), + listBranches: (input) => getRpcClient().git.listBranches(input), + createWorktree: (input) => getRpcClient().git.createWorktree(input), + removeWorktree: (input) => getRpcClient().git.removeWorktree(input), + createBranch: (input) => getRpcClient().git.createBranch(input), + checkout: (input) => getRpcClient().git.checkout(input), + init: (input) => getRpcClient().git.init(input), + resolvePullRequest: (input) => getRpcClient().git.resolvePullRequest(input), + preparePullRequestThread: (input) => getRpcClient().git.preparePullRequestThread(input), }, contextMenu: { show: async ( @@ -83,22 +83,48 @@ export function createWsNativeApi(): NativeApi { }, }, server: { - getConfig: rpcClient.server.getConfig, - refreshProviders: rpcClient.server.refreshProviders, - upsertKeybinding: rpcClient.server.upsertKeybinding, - getSettings: rpcClient.server.getSettings, - updateSettings: rpcClient.server.updateSettings, + getConfig: () => getRpcClient().server.getConfig(), + refreshProviders: () => getRpcClient().server.refreshProviders(), + upsertKeybinding: (input) => getRpcClient().server.upsertKeybinding(input), + getSettings: () => getRpcClient().server.getSettings(), + updateSettings: (patch) => getRpcClient().server.updateSettings(patch), + }, + vault: { + listSecrets: async () => { + if (!window.desktopBridge) { + throw new Error("Vault secrets are only available in the desktop app."); + } + return window.desktopBridge.listVaultSecrets(); + }, + saveSecret: async (input) => { + if (!window.desktopBridge) { + throw new Error("Vault secrets are only available in the desktop app."); + } + return window.desktopBridge.saveVaultSecret(input); + }, + deleteSecret: async (input) => { + if (!window.desktopBridge) { + throw new Error("Vault secrets are only available in the desktop app."); + } + return window.desktopBridge.deleteVaultSecret(input); + }, + subscribeSecrets: (callback) => { + if (!window.desktopBridge) { + return () => undefined; + } + return window.desktopBridge.subscribeVaultSecrets(callback); + }, }, orchestration: { - getSnapshot: rpcClient.orchestration.getSnapshot, - dispatchCommand: rpcClient.orchestration.dispatchCommand, - getTurnDiff: rpcClient.orchestration.getTurnDiff, - getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, + getSnapshot: () => getRpcClient().orchestration.getSnapshot(), + dispatchCommand: (command) => getRpcClient().orchestration.dispatchCommand(command), + getTurnDiff: (input) => getRpcClient().orchestration.getTurnDiff(input), + getFullThreadDiff: (input) => getRpcClient().orchestration.getFullThreadDiff(input), replayEvents: (fromSequenceExclusive) => - rpcClient.orchestration - .replayEvents({ fromSequenceExclusive }) + getRpcClient() + .orchestration.replayEvents({ fromSequenceExclusive }) .then((events) => [...events]), - onDomainEvent: (callback) => rpcClient.orchestration.onDomainEvent(callback), + onDomainEvent: (callback) => getRpcClient().orchestration.onDomainEvent(callback), }, }; diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 60f51ba707..d915e84ef7 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -11,6 +11,7 @@ import { Effect, Stream } from "effect"; import { type WsRpcProtocolClient } from "./rpc/protocol"; import { WsTransport } from "./wsTransport"; +import { resolveServerUrl } from "./lib/utils"; type RpcTag = keyof WsRpcProtocolClient & string; type RpcMethod = WsRpcProtocolClient[TTag]; @@ -96,18 +97,32 @@ export interface WsRpcClient { } let sharedWsRpcClient: WsRpcClient | null = null; +let sharedWsRpcClientUrl: string | null = null; + +function resolveCurrentWsRpcUrl(): string { + return resolveServerUrl({ + protocol: window.location.protocol === "https:" ? "wss" : "ws", + pathname: "/ws", + }); +} export function getWsRpcClient(): WsRpcClient { - if (sharedWsRpcClient) { + const nextUrl = resolveCurrentWsRpcUrl(); + if (sharedWsRpcClient && sharedWsRpcClientUrl === nextUrl) { return sharedWsRpcClient; } - sharedWsRpcClient = createWsRpcClient(); + + const previousClient = sharedWsRpcClient; + sharedWsRpcClient = createWsRpcClient(new WsTransport(nextUrl)); + sharedWsRpcClientUrl = nextUrl; + void previousClient?.dispose(); return sharedWsRpcClient; } export async function __resetWsRpcClientForTests() { await sharedWsRpcClient?.dispose(); sharedWsRpcClient = null; + sharedWsRpcClientUrl = null; } export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { diff --git a/bun.lock b/bun.lock index af243cf4eb..7bfe9b461c 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,11 @@ "name": "@t3tools/desktop", "version": "0.0.15", "dependencies": { + "@modelcontextprotocol/server": "^2.0.0-alpha.2", "effect": "catalog:", "electron": "40.6.0", "electron-updater": "^6.6.2", + "zod": "^4.3.6", }, "devDependencies": { "@t3tools/contracts": "workspace:*", @@ -169,6 +171,7 @@ }, }, "trustedDependencies": [ + "electron", "node-pty", ], "overrides": { @@ -485,6 +488,8 @@ "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-alpha.2", "", { "dependencies": { "zod": "^4.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], diff --git a/package.json b/package.json index a26a359c03..7f4011fce0 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ ] }, "trustedDependencies": [ + "electron", "node-pty" ] } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..24c47ba015 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,3 +12,4 @@ export * from "./orchestration"; export * from "./editor"; export * from "./project"; export * from "./rpc"; +export * from "./vault"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3114f6f5be..fd0e7bec7e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -49,6 +49,7 @@ import type { } from "./orchestration"; import { EditorId } from "./editor"; import { ServerSettings, ServerSettingsPatch } from "./settings"; +import type { VaultSecretDeleteInput, VaultSecretUpsertInput, VaultSecretsSnapshot } from "./vault"; export interface ContextMenuItem { id: T; @@ -119,6 +120,10 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + listVaultSecrets: () => Promise; + saveVaultSecret: (input: VaultSecretUpsertInput) => Promise; + deleteVaultSecret: (input: VaultSecretDeleteInput) => Promise; + subscribeVaultSecrets: (listener: (snapshot: VaultSecretsSnapshot) => void) => () => void; } export interface NativeApi { @@ -172,6 +177,12 @@ export interface NativeApi { getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; }; + vault: { + listSecrets: () => Promise; + saveSecret: (input: VaultSecretUpsertInput) => Promise; + deleteSecret: (input: VaultSecretDeleteInput) => Promise; + subscribeSecrets: (listener: (snapshot: VaultSecretsSnapshot) => void) => () => void; + }; orchestration: { getSnapshot: () => Promise; dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6..1294a5d633 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -8,6 +8,7 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, } from "./model"; import { ModelSelection } from "./orchestration"; +import { VaultVariable } from "./vault"; // ── Client Settings (local-only) ─────────────────────────────── @@ -95,6 +96,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), + vaultVariables: Schema.Array(VaultVariable).pipe(Schema.withDecodingDefault(() => [])), }); export type ServerSettings = typeof ServerSettings.Type; @@ -177,5 +179,6 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), }), ), + vaultVariables: Schema.optionalKey(Schema.Array(VaultVariable)), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/contracts/src/vault.ts b/packages/contracts/src/vault.ts new file mode 100644 index 0000000000..54676f53d7 --- /dev/null +++ b/packages/contracts/src/vault.ts @@ -0,0 +1,42 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; + +export const VaultSecretId = TrimmedNonEmptyString.pipe(Schema.brand("VaultSecretId")); +export type VaultSecretId = typeof VaultSecretId.Type; + +export const VaultVariableId = TrimmedNonEmptyString.pipe(Schema.brand("VaultVariableId")); +export type VaultVariableId = typeof VaultVariableId.Type; + +export const VaultVariable = Schema.Struct({ + id: VaultVariableId, + key: TrimmedNonEmptyString, + value: TrimmedNonEmptyString, +}); +export type VaultVariable = typeof VaultVariable.Type; + +export const VaultSecretSummary = Schema.Struct({ + id: VaultSecretId, + key: TrimmedNonEmptyString, + updatedAt: IsoDateTime, +}); +export type VaultSecretSummary = typeof VaultSecretSummary.Type; + +export const VaultSecretsSnapshot = Schema.Struct({ + enabled: Schema.Boolean, + safeStorageAvailable: Schema.Boolean, + message: Schema.NullOr(TrimmedString), + secrets: Schema.Array(VaultSecretSummary), +}); +export type VaultSecretsSnapshot = typeof VaultSecretsSnapshot.Type; + +export const VaultSecretUpsertInput = Schema.Struct({ + id: Schema.optional(VaultSecretId), + key: TrimmedNonEmptyString, + value: Schema.optional(TrimmedNonEmptyString), +}); +export type VaultSecretUpsertInput = typeof VaultSecretUpsertInput.Type; + +export const VaultSecretDeleteInput = Schema.Struct({ + id: VaultSecretId, +}); +export type VaultSecretDeleteInput = typeof VaultSecretDeleteInput.Type; From fad054d36cf61394be933b25f95c5505059ab923 Mon Sep 17 00:00:00 2001 From: Avi Volah <99116185+AviVolah@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:00:22 +0300 Subject: [PATCH 2/2] Fix timeline virtualization harness props --- .../components/chat/MessagesTimeline.virtualization.browser.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index 1e947a3c84..0f1f6ca693 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -162,7 +162,9 @@ function createBaseTimelineProps(input: { onToggleWorkGroup: () => {}, onOpenTurnDiff: () => {}, revertTurnCountByUserMessageId: new Map(), + revertTurnCountByTurnId: new Map(), onRevertUserMessage: () => {}, + onRevertTurn: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, markdownCwd: MARKDOWN_CWD,