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. That's the default: writing a reader's token to your origin's storage is a decision to opt into, not one a library makes for you. And whatever the option, a secret in use 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.

localSecrets() writes the secrets document to localStorage and never in the clear:

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

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

The first secret an extension stores is sealed under a non-extractable AES-GCM key generated in the browser and kept in IndexedDB. The page can use that key and cannot read it, so what lands on disk is ciphertext to a backup, a copied profile, another origin, and any script that ships localStorage somewhere — and it costs the reader nothing at all.

Right after that first secret, the reader is asked once whether to put their own passkey in front of it instead. WebAuthn's PRF derives the key inside the authenticator, so opening the document then needs the reader present rather than only the tab open, and the browser's own key is deleted. Saying no leaves everything encrypted; only the key changes. The question is asked once ever — the answer is written down beside the document — and their click is also the gesture the ceremony needs, which is the one thing that works on every browser.

A later page with a passkey document does not ask on its own — what is sealed stays sealed, and reads answer with whatever that page holds until the reader unlocks it, so nothing prompts for a fingerprint out of a tab that has only just opened. A device document has nobody to ask, so it opens on the first read. Three commands land in the palette, and the Accounts item carries the unlock where signing in is what the reader came for:

  • Secrets: Protect Secrets with a Passkey — the offer again, for a reader who said no or has changed their mind.
  • Secrets: Remember Secrets on This Device — unlocks a passkey document from a click, and starts saving again after the row below.
  • Secrets: Forget Secrets on This Device — drops everything saved here, the browser's key with it, and stops keeping until the row above is used again. A 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, and the IndexedDB database beside it.
attachment"platform" \| "any""platform"Which authenticators may hold the key.
offerPasskeybooleantrueWhether to ask at all. false keeps the browser's key and no ask.

"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
  protect: () => mine.harden(), // optional — the reader asking for a stronger key
  protection: () => "device", // optional — "page" | "device" | "passkey"
  forget: () => mine.clear(), // optional
  remembering: () => mine.unlocked, // optional
  locked: () => mine.sealed && !mine.unlocked, // optional — offers the unlock where there is one
  available: () => mine.names, // optional — `<extension>/<key>`, so chrome can say what is kept
  reports: (to) => mine.onMessage(to), // optional — a Secrets log line, a toast for `notify`
  asks: (to) => mine.onQuestion(to), // optional — a question, drawn as the workbench's dialog
};

Warning

localSecrets() 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: with the browser's own key, any script here can ask it to decrypt without ever seeing the key; with a passkey, any script here can read the plaintext once the reader has unlocked. That is the difference the passkey buys and the whole of it.

One thing is deliberately not sealed: the record lists which secrets exist, as <extension>/<key>, so the workbench can offer to unlock a sign-in it can see is there rather than mint a second one over it. The names are legible to a disk reader; the values are not.

Read more in API > Workspace.