@myrialabs/clopen 0.1.9 → 0.2.0

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 (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
5
5
  "author": "Myria Labs",
6
6
  "license": "MIT",
@@ -62,6 +62,7 @@
62
62
  "@types/bun": "^1.2.18",
63
63
  "@types/node": "^24.0.14",
64
64
  "@types/qrcode": "^1.5.6",
65
+ "@types/random-useragent": "^0.3.3",
65
66
  "concurrently": "^9.2.1",
66
67
  "eslint": "^9.31.0",
67
68
  "eslint-plugin-svelte": "^3.10.1",
@@ -74,14 +75,14 @@
74
75
  "vite": "^7.0.4"
75
76
  },
76
77
  "dependencies": {
77
- "@anthropic-ai/claude-agent-sdk": "^0.2.63",
78
- "@anthropic-ai/sdk": "^0.78.0",
78
+ "@anthropic-ai/claude-agent-sdk": "0.2.63",
79
+ "@anthropic-ai/sdk": "0.78.0",
80
+ "@opencode-ai/sdk": "1.2.15",
79
81
  "@elysiajs/cors": "^1.4.0",
80
82
  "@iconify-json/lucide": "^1.2.57",
81
83
  "@iconify-json/material-icon-theme": "^1.2.16",
82
84
  "@modelcontextprotocol/sdk": "^1.26.0",
83
85
  "@monaco-editor/loader": "^1.5.0",
84
- "@opencode-ai/sdk": "^1.2.15",
85
86
  "@xterm/addon-clipboard": "^0.2.0",
86
87
  "@xterm/addon-fit": "^0.11.0",
87
88
  "@xterm/addon-ligatures": "^0.10.0",
@@ -96,8 +97,9 @@
96
97
  "marked": "^16.1.1",
97
98
  "monaco-editor": "^0.52.2",
98
99
  "nanoid": "^5.1.6",
99
- "puppeteer": "^24.33.0",
100
- "puppeteer-cluster": "^0.25.0",
100
+ "puppeteer": "^24.37.5",
101
+ "puppeteer-extra": "^3.3.6",
102
+ "puppeteer-extra-plugin-stealth": "^2.11.2",
101
103
  "qrcode": "^1.5.4"
102
104
  },
