Settings storage with scopes, and project setup that can live in the repository (#3413)
* refactor(settings): settings registry and intent-gated writes
Problem: every setting lived in a flat document with ten hand-maintained
key lists that had drifted (three keys the server silently dropped, five
it kept that nothing read), and three code paths wrote to the server
without a person changing anything: the theme persist effect on mount,
bootstrap seeding of server-missing keys, and the auto-save echoing
values just adopted from the server.
Approach: one registry (packages/ui/src/lib/settings/registry.ts) names
every key with its scope (instance / profile / device), a boundary parser
and its store binding; DesktopSettings, the sanitizer, the mirror, the
apply step and the auto-save derive from it. A generated JSON snapshot
carries the key list to the server and the VS Code bridge. Writes carry
intent: the theme context writes only from its user-facing setters, a
missing server key leaves the local store alone instead of resetting it,
updateDesktopSettings drops values the server already holds, and the
auto-savers treat values applied from the server as a new baseline.
Testing: bun test packages/ui (registry + persistence suites cover zero
writes on load, dedup, toggle-back cancellation, failed-save retry, and
snapshot freshness); tsc for every workspace.
* refactor(ui): read and write settings through the shared path only
Problem: fourteen pages and stores fetched /api/config/settings on their
own and re-parsed the raw document by hand, so the registry could not
guard them and two of them treated a failed load as an empty list.
Approach: loadDesktopSettings() and updateDesktopSettings() (which now
resolves { ok }) replace every direct call; SkillsCatalogPage and
AddCatalogDialog refuse to write the catalog list until it is known.
Testing: bun test packages/ui (403 files), eslint on the changed files.
* refactor(server): validate settings writes against the registry snapshot
Problem: the server whitelist was the only guard on PUT /api/config/settings
and had drifted from the client; dead keys were still persisted.
Approach: settings-helpers.js drops any key the generated registry
snapshot does not list as persistable and strips secret keys from
responses; the dead keys (markdownDisplayMode, toolCallExpansion,
typographySizes, expandedEditorToolbar, gitProviderId/gitModelId) are
gone; the profile keys that were client-only now round-trip. A drift
test requires a valid sample for every persistable registry key.
Testing: vitest run in packages/web (182 files), including the packed
tarball import.
* refactor(vscode): gate bridge settings writes by the registry
Problem: the extension host wrote any key the webview sent straight into
settings.json, and commit-message generation read the dead
gitProviderId/gitModelId pair instead of the small-model setting.
Approach: filterPersistableSettingsChanges applies the registry snapshot
before the file write; chooseBridgeGitGenerationModel honours
smallModelUseDefault/smallModelOverride ahead of the zen fallback.
Testing: bun test packages/vscode (37 files), tsc, build:extension.
* feat(settings): split the user's profile into preferences.json
Problem: one flat settings.json held instance facts, the user's
preferences and device state together, so device state travelled between
installs and the profile had no document of its own to sync from.
Approach: the server keeps one merged document for clients but routes
each key by registry scope on disk (settings-files.js): profile keys go to
preferences.json as { value, updatedAt } entries stamped when the value
changes, everything else stays in settings.json, device keys are dropped
from writes. A missing preferences.json is seeded once from settings.json,
which is left intact; an unreadable one is a failure that pauses profile
writes and never gets overwritten. Server modules that read a profile key
off the disk use the merged sync read. Electron main reads the theme mode
from both files and now owns the splash colours, handed over the
window-theme IPC instead of the settings document. Clients stop sending
device keys, seed them once from a pre-split document, and persist
inputBarOffset locally. The PWA manifest keys are instance facts.
Testing: vitest in packages/web (seed, split write, timestamp retention,
unreadable file), bun test in packages/ui and packages/electron, tsc for
every workspace.
* feat(vscode): write the profile to preferences.json from the extension host
Problem: the extension host writes the shared settings files directly and
had to follow the server's split, and its file writes reported success on
failure.
Approach: settings-files.ts mirrors the server's format and split rules
(seed once, unreadable preferences.json is a failure); persistSettings
routes profile keys to preferences.json and the rest to settings.json,
and the atomic writers now throw so a failed save reaches the webview.
Clearing a key now actually removes it from the owning file.
Testing: bun test packages/vscode (38 files), tsc, build:extension.
* feat(settings): store the per-surface profile fields by surface kind
Problem: theme, chat-layout switches and typography sizes are one value
for every client of an instance, so the phone and the desktop cannot
disagree without a hard-coded runtime branch.
Approach: every settings request carries the client's surface kind in the
x-openchamber-surface header (web, desktop, vscode, mobile — the phone app
and the hosted mobile shell are one kind). For the registry's perSurface
keys the store writes a changed value under fields[key].surfaces[kind] in
preferences.json and never touches the base from a surface; reads resolve
the kind's own value, then the base, then nothing. Writes without a
surface (migrations, the seed) set the base. The VS Code host is always
vscode; Electron main resolves desktop for the native window theme. The
Settings UI is unchanged.
Testing: vitest in packages/web (surface write/read, no base copy, unknown
surface falls back to base), bun test in packages/vscode and packages/ui,
tsc for every workspace, build:extension.
* fix(settings): keep a legacy copy of the profile in settings.json
The first write after the split rewrote settings.json with the instance
part only, and that write happens on startup (relay reconcile). A build
from before the split reads only settings.json, so rolling back would
have lost every preference: theme, default model, all of it.
Every write now stores the profile's base values in settings.json next
to the instance part (`legacySettingsDocumentOf`), on the server and in
the VS Code extension host alike. Current builds ignore the copy because
preferences.json wins in the merged read. When preferences.json is
unreadable the copy already on disk is kept rather than dropped.
Testing: settings-runtime tests updated for the copy; full web suite
(182 files), VS Code tests and extension build, tsc clean. Verified live
on a scratch OPENCHAMBER_DATA_DIR: all 136 keys survive startup, theme
changes land per surface, plain keys land in the base.
* feat(settings): make the UI password and tunnel preset tokens write-only
GET /api/config/settings returned desktopUiPassword and the managed
remote tunnel preset tokens to every authenticated client, including
paired phones and the VS Code webview that never need them.
Both keys are now `secret` in the registry: accepted on write, withheld
from reads. The server answers with a hasDesktopUiPassword flag; the
desktop network page shows "Password set" and sends a value only when
the user types a new one or presses "Remove password" (an empty string
clears it and turns LAN access off). The tunnel page already learned
token presence from the status endpoint. The VS Code bridge strips
secret keys from what it hands the webview while still merging them
from disk on write.
Testing: registry, i18n parity, server settings, VS Code gate tests and
tsc; workspace type-check. Verified against a scratch server: GET
carries the flag and no password, PUT with '' clears, PUT with a value
sets. The desktop-only page itself awaits the owner's run.
* fix(settings): send the surface kind as a query parameter, not a header
The packaged desktop shell (openchamber-ui://app) and the phone app are
cross-origin to the OpenChamber server, so the x-openchamber-surface
header turned every settings request into a CORS preflight the server
did not allow. Settings looked reset and every save reported "Save
failed" without reaching persistSettings. An older remote instance would
refuse the header the same way even with the allow-list fixed.
The client now sends ?surface=<kind>, which keeps the request
CORS-simple on every server version; the server reads the query
parameter and still honours the header. The header is also in the CORS
allow-list for completeness.
Testing: workspace type-check, persistence and registry tests, server
opencode tests. On a scratch server: PUT with ?surface=vscode lands
under surfaces.vscode, GET without or with an unknown surface serves the
base, the header fallback resolves. Confirmed in the owner's rebuilt
desktop and on the phone.
* refactor(settings): drop the show-password toggle from the desktop network page
With the password write-only, the field only ever holds a value the user
is typing right now; the reveal toggle and its strings are gone from
every locale.
* refactor(projects): serve project setup through the server, drop the legacy migration
The shared UI read and wrote ~/.config/openchamber/projects/<id>.json
itself: it resolved the home directory, composed the path, and used the
Files API, which only desktop and VS Code have natively and which cannot
see a remote instance's file at all. It also still carried the months-old
migration from <repo>/.openchamber/openchamber.json, which deleted files in
the folder the upcoming shared project config will use.
The client-owned keys (worktree setup commands, project actions, draft
starters) now live behind GET/PUT /api/projects/:projectId/config.
project-setup.js sanitizes and builds the view; the project-config runtime
merges a patch under the same cross-process lock the scheduled-task writers
hold, so unknown and server-owned keys survive. A wrongly shaped key is a
400, not a silent drop. openchamberConfig.ts keeps its exported functions
and is now an HTTP client. The VS Code webview handles the route locally
and bridges to the extension host, which owns the file with a TS mirror of
the sanitizers.
Testing: server tests for sanitizers, round trip, lock, and invalid patch;
client tests against a mocked route; VS Code sanitizer and bridge tests;
workspace type-check, both VS Code builds, UI isolated suite (409 files),
server projects and project-context suites. Live GET/PUT against a
running server with the owner's real project config.
* feat(projects): read the team's shared config and merge it with the personal one
A project can now carry <repo>/.openchamber/project.json (version 1:
setupWorktree, setupWorktreeWait, projectActions, draftStarters,
plansDir). The server finds the checkout from the path-derived project
id, parses the file, and answers GET /api/projects/:id/config with one
merged view: what runs at the top level, plus shared and personal blocks
so a page can edit the personal file without copying a teammate's entry
into it.
Merge rules: shared setup commands run first (a personal
setupWorktreeMode of "replace" uses the personal list only); the
personal wait flag wins when set; actions union by id with a personal
action replacing the shared one and personal hiddenSharedActionIds
dropping shared ones; starters union by type:name; the primary action is
personal only. A shared file that exists but cannot be parsed, or that
names a plansDir outside the repo, is reported as invalid with a reason
and never treated as "no shared setup". Nothing writes the repo file yet.
Client: getProjectSetup exposes the view; the existing helpers return
effective values, while the Projects page sections and the draft
starters hook edit the personal block only. Shared entries show a quiet
"shared" mark in the actions dropdown and read-only lists above the
editable ones on the Projects page; shared starter chips have no remove
handle. The VS Code extension host mirrors the parser and merge.
Testing: server tests for the parser, plansDir guard, merge table, id
round trip, and a runtime test against a temp checkout; client tests
against a mocked route; VS Code sanitizer, merge, and bridge tests; the
section test covers the shared row; locale parity; workspace type-check;
UI isolated suite (409 files). Live: GET against a temp repo with a
shared file and with a broken one.
* feat(projects): ask before the team's shared commands run, once per set of commands
Shared setup commands and shared actions come from a file a git pull can
change, and they run on the machine of whoever pulls. The first time one
would run, a dialog now shows exactly what would run and asks: "Trust and
run" or "Not this time". A "trust" answer is recorded in the personal
config against a SHA-256 of the executable parts (setup commands and each
action's id, command, and runIn; renames and icons do not count), so a
pull that changes a command brings the prompt back. Nothing asks when the
shared file has nothing that executes.
Worktree creation (session creator, new-worktree dialog, session store,
multi-run launcher, agent-manager empty state) resolves its commands
through the prompt; "not this time" runs only the user's own commands.
The actions dropdown asks before a shared action runs. The Projects page
shows "Trusted on this instance" with a "Reset trust" button next to the
shared actions. The dialog is mounted beside the app-link confirmation on
every shell. The VS Code extension host mirrors the hash and the record.
Testing: server tests for hash stability, ordering, and the trusted flag,
plus a runtime test that changes the shared file and sees trust drop;
client tests for the confirmation store (ask, trust, skip, replace mode,
newer request, failed record, reset); VS Code mirror tests; the actions
button, new-worktree dialog, and issue-2039 tests updated for the trust
path; locale parity; workspace type-check; UI isolated suite (410 files).
* feat(projects): share and unshare setup with the team from the Projects page
The repo file <repo>/.openchamber/project.json is now written by the app,
and only when the user shares something: nothing appears in a repository
until then. PUT /api/projects/:id/config/shared replaces the keys it
names over the current file, writes it pretty-printed with version first
and only the keys that carry something, removes the file (and an empty
.openchamber folder) when nothing is left, refuses a missing checkout or
a plansDir outside the repo, and records trust for the writer, who has
seen what they shared.
On the Projects page, actions and setup commands get "Share with team"
and "Make personal"; shared actions can be hidden for this user; a
checkbox switches to "Use only my setup commands". Project starter chips
get share and make-personal hover buttons. A new "Shared config" block
shows the file's path and status, the shared plans folder, and the trust
status with "Reset trust". A share is a repo write followed by a personal
write; a failure after the first leaves the item visible once, as
personal. The VS Code extension host mirrors the writer.
Testing: server tests for the patch, serialization, emptiness, the write
and removal round trip, the writer's trust record, and the refusals;
client test for the shared route; VS Code bridge test for write and
removal; locale parity; workspace type-check; UI isolated suite (410
files). Live on a scratch server: share, invalid plansDir (400), unshare
to removal of file and folder.
* feat(projects): list, edit, and move plans in the team's shared plans folder
When the shared config names a plansDir, every markdown file in that
folder is a plan on the Plans tab: listed after the user's own plans,
marked shared, addressed as shared:<file>, read and edited in place
(the raw document is written verbatim, so a plan another tool wrote
keeps its shape), and deletable. Share moves one of the user's plans
into the folder; make personal moves it back under a new id; a name
collision gets a numeric suffix. Sharing is refused, with a hint in the
panel, until a shared plans folder is set in Project settings. This
answers the request to read plans from an existing folder such as
docs/plans.
Server: the project-context runtime takes resolveSharedPlansDir from the
project-config runtime; readContext reports sharedPlansDir; POST
.../plans/:id/share and /unshare. Client: movePlan in the context store,
a shared badge and a share / make-personal button per plan row. Session
attachments reference plan ids, so an attached plan that moves has to be
attached again.
Testing: runtime tests for listing, foreign markdown titles, id
traversal, in-place update and delete, share and unshare with a
collision, and the refusal without a folder; HTTP route tests; store and
locale parity tests; workspace type-check; full web suite (183 files);
UI isolated suite (410 files). Live on a scratch server against a temp
repo: list, share, read, unshare.
* fix(server): make OPENCHAMBER_DATA_DIR move every folder, not just the flat files
The variable is documented as the OpenChamber data directory, but only
settings, preferences, auth, and push files followed it; projects,
themes, speech models, and the chats default stayed under
~/.config/openchamber. A second instance started with a custom
directory therefore read and wrote the default instance's project
configs.
Every folder now hangs off the one root. An instance that already used
a custom directory gets projects, themes, and speech-models copied in
once at startup; copied, not moved, so a second instance beside the
default one cannot strip it, and nothing is merged into a folder that
already exists. Existing managed chats are not copied, as with
OPENCHAMBER_CHATS_DIR.
Testing: migration tests for copy-once, no-merge, and same-root no-op;
full web suite; a scratch server with an empty data dir copied the real
project configs and kept its writes in the copy.
* fix(projects): keep a plan's id when it moves into or out of the repository folder
A plan moved into the repository plans folder used to be listed under a
new shared:<file> id, so a session that had attached it lost the
attachment. The manifest entry now stays with a `shared` flag that says
which folder holds the file; the id survives both directions. Only a
plan that never had an entry (one written by another tool) gets an id
when it is brought in. A personal file and a repository file may share
a name because they live in different folders.
Testing: runtime tests for share and unshare with a stable id, reading
and editing the moved plan, the suffix on a name collision, and the
adoption of a foreign file.
* feat(projects): default repository plans folder, "move to repository" wording, tooltips
Plans now have a repository folder without any setup: .openchamber/plans
by default. A custom plansDir replaces the default outright (only that
folder is read and written; moving files between the two is the user's
job), and the field's placeholder and hint say so. The move buttons on
plans are therefore always available.
The word "share" is gone from the UI: it read like publishing, while
the action stores an item in the repository so everyone who pulls it
gets it. Labels are "Move to repository" / "Move to my settings", the
badge is "In repo", the block is "Repository config", and every button
on the Projects page carries a tooltip that says what happens (the
"Move to repository" button explains that edits save first while the
form is dirty). The trust status with "reset trust" moved from the
repository block into the Worktree section next to the commands it
guards; the plan row's badge sits beside the title.
Testing: locale parity, section test, workspace type-check, UI isolated
suite (410 files), full web suite.
* fix(projects): leave the icon key out of the repository file when an action has none
Actions without an icon were written as "icon": null into
.openchamber/project.json. The key is now omitted; readers already fall
back to the play icon. Server and VS Code serializers, tests updated.
* docs: describe the repository config file and how items move into it
A new page in every locale: what stays personal and what can move into
the repository, the .openchamber/project.json format with an example
and every key explained (setup commands, actions with the supported icon
names, starters, plansDir), the merge rules, the trust prompt, and plans
in the repository. Linked from the sidebar and from Project Actions.
Translations written by hand.
This commit is contained in:
committed by
GitHub
parent
ff75dc9bd5
commit
85c4320825
@@ -17,6 +17,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
|
||||
|
||||
- `bridge-git-special-runtime.ts`
|
||||
- Specialized Git flows (`pr-description`, `conflict-details`) and generation helpers.
|
||||
- Generation model choice lives in `bridge-git-generation-model.ts`: request model first, then the user's small-model override (`smallModelUseDefault === false` plus `smallModelOverride` as `provider/model`) when the catalog has it, then the zen fallback. The old `gitProviderId`/`gitModelId` pair is no longer read.
|
||||
|
||||
- `bridge-git-process-runtime.ts`
|
||||
- Git process execution and environment setup (`execGit`), including SSH agent socket resolution.
|
||||
@@ -65,8 +66,15 @@ The webview build emits each worker as one self-contained file. VS Code webviews
|
||||
- Includes OpenCode resolution diagnostics parity handler used by shared UI (`/api/config/opencode-resolution`).
|
||||
- OpenCode JSONC reads in `opencodeConfig.ts` fail closed on a partial or non-object `jsonc-parser` tree (`INVALID_JSONC`) so mutations cannot rewrite a `$schema`-only stub over an existing config. Comment-only files read as empty, while other content that yields no JSON value (YAML, plain text) fails closed. A broken layer is omitted from the merge and recorded on `layerErrors`; valid sibling layers still load, including plugin list/read via `getPluginConfigSources`. Writes still refuse to overwrite the broken file.
|
||||
|
||||
- `bridge-project-setup-runtime.ts`
|
||||
- Extension-host side of `GET/PUT /api/projects/:projectId/config` (the webview handles the route locally and bridges `api:project-setup:get` / `api:project-setup:update`). Reads and writes the client-owned keys of `~/.config/openchamber/projects/<projectId>.json` (worktree setup commands, project actions, draft starters) with the rules in `project-setup.ts`, a mirror of the server's `packages/web/server/lib/projects/project-setup.js`; keep the two in sync. Writes to one file are chained; server-owned and unknown keys survive. The read also merges the team's optional `<workspace>/.openchamber/project.json` (checkout path decoded from the `path_<base64url>` id) by the same rules as the server, so the webview sees one view with `shared` / `personal` blocks. The shared UI (`openchamberConfig.ts`) no longer composes that path or reads it through the fs bridge.
|
||||
- `bridge-settings-runtime.ts`
|
||||
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
|
||||
- Writes are gated by the generated registry snapshot (`settings-registry.json`, via `settings-registry-gate.ts`): keys the registry does not list, or marks `computed`, `local`, or `owner: desktop-shell`, never reach the shared settings files. Regenerate the snapshot with `bun run settings-registry:generate` when the UI registry changes.
|
||||
- Shared settings live in two files under `~/.config/openchamber/`, split by `settings-files.ts` (a pure mirror of the server's `settings-files.js`; both write the same bytes): `settings.json` holds instance facts and legacy keys, `preferences.json` (`{ version: 1, fields: { key: { value, updatedAt } } }`) holds every registry `profile` key. `updatedAt` is stamped by the extension host only when a value actually changes. Reads return the merged view (preferences win). A missing `preferences.json` is seeded once from the profile keys still in `settings.json`; every write keeps a copy of the profile's base values in `settings.json` too, so a build from before the split (which reads only that file) still finds the user's preferences; it is ignored by current builds.
|
||||
- An existing but unparseable `preferences.json` is a failure, not an empty profile: it is never seeded over or rewritten, one warning is logged per process, reads return `settings.json` only, and writes drop the profile part until a later read succeeds.
|
||||
- Both files are written atomically (tmp file + rename). Write failures throw, so `persistSettings` rejects and the webview sees the save fail instead of a silent success.
|
||||
- The extension host is always the `vscode` surface kind: per-surface profile keys it changes land under `surfaces.vscode` in `preferences.json` and reads resolve `vscode` first, base otherwise (mirrors the server's header-driven behaviour).
|
||||
|
||||
- `bridge-system-runtime.ts`
|
||||
- System/editor/provider/quota/notification/update-check message handlers.
|
||||
@@ -175,7 +183,7 @@ Handlers with no reachable caller in the VS Code webview.
|
||||
| `api:fs:write`, `api:fs:rename`, `api:fs:delete`, `api:fs:reveal`, `api:fs:mkdir` | `FilesView`, `SidebarFilesTree`, `PlanView` only |
|
||||
| `api:fs:exec` | Terminal API is a throwing stub; no other caller |
|
||||
|
||||
Reachable filesystem routes: `api:fs:read` (attachments, config), `api:fs:search`
|
||||
Reachable filesystem routes: `api:fs:read` (attachments), `api:fs:search`
|
||||
(`useFileSearchStore` behind composer file mentions), `api:fs:list`, `api:fs:stat`.
|
||||
|
||||
Maintenance: reviews, changelog entries, and parity claims consult this map;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BRIDGE_ZEN_DEFAULT_MODEL, chooseBridgeGitGenerationModel } from './bridge-git-generation-model';
|
||||
|
||||
const catalogOf = (...refs: string[]) => {
|
||||
const set = new Set(refs);
|
||||
return (providerID: string, modelID: string) => set.has(`${providerID}/${modelID}`);
|
||||
};
|
||||
|
||||
describe('chooseBridgeGitGenerationModel', () => {
|
||||
test('request payload model wins when it is in the catalog', () => {
|
||||
const choice = chooseBridgeGitGenerationModel(
|
||||
{ providerId: 'anthropic', modelId: 'claude-sonnet-4' },
|
||||
{ smallModelUseDefault: false, smallModelOverride: 'openai/gpt-4.1-mini' },
|
||||
catalogOf('anthropic/claude-sonnet-4', 'openai/gpt-4.1-mini'),
|
||||
);
|
||||
assert.deepEqual(choice, { providerID: 'anthropic', modelID: 'claude-sonnet-4' });
|
||||
});
|
||||
|
||||
test('small-model override is honoured when present in the catalog', () => {
|
||||
const choice = chooseBridgeGitGenerationModel(
|
||||
{},
|
||||
{ smallModelUseDefault: false, smallModelOverride: 'openai/gpt-4.1-mini' },
|
||||
catalogOf('openai/gpt-4.1-mini'),
|
||||
);
|
||||
assert.deepEqual(choice, { providerID: 'openai', modelID: 'gpt-4.1-mini' });
|
||||
});
|
||||
|
||||
test('override model ids may contain slashes; only the first splits provider from model', () => {
|
||||
const choice = chooseBridgeGitGenerationModel(
|
||||
{},
|
||||
{ smallModelUseDefault: false, smallModelOverride: 'openrouter/meta/llama-3' },
|
||||
catalogOf('openrouter/meta/llama-3'),
|
||||
);
|
||||
assert.deepEqual(choice, { providerID: 'openrouter', modelID: 'meta/llama-3' });
|
||||
});
|
||||
|
||||
test('override is ignored when smallModelUseDefault is not false', () => {
|
||||
const hasModel = catalogOf('openai/gpt-4.1-mini');
|
||||
for (const useDefault of [true, undefined, 'false']) {
|
||||
const choice = chooseBridgeGitGenerationModel(
|
||||
{},
|
||||
{ smallModelUseDefault: useDefault, smallModelOverride: 'openai/gpt-4.1-mini' },
|
||||
hasModel,
|
||||
);
|
||||
assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL });
|
||||
}
|
||||
});
|
||||
|
||||
test('override is ignored when it is not in the catalog or malformed', () => {
|
||||
const hasModel = catalogOf('openai/gpt-4.1-mini');
|
||||
for (const override of ['openai/gpt-4o', 'openai', '/gpt-4.1-mini', 'openai/', ' ', 42]) {
|
||||
const choice = chooseBridgeGitGenerationModel(
|
||||
{},
|
||||
{ smallModelUseDefault: false, smallModelOverride: override },
|
||||
hasModel,
|
||||
);
|
||||
assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL });
|
||||
}
|
||||
});
|
||||
|
||||
test('the removed gitProviderId/gitModelId pair is no longer read', () => {
|
||||
const choice = chooseBridgeGitGenerationModel(
|
||||
{},
|
||||
{ gitProviderId: 'openai', gitModelId: 'gpt-4.1-mini' },
|
||||
catalogOf('openai/gpt-4.1-mini'),
|
||||
);
|
||||
assert.deepEqual(choice, { providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL });
|
||||
});
|
||||
|
||||
test('zen fallback prefers the request zen model, then settings, then the default', () => {
|
||||
const none = () => false;
|
||||
assert.deepEqual(
|
||||
chooseBridgeGitGenerationModel({ zenModel: ' gpt-5-mini ' }, { zenModel: 'other' }, none),
|
||||
{ providerID: 'zen', modelID: 'gpt-5-mini' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
chooseBridgeGitGenerationModel({}, { zenModel: 'other' }, none),
|
||||
{ providerID: 'zen', modelID: 'other' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
chooseBridgeGitGenerationModel({}, {}, none),
|
||||
{ providerID: 'zen', modelID: BRIDGE_ZEN_DEFAULT_MODEL },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Which model a bridge Git generation flow (PR description) talks to. Pure so
|
||||
// the choice is unit-tested without `vscode`; the catalog lookup is injected.
|
||||
//
|
||||
// Order: the request's explicit model, then the user's small-model override
|
||||
// from OpenChamber settings (the same setting every other utility generation in
|
||||
// the product uses), then the zen fallback.
|
||||
|
||||
export const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
|
||||
|
||||
export type BridgeGitGenerationPayloadModel = {
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
zenModel?: string;
|
||||
};
|
||||
|
||||
type BridgeGitGenerationModelChoice = { providerID: string; modelID: string };
|
||||
|
||||
// Bridge settings are the merged persisted dictionary; a value is a string
|
||||
// only when the stored file says so, hence the narrowing here.
|
||||
const readStringField = (settings: Record<string, unknown>, key: string): string => {
|
||||
const candidate = settings[key];
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* `smallModelOverride` is stored as `provider/model`; the model id may itself
|
||||
* contain slashes, so only the first one separates the two.
|
||||
*/
|
||||
const readSmallModelOverride = (settings: Record<string, unknown>): BridgeGitGenerationModelChoice | null => {
|
||||
if (settings.smallModelUseDefault !== false) return null;
|
||||
const override = readStringField(settings, 'smallModelOverride');
|
||||
const separator = override.indexOf('/');
|
||||
if (separator <= 0) return null;
|
||||
const providerID = override.slice(0, separator).trim();
|
||||
const modelID = override.slice(separator + 1).trim();
|
||||
if (!providerID || !modelID) return null;
|
||||
return { providerID, modelID };
|
||||
};
|
||||
|
||||
export const chooseBridgeGitGenerationModel = (
|
||||
payloadModel: BridgeGitGenerationPayloadModel,
|
||||
settings: Record<string, unknown>,
|
||||
hasModel: (providerID: string, modelID: string) => boolean,
|
||||
): BridgeGitGenerationModelChoice => {
|
||||
// The payload reaches here from a webview message that is cast, not parsed,
|
||||
// so a wrong-typed field must degrade to "absent" instead of throwing.
|
||||
const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : '';
|
||||
const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : '';
|
||||
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
|
||||
return { providerID: requestProviderId, modelID: requestModelId };
|
||||
}
|
||||
|
||||
const override = readSmallModelOverride(settings);
|
||||
if (override && hasModel(override.providerID, override.modelID)) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : '';
|
||||
const settingsZenModel = readStringField(settings, 'zenModel');
|
||||
return {
|
||||
providerID: 'zen',
|
||||
modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import * as gitService from './gitService';
|
||||
import { chooseBridgeGitGenerationModel, type BridgeGitGenerationPayloadModel } from './bridge-git-generation-model';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
@@ -17,7 +18,6 @@ type SpecialGitDeps = {
|
||||
execGit: (args: string[], cwd: string) => Promise<ExecGitResult>;
|
||||
};
|
||||
|
||||
const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
|
||||
const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500;
|
||||
const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000;
|
||||
@@ -71,13 +71,6 @@ const createBridgeGitClient = (apiUrl: string, authHeaders?: Record<string, stri
|
||||
headers: authHeaders || {},
|
||||
});
|
||||
|
||||
const readStringField = (value: unknown, key: string): string => {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidate = record[key];
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
};
|
||||
|
||||
const fetchBridgeGitModelCatalog = async (
|
||||
apiUrl: string,
|
||||
authHeaders?: Record<string, string>
|
||||
@@ -115,7 +108,7 @@ const fetchBridgeGitModelCatalog = async (
|
||||
};
|
||||
|
||||
const resolveBridgeGitGenerationModel = async (
|
||||
payloadModel: { providerId?: string; modelId?: string; zenModel?: string },
|
||||
payloadModel: BridgeGitGenerationPayloadModel,
|
||||
settings: Record<string, unknown>,
|
||||
apiUrl: string,
|
||||
authHeaders?: Record<string, string>
|
||||
@@ -134,24 +127,7 @@ const resolveBridgeGitGenerationModel = async (
|
||||
return catalog.has(`${providerID}/${modelID}`);
|
||||
};
|
||||
|
||||
const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : '';
|
||||
const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : '';
|
||||
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
|
||||
return { providerID: requestProviderId, modelID: requestModelId };
|
||||
}
|
||||
|
||||
const settingsProviderId = readStringField(settings, 'gitProviderId');
|
||||
const settingsModelId = readStringField(settings, 'gitModelId');
|
||||
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
|
||||
return { providerID: settingsProviderId, modelID: settingsModelId };
|
||||
}
|
||||
|
||||
const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : '';
|
||||
const settingsZenModel = readStringField(settings, 'zenModel');
|
||||
return {
|
||||
providerID: 'zen',
|
||||
modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL,
|
||||
};
|
||||
return chooseBridgeGitGenerationModel(payloadModel, settings, hasModel);
|
||||
};
|
||||
|
||||
const extractTextFromMessageParts = (parts: unknown): string => {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Extension-host side of the project setup routes
|
||||
// (`GET/PUT /api/projects/:projectId/config`): the webview cannot reach the
|
||||
// filesystem, so it bridges here and this module reads and writes
|
||||
// `~/.config/openchamber/projects/<projectId>.json` with the same rules the
|
||||
// OpenChamber server applies (`project-setup.ts`). Server-owned keys in the
|
||||
// file (`version`, `scheduledTasks`) and keys from newer builds survive a
|
||||
// write untouched.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
EMPTY_SHARED_PROJECT_CONFIG,
|
||||
ProjectSetupValidationError,
|
||||
SHARED_CONFIG_RELATIVE_PATH,
|
||||
applySharedProjectSetupPatch,
|
||||
isSharedProjectConfigEmpty,
|
||||
mergeProjectSetup,
|
||||
parseSharedProjectConfig,
|
||||
personalProjectSetupOf,
|
||||
projectSetupPatchToStored,
|
||||
serializeSharedProjectConfig,
|
||||
sharedTrustHashOf,
|
||||
type ProjectSetupView,
|
||||
type SharedProjectConfigRead,
|
||||
} from './project-setup';
|
||||
|
||||
export type ProjectSetupBridgeMessage = { id: string; type: string; payload?: unknown };
|
||||
export type ProjectSetupBridgeResponse = { id: string; type: string; success: boolean; data?: unknown; error?: string };
|
||||
|
||||
export type ProjectSetupStore = {
|
||||
read: (projectId: string) => Promise<ProjectSetupView>;
|
||||
update: (projectId: string, patch: unknown) => Promise<ProjectSetupView>;
|
||||
updateShared: (projectId: string, patch: unknown) => Promise<ProjectSetupView>;
|
||||
};
|
||||
|
||||
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
|
||||
|
||||
/** The checkout a `path_<base64url>` id names, or `''` for ids of another form. */
|
||||
export const projectPathFromId = (projectId: string): string => {
|
||||
if (!projectId.startsWith('path_')) return '';
|
||||
const encoded = projectId.slice('path_'.length);
|
||||
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
};
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const sanitizeProjectId = (value: unknown): string => {
|
||||
const projectId = typeof value === 'string' ? value.trim() : '';
|
||||
if (!projectId) throw new ProjectSetupValidationError('projectId is required');
|
||||
if (!PROJECT_ID_PATTERN.test(projectId)) throw new ProjectSetupValidationError('projectId contains unsupported characters');
|
||||
return projectId;
|
||||
};
|
||||
|
||||
const readJsonDocument = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return {};
|
||||
throw error;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isObjectRecord(parsed) ? parsed : {};
|
||||
};
|
||||
|
||||
const writeJsonAtomic = async (filePath: string, text: string): Promise<void> => {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, text, 'utf8');
|
||||
await fs.promises.rename(tmp, filePath);
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tmp, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/** A store over one projects directory; the default is the shared OpenChamber one. */
|
||||
export const createProjectSetupStore = (
|
||||
projectsDir: string = path.join(os.homedir(), '.config', 'openchamber', 'projects'),
|
||||
): ProjectSetupStore => {
|
||||
const filePathFor = (projectId: string): string => path.join(projectsDir, `${sanitizeProjectId(projectId)}.json`);
|
||||
// Writes to one file are chained so two quick saves from the webview cannot
|
||||
// interleave their read-modify-write.
|
||||
const writeChains = new Map<string, Promise<unknown>>();
|
||||
|
||||
// The shared file lives in the checkout the id names (the personal file's
|
||||
// `projectPath` is the fallback). A missing file is the normal case; an
|
||||
// unreadable or unparsable one is reported, never treated as empty.
|
||||
const projectPathOf = (projectId: string, personalRaw: Record<string, unknown>): string => {
|
||||
const storedPath = personalRaw.projectPath;
|
||||
return projectPathFromId(projectId) || (typeof storedPath === 'string' ? storedPath.trim() : '');
|
||||
};
|
||||
const sharedConfigPathOf = (projectPath: string): string => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
|
||||
|
||||
const readShared = async (projectId: string, personalRaw: Record<string, unknown>): Promise<SharedProjectConfigRead> => {
|
||||
const projectPath = projectPathOf(projectId, personalRaw);
|
||||
if (!projectPath) return { status: 'missing' };
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(sharedConfigPathOf(projectPath), 'utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return { status: 'missing' };
|
||||
return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
return parseSharedProjectConfig(raw);
|
||||
};
|
||||
|
||||
const mergedViewOf = async (projectId: string, personalRaw: Record<string, unknown>): Promise<ProjectSetupView> =>
|
||||
mergeProjectSetup(personalProjectSetupOf(personalRaw), await readShared(projectId, personalRaw));
|
||||
|
||||
const read = async (projectId: string): Promise<ProjectSetupView> => mergedViewOf(projectId, await readJsonDocument(filePathFor(projectId)));
|
||||
|
||||
const update = async (projectId: string, patch: unknown): Promise<ProjectSetupView> => {
|
||||
const filePath = filePathFor(projectId);
|
||||
const stored = projectSetupPatchToStored(patch);
|
||||
const previous = writeChains.get(filePath) ?? Promise.resolve();
|
||||
const next = previous.then(async () => {
|
||||
const existing = await readJsonDocument(filePath);
|
||||
const merged: Record<string, unknown> = { ...existing, ...stored };
|
||||
for (const [key, value] of Object.entries(stored)) {
|
||||
if (value === undefined) delete merged[key];
|
||||
}
|
||||
await writeJsonAtomic(filePath, JSON.stringify(merged, null, 2));
|
||||
return mergedViewOf(projectId, merged);
|
||||
});
|
||||
writeChains.set(filePath, next.catch(() => undefined));
|
||||
return next;
|
||||
};
|
||||
|
||||
// The team's shared file in the checkout; same rules as the server: a
|
||||
// broken file counts as empty, an empty result removes the file, and the
|
||||
// writer's own trust record is set to the new hash.
|
||||
const updateShared = async (projectId: string, patch: unknown): Promise<ProjectSetupView> => {
|
||||
const filePath = filePathFor(projectId);
|
||||
const previous = writeChains.get(filePath) ?? Promise.resolve();
|
||||
const next = previous.then(async () => {
|
||||
const personalRaw = await readJsonDocument(filePath);
|
||||
const projectPath = projectPathOf(projectId, personalRaw);
|
||||
if (!projectPath) throw new ProjectSetupValidationError('project checkout not found');
|
||||
const isDirectory = await fs.promises.stat(projectPath).then((stat) => stat.isDirectory()).catch(() => false);
|
||||
if (!isDirectory) throw new ProjectSetupValidationError('project checkout not found');
|
||||
const currentRead = await readShared(projectId, personalRaw);
|
||||
const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
|
||||
const nextShared = applySharedProjectSetupPatch(current, patch);
|
||||
const sharedPath = sharedConfigPathOf(projectPath);
|
||||
if (isSharedProjectConfigEmpty(nextShared)) {
|
||||
await fs.promises.rm(sharedPath, { force: true });
|
||||
await fs.promises.rmdir(path.dirname(sharedPath)).catch(() => {});
|
||||
} else {
|
||||
await writeJsonAtomic(sharedPath, serializeSharedProjectConfig(nextShared));
|
||||
}
|
||||
const hash = sharedTrustHashOf(nextShared);
|
||||
const personalNext: Record<string, unknown> = { ...personalRaw };
|
||||
if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
|
||||
else delete personalNext.sharedTrust;
|
||||
await writeJsonAtomic(filePath, JSON.stringify(personalNext, null, 2));
|
||||
return mergedViewOf(projectId, personalNext);
|
||||
});
|
||||
writeChains.set(filePath, next.catch(() => undefined));
|
||||
return next;
|
||||
};
|
||||
|
||||
return { read, update, updateShared };
|
||||
};
|
||||
|
||||
export async function handleProjectSetupBridgeMessage(
|
||||
message: ProjectSetupBridgeMessage,
|
||||
store: ProjectSetupStore,
|
||||
): Promise<ProjectSetupBridgeResponse | null> {
|
||||
const { id, type, payload } = message;
|
||||
if (type !== 'api:project-setup:get' && type !== 'api:project-setup:update' && type !== 'api:project-setup:update-shared') return null;
|
||||
|
||||
try {
|
||||
const request = isObjectRecord(payload) ? payload : {};
|
||||
const projectId = sanitizeProjectId(request.projectId);
|
||||
const data = type === 'api:project-setup:get'
|
||||
? await store.read(projectId)
|
||||
: type === 'api:project-setup:update'
|
||||
? await store.update(projectId, request.patch)
|
||||
: await store.updateShared(projectId, request.patch);
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Project config request failed';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,24 @@ import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
|
||||
import type { BridgeContext } from './bridge';
|
||||
import { filterPersistableSettingsChanges, withoutSecretSettings } from './settings-registry-gate';
|
||||
import {
|
||||
buildPreferencesFields,
|
||||
flattenPreferences,
|
||||
instancePartOf,
|
||||
legacySettingsDocumentOf,
|
||||
profilePartOf,
|
||||
parsePreferencesDocument,
|
||||
preferencesFilePathFor,
|
||||
seedPreferencesFrom,
|
||||
serializePreferencesDocument,
|
||||
type PreferenceFields,
|
||||
VSCODE_SETTINGS_SURFACE,
|
||||
} from './settings-files';
|
||||
|
||||
const SETTINGS_KEY = 'openchamber.settings';
|
||||
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
const OPENCHAMBER_PREFERENCES_PATH = preferencesFilePathFor(OPENCHAMBER_SHARED_SETTINGS_PATH);
|
||||
const OPENCHAMBER_MAGIC_PROMPTS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'magic-prompts.json');
|
||||
const MAGIC_PROMPTS_FILE_VERSION = 1;
|
||||
const MAGIC_PROMPT_ID_PATTERN = /^[a-z0-9._-]{1,160}$/;
|
||||
@@ -160,11 +175,21 @@ export const fetchOpenCodeSkillsFromApi = async (
|
||||
}
|
||||
};
|
||||
|
||||
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
// Settings live in two files beside each other (see `settings-files.ts`):
|
||||
// `settings.json` holds instance facts and legacy keys, `preferences.json`
|
||||
// holds the profile keys with their `updatedAt` stamps. Reads return the
|
||||
// merged view; writes split a merged document back into the two files.
|
||||
//
|
||||
// A settings.json parse failure (corrupt or non-object file) is still coerced
|
||||
// to `{}`, which lets the next write replace it; tracked in the settings-scopes
|
||||
// plan. preferences.json already fails closed below.
|
||||
const readSettingsJsonFromDisk = (): Record<string, unknown> => {
|
||||
try {
|
||||
const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8');
|
||||
// SAFETY: JSON.parse returns untyped data; the check below keeps only a plain object.
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
// SAFETY: a non-array object parsed from JSON is a string-keyed dictionary.
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
@@ -173,22 +198,122 @@ const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Promise<void> => {
|
||||
let tmp: string | null = null;
|
||||
type PreferencesReadResult =
|
||||
| { status: 'ok'; fields: PreferenceFields }
|
||||
| { status: 'missing' }
|
||||
| { status: 'unreadable'; reason: string };
|
||||
|
||||
// True after preferences.json was found but could not be read or parsed. While
|
||||
// set, the file is left alone: reads return settings.json only and writes drop
|
||||
// profile keys instead of replacing a file whose content we cannot see.
|
||||
let preferencesUnavailable = false;
|
||||
let preferencesUnavailableLogged = false;
|
||||
|
||||
const readPreferencesFromDisk = (): PreferencesReadResult => {
|
||||
let result: PreferencesReadResult;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
|
||||
const current = readSharedSettingsFromDisk();
|
||||
const next: Record<string, unknown> = { ...current, ...changes };
|
||||
// Atomic write: tmp file + rename. Readers never see a partial/truncated
|
||||
// JSON that would fail to parse and silently get coerced to {}.
|
||||
tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fs.promises.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, OPENCHAMBER_SHARED_SETTINGS_PATH);
|
||||
} catch {
|
||||
if (tmp) {
|
||||
await fs.promises.rm(tmp, { force: true }).catch(() => {});
|
||||
}
|
||||
const parsed = parsePreferencesDocument(fs.readFileSync(OPENCHAMBER_PREFERENCES_PATH, 'utf8'));
|
||||
result = parsed.ok ? { status: 'ok', fields: parsed.fields } : { status: 'unreadable', reason: parsed.reason };
|
||||
} catch (error) {
|
||||
// SAFETY: fs errors carry a `code` string; anything else is reported by message.
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
result = code === 'ENOENT'
|
||||
? { status: 'missing' }
|
||||
: { status: 'unreadable', reason: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
if (result.status === 'unreadable') {
|
||||
preferencesUnavailable = true;
|
||||
if (!preferencesUnavailableLogged) {
|
||||
preferencesUnavailableLogged = true;
|
||||
console.warn(`[OpenChamber] ${OPENCHAMBER_PREFERENCES_PATH} could not be read (${result.reason}); profile settings are unavailable until the file is fixed or removed.`);
|
||||
}
|
||||
} else {
|
||||
preferencesUnavailable = false;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Atomic write: tmp file + rename, so readers never see a partial JSON. Throws
|
||||
// on failure (after removing the tmp file) so a failed save is reported, not
|
||||
// mistaken for success.
|
||||
const writeJsonAtomic = async (filePath: string, text: string): Promise<void> => {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
await fs.promises.writeFile(tmp, text, 'utf8');
|
||||
await fs.promises.rename(tmp, filePath);
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tmp, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeJsonAtomicSync = (filePath: string, text: string): void => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, text, 'utf8');
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Nothing more to clean up.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Merged view of both files. A missing preferences.json is seeded once from the
|
||||
// profile keys settings.json still carries; every write keeps a copy of the
|
||||
// profile's base values in settings.json, so an older build can still read it.
|
||||
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
|
||||
const settings = readSettingsJsonFromDisk();
|
||||
let preferences = readPreferencesFromDisk();
|
||||
if (preferences.status === 'missing') {
|
||||
const seeded = seedPreferencesFrom(stripDerived(settings), Date.now());
|
||||
try {
|
||||
writeJsonAtomicSync(OPENCHAMBER_PREFERENCES_PATH, serializePreferencesDocument(seeded));
|
||||
} catch (error) {
|
||||
console.warn('[OpenChamber] Failed to seed preferences.json:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
preferences = { status: 'ok', fields: seeded };
|
||||
}
|
||||
if (preferences.status !== 'ok') {
|
||||
return settings;
|
||||
}
|
||||
return { ...settings, ...flattenPreferences(preferences.fields, VSCODE_SETTINGS_SURFACE) };
|
||||
};
|
||||
|
||||
// Write a complete merged document: profile keys go to preferences.json (keeping
|
||||
// the stamps of unchanged values), everything else to settings.json. A key the
|
||||
// document no longer carries leaves whichever file owned it.
|
||||
const writeSharedSettingsToDisk = async (
|
||||
document: Record<string, unknown>,
|
||||
changedKeys: Iterable<string> | null = null,
|
||||
): Promise<void> => {
|
||||
const preferences = readPreferencesFromDisk();
|
||||
if (preferencesUnavailable) {
|
||||
console.warn('[OpenChamber] preferences.json is unreadable; profile settings were not saved.');
|
||||
// settings.json keeps whatever legacy profile copy it already holds.
|
||||
const onDisk = readSettingsJsonFromDisk();
|
||||
await writeJsonAtomic(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify({
|
||||
...instancePartOf(document),
|
||||
...profilePartOf(onDisk),
|
||||
}, null, 2));
|
||||
return;
|
||||
}
|
||||
const previousFields = preferences.status === 'ok' ? preferences.fields : {};
|
||||
// This host is always the VS Code surface kind: per-surface profile keys it
|
||||
// changed land under `surfaces.vscode`; keys it did not change keep their entry.
|
||||
const nextFields = buildPreferencesFields(previousFields, document, Date.now(), {
|
||||
surface: VSCODE_SETTINGS_SURFACE,
|
||||
changedKeys,
|
||||
});
|
||||
await writeJsonAtomic(OPENCHAMBER_PREFERENCES_PATH, serializePreferencesDocument(nextFields));
|
||||
// The legacy copy of the profile's base values rides along for older builds.
|
||||
await writeJsonAtomic(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(legacySettingsDocumentOf(document, nextFields), null, 2));
|
||||
};
|
||||
|
||||
// Fields derived from runtime context — never persisted, always recomputed.
|
||||
@@ -263,15 +388,19 @@ const readPersistedSettings = (ctx?: BridgeContext): Record<string, unknown> =>
|
||||
}
|
||||
if (Object.keys(missingFromDisk).length > 0) {
|
||||
// Fire-and-forget; readers already have an in-memory merged view.
|
||||
void writeSharedSettingsToDisk(missingFromDisk);
|
||||
void writeSharedSettingsToDisk({ ...fromDisk, ...missingFromDisk }).catch((error: unknown) => {
|
||||
console.warn('[OpenChamber] Failed to migrate settings from globalState:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...fromGlobalState, ...fromDisk };
|
||||
};
|
||||
|
||||
// Everything the webview may see: the persisted document minus the keys the
|
||||
// registry marks `secret` (a UI password, tunnel tokens), which are write-only.
|
||||
export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
||||
const persisted = readPersistedSettings(ctx);
|
||||
const persisted = withoutSecretSettings(readPersistedSettings(ctx));
|
||||
const persistedOpencodeBinary =
|
||||
typeof persisted.opencodeBinary === 'string' ? String(persisted.opencodeBinary).trim() : '';
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
@@ -291,7 +420,8 @@ export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
||||
|
||||
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
|
||||
const current = readSettings(ctx);
|
||||
const restChanges = stripDerived({ ...(changes || {}) });
|
||||
// Only keys the settings registry knows as stored shared fields reach disk.
|
||||
const restChanges = filterPersistableSettingsChanges(stripDerived({ ...(changes || {}) }));
|
||||
|
||||
const keysToClear = new Set<string>();
|
||||
|
||||
@@ -341,15 +471,15 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
|
||||
delete persistable[key];
|
||||
}
|
||||
|
||||
// Write to the shared file (canonical, cross-client). Also mirror into
|
||||
// globalState so older builds can still read recent values if a user
|
||||
// downgrades the extension.
|
||||
await writeSharedSettingsToDisk(persistable);
|
||||
// Write to the shared files (canonical, cross-client); a failed write rejects
|
||||
// so the webview reports the save as failed. Also mirror into globalState so
|
||||
// older builds can still read recent values if a user downgrades the extension.
|
||||
await writeSharedSettingsToDisk(persistable, [...Object.keys(restChanges), ...keysToClear]);
|
||||
await ctx?.context?.globalState.update(SETTINGS_KEY, persistable);
|
||||
|
||||
// Return the same shape as readSettings (with derived fields re-applied).
|
||||
// Return the same shape as readSettings (derived fields re-applied, secrets withheld).
|
||||
return {
|
||||
...persistable,
|
||||
...withoutSecretSettings(persistable),
|
||||
themeVariant: current.themeVariant,
|
||||
lastDirectory: current.lastDirectory,
|
||||
opencodeBinary:
|
||||
|
||||
@@ -7,6 +7,7 @@ import { handleConfigBridgeMessage } from './bridge-config-runtime';
|
||||
import { handleSystemBridgeMessage } from './bridge-system-runtime';
|
||||
import { handleProxyBridgeMessage } from './bridge-proxy-runtime';
|
||||
import { handlePermissionAutoAcceptBridgeMessage } from './bridge-permission-auto-accept-runtime';
|
||||
import { createProjectSetupStore, handleProjectSetupBridgeMessage } from './bridge-project-setup-runtime';
|
||||
import {
|
||||
fetchOpenCodeSkillsFromApi,
|
||||
persistSettings,
|
||||
@@ -55,6 +56,7 @@ export interface BridgeContext {
|
||||
}
|
||||
|
||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||
const projectSetupStore = createProjectSetupStore();
|
||||
|
||||
const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check';
|
||||
const GITHUB_BACKEND_DISABLED_ERROR = 'OpenChamber VS Code backend GitHub integration is disabled. Use native VS Code GitHub integrations.';
|
||||
@@ -88,6 +90,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
if (specialGitResponse) {
|
||||
return specialGitResponse;
|
||||
}
|
||||
const projectSetupResponse = await handleProjectSetupBridgeMessage({ id, type, payload }, projectSetupStore);
|
||||
if (projectSetupResponse) {
|
||||
return projectSetupResponse;
|
||||
}
|
||||
const fsResponse = await handleFsBridgeMessage(
|
||||
{ id, type, payload },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
ProjectSetupValidationError,
|
||||
mergeProjectSetup,
|
||||
normalizePlansDir,
|
||||
parseSharedProjectConfig,
|
||||
personalProjectSetupOf,
|
||||
projectSetupPatchToStored,
|
||||
sanitizeDraftStarters,
|
||||
sanitizeProjectActions,
|
||||
sanitizeSetupCommands,
|
||||
sharedTrustHashOf,
|
||||
type PersonalProjectSetup,
|
||||
} from './project-setup';
|
||||
import { createProjectSetupStore, handleProjectSetupBridgeMessage, projectPathFromId } from './bridge-project-setup-runtime';
|
||||
|
||||
const emptyPersonal: PersonalProjectSetup = {
|
||||
setupWorktree: [],
|
||||
setupWorktreeWait: null,
|
||||
setupWorktreeMode: 'append',
|
||||
projectActions: [],
|
||||
projectActionsPrimaryId: null,
|
||||
draftStarters: [],
|
||||
hiddenSharedActionIds: [],
|
||||
sharedTrust: null,
|
||||
};
|
||||
|
||||
const projectIdFor = (projectPath: string): string => `path_${Buffer.from(projectPath, 'utf8').toString('base64url')}`;
|
||||
|
||||
describe('project setup sanitizers', () => {
|
||||
test('keeps only non-empty trimmed setup commands', () => {
|
||||
assert.deepEqual(sanitizeSetupCommands([' bun install ', '', 42, '\n']), ['bun install']);
|
||||
assert.deepEqual(sanitizeSetupCommands('bun install'), []);
|
||||
});
|
||||
|
||||
test('drops incomplete actions and duplicate ids, keeps only set optional fields', () => {
|
||||
assert.deepEqual(sanitizeProjectActions([
|
||||
{ id: 'a', name: 'Dev', command: 'bun run dev', runIn: 'parent', platforms: ['macos', 'plan9'], icon: '' },
|
||||
{ id: 'a', name: 'Again', command: 'x' },
|
||||
{ id: '', name: 'No id', command: 'x' },
|
||||
{ id: 'b', name: 'B', command: 'x', runIn: 'worktree' },
|
||||
]), [
|
||||
{ id: 'a', name: 'Dev', command: 'bun run dev', icon: null, platforms: ['macos'], runIn: 'parent' },
|
||||
{ id: 'b', name: 'B', command: 'x', icon: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dedupes draft starters by type and name', () => {
|
||||
assert.deepEqual(sanitizeDraftStarters([
|
||||
{ type: 'skill', name: 'triage-prs' },
|
||||
{ type: 'skill', name: 'triage-prs' },
|
||||
{ type: 'agent', name: 'nope' },
|
||||
]), [{ type: 'skill', name: 'triage-prs' }]);
|
||||
});
|
||||
|
||||
test('builds the personal view from on-disk keys and nulls a dangling primary action', () => {
|
||||
assert.deepEqual(personalProjectSetupOf({
|
||||
'setup-worktree': ['bun install'],
|
||||
'setup-worktree-wait': true,
|
||||
setupWorktreeMode: 'replace',
|
||||
projectActions: [{ id: 'a', name: 'A', command: 'x' }],
|
||||
projectActionsPrimaryId: 'missing',
|
||||
hiddenSharedActionIds: ['dev', 'dev', 3],
|
||||
}), {
|
||||
setupWorktree: ['bun install'],
|
||||
setupWorktreeWait: true,
|
||||
setupWorktreeMode: 'replace',
|
||||
projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }],
|
||||
projectActionsPrimaryId: null,
|
||||
draftStarters: [],
|
||||
hiddenSharedActionIds: ['dev'],
|
||||
sharedTrust: null,
|
||||
});
|
||||
assert.deepEqual(personalProjectSetupOf(null), emptyPersonal);
|
||||
});
|
||||
|
||||
test('parses a shared file and refuses a broken one', () => {
|
||||
const ok = parseSharedProjectConfig(JSON.stringify({ version: 1, setupWorktree: ['bun install'], plansDir: 'docs/plans' }));
|
||||
assert.equal(ok.status, 'ok');
|
||||
if (ok.status === 'ok') {
|
||||
assert.deepEqual(ok.config, { setupWorktree: ['bun install'], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: 'docs/plans' });
|
||||
}
|
||||
assert.equal(parseSharedProjectConfig('{ nope').status, 'invalid');
|
||||
assert.equal(parseSharedProjectConfig('{"version":2}').status, 'invalid');
|
||||
assert.equal(parseSharedProjectConfig('{"version":1,"plansDir":"../x"}').status, 'invalid');
|
||||
assert.equal(normalizePlansDir('./docs/plans/'), 'docs/plans');
|
||||
assert.equal(normalizePlansDir('/abs'), null);
|
||||
});
|
||||
|
||||
test('merges shared and personal by the agreed rules', () => {
|
||||
const merged = mergeProjectSetup({
|
||||
...emptyPersonal,
|
||||
setupWorktree: ['mine'],
|
||||
projectActions: [{ id: 'test', name: 'My test', command: 'x', icon: null }],
|
||||
hiddenSharedActionIds: ['lint'],
|
||||
draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }],
|
||||
}, {
|
||||
status: 'ok',
|
||||
config: {
|
||||
setupWorktree: ['bun install'],
|
||||
setupWorktreeWait: true,
|
||||
projectActions: [
|
||||
{ id: 'dev', name: 'Dev', command: 'd', icon: null },
|
||||
{ id: 'test', name: 'Test', command: 't', icon: null },
|
||||
{ id: 'lint', name: 'Lint', command: 'l', icon: null },
|
||||
],
|
||||
draftStarters: [{ type: 'command', name: 'both' }],
|
||||
plansDir: null,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(merged.setupWorktree, ['bun install', 'mine']);
|
||||
assert.equal(merged.setupWorktreeWait, true);
|
||||
assert.deepEqual(merged.projectActions.map((action) => `${action.id}:${action.source}`), ['dev:shared', 'test:personal']);
|
||||
assert.deepEqual(merged.draftStarters.map((starter) => `${starter.name}:${starter.source}`), ['both:shared', 'mine:personal']);
|
||||
assert.equal(merged.trust.trusted, false);
|
||||
assert.match(merged.trust.hash ?? '', /^sha256:/);
|
||||
});
|
||||
|
||||
test('trusts only the recorded hash and nothing when nothing executes', () => {
|
||||
const shared = { setupWorktree: ['bun install'], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null };
|
||||
const hash = sharedTrustHashOf(shared);
|
||||
assert.equal(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: hash ?? '', trustedAt: 1 } }, { status: 'ok', config: shared }).trust.trusted, true);
|
||||
assert.equal(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:old', trustedAt: 1 } }, { status: 'ok', config: shared }).trust.trusted, false);
|
||||
assert.deepEqual(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust, { hash: null, trusted: true });
|
||||
assert.equal(sharedTrustHashOf({ ...shared, setupWorktree: [] }), null);
|
||||
assert.deepEqual(projectSetupPatchToStored({ sharedTrustHash: null }), { sharedTrust: undefined });
|
||||
assert.throws(() => projectSetupPatchToStored({ sharedTrustHash: '' }), ProjectSetupValidationError);
|
||||
});
|
||||
|
||||
test('rejects wrongly shaped patch keys', () => {
|
||||
assert.throws(() => projectSetupPatchToStored({ setupWorktree: 'x' }), ProjectSetupValidationError);
|
||||
assert.throws(() => projectSetupPatchToStored(null), ProjectSetupValidationError);
|
||||
assert.deepEqual(projectSetupPatchToStored({ projectActionsPrimaryId: null }), { projectActionsPrimaryId: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('project setup bridge', () => {
|
||||
const withStore = async (run: (store: ReturnType<typeof createProjectSetupStore>, dir: string) => Promise<void>) => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'oc-vscode-project-setup-'));
|
||||
try {
|
||||
await run(createProjectSetupStore(dir), dir);
|
||||
} finally {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
test('round-trips a patch through the bridge and preserves foreign keys', async () => {
|
||||
await withStore(async (store, dir) => {
|
||||
await fs.promises.writeFile(path.join(dir, 'project-a.json'), JSON.stringify({
|
||||
version: 1,
|
||||
scheduledTasks: [{ id: 'keep' }],
|
||||
'setup-worktree': ['old'],
|
||||
}));
|
||||
|
||||
const updated = await handleProjectSetupBridgeMessage(
|
||||
{ id: '1', type: 'api:project-setup:update', payload: { projectId: 'project-a', patch: { setupWorktree: ['bun install'], projectPath: '/repo' } } },
|
||||
store,
|
||||
);
|
||||
assert.equal(updated?.success, true);
|
||||
const view = updated?.data as { setupWorktree: string[]; setupWorktreeWait: boolean; shared: { status: string } };
|
||||
assert.deepEqual(view.setupWorktree, ['bun install']);
|
||||
assert.equal(view.setupWorktreeWait, false);
|
||||
assert.equal(view.shared.status, 'missing');
|
||||
|
||||
const raw = JSON.parse(await fs.promises.readFile(path.join(dir, 'project-a.json'), 'utf8'));
|
||||
assert.deepEqual(raw.scheduledTasks, [{ id: 'keep' }]);
|
||||
assert.equal(raw.projectPath, '/repo');
|
||||
|
||||
const read = await handleProjectSetupBridgeMessage({ id: '2', type: 'api:project-setup:get', payload: { projectId: 'project-a' } }, store);
|
||||
assert.deepEqual(read?.data, updated?.data);
|
||||
});
|
||||
});
|
||||
|
||||
test('answers a bad patch or project id with a failure, and ignores other messages', async () => {
|
||||
await withStore(async (store) => {
|
||||
const bad = await handleProjectSetupBridgeMessage(
|
||||
{ id: '1', type: 'api:project-setup:update', payload: { projectId: 'project-a', patch: { setupWorktree: 'x' } } },
|
||||
store,
|
||||
);
|
||||
assert.equal(bad?.success, false);
|
||||
assert.match(bad?.error ?? '', /setupWorktree must be/);
|
||||
|
||||
const badId = await handleProjectSetupBridgeMessage({ id: '2', type: 'api:project-setup:get', payload: { projectId: '../etc' } }, store);
|
||||
assert.equal(badId?.success, false);
|
||||
|
||||
assert.equal(await handleProjectSetupBridgeMessage({ id: '3', type: 'api:fs:read', payload: {} }, store), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('reads the shared file from the checkout the id names', async () => {
|
||||
await withStore(async (store, dir) => {
|
||||
const repo = path.join(dir, 'repo');
|
||||
await fs.promises.mkdir(path.join(repo, '.openchamber'), { recursive: true });
|
||||
await fs.promises.writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({
|
||||
version: 1,
|
||||
setupWorktree: ['bun install'],
|
||||
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
|
||||
}));
|
||||
const projectId = projectIdFor(repo);
|
||||
assert.equal(projectPathFromId(projectId), repo);
|
||||
const view = await store.update(projectId, { setupWorktree: ['mine'], hiddenSharedActionIds: ['dev'] });
|
||||
assert.equal(view.shared.status, 'ok');
|
||||
assert.deepEqual(view.setupWorktree, ['bun install', 'mine']);
|
||||
assert.deepEqual(view.projectActions, []);
|
||||
await fs.promises.writeFile(path.join(repo, '.openchamber', 'project.json'), '{ broken');
|
||||
const broken = await store.read(projectId);
|
||||
assert.equal(broken.shared.status, 'invalid');
|
||||
assert.deepEqual(broken.setupWorktree, ['mine']);
|
||||
});
|
||||
});
|
||||
|
||||
test('writes and removes the shared file through the bridge, trusting the writer', async () => {
|
||||
await withStore(async (store, dir) => {
|
||||
const repo = path.join(dir, 'repo');
|
||||
await fs.promises.mkdir(repo, { recursive: true });
|
||||
const projectId = projectIdFor(repo);
|
||||
const shared = await handleProjectSetupBridgeMessage(
|
||||
{ id: '1', type: 'api:project-setup:update-shared', payload: { projectId, patch: { setupWorktree: ['bun install'], plansDir: 'docs/plans' } } },
|
||||
store,
|
||||
);
|
||||
assert.equal(shared?.success, true);
|
||||
const view = shared?.data as { trust: { trusted: boolean }; shared: { status: string; plansDir: string | null } };
|
||||
assert.equal(view.shared.status, 'ok');
|
||||
assert.equal(view.shared.plansDir, 'docs/plans');
|
||||
assert.equal(view.trust.trusted, true);
|
||||
const raw = JSON.parse(await fs.promises.readFile(path.join(repo, '.openchamber', 'project.json'), 'utf8'));
|
||||
assert.deepEqual(raw, { version: 1, setupWorktree: ['bun install'], plansDir: 'docs/plans' });
|
||||
|
||||
const emptied = await store.updateShared(projectId, { setupWorktree: [], plansDir: null });
|
||||
assert.equal(emptied.shared.status, 'missing');
|
||||
assert.equal(fs.existsSync(path.join(repo, '.openchamber')), false);
|
||||
|
||||
const missing = await handleProjectSetupBridgeMessage(
|
||||
{ id: '2', type: 'api:project-setup:update-shared', payload: { projectId: projectIdFor(path.join(dir, 'nope')), patch: {} } },
|
||||
store,
|
||||
);
|
||||
assert.equal(missing?.success, false);
|
||||
assert.match(missing?.error ?? '', /checkout not found/);
|
||||
});
|
||||
});
|
||||
|
||||
test('serializes two quick updates to one file', async () => {
|
||||
await withStore(async (store, dir) => {
|
||||
await Promise.all([
|
||||
store.update('project-a', { setupWorktree: ['a'] }),
|
||||
store.update('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }),
|
||||
]);
|
||||
const raw = JSON.parse(await fs.promises.readFile(path.join(dir, 'project-a.json'), 'utf8'));
|
||||
assert.deepEqual(raw['setup-worktree'], ['a']);
|
||||
assert.deepEqual(raw.draftStarters, [{ type: 'skill', name: 's' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
// The client-owned part of a project's config file
|
||||
// (`~/.config/openchamber/projects/<projectId>.json`): worktree setup
|
||||
// commands, project actions, and pinned draft starters. A mirror of the
|
||||
// server's `packages/web/server/lib/projects/project-setup.js`; keep the
|
||||
// sanitizing rules in sync so a value written from VS Code reads back the
|
||||
// same on every other surface.
|
||||
//
|
||||
// Kept free of `vscode` imports so it is unit-tested directly.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ACTION_NAME_MAX_LENGTH = 80;
|
||||
const ACTION_COMMAND_MAX_LENGTH = 4000;
|
||||
const ACTION_OPEN_URL_MAX_LENGTH = 2000;
|
||||
const ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
|
||||
const SETUP_COMMAND_MAX_LENGTH = 4000;
|
||||
const SETUP_COMMANDS_MAX = 50;
|
||||
|
||||
type ActionPlatform = 'macos' | 'linux' | 'windows';
|
||||
const ACTION_PLATFORMS: ReadonlySet<string> = new Set<ActionPlatform>(['macos', 'linux', 'windows']);
|
||||
|
||||
export type ProjectAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
icon: string | null;
|
||||
autoOpenUrl?: true;
|
||||
openUrl?: string;
|
||||
desktopOpenSshForward?: string;
|
||||
platforms?: ActionPlatform[];
|
||||
runIn?: 'parent';
|
||||
};
|
||||
|
||||
export type DraftStarter = { type: 'command' | 'skill'; name: string };
|
||||
|
||||
export type SetupWorktreeMode = 'append' | 'replace';
|
||||
|
||||
/** The personal file's part of the setup; the wait flag is `null` when the file does not set it. */
|
||||
export type PersonalProjectSetup = {
|
||||
setupWorktree: string[];
|
||||
setupWorktreeWait: boolean | null;
|
||||
setupWorktreeMode: SetupWorktreeMode;
|
||||
projectActions: ProjectAction[];
|
||||
projectActionsPrimaryId: string | null;
|
||||
draftStarters: DraftStarter[];
|
||||
hiddenSharedActionIds: string[];
|
||||
/** The recorded answer to the trust prompt: which shared commands were trusted, and when. */
|
||||
sharedTrust: { hash: string; trustedAt: number } | null;
|
||||
};
|
||||
|
||||
export type SharedProjectConfig = {
|
||||
setupWorktree: string[];
|
||||
setupWorktreeWait: boolean | null;
|
||||
projectActions: ProjectAction[];
|
||||
draftStarters: DraftStarter[];
|
||||
plansDir: string | null;
|
||||
};
|
||||
|
||||
export type SharedProjectConfigRead =
|
||||
| { status: 'missing' }
|
||||
| { status: 'ok'; config: SharedProjectConfig }
|
||||
| { status: 'invalid'; reason: string };
|
||||
|
||||
export type ProjectSetupSource = 'shared' | 'personal';
|
||||
|
||||
/** The merged view every client sees; see `mergeProjectSetup` for the rules. */
|
||||
export type ProjectSetupView = {
|
||||
/** Nothing to trust when `hash` is null; otherwise trusted only for the recorded hash. */
|
||||
trust: { hash: string | null; trusted: boolean };
|
||||
setupWorktree: string[];
|
||||
setupWorktreeWait: boolean;
|
||||
projectActions: Array<ProjectAction & { source: ProjectSetupSource }>;
|
||||
projectActionsPrimaryId: string | null;
|
||||
draftStarters: Array<DraftStarter & { source: ProjectSetupSource }>;
|
||||
shared: SharedProjectConfig & { status: SharedProjectConfigRead['status']; reason?: string; path: string };
|
||||
personal: PersonalProjectSetup;
|
||||
};
|
||||
|
||||
export const SHARED_CONFIG_RELATIVE_PATH = '.openchamber/project.json';
|
||||
const SHARED_CONFIG_VERSION = 1;
|
||||
|
||||
/**
|
||||
* The on-disk keys this module owns inside the personal config document, as
|
||||
* a patch: a key set to `undefined` is removed from the document.
|
||||
*/
|
||||
type StoredProjectSetupPatch = {
|
||||
'setup-worktree'?: string[];
|
||||
'setup-worktree-wait'?: boolean;
|
||||
setupWorktreeMode?: SetupWorktreeMode;
|
||||
projectActions?: ProjectAction[];
|
||||
projectActionsPrimaryId?: string | undefined;
|
||||
draftStarters?: DraftStarter[];
|
||||
hiddenSharedActionIds?: string[];
|
||||
sharedTrust?: { hash: string; trustedAt: number } | undefined;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
export class ProjectSetupValidationError extends Error {}
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const clamp = (value: string, maxLength: number): string => (value.length > maxLength ? value.slice(0, maxLength) : value);
|
||||
|
||||
const trimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
export const sanitizeSetupCommands = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const commands: string[] = [];
|
||||
for (const entry of value) {
|
||||
const command = clamp(trimmedString(entry), SETUP_COMMAND_MAX_LENGTH);
|
||||
if (!command) continue;
|
||||
commands.push(command);
|
||||
if (commands.length >= SETUP_COMMANDS_MAX) break;
|
||||
}
|
||||
return commands;
|
||||
};
|
||||
|
||||
const sanitizeActionPlatforms = (value: unknown): ActionPlatform[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const platforms: ActionPlatform[] = [];
|
||||
for (const entry of value) {
|
||||
const platform = trimmedString(entry).toLowerCase();
|
||||
if (!ACTION_PLATFORMS.has(platform)) continue;
|
||||
// SAFETY: membership in ACTION_PLATFORMS was just checked.
|
||||
const known = platform as ActionPlatform;
|
||||
if (!platforms.includes(known)) platforms.push(known);
|
||||
}
|
||||
return platforms;
|
||||
};
|
||||
|
||||
export const sanitizeProjectActions = (value: unknown): ProjectAction[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const actions: ProjectAction[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const entry of value) {
|
||||
if (!isObjectRecord(entry)) continue;
|
||||
const id = trimmedString(entry.id);
|
||||
const name = clamp(trimmedString(entry.name), ACTION_NAME_MAX_LENGTH);
|
||||
const command = clamp(trimmedString(entry.command), ACTION_COMMAND_MAX_LENGTH);
|
||||
if (!id || !name || !command || seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
|
||||
const icon = trimmedString(entry.icon);
|
||||
const platforms = sanitizeActionPlatforms(entry.platforms);
|
||||
const openUrl = clamp(trimmedString(entry.openUrl), ACTION_OPEN_URL_MAX_LENGTH);
|
||||
const desktopOpenSshForward = clamp(trimmedString(entry.desktopOpenSshForward), ACTION_DESKTOP_FORWARD_MAX_LENGTH);
|
||||
|
||||
const action: ProjectAction = { id, name, command, icon: icon || null };
|
||||
if (entry.autoOpenUrl === true) action.autoOpenUrl = true;
|
||||
if (openUrl) action.openUrl = openUrl;
|
||||
if (desktopOpenSshForward) action.desktopOpenSshForward = desktopOpenSshForward;
|
||||
if (platforms.length > 0) action.platforms = platforms;
|
||||
if (entry.runIn === 'parent') action.runIn = 'parent';
|
||||
actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
|
||||
export const sanitizeDraftStarters = (value: unknown): DraftStarter[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const starters: DraftStarter[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value) {
|
||||
if (!isObjectRecord(entry)) continue;
|
||||
const type = entry.type === 'command' || entry.type === 'skill' ? entry.type : null;
|
||||
const name = trimmedString(entry.name);
|
||||
if (!type || !name) continue;
|
||||
const key = `${type}:${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
starters.push({ type, name });
|
||||
}
|
||||
return starters;
|
||||
};
|
||||
|
||||
const sanitizeIdList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids: string[] = [];
|
||||
for (const entry of value) {
|
||||
const id = trimmedString(entry);
|
||||
if (id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
const setupWorktreeModeOf = (value: unknown): SetupWorktreeMode => (value === 'replace' ? 'replace' : 'append');
|
||||
|
||||
/** The personal part of the view, straight from the personal file. */
|
||||
export const personalProjectSetupOf = (raw: unknown): PersonalProjectSetup => {
|
||||
const document = isObjectRecord(raw) ? raw : {};
|
||||
const projectActions = sanitizeProjectActions(document.projectActions);
|
||||
const primaryRaw = trimmedString(document.projectActionsPrimaryId);
|
||||
const wait = document['setup-worktree-wait'];
|
||||
return {
|
||||
setupWorktree: sanitizeSetupCommands(document['setup-worktree']),
|
||||
setupWorktreeWait: typeof wait === 'boolean' ? wait : null,
|
||||
setupWorktreeMode: setupWorktreeModeOf(document.setupWorktreeMode),
|
||||
projectActions,
|
||||
projectActionsPrimaryId: primaryRaw && projectActions.some((action) => action.id === primaryRaw) ? primaryRaw : null,
|
||||
draftStarters: sanitizeDraftStarters(document.draftStarters),
|
||||
hiddenSharedActionIds: sanitizeIdList(document.hiddenSharedActionIds),
|
||||
sharedTrust: sharedTrustOf(document.sharedTrust),
|
||||
};
|
||||
};
|
||||
|
||||
const sharedTrustOf = (value: unknown): PersonalProjectSetup['sharedTrust'] => {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
const hash = trimmedString(value.hash);
|
||||
if (!hash) return null;
|
||||
const trustedAt = value.trustedAt;
|
||||
return { hash, trustedAt: typeof trustedAt === 'number' && Number.isFinite(trustedAt) ? trustedAt : 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* What a trust answer covers: the shared setup commands and the shared
|
||||
* actions' commands, canonical order, hashed; `null` when nothing executes.
|
||||
*/
|
||||
export const sharedTrustHashOf = (shared: SharedProjectConfig): string | null => {
|
||||
const commands = shared.setupWorktree;
|
||||
const actions = shared.projectActions
|
||||
.map((action) => {
|
||||
const executable: { id: string; command: string; runIn?: 'parent' } = { id: action.id, command: action.command };
|
||||
if (action.runIn) executable.runIn = action.runIn;
|
||||
return executable;
|
||||
})
|
||||
.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
|
||||
if (commands.length === 0 && actions.length === 0) return null;
|
||||
const digest = crypto.createHash('sha256').update(JSON.stringify({ setupWorktree: commands, projectActions: actions })).digest('hex');
|
||||
return `sha256:${digest}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `plansDir` is a relative path inside the repo: no absolute paths, no
|
||||
* drive letters, no `..` segments, forward slashes.
|
||||
*/
|
||||
export const normalizePlansDir = (value: unknown): string | null => {
|
||||
const raw = trimmedString(value).replace(/\\/g, '/');
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) return null;
|
||||
const segments = raw.split('/').filter((segment) => segment.length > 0 && segment !== '.');
|
||||
if (segments.length === 0 || segments.some((segment) => segment === '..')) return null;
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
const EMPTY_SHARED: SharedProjectConfig = {
|
||||
setupWorktree: [],
|
||||
setupWorktreeWait: null,
|
||||
projectActions: [],
|
||||
draftStarters: [],
|
||||
plansDir: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the text of a shared file. Anything that is not a version-1 object
|
||||
* is `invalid` with a reason, never an empty config.
|
||||
*/
|
||||
export const parseSharedProjectConfig = (raw: string): SharedProjectConfigRead => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { status: 'invalid', reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
if (!isObjectRecord(parsed)) return { status: 'invalid', reason: 'not an object' };
|
||||
if (parsed.version !== SHARED_CONFIG_VERSION) return { status: 'invalid', reason: `unsupported version ${JSON.stringify(parsed.version)}` };
|
||||
if ('setupWorktree' in parsed && !Array.isArray(parsed.setupWorktree)) return { status: 'invalid', reason: 'setupWorktree must be an array' };
|
||||
if ('setupWorktreeWait' in parsed && typeof parsed.setupWorktreeWait !== 'boolean') return { status: 'invalid', reason: 'setupWorktreeWait must be a boolean' };
|
||||
if ('projectActions' in parsed && !Array.isArray(parsed.projectActions)) return { status: 'invalid', reason: 'projectActions must be an array' };
|
||||
if ('draftStarters' in parsed && !Array.isArray(parsed.draftStarters)) return { status: 'invalid', reason: 'draftStarters must be an array' };
|
||||
let plansDir: string | null = null;
|
||||
if ('plansDir' in parsed && parsed.plansDir !== null) {
|
||||
plansDir = normalizePlansDir(parsed.plansDir);
|
||||
if (!plansDir) return { status: 'invalid', reason: 'plansDir must be a relative path inside the repository' };
|
||||
}
|
||||
const wait = parsed.setupWorktreeWait;
|
||||
return {
|
||||
status: 'ok',
|
||||
config: {
|
||||
setupWorktree: sanitizeSetupCommands(parsed.setupWorktree),
|
||||
setupWorktreeWait: typeof wait === 'boolean' ? wait : null,
|
||||
projectActions: sanitizeProjectActions(parsed.projectActions),
|
||||
draftStarters: sanitizeDraftStarters(parsed.draftStarters),
|
||||
plansDir,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const withSource = <T,>(entries: T[], source: ProjectSetupSource): Array<T & { source: ProjectSetupSource }> =>
|
||||
entries.map((entry) => ({ ...entry, source }));
|
||||
|
||||
/**
|
||||
* One merged view from the personal part and the shared read. Same rules as
|
||||
* the server: shared setup commands first (unless personal replaces), the
|
||||
* personal wait flag wins when set, actions union by id with personal
|
||||
* replacing shared and hidden shared ids dropped, starters union by key.
|
||||
*/
|
||||
export const mergeProjectSetup = (personal: PersonalProjectSetup, sharedRead: SharedProjectConfigRead): ProjectSetupView => {
|
||||
const shared = sharedRead.status === 'ok' ? sharedRead.config : EMPTY_SHARED;
|
||||
const hidden = new Set(personal.hiddenSharedActionIds);
|
||||
const personalIds = new Set(personal.projectActions.map((action) => action.id));
|
||||
const sharedActions = shared.projectActions.filter((action) => !hidden.has(action.id) && !personalIds.has(action.id));
|
||||
const starterKeys = new Set(shared.draftStarters.map((starter) => `${starter.type}:${starter.name}`));
|
||||
const personalStarters = personal.draftStarters.filter((starter) => !starterKeys.has(`${starter.type}:${starter.name}`));
|
||||
const trustHash = sharedTrustHashOf(shared);
|
||||
return {
|
||||
trust: { hash: trustHash, trusted: trustHash === null || personal.sharedTrust?.hash === trustHash },
|
||||
setupWorktree: personal.setupWorktreeMode === 'replace'
|
||||
? personal.setupWorktree
|
||||
: [...shared.setupWorktree, ...personal.setupWorktree],
|
||||
setupWorktreeWait: personal.setupWorktreeWait !== null
|
||||
? personal.setupWorktreeWait
|
||||
: shared.setupWorktreeWait === true,
|
||||
projectActions: [...withSource(sharedActions, 'shared'), ...withSource(personal.projectActions, 'personal')],
|
||||
projectActionsPrimaryId: personal.projectActionsPrimaryId,
|
||||
draftStarters: [...withSource(shared.draftStarters, 'shared'), ...withSource(personalStarters, 'personal')],
|
||||
shared: sharedBlockOf(sharedRead, shared),
|
||||
personal,
|
||||
};
|
||||
};
|
||||
|
||||
const sharedBlockOf = (sharedRead: SharedProjectConfigRead, shared: SharedProjectConfig): ProjectSetupView['shared'] => {
|
||||
const block: ProjectSetupView['shared'] = { status: sharedRead.status, path: SHARED_CONFIG_RELATIVE_PATH, ...shared };
|
||||
if (sharedRead.status === 'invalid') block.reason = sharedRead.reason;
|
||||
return block;
|
||||
};
|
||||
|
||||
/** An action without an icon is written without the key; readers fall back to the play icon. */
|
||||
const withoutEmptyIcon = (action: ProjectAction): Omit<ProjectAction, 'icon'> & { icon?: string } => {
|
||||
const { icon, ...rest } = action;
|
||||
return icon === null ? rest : { ...rest, icon };
|
||||
};
|
||||
|
||||
/** True when the shared config carries nothing: the file should not exist. */
|
||||
export const isSharedProjectConfigEmpty = (config: SharedProjectConfig): boolean => (
|
||||
config.setupWorktree.length === 0
|
||||
&& config.setupWorktreeWait === null
|
||||
&& config.projectActions.length === 0
|
||||
&& config.draftStarters.length === 0
|
||||
&& config.plansDir === null
|
||||
);
|
||||
|
||||
/** The bytes of a shared file: version first, only the keys that carry something, pretty-printed. */
|
||||
export const serializeSharedProjectConfig = (config: SharedProjectConfig): string => {
|
||||
const document: Record<string, unknown> = { version: SHARED_CONFIG_VERSION };
|
||||
if (config.setupWorktree.length > 0) document.setupWorktree = config.setupWorktree;
|
||||
if (config.setupWorktreeWait !== null) document.setupWorktreeWait = config.setupWorktreeWait;
|
||||
if (config.projectActions.length > 0) document.projectActions = sanitizeProjectActions(config.projectActions).map(withoutEmptyIcon);
|
||||
if (config.draftStarters.length > 0) document.draftStarters = config.draftStarters;
|
||||
if (config.plansDir !== null) document.plansDir = config.plansDir;
|
||||
return `${JSON.stringify(document, null, 2)}\n`;
|
||||
};
|
||||
|
||||
export const EMPTY_SHARED_PROJECT_CONFIG: SharedProjectConfig = EMPTY_SHARED;
|
||||
|
||||
/** The next shared config after a client patch over the current one; wrong shapes are validation errors. */
|
||||
export const applySharedProjectSetupPatch = (current: SharedProjectConfig, patch: unknown): SharedProjectConfig => {
|
||||
if (!isObjectRecord(patch)) throw new ProjectSetupValidationError('patch must be an object');
|
||||
const next: SharedProjectConfig = { ...current };
|
||||
if ('setupWorktree' in patch) {
|
||||
if (!Array.isArray(patch.setupWorktree)) throw new ProjectSetupValidationError('setupWorktree must be an array of commands');
|
||||
next.setupWorktree = sanitizeSetupCommands(patch.setupWorktree);
|
||||
}
|
||||
if ('setupWorktreeWait' in patch) {
|
||||
const wait = patch.setupWorktreeWait;
|
||||
if (wait !== null && typeof wait !== 'boolean') throw new ProjectSetupValidationError('setupWorktreeWait must be a boolean or null');
|
||||
next.setupWorktreeWait = wait;
|
||||
}
|
||||
if ('projectActions' in patch) {
|
||||
if (!Array.isArray(patch.projectActions)) throw new ProjectSetupValidationError('projectActions must be an array');
|
||||
next.projectActions = sanitizeProjectActions(patch.projectActions);
|
||||
}
|
||||
if ('draftStarters' in patch) {
|
||||
if (!Array.isArray(patch.draftStarters)) throw new ProjectSetupValidationError('draftStarters must be an array');
|
||||
next.draftStarters = sanitizeDraftStarters(patch.draftStarters);
|
||||
}
|
||||
if ('plansDir' in patch) {
|
||||
const raw = patch.plansDir;
|
||||
if (raw === null || (typeof raw === 'string' && !raw.trim())) {
|
||||
next.plansDir = null;
|
||||
} else {
|
||||
const plansDir = normalizePlansDir(raw);
|
||||
if (!plansDir) throw new ProjectSetupValidationError('plansDir must be a relative path inside the repository');
|
||||
next.plansDir = plansDir;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* The stored keys a client patch changes; `undefined` marks a key to remove.
|
||||
* A key with the wrong shape is a validation error, never silently dropped.
|
||||
*/
|
||||
export const projectSetupPatchToStored = (patch: unknown): StoredProjectSetupPatch => {
|
||||
if (!isObjectRecord(patch)) {
|
||||
throw new ProjectSetupValidationError('patch must be an object');
|
||||
}
|
||||
const stored: StoredProjectSetupPatch = {};
|
||||
if ('setupWorktree' in patch) {
|
||||
if (!Array.isArray(patch.setupWorktree)) throw new ProjectSetupValidationError('setupWorktree must be an array of commands');
|
||||
stored['setup-worktree'] = sanitizeSetupCommands(patch.setupWorktree);
|
||||
}
|
||||
if ('setupWorktreeWait' in patch) {
|
||||
if (typeof patch.setupWorktreeWait !== 'boolean') throw new ProjectSetupValidationError('setupWorktreeWait must be a boolean');
|
||||
stored['setup-worktree-wait'] = patch.setupWorktreeWait;
|
||||
}
|
||||
if ('projectActions' in patch) {
|
||||
if (!Array.isArray(patch.projectActions)) throw new ProjectSetupValidationError('projectActions must be an array');
|
||||
stored.projectActions = sanitizeProjectActions(patch.projectActions);
|
||||
}
|
||||
if ('projectActionsPrimaryId' in patch) {
|
||||
const primary = patch.projectActionsPrimaryId;
|
||||
if (primary !== null && typeof primary !== 'string') {
|
||||
throw new ProjectSetupValidationError('projectActionsPrimaryId must be a string or null');
|
||||
}
|
||||
stored.projectActionsPrimaryId = trimmedString(primary) || undefined;
|
||||
}
|
||||
if ('draftStarters' in patch) {
|
||||
if (!Array.isArray(patch.draftStarters)) throw new ProjectSetupValidationError('draftStarters must be an array');
|
||||
stored.draftStarters = sanitizeDraftStarters(patch.draftStarters);
|
||||
}
|
||||
if ('hiddenSharedActionIds' in patch) {
|
||||
if (!Array.isArray(patch.hiddenSharedActionIds)) throw new ProjectSetupValidationError('hiddenSharedActionIds must be an array');
|
||||
stored.hiddenSharedActionIds = sanitizeIdList(patch.hiddenSharedActionIds);
|
||||
}
|
||||
if ('setupWorktreeMode' in patch) {
|
||||
if (patch.setupWorktreeMode !== 'append' && patch.setupWorktreeMode !== 'replace') {
|
||||
throw new ProjectSetupValidationError('setupWorktreeMode must be "append" or "replace"');
|
||||
}
|
||||
stored.setupWorktreeMode = patch.setupWorktreeMode;
|
||||
}
|
||||
if ('sharedTrustHash' in patch) {
|
||||
const hash = patch.sharedTrustHash;
|
||||
if (hash !== null && (typeof hash !== 'string' || !hash.trim())) {
|
||||
throw new ProjectSetupValidationError('sharedTrustHash must be a non-empty string or null');
|
||||
}
|
||||
stored.sharedTrust = hash === null ? undefined : { hash: hash.trim(), trustedAt: Date.now() };
|
||||
}
|
||||
if ('projectPath' in patch) {
|
||||
if (typeof patch.projectPath !== 'string') throw new ProjectSetupValidationError('projectPath must be a string');
|
||||
const projectPath = patch.projectPath.trim();
|
||||
if (projectPath) stored.projectPath = projectPath;
|
||||
}
|
||||
return stored;
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildPreferencesFields,
|
||||
flattenPreferences,
|
||||
isPerSurfaceSettingsKey,
|
||||
instancePartOf,
|
||||
isDeviceSettingsKey,
|
||||
isProfileSettingsKey,
|
||||
parsePreferencesDocument,
|
||||
preferencesFilePathFor,
|
||||
seedPreferencesFrom,
|
||||
serializePreferencesDocument,
|
||||
} from './settings-files';
|
||||
import { SETTINGS_REGISTRY_FIELDS } from './settings-registry-gate';
|
||||
|
||||
const firstKeyWithScope = (scope: string): string => {
|
||||
const key = Object.keys(SETTINGS_REGISTRY_FIELDS).find((candidate) => SETTINGS_REGISTRY_FIELDS[candidate].scope === scope);
|
||||
assert.ok(key, `snapshot has a ${scope} key`);
|
||||
return key;
|
||||
};
|
||||
const deviceKey = firstKeyWithScope('device');
|
||||
|
||||
describe('parsePreferencesDocument', () => {
|
||||
test('rejects invalid JSON', () => {
|
||||
const result = parsePreferencesDocument('{ not json');
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(!result.ok && result.reason.startsWith('invalid JSON'));
|
||||
});
|
||||
|
||||
test('rejects a wrong version', () => {
|
||||
const result = parsePreferencesDocument(JSON.stringify({ version: 2, fields: {} }));
|
||||
assert.deepEqual(result, { ok: false, reason: 'not a version-1 preferences document' });
|
||||
});
|
||||
|
||||
test('rejects non-object fields', () => {
|
||||
assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1, fields: [] })).ok, false);
|
||||
assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1, fields: 'x' })).ok, false);
|
||||
assert.equal(parsePreferencesDocument(JSON.stringify({ version: 1 })).ok, false);
|
||||
});
|
||||
|
||||
test('rejects an entry without a value', () => {
|
||||
const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { themeId: { updatedAt: 5 } } }));
|
||||
assert.deepEqual(result, { ok: false, reason: 'field "themeId" is not a { value, updatedAt } entry' });
|
||||
});
|
||||
|
||||
test('accepts an empty document and defaults a missing stamp to 0', () => {
|
||||
assert.deepEqual(parsePreferencesDocument(JSON.stringify({ version: 1, fields: {} })), { ok: true, fields: {} });
|
||||
const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { themeId: { value: 'nord' } } }));
|
||||
assert.deepEqual(result, { ok: true, fields: { themeId: { value: 'nord', updatedAt: 0 } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPreferencesFields', () => {
|
||||
const previous = {
|
||||
themeId: { value: 'nord', updatedAt: 100 },
|
||||
defaultModel: { value: 'zen/gpt-5', updatedAt: 100 },
|
||||
darkThemeId: { value: 'dracula', updatedAt: 100 },
|
||||
};
|
||||
|
||||
test('keeps the stamp for unchanged values and restamps changed ones', () => {
|
||||
const next = buildPreferencesFields(previous, { themeId: 'nord', defaultModel: 'zen/gpt-5-mini', darkThemeId: 'dracula' }, 200);
|
||||
assert.deepEqual(next, {
|
||||
themeId: { value: 'nord', updatedAt: 100 },
|
||||
defaultModel: { value: 'zen/gpt-5-mini', updatedAt: 200 },
|
||||
darkThemeId: { value: 'dracula', updatedAt: 100 },
|
||||
});
|
||||
});
|
||||
|
||||
test('compares structurally, so an equal object keeps its stamp', () => {
|
||||
const before = { themeId: { value: { a: 1, b: [1, 2] }, updatedAt: 7 } };
|
||||
const next = buildPreferencesFields(before, { themeId: { a: 1, b: [1, 2] } }, 9);
|
||||
assert.deepEqual(next, before);
|
||||
});
|
||||
|
||||
test('drops profile keys the document no longer carries and ignores non-profile keys', () => {
|
||||
const next = buildPreferencesFields(previous, { themeId: 'nord', opencodeBinary: '/usr/bin/opencode', [deviceKey]: '#fff', unknownKey: 1 }, 200);
|
||||
assert.deepEqual(next, { themeId: { value: 'nord', updatedAt: 100 } });
|
||||
});
|
||||
|
||||
test('skips undefined values', () => {
|
||||
assert.deepEqual(buildPreferencesFields({}, { themeId: undefined }, 1), {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('instancePartOf', () => {
|
||||
test('excludes profile keys and keeps instance and unknown legacy keys', () => {
|
||||
const document = { themeId: 'nord', defaultModel: 'x', opencodeBinary: '/bin/oc', legacyKey: true, dropped: undefined };
|
||||
assert.deepEqual(instancePartOf(document), { opencodeBinary: '/bin/oc', legacyKey: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope helpers', () => {
|
||||
test('classify keys by the checked-in registry snapshot', () => {
|
||||
assert.equal(isProfileSettingsKey('themeId'), true);
|
||||
assert.equal(isProfileSettingsKey('opencodeBinary'), false);
|
||||
assert.equal(isDeviceSettingsKey(deviceKey), true);
|
||||
assert.equal(isDeviceSettingsKey('themeId'), false);
|
||||
assert.equal(isProfileSettingsKey('constructor'), false);
|
||||
assert.equal(isProfileSettingsKey('nope'), false);
|
||||
});
|
||||
|
||||
test('preferences.json sits beside settings.json', () => {
|
||||
assert.equal(preferencesFilePathFor('/home/u/.config/openchamber/settings.json'), '/home/u/.config/openchamber/preferences.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round trip', () => {
|
||||
test('serialize then parse yields the same fields, and flatten yields the values', () => {
|
||||
const fields = seedPreferencesFrom({ themeId: 'nord', defaultModel: 'zen/gpt-5', opencodeBinary: '/bin/oc' }, 42);
|
||||
assert.deepEqual(fields, {
|
||||
themeId: { value: 'nord', updatedAt: 42 },
|
||||
defaultModel: { value: 'zen/gpt-5', updatedAt: 42 },
|
||||
});
|
||||
const text = serializePreferencesDocument(fields);
|
||||
assert.ok(text.startsWith('{\n "version": 1,\n "fields": {'));
|
||||
const parsed = parsePreferencesDocument(text);
|
||||
assert.deepEqual(parsed, { ok: true, fields });
|
||||
assert.deepEqual(flattenPreferences(fields), { themeId: 'nord', defaultModel: 'zen/gpt-5' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-surface keys', () => {
|
||||
const perSurfaceKey = Object.keys(SETTINGS_REGISTRY_FIELDS).find((key) => SETTINGS_REGISTRY_FIELDS[key].perSurface === true);
|
||||
const plainProfileKey = Object.keys(SETTINGS_REGISTRY_FIELDS).find(
|
||||
(key) => SETTINGS_REGISTRY_FIELDS[key].scope === 'profile' && SETTINGS_REGISTRY_FIELDS[key].perSurface !== true,
|
||||
);
|
||||
|
||||
test('the snapshot names at least one per-surface profile key', () => {
|
||||
assert.ok(perSurfaceKey && isPerSurfaceSettingsKey(perSurfaceKey));
|
||||
assert.ok(plainProfileKey && !isPerSurfaceSettingsKey(plainProfileKey));
|
||||
});
|
||||
|
||||
test('a surface write lands under the surface and leaves the base as it was', () => {
|
||||
assert.ok(perSurfaceKey && plainProfileKey);
|
||||
const previous = { [perSurfaceKey]: { value: 'base', updatedAt: 1 } };
|
||||
const next = buildPreferencesFields(previous, { [perSurfaceKey]: 'mine', [plainProfileKey]: 'shared' }, 5, {
|
||||
surface: 'vscode',
|
||||
changedKeys: [perSurfaceKey, plainProfileKey],
|
||||
});
|
||||
assert.deepEqual(next[perSurfaceKey], { value: 'base', updatedAt: 1, surfaces: { vscode: { value: 'mine', updatedAt: 5 } } });
|
||||
assert.deepEqual(next[plainProfileKey], { value: 'shared', updatedAt: 5 });
|
||||
assert.equal(flattenPreferences(next, 'vscode')[perSurfaceKey], 'mine');
|
||||
assert.equal(flattenPreferences(next, 'mobile')[perSurfaceKey], 'base');
|
||||
assert.equal(flattenPreferences(next)[perSurfaceKey], 'base');
|
||||
});
|
||||
|
||||
test('a per-surface key the write did not change keeps its whole entry', () => {
|
||||
assert.ok(perSurfaceKey && plainProfileKey);
|
||||
const previous = { [perSurfaceKey]: { value: 'base', updatedAt: 1, surfaces: { mobile: { value: 'phone', updatedAt: 2 } } } };
|
||||
const next = buildPreferencesFields(previous, { [perSurfaceKey]: 'base', [plainProfileKey]: 'x' }, 9, {
|
||||
surface: 'vscode',
|
||||
changedKeys: [plainProfileKey],
|
||||
});
|
||||
assert.deepEqual(next[perSurfaceKey], previous[perSurfaceKey]);
|
||||
});
|
||||
|
||||
test('a per-surface key first set from one surface has no base', () => {
|
||||
assert.ok(perSurfaceKey);
|
||||
const next = buildPreferencesFields({}, { [perSurfaceKey]: 'mine' }, 3, { surface: 'vscode', changedKeys: [perSurfaceKey] });
|
||||
assert.equal('value' in next[perSurfaceKey], false);
|
||||
assert.deepEqual(next[perSurfaceKey].surfaces, { vscode: { value: 'mine', updatedAt: 3 } });
|
||||
const parsed = parsePreferencesDocument(serializePreferencesDocument(next));
|
||||
assert.ok(parsed.ok);
|
||||
assert.equal(flattenPreferences(parsed.fields, 'mobile')[perSurfaceKey], undefined);
|
||||
});
|
||||
|
||||
test('rejects an unknown surface in the file', () => {
|
||||
const result = parsePreferencesDocument(JSON.stringify({ version: 1, fields: { x: { surfaces: { toaster: { value: 1 } } } } }));
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// The two settings files and how a merged document is split between them.
|
||||
//
|
||||
// `settings.json` holds instance facts (and, untouched, whatever legacy keys
|
||||
// older builds left there). `preferences.json` holds the user's profile: the
|
||||
// keys the settings registry marks `profile`, each with the time the store
|
||||
// last accepted a new value for it. Device keys never reach either file.
|
||||
//
|
||||
// Mirrors the server implementation in
|
||||
// `packages/web/server/lib/opencode/settings-files.js`; both sides must write
|
||||
// byte-compatible files, so keep format changes in sync.
|
||||
//
|
||||
// Kept free of `vscode` imports so it is unit-tested directly.
|
||||
import * as path from 'path';
|
||||
import { SETTINGS_REGISTRY_FIELDS } from './settings-registry-gate';
|
||||
|
||||
const PREFERENCES_FILE_NAME = 'preferences.json';
|
||||
const PREFERENCES_DOCUMENT_VERSION = 1;
|
||||
|
||||
type SettingsSurface = 'web' | 'desktop' | 'vscode' | 'mobile';
|
||||
const SETTINGS_SURFACES: readonly SettingsSurface[] = ['web', 'desktop', 'vscode', 'mobile'];
|
||||
// SAFETY: widening the tuple to `readonly string[]` only for the membership test; the guard's result is what narrows.
|
||||
const isSettingsSurface = (value: string): value is SettingsSurface => (SETTINGS_SURFACES as readonly string[]).includes(value);
|
||||
|
||||
/** The extension host is always the VS Code surface kind. */
|
||||
export const VSCODE_SETTINGS_SURFACE: SettingsSurface = 'vscode';
|
||||
|
||||
// Boundary parser: values are whatever JSON the file (or the webview) carries.
|
||||
type SurfaceValue = { value: unknown; updatedAt: number };
|
||||
// The base value is optional: a per-surface key first set from one surface kind has none.
|
||||
type PreferenceField = { value?: unknown; updatedAt: number; surfaces?: Partial<Record<SettingsSurface, SurfaceValue>> };
|
||||
export type PreferenceFields = Record<string, PreferenceField>;
|
||||
|
||||
type ParsedPreferencesDocument =
|
||||
| { ok: true; fields: PreferenceFields }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/** The registry scope for a key, or `null` when the registry does not know it. */
|
||||
const getSettingsScope = (key: string): string | null =>
|
||||
Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY_FIELDS, key) ? SETTINGS_REGISTRY_FIELDS[key].scope : null;
|
||||
|
||||
export const isProfileSettingsKey = (key: string): boolean => getSettingsScope(key) === 'profile';
|
||||
export const isDeviceSettingsKey = (key: string): boolean => getSettingsScope(key) === 'device';
|
||||
/** Profile keys the owner chose to store per surface kind. */
|
||||
export const isPerSurfaceSettingsKey = (key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(SETTINGS_REGISTRY_FIELDS, key) && SETTINGS_REGISTRY_FIELDS[key].perSurface === true;
|
||||
|
||||
export const preferencesFilePathFor = (settingsFilePath: string): string =>
|
||||
path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME);
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const parseStamp = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
||||
|
||||
const sameValue = (left: unknown, right: unknown): boolean => {
|
||||
if (left === right) return true;
|
||||
if (left === undefined || right === undefined) return false;
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the text of a preferences file. A missing file is the caller's case
|
||||
* (ENOENT); anything that is not a version-1 document with a `fields` object
|
||||
* is a failure, never an empty profile.
|
||||
*/
|
||||
export const parsePreferencesDocument = (raw: string): ParsedPreferencesDocument => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
if (!isPlainObject(parsed) || parsed.version !== PREFERENCES_DOCUMENT_VERSION || !isPlainObject(parsed.fields)) {
|
||||
return { ok: false, reason: 'not a version-1 preferences document' };
|
||||
}
|
||||
const fields: PreferenceFields = {};
|
||||
for (const [key, entry] of Object.entries(parsed.fields)) {
|
||||
if (!isPlainObject(entry) || (!('value' in entry) && !isPlainObject(entry.surfaces))) {
|
||||
return { ok: false, reason: `field "${key}" is not a { value, updatedAt } entry` };
|
||||
}
|
||||
const next: PreferenceField = { updatedAt: parseStamp(entry.updatedAt) };
|
||||
if ('value' in entry) next.value = entry.value;
|
||||
if (isPlainObject(entry.surfaces)) {
|
||||
const surfaces: Partial<Record<SettingsSurface, SurfaceValue>> = {};
|
||||
for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) {
|
||||
if (!isSettingsSurface(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) {
|
||||
return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` };
|
||||
}
|
||||
surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) };
|
||||
}
|
||||
next.surfaces = surfaces;
|
||||
}
|
||||
fields[key] = next;
|
||||
}
|
||||
return { ok: true, fields };
|
||||
};
|
||||
|
||||
export const serializePreferencesDocument = (fields: PreferenceFields): string =>
|
||||
JSON.stringify({ version: PREFERENCES_DOCUMENT_VERSION, fields }, null, 2);
|
||||
|
||||
/**
|
||||
* The plain key → value view of preference fields as one surface kind sees it:
|
||||
* that surface's own value first, the base value otherwise; a key with neither
|
||||
* is absent (the webview keeps what it holds, or its default).
|
||||
*/
|
||||
export const flattenPreferences = (fields: PreferenceFields, surface: SettingsSurface | null = null): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(fields)) {
|
||||
const own = surface ? entry.surfaces?.[surface] : undefined;
|
||||
if (own) {
|
||||
values[key] = own.value;
|
||||
} else if ('value' in entry) {
|
||||
values[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
/**
|
||||
* The next preference fields for a merged document: every profile key it
|
||||
* carries, stamped `now` when its value differs from what the file held and
|
||||
* keeping the earlier stamp otherwise. Profile keys the document no longer
|
||||
* carries are dropped (that is how a cleared key leaves the file).
|
||||
*/
|
||||
export const buildPreferencesFields = (
|
||||
previousFields: PreferenceFields,
|
||||
document: Record<string, unknown>,
|
||||
now: number,
|
||||
options: { surface?: SettingsSurface | null; changedKeys?: Iterable<string> | null } = {},
|
||||
): PreferenceFields => {
|
||||
const surface = options.surface ?? null;
|
||||
const changed = options.changedKeys ? new Set(options.changedKeys) : null;
|
||||
const fields: PreferenceFields = {};
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (value === undefined || !isProfileSettingsKey(key)) continue;
|
||||
const previous = previousFields[key];
|
||||
// Per-surface keys: a surface's write lands under its own entry and leaves
|
||||
// the base as it was; a key the write did not change keeps its whole entry
|
||||
// (the document only carries this surface's resolved view of it).
|
||||
if (surface && isPerSurfaceSettingsKey(key)) {
|
||||
if (changed && !changed.has(key)) {
|
||||
if (previous) fields[key] = previous;
|
||||
continue;
|
||||
}
|
||||
const previousOwn = previous?.surfaces?.[surface];
|
||||
const own: SurfaceValue = previousOwn && sameValue(previousOwn.value, value) ? previousOwn : { value, updatedAt: now };
|
||||
fields[key] = {
|
||||
...(previous ?? { updatedAt: 0 }),
|
||||
surfaces: { ...(previous?.surfaces ?? {}), [surface]: own },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (previous && 'value' in previous && sameValue(previous.value, value)) {
|
||||
fields[key] = previous;
|
||||
} else {
|
||||
fields[key] = { ...(previous ?? {}), value, updatedAt: now };
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* The part of a merged document that belongs in `settings.json`: everything
|
||||
* that is not a profile key. Device keys are already filtered by the registry
|
||||
* gate on the write path; ones older builds persisted stay in place.
|
||||
*/
|
||||
export const instancePartOf = (document: Record<string, unknown>): Record<string, unknown> => {
|
||||
const instance: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (value === undefined || isProfileSettingsKey(key)) continue;
|
||||
instance[key] = value;
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
/** The profile keys of a document, as they would seed a fresh preferences file. */
|
||||
/** The profile keys of a document (the part `instancePartOf` leaves out). */
|
||||
export const profilePartOf = (document: Record<string, unknown>): Record<string, unknown> => {
|
||||
const profile: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (value !== undefined && isProfileSettingsKey(key)) profile[key] = value;
|
||||
}
|
||||
return profile;
|
||||
};
|
||||
|
||||
/**
|
||||
* What `settings.json` holds after a write: the instance part plus a copy of
|
||||
* the profile's base values, so a build from before the split (which reads
|
||||
* only this file) still finds the user's preferences. Current builds ignore
|
||||
* the copy: `preferences.json` wins in the merged read.
|
||||
*/
|
||||
export const legacySettingsDocumentOf = (
|
||||
document: Record<string, unknown>,
|
||||
preferenceFields: PreferenceFields,
|
||||
): Record<string, unknown> => ({
|
||||
...instancePartOf(document),
|
||||
...flattenPreferences(preferenceFields),
|
||||
});
|
||||
|
||||
export const seedPreferencesFrom = (document: Record<string, unknown>, now: number): PreferenceFields =>
|
||||
buildPreferencesFields({}, document, now);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SETTINGS_REGISTRY_FIELDS, filterPersistableSettingsChanges, withoutSecretSettings, type SettingsRegistryGateFields } from './settings-registry-gate';
|
||||
|
||||
const fields: SettingsRegistryGateFields = {
|
||||
themeId: { scope: 'profile' },
|
||||
smallModelOverride: { scope: 'profile' },
|
||||
hasDesktopSettings: { scope: 'instance', computed: true },
|
||||
sidebarWidth: { scope: 'device', local: true },
|
||||
windowBounds: { scope: 'instance', owner: 'desktop-shell' },
|
||||
desktopUiPassword: { scope: 'instance', secret: true },
|
||||
};
|
||||
|
||||
describe('withoutSecretSettings', () => {
|
||||
test('withholds secret keys and keeps everything else', () => {
|
||||
assert.deepEqual(withoutSecretSettings({ desktopUiPassword: 'pw', themeId: 'a' }, fields), { themeId: 'a' });
|
||||
});
|
||||
|
||||
test('the real registry marks the UI password and tunnel tokens secret', () => {
|
||||
const stripped = withoutSecretSettings({
|
||||
desktopUiPassword: 'pw',
|
||||
managedRemoteTunnelToken: 't',
|
||||
managedRemoteTunnelPresetTokens: { a: 't' },
|
||||
themeId: 'a',
|
||||
}, SETTINGS_REGISTRY_FIELDS);
|
||||
assert.deepEqual(stripped, { themeId: 'a' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterPersistableSettingsChanges', () => {
|
||||
test('keeps stored shared fields and preserves their values as sent', () => {
|
||||
const result = filterPersistableSettingsChanges(
|
||||
{ themeId: 'nord', smallModelOverride: '', unrelated: 1 },
|
||||
fields,
|
||||
);
|
||||
assert.deepEqual(result, { themeId: 'nord', smallModelOverride: '' });
|
||||
});
|
||||
|
||||
test('drops keys the registry does not know', () => {
|
||||
assert.deepEqual(filterPersistableSettingsChanges({ gitProviderId: 'zen', gitModelId: 'x' }, fields), {});
|
||||
});
|
||||
|
||||
test('drops computed, local, and desktop-shell owned keys', () => {
|
||||
const result = filterPersistableSettingsChanges(
|
||||
{ hasDesktopSettings: true, sidebarWidth: 320, windowBounds: { x: 0 }, themeId: 'a' },
|
||||
fields,
|
||||
);
|
||||
assert.deepEqual(result, { themeId: 'a' });
|
||||
});
|
||||
|
||||
test('ignores prototype keys that are not registry fields', () => {
|
||||
assert.deepEqual(filterPersistableSettingsChanges({ constructor: 'x', toString: 'y' }, fields), {});
|
||||
});
|
||||
|
||||
test('the checked-in snapshot drops derived-at-read and desktop-shell keys but keeps profile settings', () => {
|
||||
const result = filterPersistableSettingsChanges({
|
||||
themeId: 'nord',
|
||||
smallModelUseDefault: false,
|
||||
smallModelOverride: 'zen/gpt-5-nano',
|
||||
gitProviderId: 'zen',
|
||||
gitModelId: 'gpt-5-nano',
|
||||
});
|
||||
assert.deepEqual(result, { themeId: 'nord', smallModelUseDefault: false, smallModelOverride: 'zen/gpt-5-nano' });
|
||||
|
||||
const computedKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.computed).map(([key]) => key);
|
||||
const localKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.local).map(([key]) => key);
|
||||
const shellKeys = Object.entries(SETTINGS_REGISTRY_FIELDS).filter(([, field]) => field.owner === 'desktop-shell').map(([key]) => key);
|
||||
assert.ok(computedKeys.length > 0 && localKeys.length > 0 && shellKeys.length > 0, 'snapshot exercises every gate branch');
|
||||
const blocked = Object.fromEntries([...computedKeys, ...localKeys, ...shellKeys].map((key) => [key, 'value']));
|
||||
assert.deepEqual(filterPersistableSettingsChanges(blocked), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Gate for the bridge's settings write path. The generated registry snapshot
|
||||
// (`settings-registry.json`, produced from the UI package's settings registry)
|
||||
// names every key OpenChamber persists; anything else the webview sends is
|
||||
// dropped here so the shared settings file never grows keys the rest of the
|
||||
// product does not know about.
|
||||
//
|
||||
// Kept free of `vscode` imports so it is unit-tested directly.
|
||||
import registrySnapshot from './settings-registry.json';
|
||||
|
||||
type SettingsRegistryGateField = {
|
||||
scope: string;
|
||||
perSurface?: boolean;
|
||||
computed?: boolean;
|
||||
secret?: boolean;
|
||||
local?: boolean;
|
||||
owner?: string;
|
||||
};
|
||||
|
||||
export type SettingsRegistryGateFields = Record<string, SettingsRegistryGateField>;
|
||||
|
||||
export const SETTINGS_REGISTRY_FIELDS: SettingsRegistryGateFields = registrySnapshot.fields;
|
||||
|
||||
/**
|
||||
* A key is persistable through the bridge only when the registry lists it as a
|
||||
* stored, shared field: not computed at read time, not local to one webview's
|
||||
* store, and not owned by the desktop shell (which keeps its own values).
|
||||
*/
|
||||
const isPersistableField = (field: SettingsRegistryGateField | undefined): boolean => {
|
||||
if (!field) return false;
|
||||
if (field.computed === true) return false;
|
||||
if (field.local === true) return false;
|
||||
if (field.owner === 'desktop-shell') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const filterPersistableSettingsChanges = (
|
||||
changes: Record<string, unknown>,
|
||||
fields: SettingsRegistryGateFields = SETTINGS_REGISTRY_FIELDS,
|
||||
): Record<string, unknown> => {
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, key)) continue;
|
||||
if (!isPersistableField(fields[key])) continue;
|
||||
next[key] = value;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/** Drop the keys the registry marks `secret`: accepted on write, never handed back to a webview. */
|
||||
export const withoutSecretSettings = (
|
||||
settings: Record<string, unknown>,
|
||||
fields: SettingsRegistryGateFields = SETTINGS_REGISTRY_FIELDS,
|
||||
): Record<string, unknown> => {
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (fields[key]?.secret === true) continue;
|
||||
next[key] = value;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
@@ -0,0 +1,743 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fields": {
|
||||
"themeId": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"useSystemTheme": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"themeVariant": {
|
||||
"scope": "profile",
|
||||
"derived": true
|
||||
},
|
||||
"lightThemeId": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"darkThemeId": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"lastDirectory": {
|
||||
"scope": "instance",
|
||||
"adopt": "bootstrap-only"
|
||||
},
|
||||
"homeDirectory": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"opencodeBinary": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"projects": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"activeProjectId": {
|
||||
"scope": "instance",
|
||||
"adopt": "bootstrap-only"
|
||||
},
|
||||
"securityScopedBookmarks": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"pinnedDirectories": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"desktopLanAccessEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopKeepAwakeEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopMinimizeToTrayEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopMacMenuBarEnabled": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopUiPassword": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"secret": true
|
||||
},
|
||||
"hasDesktopUiPassword": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"computed": true
|
||||
},
|
||||
"desktopLanAccessActive": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"computed": true
|
||||
},
|
||||
"desktopLanAccessBlockedReason": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
],
|
||||
"computed": true
|
||||
},
|
||||
"githubClientId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"githubScopes": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"skillCatalogs": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"defaultGitIdentityId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"permissionAutoAccept": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentControlToolEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentWebToolEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentMemoryToolEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"agentMemoryFeatureAvailable": {
|
||||
"scope": "instance",
|
||||
"computed": true
|
||||
},
|
||||
"openCodeUpdateToastDismissedVersion": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"autoDeleteEnabled": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"autoDeleteAfterDays": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sessionRetentionAction": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"terminalShell": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"terminalLoginShells": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"openInAppId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"dictationEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sttProvider": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttServerUrl": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttModel": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttLocalModel": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"sttLanguage": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"tunnelProvider": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"tunnelMode": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"tunnelBootstrapTtlMs": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"tunnelSessionTtlMs": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedLocalTunnelConfigPath": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelHostname": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelToken": {
|
||||
"scope": "instance",
|
||||
"secret": true
|
||||
},
|
||||
"hasManagedRemoteTunnelToken": {
|
||||
"scope": "instance",
|
||||
"computed": true
|
||||
},
|
||||
"managedRemoteTunnelPresets": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelSelectedPresetId": {
|
||||
"scope": "instance"
|
||||
},
|
||||
"managedRemoteTunnelPresetTokens": {
|
||||
"scope": "instance",
|
||||
"secret": true
|
||||
},
|
||||
"sidebarProjectDisplayMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sidebarSessionGroupingMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sidebarProjectSortOrder": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sidebarShowRecentSection": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"workStatusPanelEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"workStatusHiddenSections": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"workStatusHiddenSectionsExplicit": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showReasoningTraces": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"streamingAutoFollowEnabled": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"collapsibleThinkingBlocks": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showTextJustificationActivity": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"chatRenderMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"activityRenderMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"mermaidRenderingMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"userMessageRenderingMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"collapsibleUserMessages": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"stickyUserHeader": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"promptNavigatorEnabled": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"wideChatLayoutEnabled": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"showSplitAssistantMessageActions": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showToolFileIcons": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"codeBlockLineWrap": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showTurnChangedFiles": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showExpandedBashTools": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showExpandedEditTools": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"toolJsonViewMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"timeFormatPreference": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"weekStartPreference": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"messageStreamTransport": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"diffLayoutPreference": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"diffWrapLines": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"gitChangesViewMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"gitmojiEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultFileViewerPreview": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"directoryShowHidden": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"filesViewShowGitignored": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"fileEditorKeymap": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"autoSaveEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"autoCreateWorktree": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionTabsEnabled": {
|
||||
"scope": "profile",
|
||||
"surfaces": [
|
||||
"web",
|
||||
"desktop",
|
||||
"vscode"
|
||||
]
|
||||
},
|
||||
"showOpenCodeRestartConfirm": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"allowPromptingSubagentSessions": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"inputSpellcheckEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"enterToSend": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"enterToSendConfigured": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"persistChatDraft": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"largeTextPasteBehavior": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"followUpBehavior": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"queueModeEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"inputHistoryScope": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"inputHistoryLimit": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStarters": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStartersVisible": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStartersCraftGoalAdded": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"draftStartersScheduleTaskAdded": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"fontSize": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"terminalFontSize": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"editorFontSize": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"uiFont": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"monoFont": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"padding": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"cornerRadius": {
|
||||
"scope": "profile",
|
||||
"perSurface": true
|
||||
},
|
||||
"shortcutOverrides": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultModel": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultVariant": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"defaultAgent": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"smallModelUseDefault": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"smallModelOverride": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"walkthroughModelOverride": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"zenModel": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"favoriteModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"hiddenModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"collapsedModelProviders": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"recentModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"recentAgents": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"recentEfforts": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"providerOrder": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionRecapEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionSuggestionEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionGoalEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionGoalDefaultBudgetEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"sessionGoalDefaultBudget": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"summarizeLastMessage": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"summaryThreshold": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"summaryLength": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"maxLastMessageLength": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showDeletionDialog": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"nativeNotificationsEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notificationMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnSubtasks": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnCompletion": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnError": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notifyOnQuestion": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"notificationTemplates": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"showOpenCodeUpdateNotifications": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"reportUsage": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageDisplayMode": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageDropdownProviders": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageSelectedModels": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageCollapsedFamilies": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageExpandedFamilies": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"usageModelGroups": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"globalBehaviorPrompt": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"responseStyleEnabled": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"responseStylePreset": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"responseStyleCustomInstructions": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"optimizeSystemPrompt": {
|
||||
"scope": "profile"
|
||||
},
|
||||
"pwaAppName": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"pwaOrientation": {
|
||||
"scope": "instance",
|
||||
"surfaces": [
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"mobileKeyboardMode": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"mobile"
|
||||
]
|
||||
},
|
||||
"desktopWindowControlsPosition": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopWindowControlsStyle": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"inputBarOffset": {
|
||||
"scope": "device",
|
||||
"surfaces": [
|
||||
"mobile",
|
||||
"web"
|
||||
]
|
||||
},
|
||||
"theme": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"isSidebarOpen": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"sidebarWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextPanelByDirectory": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextRailOrder": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextRailHiddenSurfaces": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextEditorTreeVisible": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"contextEditorTreeWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"notesPanelHeight": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"workStatusExpandedSections": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"workStatusScrollTop": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"isSessionSwitcherOpen": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"sidebarSection": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsPage": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsHasOpenedOnce": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsProjectsSelectedId": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"settingsRemoteInstancesSelectedId": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"isSessionCreateDialogOpen": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"autoDeleteLastRunAt": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"messageLimit": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"walkthroughTocWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListStatus": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListAssignee": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListTeamIdByRuntime": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"linearIssueListPriority": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"showTerminalQuickKeysOnDesktop": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"dockBadgeEnabled": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"agentMemoryViewedAt": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"projectContextSidebarWidth": {
|
||||
"scope": "device",
|
||||
"local": true
|
||||
},
|
||||
"desktopSplashColors": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopHosts": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopDefaultHostId": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopInstallId": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopLocalPort": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopSshInstances": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
},
|
||||
"desktopWindowState": {
|
||||
"scope": "instance",
|
||||
"owner": "desktop-shell",
|
||||
"surfaces": [
|
||||
"desktop"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user