@bibobonking/opensol 0.0.0-stage → 3.0.2
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.
- package/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { open, readFile, stat, unlink } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { DATA_DIR, ensureDataDir } from './lib/store.mjs'
|
|
7
|
+
import { daemonStatus, startServer, stopDaemon } from './daemon.mjs'
|
|
8
|
+
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const DAEMON = path.join(HERE, 'daemon.mjs')
|
|
11
|
+
const LOG_FILE = path.join(DATA_DIR, 'daemon.log')
|
|
12
|
+
const PID_FILE = path.join(DATA_DIR, 'daemon.json')
|
|
13
|
+
|
|
14
|
+
const HELP = `OpenSol — local coding agent daemon
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
opensol start [--port=4174] [--open] [--foreground]
|
|
18
|
+
opensol stop
|
|
19
|
+
opensol status
|
|
20
|
+
opensol open
|
|
21
|
+
opensol logs
|
|
22
|
+
|
|
23
|
+
The daemon serves the built app from dist/ and keeps your settings, chats and
|
|
24
|
+
encrypted provider keys in ${DATA_DIR}. It listens on 127.0.0.1 only.
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
function parseFlags(args) {
|
|
28
|
+
const flags = { port: 4174, open: false, foreground: false }
|
|
29
|
+
for (const arg of args) {
|
|
30
|
+
if (arg === '--open') flags.open = true
|
|
31
|
+
else if (arg === '--foreground') flags.foreground = true
|
|
32
|
+
else if (arg.startsWith('--port=')) {
|
|
33
|
+
const value = Number(arg.slice('--port='.length))
|
|
34
|
+
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
|
35
|
+
throw new Error('Use --port=<1-65535>.')
|
|
36
|
+
}
|
|
37
|
+
flags.port = value
|
|
38
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
39
|
+
flags.help = true
|
|
40
|
+
} else {
|
|
41
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return flags
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openerFor(url) {
|
|
48
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
49
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
50
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
|
|
51
|
+
child.on('error', () => console.log(`Open ${url} in your browser.`))
|
|
52
|
+
child.unref()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function tailLog(lines = 40) {
|
|
56
|
+
const info = await stat(LOG_FILE).catch(() => null)
|
|
57
|
+
if (!info) {
|
|
58
|
+
console.log('No daemon log yet.')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const raw = await readFile(LOG_FILE, 'utf8')
|
|
62
|
+
const slice = raw.trimEnd().split('\n').slice(-lines)
|
|
63
|
+
console.log(slice.join('\n'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function rotateLog() {
|
|
67
|
+
const info = await stat(LOG_FILE).catch(() => null)
|
|
68
|
+
if (info && info.size > 5 * 1024 * 1024) {
|
|
69
|
+
const previous = `${LOG_FILE}.1`
|
|
70
|
+
await unlink(previous).catch(() => undefined)
|
|
71
|
+
const { rename } = await import('node:fs/promises')
|
|
72
|
+
await rename(LOG_FILE, previous).catch(() => undefined)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function waitForDaemon(pid, timeoutMs = 15_000) {
|
|
77
|
+
const started = Date.now()
|
|
78
|
+
while (Date.now() - started < timeoutMs) {
|
|
79
|
+
const status = await daemonStatus()
|
|
80
|
+
if (status.running) return status
|
|
81
|
+
if (pid) {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 0)
|
|
84
|
+
} catch {
|
|
85
|
+
return { running: false, info: null }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
89
|
+
}
|
|
90
|
+
return { running: false, info: null }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function commandStart(args) {
|
|
94
|
+
const flags = parseFlags(args)
|
|
95
|
+
if (flags.help) {
|
|
96
|
+
console.log(HELP)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
await ensureDataDir()
|
|
100
|
+
const existing = await daemonStatus()
|
|
101
|
+
if (existing.running) {
|
|
102
|
+
console.log(`OpenSol is already running at ${existing.info.url}`)
|
|
103
|
+
if (flags.open) openerFor(existing.info.url)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
await unlink(PID_FILE).catch(() => undefined)
|
|
107
|
+
|
|
108
|
+
if (flags.foreground) {
|
|
109
|
+
await startServer({ port: flags.port, open: flags.open })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await rotateLog()
|
|
114
|
+
const handle = await open(LOG_FILE, 'a', 0o600)
|
|
115
|
+
const child = spawn(process.execPath, [DAEMON, `--port=${flags.port}`], {
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: ['ignore', handle.fd, handle.fd],
|
|
118
|
+
cwd: path.resolve(HERE, '..'),
|
|
119
|
+
env: { ...process.env, OPENSOL_DATA: DATA_DIR, MAXCODE_DATA: DATA_DIR },
|
|
120
|
+
})
|
|
121
|
+
child.unref()
|
|
122
|
+
await handle.close()
|
|
123
|
+
|
|
124
|
+
const status = await waitForDaemon(child.pid)
|
|
125
|
+
if (!status.running) {
|
|
126
|
+
console.error('OpenSol did not start. Recent log:')
|
|
127
|
+
await tailLog(20)
|
|
128
|
+
process.exitCode = 1
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
console.log(`OpenSol is running at ${status.info.url}`)
|
|
132
|
+
console.log(`Data: ${DATA_DIR}`)
|
|
133
|
+
console.log(`Log: ${LOG_FILE}`)
|
|
134
|
+
if (flags.open) openerFor(status.info.url)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function commandStatus() {
|
|
138
|
+
const status = await daemonStatus()
|
|
139
|
+
if (!status.running) {
|
|
140
|
+
console.log('OpenSol is not running.')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
console.log(`OpenSol is running at ${status.info.url}`)
|
|
144
|
+
console.log(`PID: ${status.info.pid}`)
|
|
145
|
+
console.log(`Data: ${status.info.dataDir ?? DATA_DIR}`)
|
|
146
|
+
console.log(`Dist ready: ${status.info.dist ? 'yes' : 'no'}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function commandOpen() {
|
|
150
|
+
const status = await daemonStatus()
|
|
151
|
+
if (!status.running) {
|
|
152
|
+
console.error('OpenSol is not running. Start it with: opensol start')
|
|
153
|
+
process.exitCode = 1
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
openerFor(status.info.url)
|
|
157
|
+
console.log(`Opened ${status.info.url}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function commandStop() {
|
|
161
|
+
const result = await stopDaemon()
|
|
162
|
+
if (!result.stopped) {
|
|
163
|
+
console.log(result.reason ?? 'OpenSol is not running.')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
console.log(`Stopped OpenSol${result.pid ? ` (pid ${result.pid})` : ''}.`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function main() {
|
|
170
|
+
const [command = 'help', ...args] = process.argv.slice(2)
|
|
171
|
+
switch (command) {
|
|
172
|
+
case 'start':
|
|
173
|
+
await commandStart(args)
|
|
174
|
+
break
|
|
175
|
+
case 'stop':
|
|
176
|
+
await commandStop()
|
|
177
|
+
break
|
|
178
|
+
case 'status':
|
|
179
|
+
await commandStatus()
|
|
180
|
+
break
|
|
181
|
+
case 'open':
|
|
182
|
+
await commandOpen()
|
|
183
|
+
break
|
|
184
|
+
case 'logs':
|
|
185
|
+
await tailLog()
|
|
186
|
+
break
|
|
187
|
+
case 'help':
|
|
188
|
+
case '--help':
|
|
189
|
+
case '-h':
|
|
190
|
+
console.log(HELP)
|
|
191
|
+
break
|
|
192
|
+
default:
|
|
193
|
+
console.error(`Unknown command: ${command}\n`)
|
|
194
|
+
console.log(HELP)
|
|
195
|
+
process.exitCode = 1
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
main().catch((error) => {
|
|
200
|
+
console.error(error instanceof Error ? error.message : error)
|
|
201
|
+
process.exitCode = 1
|
|
202
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "maxcode",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"maxcode": "./server/maxcode.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node server/maxcode.mjs start && vite",
|
|
11
|
+
"build": "tsc -b && vite build",
|
|
12
|
+
"preview": "vite preview",
|
|
13
|
+
"typecheck": "tsc -b --pretty",
|
|
14
|
+
"start": "node server/maxcode.mjs start --open",
|
|
15
|
+
"daemon": "node server/maxcode.mjs start",
|
|
16
|
+
"daemon:foreground": "node server/daemon.mjs",
|
|
17
|
+
"daemon:stop": "node server/maxcode.mjs stop",
|
|
18
|
+
"daemon:status": "node server/maxcode.mjs status",
|
|
19
|
+
"daemon:logs": "node server/maxcode.mjs logs"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"clsx": "^2.1.1",
|
|
23
|
+
"highlight.js": "^11.11.1",
|
|
24
|
+
"lucide-react": "^0.544.0",
|
|
25
|
+
"react": "^19.1.1",
|
|
26
|
+
"react-dom": "^19.1.1",
|
|
27
|
+
"react-markdown": "^10.1.0",
|
|
28
|
+
"rehype-highlight": "^7.0.2",
|
|
29
|
+
"remark-gfm": "^4.0.1",
|
|
30
|
+
"zustand": "^5.0.8"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "^19.1.10",
|
|
34
|
+
"@types/react-dom": "^19.1.7",
|
|
35
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
36
|
+
"autoprefixer": "^10.4.21",
|
|
37
|
+
"postcss": "^8.5.6",
|
|
38
|
+
"tailwindcss": "^3.4.19",
|
|
39
|
+
"typescript": "^5.9.2",
|
|
40
|
+
"vite": "^7.1.3"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
+
})
|