103
105
  "trustedDependencies": [
@@ -1,5 +1,6 @@
1
1
  import type { EngineType } from '$shared/types/engine';
2
2
 
3
+ /** Per-user settings (stored per user) */
3
4
  export interface AppSettings {
4
5
  selectedEngine: EngineType;
5
6
  selectedModel: string;
@@ -10,10 +11,23 @@ export interface AppSettings {
10
11
  soundNotifications: boolean;
11
12
  pushNotifications: boolean;
12
13
  layoutPresetVisibility: Record<string, boolean>;
13
- /** Restrict folder browser to only these base paths. Empty = no restriction. */
14
- allowedBasePaths: string[];
15
14
  /** Base font size in pixels (10–20). Default: 13. */
16
15
  fontSize: number;
16
+ }
17
+
18
+ /** Authentication mode */
19
+ export type AuthMode = 'none' | 'required';
20
+
21
+ /** System-wide settings (admin-only, shared across all users) */
22
+ export interface SystemSettings {
23
+ /** Authentication mode: 'none' = single user no login, 'required' = multi-user with login. Default: 'required'. */
24
+ authMode: AuthMode;
25
+ /** Whether the initial setup wizard has been completed. Default: false. */
26
+ onboardingComplete: boolean;
27
+ /** Restrict folder browser to only these base paths. Empty = no restriction. */
28
+ allowedBasePaths: string[];
17
29
  /** Automatically update to the latest version when available. Default: false. */
18
30
  autoUpdate: boolean;
31
+ /** Session lifetime in days. Default: 30. */
32
+ sessionLifetimeDays: number;
19
33
  }
@@ -39,6 +39,7 @@ export type LogLabel =
39
39
  // User
40
40
  | 'user'
41
41
  | 'session'
42
+ | 'auth'
42
43
 
43
44
  // System
44
45
  | 'server'
@@ -221,6 +221,9 @@ export class WSClient<TAPI extends { client: any; server: any }> {
221
221
  projectId: null
222
222
  };
223
223
 
224
+ /** Session token for re-authentication on reconnection */
225
+ private sessionToken: string | null = null;
226
+
224
227
  /** Pending context sync (for reconnection) */
225
228
  private pendingContextSync = false;
226
229
 
@@ -272,8 +275,18 @@ export class WSClient<TAPI extends { client: any; server: any }> {
272
275
  this.reconnectAttempts = 0;
273
276
  this.options.onStatusChange?.('connected', 0);
274
277
 
275
- // Sync context on reconnection - MUST await before flushing queue
276
- if (this.context.userId || this.context.projectId) {
278
+ // Re-authenticate on reconnection using stored session token
279
+ if (this.sessionToken) {
280
+ try {
281
+ await this.http('auth:login' as any, { token: this.sessionToken } as any);
282
+ debug.log('websocket', 'Re-authenticated after reconnection');
283
+ } catch (err) {
284
+ debug.error('websocket', 'Failed to re-authenticate on reconnection:', err);
285
+ }
286
+ }
287
+
288
+ // Sync project context on reconnection (userId set by auth above)
289
+ if (this.context.projectId) {
277
290
  try {
278
291
  await this.syncContext();
279
292
  debug.log('websocket', 'Context synced after reconnection');
@@ -570,18 +583,23 @@ export class WSClient<TAPI extends { client: any; server: any }> {
570
583
  private contextSyncPromise: Promise<void> | null = null;
571
584
 
572
585
  /**
573
- * Set user context (auto-syncs with server)
574
- * @returns Promise that resolves when context is synced with server
586
+ * Set session token for reconnection auth.
587
+ * Called by auth store after successful login.
588
+ */
589
+ setSessionToken(token: string | null): void {
590
+ this.sessionToken = token;
591
+ debug.log('websocket', 'Session token set for reconnection auth');
592
+ }
593
+
594
+ /**
595
+ * Set user context locally (for reconnection tracking).
596
+ * userId is now set server-side by auth handlers — this only updates the local cache.
575
597
  */
576
598
  async setUser(userId: string | null): Promise<void> {
577
599
  if (this.context.userId === userId) return;
578
-
579
600
  this.context.userId = userId;
580
- debug.log('websocket', 'Context: user set to', userId);
581
-
582
- if (this.isConnected) {
583
- await this.syncContext();
584
- }
601
+ debug.log('websocket', 'Context: user set locally to', userId);
602
+ // Note: userId is NOT synced via ws:set-context — it's set server-side by auth handlers
585
603
  }
586
604
 
587
605
  /**
@@ -679,16 +697,15 @@ export class WSClient<TAPI extends { client: any; server: any }> {
679
697
  // Register response listener
680
698
  unsubResponse = this.on('ws:set-context:response' as any, handleResponse);
681
699
 
682
- // Send context sync request
700
+ // Send context sync request (only projectId — userId is set server-side by auth)
683
701
  this.emit('ws:set-context' as any, {
684
702
  requestId,
685
703
  data: {
686
- userId: this.context.userId,
687
704
  projectId: this.context.projectId
688
705
  }
689
706
  } as any);
690
707
 
691
- debug.log('websocket', 'Context sync sent:', this.context);
708
+ debug.log('websocket', 'Context sync sent: projectId=', this.context.projectId);
692
709
  });
693
710
  }
694
711
 
@@ -279,11 +279,23 @@ export class WSRouter<
279
279
  private httpRoutes = new Map<string, HTTPRoute>();
280
280
  private eventSchemas = new Map<string, TSchema>();
281
281
 
282
+ /** Optional auth middleware — called before every route handler */
283
+ private authMiddleware: ((conn: WSConnection, action: string) => Promise<{ allowed: boolean; error?: string }>) | null = null;
284
+
282
285
  constructor() {
283
286
  // Register built-in context management route
284
287
  this.registerContextHandler();
285
288
  }
286
289
 
290
+ /**
291
+ * Set an auth middleware function that gates all route handlers.
292
+ * The middleware receives the connection and action, and returns { allowed, error? }.
293
+ * If not allowed, the handler is not called and an auth:error event is sent to the client.
294
+ */
295
+ setAuthMiddleware(fn: (conn: WSConnection, action: string) => Promise<{ allowed: boolean; error?: string }>): void {
296
+ this.authMiddleware = fn;
297
+ }
298
+
287
299
  /**
288
300
  * Register built-in ws:set-context handler
289
301
  * Allows frontend to sync user/project context
@@ -292,7 +304,6 @@ export class WSRouter<
292
304
  this.httpRoutes.set('ws:set-context', {
293
305
  action: 'ws:set-context',
294
306
  dataSchema: t.Object({
295
- userId: t.Optional(t.Union([t.String(), t.Null()])),
296
307
  projectId: t.Optional(t.Union([t.String(), t.Null()]))
297
308
  }),
298
309
  responseSchema: t.Object({
@@ -303,9 +314,8 @@ export class WSRouter<
303
314
  // Import ws server to update context
304
315
  const { ws: wsServer } = await import('$backend/lib/utils/ws');
305
316
 
306
- if (data.userId !== undefined) {
307
- wsServer.setUser(conn, data.userId);
308
- }
317
+ // userId is set exclusively by auth handlers (auth:login, auth:setup, auth:accept-invite)
318
+ // ws:set-context only handles projectId
309
319
  if (data.projectId !== undefined) {
310
320
  wsServer.setProject(conn, data.projectId);
311
321
  }
@@ -576,6 +586,34 @@ export class WSRouter<
576
586
  return;
577
587
  }
578
588
 
589
+ // ═══ AUTH GATE ═══
590
+ if (this.authMiddleware) {
591
+ const authResult = await this.authMiddleware(conn, action);
592
+ if (!authResult.allowed) {
593
+ try {
594
+ // If this is an HTTP route, send error as HTTP-style response
595
+ // so ws.http() on the client receives it instead of timing out
596
+ if (this.httpRoutes.has(action)) {
597
+ const requestId = payload?.requestId;
598
+ conn.send(JSON.stringify({
599
+ action: `${action}:response`,
600
+ payload: { success: false, error: authResult.error, requestId }
601
+ }));
602
+ } else {
603
+ conn.send(JSON.stringify({
604
+ action: 'auth:error',
605
+ payload: { error: authResult.error, blockedAction: action }
606
+ }));
607
+ }
608
+ } catch {
609
+ // Connection may be closed
610
+ }
611
+ debug.warn('websocket', `Auth blocked: ${action} — ${authResult.error}`);
612
+ return;
613
+ }
614
+ }
615
+ // ═══ END AUTH GATE ═══
616
+
579
617
  // Check if this is an HTTP route
580
618
  const httpRoute = this.httpRoutes.get(action);
581
619
  if (httpRoute) {
@@ -1,103 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * MCP Stdio Server for Open Code
4
- *
5
- * Standalone subprocess that exposes our custom MCP tools via stdio transport.
6
- * Open Code spawns this process and communicates over stdin/stdout (JSON-RPC 2.0).
7
- *
8
- * Tool definitions (schema, description) are loaded from the SAME source as
9
- * Claude Code — the `serverMetadata` registry in `./servers/index.ts`.
10
- *
11
- * ALL tool calls are proxied to the main Clopen server via WSClient bridge,
12
- * because handlers need in-process access to browser instances, project context, etc.
13
- */
14
-
15
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
- import { WSClient } from '$shared/utils/ws-client';
18
- import { serverMetadata } from './servers/index';
19
- import { mcpServers } from './config';
20
-
21
- // ============================================================================
22
- // WebSocket Bridge — proxies tool calls to the main Clopen server via WSClient
23
- // ============================================================================
24
-
25
- const BRIDGE_PORT = process.env.CLOPEN_PORT || '9141';
26
- const WS_URL = `ws://localhost:${BRIDGE_PORT}/ws`;
27
-
28
- /**
29
- * WSClient instance for communicating with the main Clopen server.
30
- * Uses the same protocol as the frontend (JSON messages with action/payload).
31
- */
32
- const wsClient = new WSClient<any>(WS_URL, {
33
- autoReconnect: true,
34
- maxReconnectAttempts: 10,
35
- reconnectDelay: 1000,
36
- maxReconnectDelay: 10000,
37
- });
38
-
39
- /**
40
- * Call a tool handler on the main Clopen server via the WS bridge.
41
- * Uses WSClient.http() which follows the standard request-response pattern.
42
- */
43
- async function callBridge(
44
- serverName: string,
45
- toolName: string,
46
- args: Record<string, unknown>
47
- ): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }> {
48
- try {
49
- return await wsClient.http('mcp:execute' as any, {
50
- server: serverName,
51
- tool: toolName,
52
- args,
53
- } as any, 30000);
54
- } catch (error) {
55
- const msg = error instanceof Error ? error.message : String(error);
56
- return {
57
- content: [{ type: 'text', text: `Bridge connection failed: ${msg}` }],
58
- isError: true,
59
- };
60
- }
61
- }
62
-
63
- // ============================================================================
64
- // Build MCP server from existing server metadata (single source of truth)
65
- // ============================================================================
66
-
67
- const server = new McpServer({
68
- name: 'clopen-mcp',
69
- version: '1.0.0',
70
- });
71
-
72
- // Iterate over all configured servers and register their enabled tools
73
- for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
74
- if (!serverConfig.enabled) continue;
75
-
76
- // Get tool definitions from the metadata registry
77
- const meta = (serverMetadata as Record<string, { toolDefs: Record<string, { description: string; schema: Record<string, any> }> }>)[serverName];
78
- if (!meta) continue;
79
-
80
- for (const toolName of serverConfig.tools) {
81
- const toolDef = meta.toolDefs[toolName];
82
- if (!toolDef) continue;
83
-
84
- // Register tool with the same schema/description, but handler goes through bridge
85
- server.registerTool(
86
- toolName,
87
- {
88
- description: toolDef.description,
89
- inputSchema: toolDef.schema,
90
- },
91
- async (args: Record<string, unknown>) => {
92
- return await callBridge(serverName, toolName, args) as any;
93
- }
94
- );
95
- }
96
- }
97
-
98
- // ============================================================================
99
- // Connect via stdio transport
100
- // ============================================================================
101
-
102
- const transport = new StdioServerTransport();
103
- await server.connect(transport);
@@ -1,61 +0,0 @@
1
- /**
2
- * MCP Bridge — WebSocket HTTP-like route for the stdio MCP server
3
- *
4
- * Provides a WS route that the MCP stdio server (spawned by Open Code)
5
- * uses to execute tool handlers in-process.
6
- *
7
- * Handlers are loaded from the SAME serverMetadata registry —
8
- * no duplication of tool names or handler imports.
9
- *
10
- * Route: mcp:execute (WS http-like request-response)
11
- */
12
-
13
- import { createRouter } from '$shared/utils/ws-server';
14
- import { t } from 'elysia';
15
- import { debug } from '$shared/utils/logger';
16
- import { serverMetadata } from '../../lib/mcp/servers/index';
17
- import { mcpServers } from '../../lib/mcp/config';
18
-
19
- export const mcpRouter = createRouter()
20
- .http('mcp:execute', {
21
- data: t.Object({
22
- server: t.String(),
23
- tool: t.String(),
24
- args: t.Record(t.String(), t.Unknown()),
25
- }),
26
- response: t.Any(),
27
- }, async ({ data }) => {
28
- const { server, tool, args } = data;
29
-
30
- debug.log('mcp', `🌉 Bridge: ${server}/${tool}`);
31
-
32
- // Validate server exists and is enabled
33
- const serverConfig = mcpServers[server];
34
- if (!serverConfig?.enabled) {
35
- return {
36
- content: [{ type: 'text', text: `Unknown or disabled MCP server: ${server}` }],
37
- isError: true,
38
- };
39
- }
40
-
41
- // Get handler from the single-source metadata registry
42
- const meta = (serverMetadata as Record<string, { toolDefs: Record<string, { handler: (args: any) => Promise<any> }> }>)[server];
43
- const toolDef = meta?.toolDefs[tool];
44
- if (!toolDef) {
45
- return {
46
- content: [{ type: 'text', text: `Unknown tool: ${tool} in server ${server}` }],
47
- isError: true,
48
- };
49
- }
50
-
51
- try {
52
- return await toolDef.handler(args);
53
- } catch (error) {
54
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
55
- debug.error('mcp', `🌉 Bridge error: ${server}/${tool}: ${errorMessage}`);
56
- return {
57
- content: [{ type: 'text', text: `Tool execution error: ${errorMessage}` }],
58
- isError: true,
59
- };
60
- }
61
- });