Codelet logoCodelet

Settings, secrets and stored state

Where an extension's settings, its own state and its secrets are kept, and how to hand a workbench somewhere of your own to keep them

Three kinds of data outlive a reload: settings the reader changed, state an extension keeps for itself, and secrets an extension was handed. Each has its own document and its own default, and each can be pointed at a store of your own.

#Settings

An extension reads its settings through workspace.getConfiguration, over two layers: every manifest's declared defaults, and whatever the reader has written over them.

vscode.workspace.getConfiguration("markdown").get("preview.theme", "auto");
await vscode.workspace.getConfiguration().update("markdown.preview.theme", "dark");

The written layer is a SettingsStore — dotted keys to values, read and written whole rather than one key at a time:

interface SettingsStore {
  read(): Record<string, unknown> | undefined;
  write(settings: Record<string, unknown>): void;
  watch?(changed: () => void): () => void;
}

A workbench passed no settings option keeps this in localStorage, under the key codelet.settings, so an update() survives a reload. localSettings("my-key") is that same store under a key of your own:

import { localSettings, Workbench } from "codelet/workbench";

new Workbench({ parent, fs, settings: localSettings("my-app-settings") });

A host with its own place to keep preferences — a dialog, a profile on a server — hands over a store instead:

import type { SettingsStore } from "codelet/workbench";

const settings: SettingsStore = {
  read: () => mine.all(),
  write: (all) => mine.save(all),
  // Optional: call `changed` when settings move somewhere else — another tab, your own dialog —
  // and every extension watching one is told through `onDidChangeConfiguration`.
  watch: (changed) => mine.subscribe(changed),
};

new Workbench({ parent, fs, settings });

The store is read once, so it's the object that has to stay stable across renders, not the values it returns. Nothing renders a setting, so renderWorkbench() takes no settings option.

For a reader-facing editor, mount codelet/extensions/settings: it adds Preferences: Open Settings (JSON) to the palette, a tab listing every key any manifest declared, and writes back to this same store as the reader types.

#Extension state

context.globalState and context.workspaceState are where an extension keeps its own data between reloads — not what the reader typed, but what the extension remembers about itself.

context.globalState.update("lastRun", Date.now());
context.workspaceState.get<string[]>("recentSearches", []);

They're kept the same way settings are, over a second SettingsStore you can also hand over:

new Workbench({ parent, fs, state: localSettings("my-app-state") });

A workbench passed no state option keeps this in localStorage too, under its own key (codelet.state), separate from the reader's settings — what an extension writes for itself shouldn't collide with what the reader wrote. Stopping an extension and starting it again reads what it wrote before; globalState is shared across every workspace an extension opens, workspaceState is scoped to the one showing.

#Secrets

context.secrets is where a token the reader typed, or a key an extension was handed, goes.

Warning

With no secrets option, a workbench keeps them in memory and loses them when the tab closes. This is the default, and it's the honest one: a page can't encrypt on its own, so writing a secret to localStorage would be a token left in the clear under an API that says otherwise. Once unlocked, a secret is readable by every script in the page — the shell, every extension you mounted, an LSP client, a .vsix the reader installed from Open VSX. No browser API hides a value from the origin holding it.

webauthnSecrets() seals the secrets document in localStorage under a key derived from the reader's own passkey:

import { Workbench, webauthnSecrets } from "codelet/workbench";

new Workbench({ parent, fs, secrets: webauthnSecrets() });

The first secret an extension stores enrolls a passkey; a later page derives the same key on the first read. Two commands land in the palette wherever a store can be unlocked:

  • Secrets: Remember Secrets on This Device — runs the enrollment from a click, the one gesture that always works (Safari refuses a passkey ceremony without one).
  • Secrets: Forget Secrets on This Device — drops everything sealed here and stops keeping, until the row above is used again. The passkey itself stays; a page has no way to delete one.
OptionTypeDefaultDescription
namestringthe page's hostWhat the reader sees the passkey called.
keystring"codelet.secrets"The localStorage key the sealed document goes under.
attachment"platform" \| "any""platform"Which authenticators may hold the key.

"platform" binds the key to this machine — Touch ID, Windows Hello, a laptop's own sensor — which is what makes "on this device" true. "any" also accepts a phone over hybrid and a security key with hmac-secret, at the cost of the key travelling with the reader rather than staying put.

A host with somewhere real to keep a secret — a server, a native shell with a keychain — hands over its own SecretsStore and gets a far better guarantee than anything a page can manage:

import type { SecretsStore } from "codelet/workbench";

const secrets: SecretsStore = {
  read: () => mine.load(),
  write: (all) => mine.save(all),
  unlock: () => mine.authenticate(), // optional — puts the palette command there at all
  forget: () => mine.clear(), // optional
  remembering: () => mine.unlocked, // optional
  reports: (to) => mine.onMessage(to), // optional — a Secrets log line, a toast for `notify`
};

Warning

webauthnSecrets() is encrypted at rest, and only at rest — exactly what VSCode's own SecretStorage promises, and no more. It stops a disk read, a backup or a shared machine from seeing anything but ciphertext. It does not stop a script running in the page once the reader has unlocked: that plaintext is in the page like everything else.

Read more in API > Workspace.