@bibobonking/opensol 0.0.0-stage → 3.0.1

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 (114) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <defs>
3
+ <radialGradient id="g" cx="32" cy="30" r="22" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#ffd25a"/>
5
+ <stop offset="1" stop-color="#f59e0b"/>
6
+ </radialGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="16" fill="#141413"/>
9
+ <rect x=".75" y=".75" width="62.5" height="62.5" rx="15.25" stroke="#fff" stroke-opacity=".08" stroke-width="1.5"/>
10
+ <circle cx="32" cy="32" r="11" fill="url(#g)"/>
11
+ <g stroke="#f59e0b" stroke-width="4" stroke-linecap="round">
12
+ <path d="M32 9v6M32 49v6M9 32h6M49 32h6"/>
13
+ <path d="M15.7 15.7l4.2 4.2M44.1 44.1l4.2 4.2M15.7 48.3l4.2-4.2M44.1 19.9l4.2-4.2" opacity=".55"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,55 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#ffffff" />
7
+ <meta
8
+ name="description"
9
+ content="OpenSol by OpenSoft is a local-first coding agent. Work in a real folder, run commands, edit files and deploy with your own model keys. Keys stay encrypted on your machine."
10
+ />
11
+ <title>OpenSol — Local coding agent by OpenSoft</title>
12
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
13
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
14
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
+ <link
16
+ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"
17
+ rel="stylesheet"
18
+ />
19
+ <script>
20
+ try {
21
+ const NAMED = ['light', 'dark', 'night-owl', 'claude-code', 'flame']
22
+ const DARK = ['dark', 'night-owl', 'claude-code', 'flame']
23
+ const stored = localStorage.getItem('opensol.theme') || localStorage.getItem('maxcode.theme')
24
+ const theme = NAMED.includes(stored)
25
+ ? stored
26
+ : window.matchMedia('(prefers-color-scheme: dark)').matches
27
+ ? 'dark'
28
+ : 'light'
29
+ const root = document.documentElement
30
+ root.dataset.theme = theme
31
+ if (DARK.includes(theme)) root.classList.add('dark')
32
+ } catch {}
33
+ </script>
34
+ <script type="module" crossorigin src="./assets/index-DgNDjkOz.js"></script>
35
+ <link rel="stylesheet" crossorigin href="./assets/index-BmN7pBji.css">
36
+ </head>
37
+ <body>
38
+ <div id="root"><div class="splash" id="boot-splash" aria-hidden="true">
39
+ <style>
40
+ .splash{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgb(var(--panel))}
41
+ .bs{display:flex;flex-direction:column;align-items:center;gap:18px}
42
+ .bs svg{color:rgb(var(--accent))}
43
+ .bs-word{font:600 22px/1 Geist,Inter,system-ui,sans-serif;letter-spacing:-.03em;color:rgb(var(--ink))}
44
+ .bs-bar{width:120px;height:2px;border-radius:2px;background:rgb(var(--line));overflow:hidden;position:relative}
45
+ .bs-bar::after{content:"";position:absolute;inset:0;width:40%;background:linear-gradient(90deg,transparent,rgb(var(--accent)),transparent);animation:bs-sweep 1.4s cubic-bezier(.65,0,.35,1) infinite}
46
+ @keyframes bs-sweep{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}
47
+ </style>
48
+ <div class="bs">
49
+ <svg viewBox="0 0 64 64" width="56" height="56" fill="none"><circle cx="32" cy="32" r="12" fill="currentColor"/><g stroke="currentColor" stroke-width="4.4" stroke-linecap="round"><path d="M32 7v7M32 50v7M7 32h7M50 32h7"/><path d="M14.3 14.3l5 5M44.7 44.7l5 5M14.3 49.7l5-5M44.7 19.3l5-5" opacity=".55"/></g></svg>
50
+ <div class="bs-word">OpenSol</div>
51
+ <div class="bs-bar"></div>
52
+ </div>
53
+ </div></div>
54
+ </body>
55
+ </html>
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenSol preview</title>
7
+ <style>
8
+ html,
9
+ body {
10
+ margin: 0;
11
+ height: 100%;
12
+ background: #fff;
13
+ color: #1f1f1d;
14
+ font: 13px/1.5 system-ui, sans-serif;
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <script>
20
+ // Hosts model-written HTML inside a sandboxed frame (no same-origin access). The parent
21
+ // posts the document once this page reports it is ready.
22
+ window.addEventListener('message', function (event) {
23
+ if (event.source !== window.parent) return
24
+ var data = event.data
25
+ if (!data || data.type !== 'maxcode-preview' || typeof data.html !== 'string') return
26
+ document.open()
27
+ document.write(data.html)
28
+ document.close()
29
+ })
30
+ window.parent.postMessage({ type: 'maxcode-preview-ready' }, '*')
31
+ </script>
32
+ </body>
33
+ </html>
package/index.html ADDED
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#ffffff" />
7
+ <meta
8
+ name="description"
9
+ content="OpenSol by OpenSoft is a local-first coding agent. Work in a real folder, run commands, edit files and deploy with your own model keys. Keys stay encrypted on your machine."
10
+ />
11
+ <title>OpenSol — Local coding agent by OpenSoft</title>
12
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
13
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
14
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
+ <link
16
+ href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap"
17
+ rel="stylesheet"
18
+ />
19
+ <script>
20
+ try {
21
+ const NAMED = ['light', 'dark', 'night-owl', 'claude-code', 'flame']
22
+ const DARK = ['dark', 'night-owl', 'claude-code', 'flame']
23
+ const stored = localStorage.getItem('opensol.theme') || localStorage.getItem('maxcode.theme')
24
+ const theme = NAMED.includes(stored)
25
+ ? stored
26
+ : window.matchMedia('(prefers-color-scheme: dark)').matches
27
+ ? 'dark'
28
+ : 'light'
29
+ const root = document.documentElement
30
+ root.dataset.theme = theme
31
+ if (DARK.includes(theme)) root.classList.add('dark')
32
+ } catch {}
33
+ </script>
34
+ </head>
35
+ <body>
36
+ <div id="root"><div class="splash" id="boot-splash" aria-hidden="true">
37
+ <style>
38
+ .splash{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgb(var(--panel))}
39
+ .bs{display:flex;flex-direction:column;align-items:center;gap:18px}
40
+ .bs svg{color:rgb(var(--accent))}
41
+ .bs-word{font:600 22px/1 Geist,Inter,system-ui,sans-serif;letter-spacing:-.03em;color:rgb(var(--ink))}
42
+ .bs-bar{width:120px;height:2px;border-radius:2px;background:rgb(var(--line));overflow:hidden;position:relative}
43
+ .bs-bar::after{content:"";position:absolute;inset:0;width:40%;background:linear-gradient(90deg,transparent,rgb(var(--accent)),transparent);animation:bs-sweep 1.4s cubic-bezier(.65,0,.35,1) infinite}
44
+ @keyframes bs-sweep{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}
45
+ </style>
46
+ <div class="bs">
47
+ <svg viewBox="0 0 64 64" width="56" height="56" fill="none"><circle cx="32" cy="32" r="12" fill="currentColor"/><g stroke="currentColor" stroke-width="4.4" stroke-linecap="round"><path d="M32 7v7M32 50v7M7 32h7M50 32h7"/><path d="M14.3 14.3l5 5M44.7 44.7l5 5M14.3 49.7l5-5M44.7 19.3l5-5" opacity=".55"/></g></svg>
48
+ <div class="bs-word">OpenSol</div>
49
+ <div class="bs-bar"></div>
50
+ </div>
51
+ </div></div>
52
+ <script type="module" src="/src/main.tsx"></script>
53
+ </body>
54
+ </html>
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@bibobonking/opensol",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "private": false,
4
+ "version": "3.0.1",
5
+ "type": "module",
6
+ "bin": {
7
+ "opensol": "./server/opensol.mjs"
8
+ },
9
+ "scripts": {
10
+ "dev": "node server/opensol.mjs start && vite",
11
+ "build": "tsc -b && vite build",
12
+ "preview": "vite preview",
13
+ "typecheck": "tsc -b --pretty",
14
+ "test": "node --test",
15
+ "start": "node server/opensol.mjs start --open",
16
+ "daemon": "node server/opensol.mjs start",
17
+ "daemon:foreground": "node server/daemon.mjs",
18
+ "daemon:stop": "node server/opensol.mjs stop",
19
+ "daemon:status": "node server/opensol.mjs status",
20
+ "daemon:logs": "node server/opensol.mjs logs"
21
+ },
22
+ "dependencies": {
23
+ "clsx": "^2.1.1",
24
+ "cordis": "^4.0.0-rc.12",
25
+ "fflate": "^0.8.2",
26
+ "highlight.js": "^11.11.1",
27
+ "lucide-react": "^0.544.0",
28
+ "react": "^19.1.1",
29
+ "react-dom": "^19.1.1",
30
+ "react-markdown": "^10.1.0",
31
+ "rehype-highlight": "^7.0.2",
32
+ "remark-gfm": "^4.0.1",
33
+ "zustand": "^5.0.8"
34
+ },
35
+ "devDependencies": {
36
+ "@types/react": "^19.1.10",
37
+ "@types/react-dom": "^19.1.7",
38
+ "@vitejs/plugin-react": "^5.0.0",
39
+ "autoprefixer": "^10.4.21",
40
+ "postcss": "^8.5.6",
41
+ "tailwindcss": "^3.4.19",
42
+ "typescript": "^5.9.2",
43
+ "vite": "^7.1.3"
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <defs>
3
+ <radialGradient id="g" cx="32" cy="30" r="22" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#ffd25a"/>
5
+ <stop offset="1" stop-color="#f59e0b"/>
6
+ </radialGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="16" fill="#141413"/>
9
+ <rect x=".75" y=".75" width="62.5" height="62.5" rx="15.25" stroke="#fff" stroke-opacity=".08" stroke-width="1.5"/>
10
+ <circle cx="32" cy="32" r="11" fill="url(#g)"/>
11
+ <g stroke="#f59e0b" stroke-width="4" stroke-linecap="round">
12
+ <path d="M32 9v6M32 49v6M9 32h6M49 32h6"/>
13
+ <path d="M15.7 15.7l4.2 4.2M44.1 44.1l4.2 4.2M15.7 48.3l4.2-4.2M44.1 19.9l4.2-4.2" opacity=".55"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenSol preview</title>
7
+ <style>
8
+ html,
9
+ body {
10
+ margin: 0;
11
+ height: 100%;
12
+ background: #fff;
13
+ color: #1f1f1d;
14
+ font: 13px/1.5 system-ui, sans-serif;
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <script>
20
+ // Hosts model-written HTML inside a sandboxed frame (no same-origin access). The parent
21
+ // posts the document once this page reports it is ready.
22
+ window.addEventListener('message', function (event) {
23
+ if (event.source !== window.parent) return
24
+ var data = event.data
25
+ if (!data || data.type !== 'maxcode-preview' || typeof data.html !== 'string') return
26
+ document.open()
27
+ document.write(data.html)
28
+ document.close()
29
+ })
30
+ window.parent.postMessage({ type: 'maxcode-preview-ready' }, '*')
31
+ </script>
32
+ </body>
33
+ </html>
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildPluginArchive, readPluginArchive, validatePluginManifest } from './plugin-package.mjs'
4
+
5
+ const manifest = {
6
+ id: 'demo-plugin',
7
+ name: 'Demo plugin',
8
+ version: '1.0.0',
9
+ apiVersion: 1,
10
+ entry: 'main.js',
11
+ stylesheet: 'main.css',
12
+ capabilities: ['ui.commands'],
13
+ domains: [],
14
+ }
15
+
16
+ test('a built package reopens with its sources and assets intact', () => {
17
+ const archive = buildPluginArchive({
18
+ manifest,
19
+ files: { 'main.js': 'maxcode.log("hi")', 'main.css': '.a{}', 'assets/icon.txt': 'icon' },
20
+ })
21
+ const reopened = readPluginArchive(archive)
22
+ assert.equal(reopened.manifest.id, 'demo-plugin')
23
+ assert.equal(reopened.files['main.js'], 'maxcode.log("hi")')
24
+ assert.equal(reopened.files['main.css'], '.a{}')
25
+ assert.equal(reopened.files['assets/icon.txt'], 'icon')
26
+ })
27
+
28
+ test('a manifest may omit domains entirely — a plugin with no network declares none', () => {
29
+ const { domains, ...withoutDomains } = manifest
30
+ assert.deepEqual(validatePluginManifest(withoutDomains).domains, [])
31
+ assert.deepEqual(readPluginArchive(buildPluginArchive({
32
+ manifest: withoutDomains,
33
+ files: { 'main.js': 'maxcode.log("hi")', 'main.css': '' },
34
+ })).manifest.domains, [])
35
+ assert.throws(() => validatePluginManifest({ ...manifest, domains: 'api.example.com' }), /domains must be an array/)
36
+ })
37
+
38
+ test('the builder rejects an invalid manifest before packaging', () => {
39
+ assert.throws(() => validatePluginManifest({ ...manifest, id: 'Bad Id' }), /Invalid plugin package/)
40
+ assert.throws(() => validatePluginManifest({ ...manifest, entry: 'main.ts' }), /entry must be JavaScript/)
41
+ assert.throws(() => validatePluginManifest({ ...manifest, capabilities: ['shell.exec'] }), /capabilit/i)
42
+ assert.throws(() => validatePluginManifest({ ...manifest, domains: ['https://api.example.com'] }), /domain/i)
43
+ })
@@ -0,0 +1,34 @@
1
+ export type PluginCapability =
2
+ | 'workspace.list'
3
+ | 'workspace.read'
4
+ | 'workspace.write'
5
+ | 'workspace.edit'
6
+ | 'ui.commands'
7
+ | 'ui.panels'
8
+ | 'ui.themes'
9
+ | 'network.request'
10
+
11
+ export interface PluginManifest {
12
+ id: string
13
+ name: string
14
+ version: string
15
+ apiVersion: 1 | 2
16
+ entry: string
17
+ stylesheet: string
18
+ capabilities: PluginCapability[]
19
+ domains: string[]
20
+ }
21
+
22
+ export interface PluginPackageSources {
23
+ manifest: PluginManifest
24
+ files: Record<string, string | Uint8Array>
25
+ }
26
+
27
+ export interface PluginPackage {
28
+ manifest: PluginManifest
29
+ files: Record<string, string>
30
+ }
31
+
32
+ export function validatePluginManifest(value: unknown): PluginManifest
33
+ export function buildPluginArchive(sources: PluginPackageSources): Uint8Array
34
+ export function readPluginArchive(bytes: Uint8Array): PluginPackage
@@ -0,0 +1,197 @@
1
+ const MAX_ARCHIVE_BYTES = 5 * 1024 * 1024
2
+ const MAX_PACKAGE_BYTES = 10 * 1024 * 1024
3
+ const MAX_FILES = 100
4
+ const MAX_MANIFEST_BYTES = 64 * 1024
5
+ const CAPABILITIES = new Set([
6
+ 'workspace.list',
7
+ 'workspace.read',
8
+ 'workspace.write',
9
+ 'workspace.edit',
10
+ 'ui.commands',
11
+ 'ui.panels',
12
+ 'ui.themes',
13
+ 'network.request',
14
+ ])
15
+ const MANIFEST_KEYS = new Set([
16
+ 'id',
17
+ 'name',
18
+ 'version',
19
+ 'apiVersion',
20
+ 'entry',
21
+ 'stylesheet',
22
+ 'capabilities',
23
+ 'domains',
24
+ ])
25
+
26
+ function packageError(message) {
27
+ throw new Error(`Invalid plugin package: ${message}`)
28
+ }
29
+
30
+ function bytesToString(bytes) {
31
+ if (typeof bytes === 'string') return bytes
32
+ return new TextDecoder('utf-8').decode(bytes)
33
+ }
34
+
35
+ function stringToBytes(str) {
36
+ return new TextEncoder().encode(str)
37
+ }
38
+
39
+ function validateSource(path, content, apiVersion) {
40
+ // API v2: skip validation — full kernel access, blob isolation is enough
41
+ if (apiVersion === 2) return
42
+
43
+ const source = bytesToString(content)
44
+ const rule = path.endsWith('.js')
45
+ ? [
46
+ /\b(?:import|export)\b|\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|importScripts)\s*\(|\bnavigator\s*\.\s*sendBeacon\s*\(/i,
47
+ 'module imports and unmanaged network calls are forbidden',
48
+ ]
49
+ : path.endsWith('.css')
50
+ ? [
51
+ /@import\b|url\s*\(\s*(['"]?)(?!data:image\/(?:png|jpeg|gif|webp);base64,)[^)]*\)/i,
52
+ 'external CSS resources and imports are forbidden',
53
+ ]
54
+ : null
55
+ if (!rule) return
56
+ const found = rule[0].exec(source)
57
+ if (!found) return
58
+ const line = source.slice(0, found.index).split('\n').length
59
+ const hit = found[0].length > 60 ? `${found[0].slice(0, 60)}…` : found[0]
60
+ packageError(`${rule[1]}: found "${hit}" at ${path}:${line}`)
61
+ }
62
+
63
+ function safePath(value) {
64
+ if (
65
+ typeof value !== 'string' ||
66
+ !value ||
67
+ value.includes('\0') ||
68
+ value.includes('\\') ||
69
+ value.startsWith('/') ||
70
+ /^[a-zA-Z]:/.test(value) ||
71
+ value.split('/').some((part) => !part || part === '.' || part === '..')
72
+ ) {
73
+ packageError(`unsafe path "${String(value)}"`)
74
+ }
75
+ return value
76
+ }
77
+
78
+ function validateDomain(value) {
79
+ if (typeof value !== 'string' || value.length > 253 || value.endsWith('.')) {
80
+ packageError('invalid domain')
81
+ }
82
+ const labels = value.split('.')
83
+ if (
84
+ labels.length < 2 ||
85
+ labels.some((label) => !/^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label))
86
+ ) {
87
+ packageError(`invalid domain "${value}"`)
88
+ }
89
+ return value.toLowerCase()
90
+ }
91
+
92
+ export function validatePluginManifest(value) {
93
+ if (!value || typeof value !== 'object' || Array.isArray(value)) packageError('manifest must be an object')
94
+ if (Object.keys(value).some((key) => !MANIFEST_KEYS.has(key))) packageError('unknown manifest field')
95
+ const { id, name, version, apiVersion, entry, stylesheet, capabilities, domains } = value
96
+ if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{1,63}$/.test(id)) packageError('invalid plugin id')
97
+ if (typeof name !== 'string' || !name.trim() || name.length > 100) packageError('invalid plugin name')
98
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
99
+ packageError('invalid plugin version')
100
+ }
101
+ if (apiVersion !== 1 && apiVersion !== 2) packageError('unsupported API version')
102
+ safePath(entry)
103
+ safePath(stylesheet)
104
+ if (!entry.endsWith('.js') || !stylesheet.endsWith('.css')) packageError('entry must be JavaScript and stylesheet must be CSS')
105
+
106
+ // API v2: capabilities optional (defaults to [])
107
+ const normalizedCapabilities = capabilities ?? []
108
+ if (!Array.isArray(normalizedCapabilities)) packageError('capabilities must be an array')
109
+ if (apiVersion === 1 && normalizedCapabilities.some((item) => !CAPABILITIES.has(item))) {
110
+ packageError('invalid capability')
111
+ }
112
+
113
+ if (domains !== undefined && !Array.isArray(domains)) packageError('domains must be an array')
114
+ const normalizedDomains = (domains ?? []).map(validateDomain)
115
+ if (new Set(normalizedDomains).size !== normalizedDomains.length) packageError('duplicate domain')
116
+ return {
117
+ id,
118
+ name: name.trim(),
119
+ version,
120
+ apiVersion,
121
+ entry,
122
+ stylesheet,
123
+ capabilities: [...new Set(normalizedCapabilities)],
124
+ domains: normalizedDomains,
125
+ }
126
+ }
127
+
128
+ function normalizeFiles(files, manifest) {
129
+ if (!files || typeof files !== 'object' || Array.isArray(files)) packageError('files must be a path-to-source object')
130
+ const paths = Object.keys(files)
131
+ if (paths.length > MAX_FILES) packageError('too many files')
132
+ const normalized = {}
133
+ let total = 0
134
+ for (const rawPath of paths) {
135
+ const filePath = safePath(rawPath)
136
+ if (filePath === 'manifest.json') packageError('manifest is generated from the manifest field')
137
+ if (/\.(?:mjs|cjs|jsx|ts|tsx)$/i.test(filePath) && filePath !== manifest.entry) {
138
+ packageError(`undeclared executable file "${filePath}"`)
139
+ }
140
+ if (filePath !== manifest.entry && filePath !== manifest.stylesheet && /\.(?:js|css)$/i.test(filePath)) {
141
+ packageError(`undeclared executable file "${filePath}"`)
142
+ }
143
+ const content = files[rawPath]
144
+ if (typeof content !== 'string' && !(content instanceof Uint8Array)) {
145
+ packageError(`file "${filePath}" must be text or bytes`)
146
+ }
147
+ const data = typeof content === 'string' ? stringToBytes(content) : content
148
+ total += data.byteLength
149
+ if (total > MAX_PACKAGE_BYTES) packageError('package is too large')
150
+ if (filePath === manifest.entry || filePath === manifest.stylesheet) validateSource(filePath, data, manifest.apiVersion)
151
+ normalized[filePath] = typeof content === 'string' ? content : bytesToString(data)
152
+ }
153
+ if (!(manifest.entry in normalized)) packageError('entry file is missing')
154
+ if (!(manifest.stylesheet in normalized)) packageError('stylesheet file is missing')
155
+ if (stringToBytes(normalized[manifest.entry]).byteLength > 2 * 1024 * 1024) packageError('entry file is too large')
156
+ return normalized
157
+ }
158
+
159
+ export function buildPluginArchive(sources) {
160
+ const manifest = validatePluginManifest(sources?.manifest)
161
+ const files = normalizeFiles(sources?.files, manifest)
162
+ const manifestJson = JSON.stringify(manifest)
163
+ if (stringToBytes(manifestJson).byteLength > MAX_MANIFEST_BYTES) packageError('manifest is too large')
164
+
165
+ const packageData = {
166
+ format: 'cordis-plugin',
167
+ manifest,
168
+ files,
169
+ }
170
+
171
+ const encoded = stringToBytes(JSON.stringify(packageData))
172
+ if (encoded.byteLength > MAX_ARCHIVE_BYTES) packageError('package is too large')
173
+ return encoded
174
+ }
175
+
176
+ export function readPluginArchive(bytes) {
177
+ if (!bytes || !(bytes instanceof Uint8Array) || bytes.byteLength === 0) {
178
+ packageError('package archive is empty or invalid')
179
+ }
180
+ let parsed
181
+ try {
182
+ const text = new TextDecoder('utf-8').decode(bytes)
183
+ parsed = JSON.parse(text)
184
+ } catch {
185
+ packageError('package is not a valid JSON archive')
186
+ }
187
+
188
+ if (!parsed || typeof parsed !== 'object') packageError('invalid package format')
189
+ const manifest = validatePluginManifest(parsed.manifest)
190
+ const files = parsed.files
191
+ if (!files || typeof files !== 'object') packageError('missing package files')
192
+
193
+ return {
194
+ manifest,
195
+ files,
196
+ }
197
+ }
@@ -0,0 +1,94 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { buildPluginArchive, readPluginArchive } from './plugin-package.mjs'
4
+
5
+ const source = {
6
+ manifest: {
7
+ id: 'sample-plugin',
8
+ name: 'Sample Plugin',
9
+ version: '1.0.0',
10
+ apiVersion: 1,
11
+ entry: 'plugin.js',
12
+ stylesheet: 'plugin.css',
13
+ capabilities: ['workspace.read'],
14
+ domains: ['api.example.test'],
15
+ },
16
+ files: {
17
+ 'plugin.js': 'api.registerCommand("sample", () => "ok")',
18
+ 'plugin.css': '.sample { color: red }',
19
+ 'icon.svg': '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
20
+ },
21
+ }
22
+
23
+ test('plugin archive round-trips manifest and local source files', () => {
24
+ const archive = buildPluginArchive(source)
25
+ const result = readPluginArchive(archive)
26
+ assert.deepEqual(result.manifest, source.manifest)
27
+ assert.deepEqual(result.files, source.files)
28
+ })
29
+
30
+ test('plugin archive rejects traversal paths before packaging', () => {
31
+ assert.throws(
32
+ () => buildPluginArchive({ ...source, files: { ...source.files, '../outside.js': 'bad' } }),
33
+ /path/i,
34
+ )
35
+ })
36
+
37
+ test('plugin archive rejects a manifest whose entry is missing', () => {
38
+ assert.throws(() => buildPluginArchive({ ...source, files: { 'plugin.css': 'x' } }), /entry/i)
39
+ })
40
+
41
+ test('plugin archive rejects undeclared executable source', () => {
42
+ assert.throws(
43
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'extra.js': 'alert(1)' } }),
44
+ /executable/i,
45
+ )
46
+ })
47
+
48
+ test('plugin archive rejects remote code declarations and invalid domains', () => {
49
+ assert.throws(
50
+ () => buildPluginArchive({ ...source, manifest: { ...source.manifest, entry: 'https://evil.test/p.js' } }),
51
+ /entry|path/i,
52
+ )
53
+ assert.throws(
54
+ () => buildPluginArchive({ ...source, manifest: { ...source.manifest, domains: ['https://example.test'] } }),
55
+ /domain/i,
56
+ )
57
+ })
58
+
59
+ test('plugin archive rejects unmanaged network APIs and external CSS resources', () => {
60
+ assert.throws(
61
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.js': 'fetch("https://evil.test")' } }),
62
+ /network|remote/i,
63
+ )
64
+ assert.throws(
65
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.css': '@import url("https://evil.test/style.css")' } }),
66
+ /network|remote|external/i,
67
+ )
68
+ })
69
+
70
+ test('a rejected source names the file, the line and the text that matched', () => {
71
+ // The pattern is text-only, so a comment or a string trips it too — the message
72
+ // has to point at the match or the author cannot find it.
73
+ assert.throws(
74
+ () =>
75
+ buildPluginArchive({
76
+ ...source,
77
+ files: { ...source.files, 'plugin.js': 'maxcode.log("hi")\n// export the palette\nconst x = 1' },
78
+ }),
79
+ /found "export" at plugin\.js:2/,
80
+ )
81
+ assert.throws(() => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.js': 'fetch("https://evil.test")' } }), /"fetch\("/)
82
+ assert.throws(
83
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.css': '.a{}\n@import url("https://evil.test/x.css")' } }),
84
+ /plugin\.css:2/,
85
+ )
86
+ })
87
+
88
+ test('plugin archive rejects malformed or oversized archives', () => {
89
+ assert.throws(() => readPluginArchive(new Uint8Array([0, 1, 2, 3])), /archive|zip|package/i)
90
+ assert.throws(
91
+ () => buildPluginArchive({ ...source, files: { ...source.files, 'plugin.js': 'x'.repeat(10_485_761) } }),
92
+ /size|large/i,
93
+ )
94
+ })