@openchamber/web 1.11.5 → 1.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-COdbjw73.js} +3 -3
  2. package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-BKSHxjMq.js} +1 -1
  3. package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-Chjg337p.js} +1 -1
  4. package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-C0lRRW8M.js} +1 -1
  5. package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-Bvil3j1u.js} +4 -4
  6. package/dist/assets/es-BZIAUghG.js +15 -0
  7. package/dist/assets/index-UcCH2KN9.css +1 -0
  8. package/dist/assets/ko-DU9l-zox.js +15 -0
  9. package/dist/assets/{main-VVcyjpiF.js → main-Blhx9Fp5.js} +2 -2
  10. package/dist/assets/main-d2-dY4er.js +232 -0
  11. package/dist/assets/miniChat-CJ7-rZFl.js +2 -0
  12. package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-DRJSYigo.js} +96 -96
  13. package/dist/assets/{pl-C577DpsX.js → pl-CdqzokG-.js} +1 -1
  14. package/dist/assets/pt-BR-Bknbr_Y3.js +15 -0
  15. package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-BxZRI73j.js} +2 -2
  16. package/dist/assets/uk-Be4E8ZNO.js +15 -0
  17. package/dist/assets/zh-CN-qpPiaZMg.js +15 -0
  18. package/dist/index.html +3 -3
  19. package/dist/mini-chat.html +3 -3
  20. package/package.json +1 -1
  21. package/server/index.js +2 -0
  22. package/server/lib/cloudflare-tunnel.js +3 -5
  23. package/server/lib/ngrok-tunnel.js +209 -0
  24. package/server/lib/opencode/core-routes.js +1 -0
  25. package/server/lib/opencode/feature-routes-runtime.js +35 -0
  26. package/server/lib/opencode/index.js +19 -0
  27. package/server/lib/opencode/npm-registry.js +157 -0
  28. package/server/lib/opencode/npm-registry.test.js +179 -0
  29. package/server/lib/opencode/plugin-routes.js +373 -0
  30. package/server/lib/opencode/plugin-routes.test.js +384 -0
  31. package/server/lib/opencode/plugin-spec.js +107 -0
  32. package/server/lib/opencode/plugin-spec.test.js +154 -0
  33. package/server/lib/opencode/plugins.js +393 -0
  34. package/server/lib/opencode/plugins.test.js +176 -0
  35. package/server/lib/opencode/settings-helpers.js +3 -0
  36. package/server/lib/opencode/settings-helpers.test.js +11 -0
  37. package/server/lib/tunnels/DOCUMENTATION.md +1 -0
  38. package/server/lib/tunnels/providers/ngrok.js +117 -0
  39. package/server/lib/tunnels/types.js +2 -0
  40. package/dist/assets/es-dIVpApmS.js +0 -15
  41. package/dist/assets/index-Bk9IWJe1.css +0 -1
  42. package/dist/assets/ko-Cqf3E9-d.js +0 -15
  43. package/dist/assets/main-D45l3Dxw.js +0 -232
  44. package/dist/assets/miniChat-a9w7WM0c.js +0 -2
  45. package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
  46. package/dist/assets/uk-CZ7XVz_D.js +0 -15
  47. package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
  48. /package/dist/assets/{index-DHluop4D.js → index-B9LvUHdG.js} +0 -0
