@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.
- 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/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
package/src/lib/usage.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ChatSession, Message } from '../types'
|
|
2
|
+
|
|
3
|
+
export type UsageRange = 'all' | '30d' | '7d'
|
|
4
|
+
|
|
5
|
+
export interface ModelUsage {
|
|
6
|
+
model: string
|
|
7
|
+
messages: number
|
|
8
|
+
tokens: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HeatmapDay {
|
|
12
|
+
key: string
|
|
13
|
+
count: number
|
|
14
|
+
date: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UsageStats {
|
|
18
|
+
sessions: number
|
|
19
|
+
messages: number
|
|
20
|
+
tokens: number
|
|
21
|
+
activeDays: number
|
|
22
|
+
peakHour: string
|
|
23
|
+
favoriteModel: string
|
|
24
|
+
favoriteShare: number
|
|
25
|
+
models: ModelUsage[]
|
|
26
|
+
heatmap: HeatmapDay[]
|
|
27
|
+
busiestDay: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DAY_MS = 86_400_000
|
|
31
|
+
const HEATMAP_WEEKS = 26
|
|
32
|
+
|
|
33
|
+
export function startOfDay(timestamp: number): number {
|
|
34
|
+
const date = new Date(timestamp)
|
|
35
|
+
date.setHours(0, 0, 0, 0)
|
|
36
|
+
return date.getTime()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function dayKey(timestamp: number): string {
|
|
40
|
+
const date = new Date(timestamp)
|
|
41
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function rangeCutoff(range: UsageRange, now: number): number {
|
|
45
|
+
if (range === 'all') return 0
|
|
46
|
+
return startOfDay(now) - (range === '7d' ? 6 : 29) * DAY_MS
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function messageTime(message: Message, fallback: number): number {
|
|
50
|
+
return typeof message.createdAt === 'number' ? message.createdAt : fallback
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function estimateTokens(message: Message): number {
|
|
54
|
+
if (message.role !== 'assistant') return 0
|
|
55
|
+
const usage = message.usage
|
|
56
|
+
if (usage && (usage.input || usage.output)) return (usage.input ?? 0) + (usage.output ?? 0)
|
|
57
|
+
const text = `${message.content ?? ''}${message.reasoning ?? ''}`
|
|
58
|
+
return text.trim() ? Math.ceil(text.length / 4) : 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hourLabel(hour: number): string {
|
|
62
|
+
if (hour === 0) return '12 AM'
|
|
63
|
+
if (hour === 12) return '12 PM'
|
|
64
|
+
return hour < 12 ? `${hour} AM` : `${hour - 12} PM`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function computeUsage(sessions: ChatSession[], range: UsageRange, now = Date.now()): UsageStats {
|
|
68
|
+
const cutoff = rangeCutoff(range, now)
|
|
69
|
+
const filteredSessions = sessions.filter((session) => session.updatedAt >= cutoff)
|
|
70
|
+
const messages: Array<{ message: Message; session: ChatSession }> = []
|
|
71
|
+
|
|
72
|
+
for (const session of filteredSessions) {
|
|
73
|
+
for (const message of session.messages) {
|
|
74
|
+
if (messageTime(message, session.updatedAt) >= cutoff) {
|
|
75
|
+
messages.push({ message, session })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hours = new Array<number>(24).fill(0)
|
|
81
|
+
const days = new Map<string, HeatmapDay>()
|
|
82
|
+
const models = new Map<string, ModelUsage>()
|
|
83
|
+
let tokens = 0
|
|
84
|
+
|
|
85
|
+
for (const { message, session } of messages) {
|
|
86
|
+
const at = messageTime(message, session.updatedAt)
|
|
87
|
+
const key = dayKey(at)
|
|
88
|
+
const day = days.get(key)
|
|
89
|
+
if (day) day.count += 1
|
|
90
|
+
else days.set(key, { key, count: 1, date: startOfDay(at) })
|
|
91
|
+
hours[new Date(at).getHours()] += 1
|
|
92
|
+
|
|
93
|
+
if (message.role !== 'assistant') continue
|
|
94
|
+
const messageTokens = estimateTokens(message)
|
|
95
|
+
tokens += messageTokens
|
|
96
|
+
const model = typeof message.model === 'string' ? message.model.trim() : ''
|
|
97
|
+
if (!model) continue
|
|
98
|
+
const entry = models.get(model) ?? { model, messages: 0, tokens: 0 }
|
|
99
|
+
entry.messages += 1
|
|
100
|
+
entry.tokens += messageTokens
|
|
101
|
+
models.set(model, entry)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rankedModels = [...models.values()].sort(
|
|
105
|
+
(a, b) => b.tokens - a.tokens || b.messages - a.messages || a.model.localeCompare(b.model),
|
|
106
|
+
)
|
|
107
|
+
const favorite = rankedModels[0]
|
|
108
|
+
const assistantMessages = messages.filter((entry) => entry.message.role === 'assistant').length
|
|
109
|
+
|
|
110
|
+
let peak = 0
|
|
111
|
+
for (let hour = 1; hour < 24; hour += 1) {
|
|
112
|
+
if (hours[hour] > hours[peak]) peak = hour
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const heatmap = buildHeatmap(days, now)
|
|
116
|
+
const busiestDay = heatmap.reduce((max, day) => Math.max(max, day.count), 0)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
sessions: filteredSessions.length,
|
|
120
|
+
messages: messages.length,
|
|
121
|
+
tokens,
|
|
122
|
+
activeDays: days.size,
|
|
123
|
+
peakHour: messages.length ? hourLabel(peak) : '—',
|
|
124
|
+
favoriteModel: favorite?.model ?? '—',
|
|
125
|
+
favoriteShare: favorite && assistantMessages ? Math.round((favorite.messages / assistantMessages) * 100) : 0,
|
|
126
|
+
models: rankedModels,
|
|
127
|
+
heatmap,
|
|
128
|
+
busiestDay,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildHeatmap(counts: Map<string, HeatmapDay>, now: number): HeatmapDay[] {
|
|
133
|
+
const today = new Date(startOfDay(now))
|
|
134
|
+
const start = new Date(today)
|
|
135
|
+
start.setDate(start.getDate() - start.getDay() - (HEATMAP_WEEKS - 1) * 7)
|
|
136
|
+
const days: HeatmapDay[] = []
|
|
137
|
+
for (let index = 0; index < HEATMAP_WEEKS * 7; index += 1) {
|
|
138
|
+
const date = start.getTime() + index * DAY_MS
|
|
139
|
+
const key = dayKey(date)
|
|
140
|
+
days.push(counts.get(key) ?? { key, count: 0, date })
|
|
141
|
+
}
|
|
142
|
+
return days
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatTokens(value: number): string {
|
|
146
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
|
|
147
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`
|
|
148
|
+
return String(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function shortModelName(model: string): string {
|
|
152
|
+
if (!model || model === '—') return model
|
|
153
|
+
const parts = model.split('/')
|
|
154
|
+
return parts[parts.length - 1] || model
|
|
155
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
|
|
3
|
+
export function cn(...inputs: ClassValue[]) {
|
|
4
|
+
return clsx(inputs)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatBytes(bytes: number) {
|
|
8
|
+
if (bytes < 1024) return `${bytes} B`
|
|
9
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
10
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatRelativeTime(timestamp: number) {
|
|
14
|
+
const seconds = Math.round((Date.now() - timestamp) / 1000)
|
|
15
|
+
if (seconds < 60) return 'now'
|
|
16
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
|
17
|
+
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
|
|
18
|
+
if (seconds < 604_800) return `${Math.floor(seconds / 86_400)}d`
|
|
19
|
+
return new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function groupChatsByDate<T extends { updatedAt: number }>(chats: T[]) {
|
|
23
|
+
const now = new Date()
|
|
24
|
+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
|
25
|
+
const startOfYesterday = startOfToday - 86_400_000
|
|
26
|
+
const sevenDays = startOfToday - 6 * 86_400_000
|
|
27
|
+
const thirtyDays = startOfToday - 29 * 86_400_000
|
|
28
|
+
const groups: Array<{ label: string; items: T[] }> = [
|
|
29
|
+
{ label: 'Today', items: [] },
|
|
30
|
+
{ label: 'Yesterday', items: [] },
|
|
31
|
+
{ label: 'Previous 7 days', items: [] },
|
|
32
|
+
{ label: 'Previous 30 days', items: [] },
|
|
33
|
+
{ label: 'Older', items: [] },
|
|
34
|
+
]
|
|
35
|
+
const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt)
|
|
36
|
+
for (const chat of sorted) {
|
|
37
|
+
let index = 4
|
|
38
|
+
if (chat.updatedAt >= startOfToday) index = 0
|
|
39
|
+
else if (chat.updatedAt >= startOfYesterday) index = 1
|
|
40
|
+
else if (chat.updatedAt >= sevenDays) index = 2
|
|
41
|
+
else if (chat.updatedAt >= thirtyDays) index = 3
|
|
42
|
+
groups[index].items.push(chat)
|
|
43
|
+
}
|
|
44
|
+
return groups.filter((group) => group.items.length > 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readFileAsDataUrl(file: File): Promise<string> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const reader = new FileReader()
|
|
50
|
+
reader.onload = () => resolve(String(reader.result))
|
|
51
|
+
reader.onerror = () => reject(reader.error ?? new Error('Could not read this file.'))
|
|
52
|
+
reader.readAsDataURL(file)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readFileAsText(file: File): Promise<string> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const reader = new FileReader()
|
|
59
|
+
reader.onload = () => resolve(String(reader.result))
|
|
60
|
+
reader.onerror = () => reject(reader.error ?? new Error('Could not read this file.'))
|
|
61
|
+
reader.readAsText(file)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function downloadText(filename: string, text: string, mime = 'text/plain') {
|
|
66
|
+
const blob = new Blob([text], { type: mime })
|
|
67
|
+
const url = URL.createObjectURL(blob)
|
|
68
|
+
const anchor = document.createElement('a')
|
|
69
|
+
anchor.href = url
|
|
70
|
+
anchor.download = filename
|
|
71
|
+
anchor.click()
|
|
72
|
+
URL.revokeObjectURL(url)
|
|
73
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import App from './App'
|
|
4
|
+
import { ErrorBoundary } from './components/ErrorBoundary'
|
|
5
|
+
import './index.css'
|
|
6
|
+
|
|
7
|
+
const root = document.getElementById('root')
|
|
8
|
+
if (!root) throw new Error('Root element not found.')
|
|
9
|
+
|
|
10
|
+
createRoot(root).render(
|
|
11
|
+
<StrictMode>
|
|
12
|
+
<ErrorBoundary>
|
|
13
|
+
<App />
|
|
14
|
+
</ErrorBoundary>
|
|
15
|
+
</StrictMode>,
|
|
16
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { allowedDomain, createPluginApi, emptyContributions, hasCapability } from './api.ts'
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: 'demo',
|
|
8
|
+
manifest: { id: 'demo', name: 'Demo', version: '1.0.0', apiVersion: 1, entry: 'main.js', stylesheet: 'main.css', capabilities: [], domains: ['api.example.com'] },
|
|
9
|
+
manifestHash: 'x',
|
|
10
|
+
installedAt: '0',
|
|
11
|
+
safe: false,
|
|
12
|
+
enabled: false,
|
|
13
|
+
grants: { capabilities: [], domains: [] },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const calls = {
|
|
17
|
+
workspace: {
|
|
18
|
+
list: async () => [{ path: 'a.ts', name: 'a.ts', type: 'file' }],
|
|
19
|
+
read: async () => 'contents',
|
|
20
|
+
write: async () => undefined,
|
|
21
|
+
edit: async () => true,
|
|
22
|
+
},
|
|
23
|
+
network: async (_id, request) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(request) }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const full = { capabilities: ['workspace.list', 'workspace.read', 'workspace.write', 'workspace.edit', 'ui.commands', 'ui.panels', 'ui.themes', 'network.request'], domains: ['api.example.com'] }
|
|
27
|
+
|
|
28
|
+
test('ungranted capabilities are refused at call time', async () => {
|
|
29
|
+
const api = createPluginApi(plugin, { capabilities: [], domains: [] }, calls)
|
|
30
|
+
assert.throws(() => api.registerCommand('x', 'X', () => undefined), /not granted ui\.commands/)
|
|
31
|
+
await assert.rejects(() => api.workspace.read('secret'), /not granted workspace\.read/)
|
|
32
|
+
assert.throws(() => api.network.request('https://api.example.com/v1'), /not granted network\.request/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('granted workspace calls reach the host and bad ids are rejected', async () => {
|
|
36
|
+
const contributions = emptyContributions()
|
|
37
|
+
const api = createPluginApi(plugin, full, calls, contributions)
|
|
38
|
+
assert.deepEqual(await api.workspace.list(), [{ path: 'a.ts', name: 'a.ts', type: 'file' }])
|
|
39
|
+
api.registerCommand('open-panel', 'Open panel', () => undefined)
|
|
40
|
+
assert.equal(contributions.commands.get('open-panel')?.label, 'Open panel')
|
|
41
|
+
assert.throws(() => api.registerCommand('../x', 'Bad', () => undefined), /lowercase/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('network stays inside the declared and granted host list', async () => {
|
|
45
|
+
const api = createPluginApi(plugin, full, calls)
|
|
46
|
+
const result = await api.network.request('https://api.example.com/v1')
|
|
47
|
+
assert.equal(result.status, 200)
|
|
48
|
+
assert.throws(() => api.network.request('https://evil.example.com/v1'), /declared and you granted/)
|
|
49
|
+
assert.throws(() => api.network.request('http://api.example.com/v1'), /declared and you granted/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('disabling a plugin drops its contributions', () => {
|
|
53
|
+
const contributions = emptyContributions()
|
|
54
|
+
const api = createPluginApi(plugin, full, calls, contributions)
|
|
55
|
+
api.registerPanel('settings', 'Settings', () => undefined)
|
|
56
|
+
contributions.commands.clear()
|
|
57
|
+
contributions.panels.clear()
|
|
58
|
+
contributions.themes.clear()
|
|
59
|
+
assert.equal(contributions.panels.size, 0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a capability is only available once the user grants it', () => {
|
|
63
|
+
assert.equal(hasCapability({ capabilities: ['workspace.read'], domains: [] }, 'workspace.read'), true)
|
|
64
|
+
assert.equal(hasCapability({ capabilities: ['workspace.read'], domains: [] }, 'workspace.write'), false)
|
|
65
|
+
assert.equal(hasCapability(undefined, 'workspace.read'), false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('network is limited to a declared-and-granted HTTPS host on 443', () => {
|
|
69
|
+
const declared = ['api.example.com']
|
|
70
|
+
const granted = { capabilities: [], domains: declared }
|
|
71
|
+
assert.equal(allowedDomain(granted, declared, 'https://api.example.com/v1'), 'api.example.com')
|
|
72
|
+
assert.equal(allowedDomain(granted, declared, 'http://api.example.com/v1'), null)
|
|
73
|
+
assert.equal(allowedDomain(granted, declared, 'https://api.example.com:8443/v1'), null)
|
|
74
|
+
assert.equal(allowedDomain(granted, declared, 'https://api.example.com.evil.com/v1'), null)
|
|
75
|
+
assert.equal(allowedDomain(granted, declared, 'not a url'), null)
|
|
76
|
+
assert.equal(allowedDomain({ capabilities: [], domains: [] }, declared, 'https://api.example.com'), null)
|
|
77
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { InstalledPlugin } from '../lib/daemon'
|
|
2
|
+
import type { PluginCapability, PluginContribution, PluginGrant, PluginRuntimeApi } from './types'
|
|
3
|
+
|
|
4
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,47}$/
|
|
5
|
+
|
|
6
|
+
export function hasCapability(grant: PluginGrant | undefined, capability: PluginCapability): boolean {
|
|
7
|
+
return Boolean(grant?.capabilities.includes(capability))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A plugin may only reach a host its manifest declared *and* the user granted.
|
|
12
|
+
* HTTPS on port 443 only; the daemon re-checks all of this server-side.
|
|
13
|
+
*/
|
|
14
|
+
export function allowedDomain(
|
|
15
|
+
grant: PluginGrant | undefined,
|
|
16
|
+
declared: string[],
|
|
17
|
+
url: string,
|
|
18
|
+
): string | null {
|
|
19
|
+
let host: string
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(url)
|
|
22
|
+
if (parsed.protocol !== 'https:') return null
|
|
23
|
+
if (parsed.port && parsed.port !== '443') return null
|
|
24
|
+
host = parsed.hostname.toLowerCase()
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
if (!declared.includes(host)) return null
|
|
29
|
+
if (!grant?.domains.includes(host)) return null
|
|
30
|
+
return host
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PluginHostCalls {
|
|
34
|
+
workspace: {
|
|
35
|
+
list(path?: string): Promise<{ path: string; name: string; type: string }[]>
|
|
36
|
+
read(path: string): Promise<string>
|
|
37
|
+
write(path: string, content: string): Promise<void>
|
|
38
|
+
edit(path: string, from: string, to: string): Promise<boolean>
|
|
39
|
+
}
|
|
40
|
+
network(pluginId: string, request: { url: string; method?: 'GET' | 'POST'; headers?: Record<string, string>; body?: string }): Promise<{
|
|
41
|
+
status: number
|
|
42
|
+
contentType: string
|
|
43
|
+
body: string
|
|
44
|
+
}>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function emptyContributions(): PluginContribution {
|
|
48
|
+
return { commands: new Map(), panels: new Map(), themes: new Map() }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function denied(capability: string): never {
|
|
52
|
+
throw new Error(`The plugin was not granted ${capability}.`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The API handed to plugin code. Every entry point re-checks the user's grants,
|
|
57
|
+
* so a plugin cannot reach a capability it never got approved for, even if it
|
|
58
|
+
* calls the API directly.
|
|
59
|
+
*/
|
|
60
|
+
export function createPluginApi(
|
|
61
|
+
plugin: InstalledPlugin,
|
|
62
|
+
grant: PluginGrant,
|
|
63
|
+
calls: PluginHostCalls,
|
|
64
|
+
contributions: PluginContribution = emptyContributions(),
|
|
65
|
+
): PluginRuntimeApi {
|
|
66
|
+
const check = (capability: Parameters<typeof hasCapability>[1]) => {
|
|
67
|
+
if (!hasCapability(grant, capability)) denied(capability)
|
|
68
|
+
}
|
|
69
|
+
const checkId = (id: string) => {
|
|
70
|
+
if (!ID.test(id)) throw new Error('A plugin contribution id must be lowercase letters, digits and dashes.')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
manifest: Object.freeze({ ...plugin.manifest }),
|
|
75
|
+
version: 1,
|
|
76
|
+
registerCommand(id, label, run) {
|
|
77
|
+
check('ui.commands')
|
|
78
|
+
checkId(id)
|
|
79
|
+
if (typeof run !== 'function') throw new Error('A command needs a function.')
|
|
80
|
+
contributions.commands.set(id, { label: String(label).slice(0, 80), run })
|
|
81
|
+
},
|
|
82
|
+
registerPanel(id, label, render) {
|
|
83
|
+
check('ui.panels')
|
|
84
|
+
checkId(id)
|
|
85
|
+
if (typeof render !== 'function') throw new Error('A panel needs a render function.')
|
|
86
|
+
contributions.panels.set(id, { label: String(label).slice(0, 80), render })
|
|
87
|
+
},
|
|
88
|
+
registerTheme(id, label, palette) {
|
|
89
|
+
check('ui.themes')
|
|
90
|
+
checkId(id)
|
|
91
|
+
if (!palette || typeof palette !== 'object') throw new Error('A theme needs a palette.')
|
|
92
|
+
contributions.themes.set(id, { label: String(label).slice(0, 80), palette: { ...palette } })
|
|
93
|
+
},
|
|
94
|
+
workspace: {
|
|
95
|
+
list: async (path) => (check('workspace.list'), calls.workspace.list(path)),
|
|
96
|
+
read: async (path) => (check('workspace.read'), calls.workspace.read(path)),
|
|
97
|
+
write: async (path, content) => (check('workspace.write'), calls.workspace.write(path, content)),
|
|
98
|
+
edit: async (path, from, to) => (check('workspace.edit'), calls.workspace.edit(path, from, to)),
|
|
99
|
+
},
|
|
100
|
+
network: {
|
|
101
|
+
request(url, init) {
|
|
102
|
+
check('network.request')
|
|
103
|
+
if (!allowedDomain(grant, plugin.manifest.domains, url)) {
|
|
104
|
+
throw new Error('This plugin may only call HTTPS hosts it declared and you granted.')
|
|
105
|
+
}
|
|
106
|
+
return calls.network(plugin.id, { url, ...init })
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
log(message) {
|
|
110
|
+
console.info(`[plugin:${plugin.id}]`, message)
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isPluginPreviewMessage(value: unknown): boolean
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,47}$/
|
|
2
|
+
|
|
3
|
+
export function isPluginPreviewMessage(value) {
|
|
4
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
5
|
+
const prototype = Object.getPrototypeOf(value)
|
|
6
|
+
if (prototype !== Object.prototype && prototype !== null) return false
|
|
7
|
+
if (value.type === 'ready') return Object.keys(value).length === 1
|
|
8
|
+
if (value.type === 'command' || value.type === 'panel') {
|
|
9
|
+
return (
|
|
10
|
+
Object.keys(value).every((key) => ['type', 'id', 'label'].includes(key)) &&
|
|
11
|
+
typeof value.id === 'string' &&
|
|
12
|
+
ID.test(value.id) &&
|
|
13
|
+
typeof value.label === 'string' &&
|
|
14
|
+
value.label.trim().length > 0 &&
|
|
15
|
+
value.label.length <= 80
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
if (value.type === 'log' || value.type === 'error') {
|
|
19
|
+
return (
|
|
20
|
+
Object.keys(value).every((key) => ['type', 'message'].includes(key)) &&
|
|
21
|
+
typeof value.message === 'string' &&
|
|
22
|
+
value.message.length <= 500
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { isPluginPreviewMessage } from './bridge.mjs'
|
|
4
|
+
|
|
5
|
+
test('preview bridge accepts bounded registration and status messages', () => {
|
|
6
|
+
assert.equal(isPluginPreviewMessage({ type: 'ready' }), true)
|
|
7
|
+
assert.equal(isPluginPreviewMessage({ type: 'command', id: 'open-panel', label: 'Open panel' }), true)
|
|
8
|
+
assert.equal(isPluginPreviewMessage({ type: 'panel', id: 'settings', label: 'Settings' }), true)
|
|
9
|
+
assert.equal(isPluginPreviewMessage({ type: 'log', message: 'preview ready' }), true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('preview bridge rejects API calls, malformed registrations, and oversized messages', () => {
|
|
13
|
+
assert.equal(isPluginPreviewMessage({ type: 'api-call', method: 'workspace.read', path: 'secret' }), false)
|
|
14
|
+
assert.equal(isPluginPreviewMessage({ type: 'command', id: '../x', label: 'Bad' }), false)
|
|
15
|
+
assert.equal(isPluginPreviewMessage({ type: 'error', message: 'x'.repeat(501) }), false)
|
|
16
|
+
assert.equal(isPluginPreviewMessage(Object.assign(Object.create({ polluted: true }), { type: 'ready' })), false)
|
|
17
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getWorkspaceTree, readWorkspaceFile, writeWorkspaceFile, pluginNetworkRequest, type InstalledPlugin } from '../lib/daemon'
|
|
2
|
+
import { createPluginRuntime, type PluginRuntime } from './runtime'
|
|
3
|
+
import type { PluginHostCalls } from './api'
|
|
4
|
+
|
|
5
|
+
const calls: PluginHostCalls = {
|
|
6
|
+
workspace: {
|
|
7
|
+
async list(path = '') {
|
|
8
|
+
const tree = await getWorkspaceTree(path)
|
|
9
|
+
return tree.entries.map((entry) => ({ path: entry.path, name: entry.name, type: entry.type }))
|
|
10
|
+
},
|
|
11
|
+
async read(path) {
|
|
12
|
+
const file = await readWorkspaceFile(path)
|
|
13
|
+
return file.content
|
|
14
|
+
},
|
|
15
|
+
async write(path, content) {
|
|
16
|
+
await writeWorkspaceFile(path, content)
|
|
17
|
+
},
|
|
18
|
+
async edit(path, from, to) {
|
|
19
|
+
const file = await readWorkspaceFile(path)
|
|
20
|
+
if (!file.content.includes(from)) return false
|
|
21
|
+
await writeWorkspaceFile(path, file.content.replace(from, to))
|
|
22
|
+
return true
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
network: (pluginId, request) => pluginNetworkRequest(pluginId, request),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let runtime: PluginRuntime | null = null
|
|
29
|
+
|
|
30
|
+
export function pluginRuntime(): PluginRuntime {
|
|
31
|
+
runtime ??= createPluginRuntime()
|
|
32
|
+
return runtime
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Loads every Safe + enabled plugin, dropping anything no longer trusted. */
|
|
36
|
+
export async function syncPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
|
37
|
+
const active = new Set(plugins.filter((plugin) => plugin.safe && plugin.enabled).map((plugin) => plugin.id))
|
|
38
|
+
const live = pluginRuntime()
|
|
39
|
+
for (const id of [...live.contributions.keys()]) {
|
|
40
|
+
if (!active.has(id)) live.deactivate(id)
|
|
41
|
+
}
|
|
42
|
+
for (const plugin of plugins) {
|
|
43
|
+
if (!active.has(plugin.id)) continue
|
|
44
|
+
try {
|
|
45
|
+
await live.activate(plugin, calls)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(error)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { calls as pluginHostCalls }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { previewDocument } from './preview.ts'
|
|
4
|
+
|
|
5
|
+
test('the preview document carries the stylesheet, the manifest identity and the source', () => {
|
|
6
|
+
const html = previewDocument({ id: 'demo', name: 'Demo' }, 'maxcode.log("hi")', 'body{color:red}')
|
|
7
|
+
assert.match(html, /<style>body\{color:red\}<\/style>/)
|
|
8
|
+
assert.match(html, /\{"id":"demo","name":"Demo"\}/)
|
|
9
|
+
assert.match(html, /maxcode\.log\("hi"\)/)
|
|
10
|
+
assert.equal(html.startsWith('<!doctype html>'), true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('the preview hands the plugin a stub API with no network and no workspace writes', () => {
|
|
14
|
+
const html = previewDocument({ id: 'demo', name: 'Demo' }, '', '')
|
|
15
|
+
assert.match(html, /network: \(\) => \{ throw new Error\('The sandboxed preview has no network access\.'\) \}/)
|
|
16
|
+
assert.match(html, /list: \(\) => \[\]/)
|
|
17
|
+
assert.doesNotMatch(html, /\bfetch\(|XMLHttpRequest|allow-same-origin/)
|
|
18
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isPluginPreviewMessage } from './bridge.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sandboxed preview: an iframe with no same-origin access and no direct network,
|
|
5
|
+
* driven only by validated messages. No plugin API is exposed here.
|
|
6
|
+
*/
|
|
7
|
+
export function previewDocument(manifest: { id: string; name: string }, source: string, css: string): string {
|
|
8
|
+
const bridge = `
|
|
9
|
+
const send = (message) => parent.postMessage(message, '*')
|
|
10
|
+
const api = {
|
|
11
|
+
manifest: ${JSON.stringify({ id: manifest.id, name: manifest.name })},
|
|
12
|
+
command: (id, label) => send({ type: 'command', id, label }),
|
|
13
|
+
panel: (id, label) => send({ type: 'panel', id, label }),
|
|
14
|
+
log: (message) => send({ type: 'log', message: String(message).slice(0, 500) }),
|
|
15
|
+
network: () => { throw new Error('The sandboxed preview has no network access.') },
|
|
16
|
+
workspace: { list: () => [], read: () => '', write: () => {}, edit: () => false },
|
|
17
|
+
}
|
|
18
|
+
try { ;(function (maxcode) { ${source} })(api) } catch (error) {
|
|
19
|
+
send({ type: 'error', message: String(error && error.message ? error.message : error).slice(0, 500) })
|
|
20
|
+
}
|
|
21
|
+
send({ type: 'ready' })
|
|
22
|
+
`
|
|
23
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body><script>${bridge}</script></body></html>`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function attachPreviewBridge(iframe: HTMLIFrameElement, onMessage: (message: unknown) => void): () => void {
|
|
27
|
+
const listener = (event: MessageEvent) => {
|
|
28
|
+
if (event.source !== iframe.contentWindow) return
|
|
29
|
+
if (!isPluginPreviewMessage(event.data)) return
|
|
30
|
+
onMessage(event.data)
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener('message', listener)
|
|
33
|
+
return () => window.removeEventListener('message', listener)
|
|
34
|
+
}
|