@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.
Files changed (127) 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/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -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
+ }
@@ -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
+ }