@@ -0,0 +1,393 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import {
5
+ AGENT_SCOPE,
6
+ readConfigFile,
7
+ writeConfig,
8
+ } from './shared.js';
9
+ import { isPathSpec } from './plugin-spec.js';
10
+
11
+ const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
12
+
13
+ /**
14
+ * @typedef {'user' | 'project'} PluginScope
15
+ * @typedef {'npm' | 'path'} PluginParsedKind
16
+ * @typedef {Object} PluginEntry
17
+ * @property {string} id base64url encoded "config:scope:spec"
18
+ * @property {string} spec
19
+ * @property {Record<string, unknown>} [options]
20
+ * @property {PluginScope} scope
21
+ * @property {'config'} kind
22
+ * @property {PluginParsedKind} parsedKind
23
+ * @property {string} sourcePath absolute path to the config file
24
+ * @typedef {Object} PluginFile
25
+ * @property {string} id base64url encoded "file:scope:fileName"
26
+ * @property {string} fileName
27
+ * @property {PluginScope} scope
28
+ * @property {'file'} kind
29
+ * @property {string} absolutePath
30
+ */
31
+
32
+ function codedError(message, code) {
33
+ const error = new Error(message);
34
+ error.code = code;
35
+ return error;
36
+ }
37
+
38
+ function validateScope(scope) {
39
+ if (scope !== AGENT_SCOPE.USER && scope !== AGENT_SCOPE.PROJECT) {
40
+ throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
41
+ }
42
+ }
43
+
44
+ function validatePluginSpec(spec) {
45
+ if (typeof spec !== 'string' || !spec.trim()) {
46
+ throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
47
+ }
48
+ if (spec.includes('\0')) {
49
+ throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
50
+ }
51
+ return spec.trim();
52
+ }
53
+
54
+ function isRecord(value) {
55
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
56
+ }
57
+
58
+ function hasOptions(options) {
59
+ return isRecord(options) && Object.keys(options).length > 0;
60
+ }
61
+
62
+ function parsedKindForSpec(spec) {
63
+ // Path indicators must include Windows paths; scoped npm packages also contain '/'.
64
+ // Do NOT use `includes(path.sep)` — scoped npm packages legitimately contain '/' (e.g. `@gitlab/opencode-gitlab-auth`).
65
+ return isPathSpec(spec) ? 'path' : 'npm';
66
+ }
67
+
68
+ function getActiveOpencodeConfigDir() {
69
+ const customConfigPath = process.env.OPENCODE_CONFIG;
70
+ if (customConfigPath) {
71
+ return path.dirname(path.resolve(customConfigPath));
72
+ }
73
+ return path.join(os.homedir(), '.config', 'opencode');
74
+ }
75
+
76
+ function getActiveUserConfigPaths() {
77
+ const configDir = getActiveOpencodeConfigDir();
78
+ return [
79
+ path.join(configDir, 'config.json'),
80
+ path.join(configDir, 'opencode.json'),
81
+ path.join(configDir, 'opencode.jsonc'),
82
+ ];
83
+ }
84
+
85
+ function getActiveCustomConfigPath() {
86
+ return process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
87
+ }
88
+
89
+ function getPrimaryUserConfigPath() {
90
+ const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
91
+ for (const userPath of [defaultPath, ...fallbackPaths]) {
92
+ if (fs.existsSync(userPath)) {
93
+ return userPath;
94
+ }
95
+ }
96
+ return defaultPath;
97
+ }
98
+
99
+ function getProjectConfigPath(workingDirectory) {
100
+ if (!workingDirectory) return null;
101
+ const candidates = [
102
+ path.join(workingDirectory, 'opencode.json'),
103
+ path.join(workingDirectory, 'opencode.jsonc'),
104
+ path.join(workingDirectory, '.opencode', 'opencode.json'),
105
+ path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
106
+ ];
107
+ return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
108
+ }
109
+
110
+ function readPluginConfigLayers(workingDirectory) {
111
+ const customPath = getActiveCustomConfigPath();
112
+ const userPath = getPrimaryUserConfigPath();
113
+ const projectPath = getProjectConfigPath(workingDirectory);
114
+ return {
115
+ userConfig: readConfigFile(userPath),
116
+ projectConfig: readConfigFile(projectPath),
117
+ customConfig: readConfigFile(customPath),
118
+ paths: {
119
+ userPath,
120
+ projectPath,
121
+ customPath,
122
+ },
123
+ };
124
+ }
125
+
126
+ function validateFileName(fileName) {
127
+ if (typeof fileName !== 'string' || !fileName) {
128
+ throw codedError('Plugin file name is required', 'INVALID_FILENAME');
129
+ }
130
+ if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..') || !PLUGIN_FILE_NAME_PATTERN.test(fileName)) {
131
+ throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
132
+ }
133
+ return fileName;
134
+ }
135
+
136
+ function ensureProjectConfigPath(workingDirectory) {
137
+ if (!workingDirectory) {
138
+ throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
139
+ }
140
+ const configDir = path.join(workingDirectory, '.opencode');
141
+ fs.mkdirSync(configDir, { recursive: true });
142
+ return path.join(configDir, 'opencode.json');
143
+ }
144
+
145
+ function configSources(layers) {
146
+ const sources = [];
147
+ if (layers.paths.customPath) {
148
+ sources.push({ config: layers.customConfig, filePath: layers.paths.customPath, scope: AGENT_SCOPE.USER });
149
+ } else {
150
+ sources.push({ config: layers.userConfig, filePath: layers.paths.userPath, scope: AGENT_SCOPE.USER });
151
+ }
152
+ if (layers.paths.projectPath) {
153
+ sources.push({ config: layers.projectConfig, filePath: layers.paths.projectPath, scope: AGENT_SCOPE.PROJECT });
154
+ }
155
+ return sources;
156
+ }
157
+
158
+ function splitScopedValue(value) {
159
+ const separator = value.indexOf(':');
160
+ if (separator === -1) {
161
+ throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
162
+ }
163
+ return {
164
+ scope: value.slice(0, separator),
165
+ value: value.slice(separator + 1),
166
+ };
167
+ }
168
+
169
+ function getPluginTarget(id, workingDirectory) {
170
+ const decoded = decodePluginId(id);
171
+ if (decoded.prefix !== 'config') {
172
+ throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
173
+ }
174
+ const { scope, value: spec } = splitScopedValue(decoded.value);
175
+ validateScope(scope);
176
+ const layers = readPluginConfigLayers(workingDirectory);
177
+ const source = configSources(layers).find((candidate) => candidate.scope === scope);
178
+ const plugin = Array.isArray(source?.config?.plugin) ? source.config.plugin : [];
179
+ const index = plugin.findIndex((raw) => parsePluginRaw(raw).spec === spec);
180
+ if (!source || index === -1) {
181
+ return null;
182
+ }
183
+ return { source, plugin, index };
184
+ }
185
+
186
+ function pluginDirForScope(scope, workingDirectory) {
187
+ validateScope(scope);
188
+ if (scope === AGENT_SCOPE.PROJECT) {
189
+ if (!workingDirectory) {
190
+ throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
191
+ }
192
+ return path.join(workingDirectory, '.opencode', 'plugins');
193
+ }
194
+ return path.join(getActiveOpencodeConfigDir(), 'plugins');
195
+ }
196
+
197
+ function fileTargetFromId(id, workingDirectory) {
198
+ const decoded = decodePluginId(id);
199
+ if (decoded.prefix !== 'file') {
200
+ throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
201
+ }
202
+ const { scope, value: fileName } = splitScopedValue(decoded.value);
203
+ validateScope(scope);
204
+ validateFileName(fileName);
205
+ return {
206
+ fileName,
207
+ scope,
208
+ absolutePath: path.join(pluginDirForScope(scope, workingDirectory), fileName),
209
+ };
210
+ }
211
+
212
+ function encodePluginId(prefix, value) {
213
+ return Buffer.from(`${prefix}:${value}`).toString('base64url');
214
+ }
215
+
216
+ function decodePluginId(id) {
217
+ const decoded = Buffer.from(id, 'base64url').toString('utf8');
218
+ const separator = decoded.indexOf(':');
219
+ if (separator === -1) {
220
+ throw codedError('Invalid plugin id', 'INVALID_SPEC');
221
+ }
222
+ return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
223
+ }
224
+
225
+ function parsePluginRaw(raw) {
226
+ if (typeof raw === 'string') {
227
+ return { spec: validatePluginSpec(raw) };
228
+ }
229
+ if (Array.isArray(raw) && raw.length === 2 && isRecord(raw[1])) {
230
+ return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
231
+ }
232
+ throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
233
+ }
234
+
235
+ function serializePluginEntry(entry) {
236
+ const spec = validatePluginSpec(entry?.spec);
237
+ if (hasOptions(entry?.options)) {
238
+ return [spec, { ...entry.options }];
239
+ }
240
+ return spec;
241
+ }
242
+
243
+ function listPluginEntries(workingDirectory) {
244
+ const layers = readPluginConfigLayers(workingDirectory);
245
+ return configSources(layers).flatMap((source) => {
246
+ if (!Array.isArray(source.config?.plugin)) {
247
+ return [];
248
+ }
249
+ return source.config.plugin.map((raw) => {
250
+ const parsed = parsePluginRaw(raw);
251
+ return {
252
+ id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
253
+ spec: parsed.spec,
254
+ ...(parsed.options !== undefined ? { options: parsed.options } : {}),
255
+ scope: source.scope,
256
+ kind: 'config',
257
+ parsedKind: parsedKindForSpec(parsed.spec),
258
+ sourcePath: source.filePath,
259
+ };
260
+ });
261
+ });
262
+ }
263
+
264
+ function getPluginEntry(id, workingDirectory) {
265
+ return listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
266
+ }
267
+
268
+ function createPluginEntry(entry, workingDirectory) {
269
+ const spec = validatePluginSpec(entry?.spec);
270
+ const scope = entry?.scope || AGENT_SCOPE.USER;
271
+ validateScope(scope);
272
+
273
+ const layers = readPluginConfigLayers(workingDirectory);
274
+ const existing = configSources(layers).find((source) => (
275
+ source.scope === scope
276
+ && Array.isArray(source.config?.plugin)
277
+ && source.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec)
278
+ ));
279
+ if (existing) {
280
+ throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
281
+ }
282
+
283
+ let targetPath = getPrimaryUserConfigPath();
284
+ let config = {};
285
+ if (scope === AGENT_SCOPE.PROJECT) {
286
+ targetPath = ensureProjectConfigPath(workingDirectory);
287
+ config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
288
+ } else {
289
+ targetPath = layers.paths.customPath || layers.paths.userPath;
290
+ config = layers.paths.customPath ? layers.customConfig : layers.userConfig;
291
+ }
292
+
293
+ if (!Array.isArray(config.plugin)) {
294
+ config.plugin = [];
295
+ }
296
+ config.plugin.push(serializePluginEntry({ spec, options: entry.options }));
297
+ writeConfig(config, targetPath);
298
+ }
299
+
300
+ function updatePluginEntry(id, updates, workingDirectory) {
301
+ const target = getPluginTarget(id, workingDirectory);
302
+ if (!target) {
303
+ throw codedError('Plugin entry not found', 'NOT_FOUND');
304
+ }
305
+ const existing = parsePluginRaw(target.plugin[target.index]);
306
+ const nextSpec = updates?.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
307
+ const nextOptions = updates?.options === undefined ? existing.options : updates.options;
308
+ target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
309
+ writeConfig(target.source.config, target.source.filePath);
310
+ }
311
+
312
+ function deletePluginEntry(id, workingDirectory) {
313
+ const target = getPluginTarget(id, workingDirectory);
314
+ if (!target) {
315
+ throw codedError('Plugin entry not found', 'NOT_FOUND');
316
+ }
317
+ target.plugin.splice(target.index, 1);
318
+ if (target.plugin.length === 0) {
319
+ delete target.source.config.plugin;
320
+ }
321
+ writeConfig(target.source.config, target.source.filePath);
322
+ }
323
+
324
+ function listPluginDirFiles(workingDirectory) {
325
+ const scopes = [AGENT_SCOPE.USER];
326
+ if (workingDirectory) {
327
+ scopes.push(AGENT_SCOPE.PROJECT);
328
+ }
329
+ return scopes.flatMap((scope) => {
330
+ const dir = pluginDirForScope(scope, workingDirectory);
331
+ if (!fs.existsSync(dir)) {
332
+ return [];
333
+ }
334
+ return fs.readdirSync(dir, { withFileTypes: true })
335
+ .filter((entry) => entry.isFile() && PLUGIN_FILE_NAME_PATTERN.test(entry.name) && !entry.name.includes('..'))
336
+ .map((entry) => ({
337
+ id: encodePluginId('file', `${scope}:${entry.name}`),
338
+ fileName: entry.name,
339
+ scope,
340
+ kind: 'file',
341
+ absolutePath: path.join(dir, entry.name),
342
+ }));
343
+ });
344
+ }
345
+
346
+ function readPluginDirFile(id, workingDirectory) {
347
+ const target = fileTargetFromId(id, workingDirectory);
348
+ if (!fs.existsSync(target.absolutePath)) {
349
+ return null;
350
+ }
351
+ return {
352
+ fileName: target.fileName,
353
+ scope: target.scope,
354
+ content: fs.readFileSync(target.absolutePath, 'utf8'),
355
+ };
356
+ }
357
+
358
+ function writePluginDirFile(file, workingDirectory, opts = {}) {
359
+ const fileName = validateFileName(file?.fileName);
360
+ const scope = file?.scope || AGENT_SCOPE.USER;
361
+ validateScope(scope);
362
+ const dir = pluginDirForScope(scope, workingDirectory);
363
+ const absolutePath = path.join(dir, fileName);
364
+ if (!opts.overwrite && fs.existsSync(absolutePath)) {
365
+ throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
366
+ }
367
+ fs.mkdirSync(dir, { recursive: true });
368
+ fs.writeFileSync(absolutePath, file?.content ?? '', 'utf8');
369
+ }
370
+
371
+ function deletePluginDirFile(id, workingDirectory) {
372
+ const target = fileTargetFromId(id, workingDirectory);
373
+ if (!fs.existsSync(target.absolutePath)) {
374
+ throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
375
+ }
376
+ fs.unlinkSync(target.absolutePath);
377
+ }
378
+
379
+ export {
380
+ listPluginEntries,
381
+ getPluginEntry,
382
+ createPluginEntry,
383
+ updatePluginEntry,
384
+ deletePluginEntry,
385
+ listPluginDirFiles,
386
+ readPluginDirFile,
387
+ writePluginDirFile,
388
+ deletePluginDirFile,
389
+ encodePluginId,
390
+ decodePluginId,
391
+ parsePluginRaw,
392
+ serializePluginEntry,
393
+ };
@@ -0,0 +1,176 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ let rootDir;
7
+ let projectDir;
8
+ let userConfigPath;
9
+ let plugins;
10
+
11
+ function thrownBy(fn) {
12
+ try {
13
+ fn();
14
+ } catch (error) {
15
+ return error;
16
+ }
17
+ throw new Error('Expected function to throw');
18
+ }
19
+
20
+ function writeJson(filePath, data) {
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
22
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
23
+ }
24
+
25
+ function readJson(filePath) {
26
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
27
+ }
28
+
29
+ describe('opencode plugins data layer', () => {
30
+ beforeAll(async () => {
31
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugins-'));
32
+ userConfigPath = path.join(rootDir, 'user-opencode.json');
33
+ process.env.OPENCODE_CONFIG = userConfigPath;
34
+ plugins = await import('./plugins.js');
35
+ });
36
+
37
+ beforeEach(() => {
38
+ process.env.OPENCODE_CONFIG = userConfigPath;
39
+ projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
40
+ fs.rmSync(userConfigPath, { force: true });
41
+ });
42
+
43
+ afterAll(() => {
44
+ fs.rmSync(rootDir, { recursive: true, force: true });
45
+ delete process.env.OPENCODE_CONFIG;
46
+ });
47
+
48
+ test('parses raw plugin entries', () => {
49
+ expect(plugins.parsePluginRaw('foo')).toEqual({ spec: 'foo' });
50
+ expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
51
+ expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
52
+ expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
53
+ expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
54
+ });
55
+
56
+ test('serializes plugin entries', () => {
57
+ expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
58
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
59
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
60
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(['foo', { a: 1 }]);
61
+ });
62
+
63
+ test('rejects invalid specs and file names', () => {
64
+ expect(() => plugins.createPluginEntry({ spec: 123, scope: 'user' }, projectDir)).toThrow('Plugin spec');
65
+ expect(() => plugins.writePluginDirFile({ fileName: '', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
66
+ expect(() => plugins.writePluginDirFile({ fileName: '../bad.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
67
+ expect(() => plugins.writePluginDirFile({ fileName: 'a/b.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
68
+ expect(() => plugins.writePluginDirFile({ fileName: 'A.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
69
+ expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
70
+ });
71
+
72
+ test('creates string and tuple entries with duplicate rejection', () => {
73
+ plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
74
+ plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
75
+
76
+ expect(readJson(userConfigPath).plugin).toEqual(['plain-plugin', ['tuple-plugin', { apiKey: 'x' }]]);
77
+ expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
78
+ expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
79
+ });
80
+
81
+ test('routes project entries to project config and user entries to custom user config', () => {
82
+ plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
83
+ plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
84
+
85
+ expect(readJson(userConfigPath).plugin).toEqual(['user-plugin']);
86
+ expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugin).toEqual(['project-plugin']);
87
+ });
88
+
89
+ test('re-resolves custom config env between calls', () => {
90
+ const firstConfigPath = path.join(rootDir, 'first', 'opencode.json');
91
+ const secondConfigPath = path.join(rootDir, 'second', 'opencode.json');
92
+
93
+ process.env.OPENCODE_CONFIG = firstConfigPath;
94
+ plugins.createPluginEntry({ spec: 'first-plugin', scope: 'user' }, projectDir);
95
+ plugins.writePluginDirFile({ fileName: 'first.js', content: 'one', scope: 'user' }, projectDir);
96
+
97
+ process.env.OPENCODE_CONFIG = secondConfigPath;
98
+ plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
99
+ plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
100
+
101
+ expect(readJson(firstConfigPath).plugin).toEqual(['first-plugin']);
102
+ expect(readJson(secondConfigPath).plugin).toEqual(['second-plugin']);
103
+ expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
104
+ expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
105
+ });
106
+
107
+ test('updates entries in place and transitions between string and tuple', () => {
108
+ writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
109
+
110
+ plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
111
+ expect(readJson(userConfigPath).plugin).toEqual(['first', 'second-new', 'third']);
112
+
113
+ plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
114
+ expect(readJson(userConfigPath).plugin).toEqual([['first-new', { b: 2 }], 'second-new', 'third']);
115
+ });
116
+
117
+ test('deletes entries and prunes empty plugin key', () => {
118
+ writeJson(userConfigPath, { plugin: ['only'] });
119
+
120
+ plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:only'), projectDir);
121
+ expect(readJson(userConfigPath)).toEqual({});
122
+ });
123
+
124
+ test('lists entries from user and project layers with scopes and parsed kinds', () => {
125
+ writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
126
+ writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
127
+
128
+ const entries = plugins.listPluginEntries(projectDir);
129
+ expect(entries).toEqual([
130
+ expect.objectContaining({ spec: 'npm-plugin', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
131
+ expect.objectContaining({ spec: '/abs/plugin.js', scope: 'user', kind: 'config', parsedKind: 'path', sourcePath: userConfigPath }),
132
+ expect.objectContaining({ spec: '@scope/pkg@1.0.0', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
133
+ expect.objectContaining({ spec: './local-plugin.js', scope: 'project', kind: 'config', parsedKind: 'path', sourcePath: path.join(projectDir, '.opencode', 'opencode.json') }),
134
+ ]);
135
+ fs.rmSync(userConfigPath, { force: true });
136
+ expect(plugins.listPluginEntries(projectDir)).toEqual([
137
+ expect.objectContaining({ spec: './local-plugin.js', scope: 'project' }),
138
+ ]);
139
+ });
140
+
141
+ test('encodes and decodes ids', () => {
142
+ const id = plugins.encodePluginId('config', 'user:oh-my-openagent@4.3.0');
143
+ expect(plugins.decodePluginId(id)).toEqual({ prefix: 'config', value: 'user:oh-my-openagent@4.3.0' });
144
+ });
145
+
146
+ test('round-trips plugin dir files', () => {
147
+ plugins.writePluginDirFile({ fileName: 'my-plugin.ts', content: 'export default {}', scope: 'project' }, projectDir);
148
+ const file = plugins.listPluginDirFiles(projectDir).find((candidate) => candidate.fileName === 'my-plugin.ts');
149
+
150
+ expect(file).toEqual(expect.objectContaining({ fileName: 'my-plugin.ts', scope: 'project', kind: 'file' }));
151
+ expect(plugins.readPluginDirFile(file.id, projectDir)).toEqual({ fileName: 'my-plugin.ts', scope: 'project', content: 'export default {}' });
152
+ plugins.deletePluginDirFile(file.id, projectDir);
153
+ expect(plugins.listPluginDirFiles(projectDir).filter((candidate) => candidate.scope === 'project')).toEqual([]);
154
+ expect(() => plugins.deletePluginDirFile(file.id, projectDir)).toThrow('not found');
155
+ });
156
+
157
+ test('rejects duplicate plugin dir files unless overwrite is true', () => {
158
+ plugins.writePluginDirFile({ fileName: 'dup.js', content: 'one', scope: 'project' }, projectDir);
159
+ expect(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir)).toThrow('already exists');
160
+ expect(thrownBy(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir))).toHaveProperty('code', 'FILE_EXISTS');
161
+
162
+ plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir, { overwrite: true });
163
+ expect(fs.readFileSync(path.join(projectDir, '.opencode', 'plugins', 'dup.js'), 'utf8')).toBe('two');
164
+ });
165
+
166
+ test('lists only valid plugin dir files', () => {
167
+ const dir = path.join(projectDir, '.opencode', 'plugins');
168
+ fs.mkdirSync(dir, { recursive: true });
169
+ fs.writeFileSync(path.join(dir, 'valid.mjs'), '', 'utf8');
170
+ fs.writeFileSync(path.join(dir, 'README.md'), '', 'utf8');
171
+
172
+ expect(plugins.listPluginDirFiles(projectDir).filter((file) => file.scope === 'project')).toEqual([
173
+ expect.objectContaining({ fileName: 'valid.mjs', scope: 'project' }),
174
+ ]);
175
+ });
176
+ });
@@ -115,6 +115,9 @@ export const createSettingsHelpers = (dependencies) => {
115
115
  if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
116
116
  result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
117
117
  }
118
+ if (typeof candidate.desktopUiPassword === 'string') {
119
+ result.desktopUiPassword = candidate.desktopUiPassword.trim();
120
+ }
118
121
  if (Array.isArray(candidate.projects)) {
119
122
  const projects = sanitizeProjects(candidate.projects);
120
123
  if (projects) {
@@ -52,6 +52,17 @@ describe('settings helpers', () => {
52
52
  });
53
53
  });
54
54
 
55
+ it('accepts desktopUiPassword as a persisted shared setting', () => {
56
+ const helpers = createTestHelpers();
57
+
58
+ expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: ' secret ' })).toEqual({
59
+ desktopUiPassword: 'secret',
60
+ });
61
+ expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: '' })).toEqual({
62
+ desktopUiPassword: '',
63
+ });
64
+ });
65
+
55
66
  it('accepts mobileKeyboardMode as a persisted shared setting', () => {
56
67
  const helpers = createTestHelpers();
57
68
 
@@ -10,6 +10,7 @@ This module contains tunnel provider orchestration for OpenChamber, including pr
10
10
  - `packages/web/server/lib/tunnels/routes.js`: tunnel API route registration and request orchestration runtime.
11
11
  - `packages/web/server/lib/tunnels/types.js`: tunnel constants, normalization, and shared type helpers.
12
12
  - `packages/web/server/lib/tunnels/providers/cloudflare.js`: Cloudflare tunnel provider implementation.
13
+ - `packages/web/server/lib/tunnels/providers/ngrok.js`: Ngrok quick tunnel provider implementation.
13
14
 
14
15
  ## Public exports (routes.js)
15
16
  - `createTunnelRoutesRuntime(dependencies)`: creates tunnel routes runtime and helpers.