@tokentop/ttop 0.2.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/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyPlugin,
|
|
3
|
+
PluginType,
|
|
4
|
+
PluginByType,
|
|
5
|
+
ProviderPlugin,
|
|
6
|
+
AgentPlugin,
|
|
7
|
+
ThemePlugin,
|
|
8
|
+
NotificationPlugin,
|
|
9
|
+
} from './types/index.ts';
|
|
10
|
+
import { loadLocalPlugin, loadNpmPlugin, discoverLocalPlugins, resolvePluginPath } from './loader.ts';
|
|
11
|
+
import { installAllNpmPlugins, resolveNpmPluginPath } from './npm-installer.ts';
|
|
12
|
+
import type { PluginsConfig } from '@/config/schema.ts';
|
|
13
|
+
|
|
14
|
+
export type PluginSource = 'builtin' | 'local' | 'npm';
|
|
15
|
+
|
|
16
|
+
type PluginStore = {
|
|
17
|
+
provider: Map<string, ProviderPlugin>;
|
|
18
|
+
agent: Map<string, AgentPlugin>;
|
|
19
|
+
theme: Map<string, ThemePlugin>;
|
|
20
|
+
notification: Map<string, NotificationPlugin>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
class PluginRegistryImpl {
|
|
24
|
+
private plugins: PluginStore = {
|
|
25
|
+
provider: new Map(),
|
|
26
|
+
agent: new Map(),
|
|
27
|
+
theme: new Map(),
|
|
28
|
+
notification: new Map(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
private sources = new Map<string, PluginSource>();
|
|
32
|
+
private initialized = false;
|
|
33
|
+
|
|
34
|
+
private sourceKey(type: string, id: string): string {
|
|
35
|
+
return `${type}-${id}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
register(plugin: AnyPlugin, source: PluginSource = 'builtin'): void {
|
|
39
|
+
const key = this.sourceKey(plugin.type, plugin.id);
|
|
40
|
+
const existingSource = this.sources.get(key);
|
|
41
|
+
|
|
42
|
+
if (existingSource === 'builtin' && source !== 'builtin') {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.sources.set(key, source);
|
|
47
|
+
switch (plugin.type) {
|
|
48
|
+
case 'provider':
|
|
49
|
+
if (this.plugins.provider.has(plugin.id)) {
|
|
50
|
+
console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
|
|
51
|
+
}
|
|
52
|
+
this.plugins.provider.set(plugin.id, plugin);
|
|
53
|
+
break;
|
|
54
|
+
case 'agent':
|
|
55
|
+
if (this.plugins.agent.has(plugin.id)) {
|
|
56
|
+
console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
|
|
57
|
+
}
|
|
58
|
+
this.plugins.agent.set(plugin.id, plugin);
|
|
59
|
+
break;
|
|
60
|
+
case 'theme':
|
|
61
|
+
if (this.plugins.theme.has(plugin.id)) {
|
|
62
|
+
console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
|
|
63
|
+
}
|
|
64
|
+
this.plugins.theme.set(plugin.id, plugin);
|
|
65
|
+
break;
|
|
66
|
+
case 'notification':
|
|
67
|
+
if (this.plugins.notification.has(plugin.id)) {
|
|
68
|
+
console.warn(`Plugin "${plugin.id}" already registered, overwriting`);
|
|
69
|
+
}
|
|
70
|
+
this.plugins.notification.set(plugin.id, plugin);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
unregister(type: PluginType, id: string): boolean {
|
|
76
|
+
return this.plugins[type].delete(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get<T extends PluginType>(type: T, id: string): PluginByType[T] | undefined {
|
|
80
|
+
return this.plugins[type].get(id) as PluginByType[T] | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getAll<T extends PluginType>(type: T): PluginByType[T][] {
|
|
84
|
+
return [...this.plugins[type].values()] as PluginByType[T][];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getAllPlugins(): AnyPlugin[] {
|
|
88
|
+
return [
|
|
89
|
+
...this.plugins.provider.values(),
|
|
90
|
+
...this.plugins.agent.values(),
|
|
91
|
+
...this.plugins.theme.values(),
|
|
92
|
+
...this.plugins.notification.values(),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
has(type: PluginType, id: string): boolean {
|
|
97
|
+
return this.plugins[type].has(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
count(type?: PluginType): number {
|
|
101
|
+
if (type) {
|
|
102
|
+
return this.plugins[type].size;
|
|
103
|
+
}
|
|
104
|
+
return (
|
|
105
|
+
this.plugins.provider.size +
|
|
106
|
+
this.plugins.agent.size +
|
|
107
|
+
this.plugins.theme.size +
|
|
108
|
+
this.plugins.notification.size
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getSource(type: PluginType, id: string): PluginSource | undefined {
|
|
113
|
+
return this.sources.get(this.sourceKey(type, id));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async loadBuiltinPlugins(): Promise<void> {
|
|
117
|
+
const [providers, agents, themes, notifications] = await Promise.all([
|
|
118
|
+
import('./providers/index.ts'),
|
|
119
|
+
import('./agents/index.ts'),
|
|
120
|
+
import('./themes/index.ts'),
|
|
121
|
+
import('./notifications/index.ts'),
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
for (const plugin of Object.values(providers)) {
|
|
125
|
+
if (isProviderPlugin(plugin)) this.register(plugin, 'builtin');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const plugin of Object.values(agents)) {
|
|
129
|
+
if (isAgentPlugin(plugin)) this.register(plugin, 'builtin');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const plugin of Object.values(themes)) {
|
|
133
|
+
if (isThemePlugin(plugin)) this.register(plugin, 'builtin');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const plugin of Object.values(notifications)) {
|
|
137
|
+
if (isNotificationPlugin(plugin)) this.register(plugin, 'builtin');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async loadLocalPlugins(extraPaths: string[] = []): Promise<void> {
|
|
142
|
+
const discoveredPaths = await discoverLocalPlugins();
|
|
143
|
+
const resolvedExtraPaths = extraPaths.map(p => resolvePluginPath(p));
|
|
144
|
+
const allPaths = [...discoveredPaths, ...resolvedExtraPaths];
|
|
145
|
+
|
|
146
|
+
for (const pluginPath of allPaths) {
|
|
147
|
+
const result = await loadLocalPlugin(pluginPath);
|
|
148
|
+
if (result.success && result.plugin) {
|
|
149
|
+
this.register(result.plugin, 'local');
|
|
150
|
+
console.info(`Loaded local plugin: ${result.plugin.name} (${result.plugin.id})`);
|
|
151
|
+
} else {
|
|
152
|
+
console.warn(`Failed to load plugin from ${pluginPath}: ${result.error}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async loadNpmPlugins(packages: string[]): Promise<void> {
|
|
158
|
+
const installResults = await installAllNpmPlugins(packages);
|
|
159
|
+
for (const ir of installResults) {
|
|
160
|
+
if (ir.error) {
|
|
161
|
+
console.warn(`Failed to install npm plugin ${ir.name}: ${ir.error}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ir.installed) {
|
|
165
|
+
console.info(`Installed npm plugin: ${ir.name}@${ir.version}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const packageName of packages) {
|
|
170
|
+
const resolvedPath = resolveNpmPluginPath(packageName);
|
|
171
|
+
const result = await loadNpmPlugin(packageName, resolvedPath);
|
|
172
|
+
if (result.success && result.plugin) {
|
|
173
|
+
this.register(result.plugin, 'npm');
|
|
174
|
+
console.info(`Loaded npm plugin: ${result.plugin.name} (${packageName})`);
|
|
175
|
+
} else {
|
|
176
|
+
console.warn(`Failed to load npm plugin ${packageName}: ${result.error}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
disablePlugin(type: PluginType, id: string): boolean {
|
|
182
|
+
return this.plugins[type].delete(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async initialize(config?: {
|
|
186
|
+
plugins?: Partial<PluginsConfig>;
|
|
187
|
+
cliPlugins?: string[];
|
|
188
|
+
}): Promise<void> {
|
|
189
|
+
if (this.initialized) return;
|
|
190
|
+
|
|
191
|
+
await this.loadBuiltinPlugins();
|
|
192
|
+
|
|
193
|
+
const localPaths = [
|
|
194
|
+
...(config?.plugins?.local ?? []),
|
|
195
|
+
...(config?.cliPlugins ?? []),
|
|
196
|
+
];
|
|
197
|
+
await this.loadLocalPlugins(localPaths);
|
|
198
|
+
|
|
199
|
+
const npmPackages = config?.plugins?.npm ?? [];
|
|
200
|
+
if (npmPackages.length > 0) {
|
|
201
|
+
await this.loadNpmPlugins(npmPackages);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const disabled = config?.plugins?.disabled ?? [];
|
|
205
|
+
for (const pluginId of disabled) {
|
|
206
|
+
for (const type of ['provider', 'agent', 'theme', 'notification'] as PluginType[]) {
|
|
207
|
+
if (this.has(type, pluginId)) {
|
|
208
|
+
this.disablePlugin(type, pluginId);
|
|
209
|
+
console.info(`Disabled plugin: ${pluginId}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.initialized = true;
|
|
215
|
+
console.info(
|
|
216
|
+
`Plugin registry initialized: ${this.count('provider')} providers, ` +
|
|
217
|
+
`${this.count('agent')} agents, ${this.count('theme')} themes, ` +
|
|
218
|
+
`${this.count('notification')} notifications`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasPluginShape(obj: unknown): obj is { id: string; type: string; name: string; version: string } {
|
|
224
|
+
return (
|
|
225
|
+
obj !== null &&
|
|
226
|
+
typeof obj === 'object' &&
|
|
227
|
+
'id' in obj &&
|
|
228
|
+
'type' in obj &&
|
|
229
|
+
'name' in obj &&
|
|
230
|
+
'version' in obj
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isProviderPlugin(obj: unknown): obj is ProviderPlugin {
|
|
235
|
+
return hasPluginShape(obj) && obj.type === 'provider';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isAgentPlugin(obj: unknown): obj is AgentPlugin {
|
|
239
|
+
return hasPluginShape(obj) && obj.type === 'agent';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isThemePlugin(obj: unknown): obj is ThemePlugin {
|
|
243
|
+
return hasPluginShape(obj) && obj.type === 'theme';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isNotificationPlugin(obj: unknown): obj is NotificationPlugin {
|
|
247
|
+
return hasPluginShape(obj) && obj.type === 'notification';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const pluginRegistry = new PluginRegistryImpl();
|
|
251
|
+
export type PluginRegistry = PluginRegistryImpl;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
deepFreeze,
|
|
4
|
+
getActivePluginGuard,
|
|
5
|
+
installGlobalFetchGuard,
|
|
6
|
+
runInPluginGuard,
|
|
7
|
+
} from './sandbox-guard.ts';
|
|
8
|
+
import { PluginPermissionError } from './types/base.ts';
|
|
9
|
+
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
const fetchCalls: string[] = [];
|
|
12
|
+
|
|
13
|
+
const mockFetch: typeof fetch = Object.assign(
|
|
14
|
+
(input: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
15
|
+
const url = typeof input === 'string'
|
|
16
|
+
? input
|
|
17
|
+
: input instanceof URL
|
|
18
|
+
? input.toString()
|
|
19
|
+
: input.url;
|
|
20
|
+
fetchCalls.push(url);
|
|
21
|
+
return Promise.resolve(new Response('ok', { status: 200 }));
|
|
22
|
+
},
|
|
23
|
+
{ preconnect: originalFetch.preconnect },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
describe('deepFreeze', () => {
|
|
27
|
+
test('freezes a plain object', () => {
|
|
28
|
+
const obj = { a: 1 };
|
|
29
|
+
deepFreeze(obj);
|
|
30
|
+
|
|
31
|
+
expect(Object.isFrozen(obj)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('freezes nested objects recursively', () => {
|
|
35
|
+
const obj = {
|
|
36
|
+
top: {
|
|
37
|
+
child: {
|
|
38
|
+
value: 1,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
deepFreeze(obj);
|
|
44
|
+
|
|
45
|
+
expect(Object.isFrozen(obj)).toBe(true);
|
|
46
|
+
expect(Object.isFrozen(obj.top)).toBe(true);
|
|
47
|
+
expect(Object.isFrozen(obj.top.child)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('returns the same reference', () => {
|
|
51
|
+
const obj = { nested: { value: 1 } };
|
|
52
|
+
const frozen = deepFreeze(obj);
|
|
53
|
+
|
|
54
|
+
expect(frozen).toBe(obj);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('handles null and undefined gracefully', () => {
|
|
58
|
+
expect(deepFreeze(null)).toBeNull();
|
|
59
|
+
expect(deepFreeze(undefined)).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('handles circular references without infinite loop', () => {
|
|
63
|
+
const obj: { self?: unknown; nested: { parent?: unknown } } = {
|
|
64
|
+
nested: {},
|
|
65
|
+
};
|
|
66
|
+
obj.self = obj;
|
|
67
|
+
obj.nested.parent = obj;
|
|
68
|
+
|
|
69
|
+
const frozen = deepFreeze(obj);
|
|
70
|
+
|
|
71
|
+
expect(frozen).toBe(obj);
|
|
72
|
+
expect(Object.isFrozen(obj)).toBe(true);
|
|
73
|
+
expect(Object.isFrozen(obj.nested)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('handles already-frozen objects', () => {
|
|
77
|
+
const obj = Object.freeze({ already: true });
|
|
78
|
+
|
|
79
|
+
const frozen = deepFreeze(obj);
|
|
80
|
+
|
|
81
|
+
expect(frozen).toBe(obj);
|
|
82
|
+
expect(Object.isFrozen(frozen)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('runInPluginGuard and getActivePluginGuard', () => {
|
|
87
|
+
test('returns undefined outside guard', () => {
|
|
88
|
+
expect(getActivePluginGuard()).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('returns plugin context inside guard', () => {
|
|
92
|
+
const context = runInPluginGuard('plugin-a', {}, () => getActivePluginGuard());
|
|
93
|
+
|
|
94
|
+
expect(context).toBeDefined();
|
|
95
|
+
expect(context?.pluginId).toBe('plugin-a');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('preserves context across async/await', async () => {
|
|
99
|
+
const context = await runInPluginGuard('plugin-async', {}, async () => {
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
return getActivePluginGuard();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(context).toBeDefined();
|
|
105
|
+
expect(context?.pluginId).toBe('plugin-async');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('nested guards use the innermost context', () => {
|
|
109
|
+
const contexts = runInPluginGuard('outer-plugin', {}, () => {
|
|
110
|
+
const outerBefore = getActivePluginGuard();
|
|
111
|
+
const inner = runInPluginGuard('inner-plugin', {}, () => getActivePluginGuard());
|
|
112
|
+
const outerAfter = getActivePluginGuard();
|
|
113
|
+
|
|
114
|
+
return { outerBefore, inner, outerAfter };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(contexts.outerBefore?.pluginId).toBe('outer-plugin');
|
|
118
|
+
expect(contexts.inner?.pluginId).toBe('inner-plugin');
|
|
119
|
+
expect(contexts.outerAfter?.pluginId).toBe('outer-plugin');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('installGlobalFetchGuard', () => {
|
|
124
|
+
beforeAll(() => {
|
|
125
|
+
globalThis.fetch = mockFetch;
|
|
126
|
+
installGlobalFetchGuard();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(() => {
|
|
130
|
+
globalThis.fetch = originalFetch;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('outside guard, fetch calls pass through', async () => {
|
|
134
|
+
fetchCalls.length = 0;
|
|
135
|
+
|
|
136
|
+
const response = await fetch('https://outside-guard.example');
|
|
137
|
+
|
|
138
|
+
expect(response.status).toBe(200);
|
|
139
|
+
expect(fetchCalls).toHaveLength(1);
|
|
140
|
+
expect(fetchCalls[0]).toContain('outside-guard.example');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('inside guard with no network permission throws PluginPermissionError', () => {
|
|
144
|
+
expect(() => {
|
|
145
|
+
runInPluginGuard('plugin-no-network', {}, () => {
|
|
146
|
+
void fetch('https://blocked.example');
|
|
147
|
+
});
|
|
148
|
+
}).toThrow(PluginPermissionError);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('inside guard with allowedDomains blocks unlisted domains', () => {
|
|
152
|
+
expect(() => {
|
|
153
|
+
runInPluginGuard(
|
|
154
|
+
'plugin-allowlist',
|
|
155
|
+
{
|
|
156
|
+
network: {
|
|
157
|
+
enabled: true,
|
|
158
|
+
allowedDomains: ['allowed.example'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
() => {
|
|
162
|
+
void fetch('https://not-allowed.example/path');
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}).toThrow(PluginPermissionError);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('inside guard with allowedDomains allows listed domains and subdomains', async () => {
|
|
169
|
+
fetchCalls.length = 0;
|
|
170
|
+
|
|
171
|
+
const response = await runInPluginGuard(
|
|
172
|
+
'plugin-allowlist',
|
|
173
|
+
{
|
|
174
|
+
network: {
|
|
175
|
+
enabled: true,
|
|
176
|
+
allowedDomains: ['allowed.example'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
async () => {
|
|
180
|
+
const rootResponse = await fetch('https://allowed.example/path');
|
|
181
|
+
await fetch('https://api.allowed.example/path');
|
|
182
|
+
return rootResponse;
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(response.status).toBe(200);
|
|
187
|
+
expect(fetchCalls).toHaveLength(2);
|
|
188
|
+
expect(fetchCalls[0]).toContain('allowed.example/path');
|
|
189
|
+
expect(fetchCalls[1]).toContain('api.allowed.example/path');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox guard — runtime enforcement for plugin permission boundaries.
|
|
3
|
+
*
|
|
4
|
+
* Uses AsyncLocalStorage to track which plugin is currently executing.
|
|
5
|
+
* This enables enforcement even when plugins bypass the provided `ctx` and
|
|
6
|
+
* call global APIs directly (e.g. `globalThis.fetch`).
|
|
7
|
+
*
|
|
8
|
+
* Two layers:
|
|
9
|
+
* 1. **Global fetch guard** — intercepts all `fetch()` calls and enforces
|
|
10
|
+
* network domain allowlists when running inside a plugin context.
|
|
11
|
+
* 2. **Deep-freeze** — prevents plugins from mutating the PluginContext.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
15
|
+
import { PluginPermissionError, type PluginPermissions } from './types/base.ts';
|
|
16
|
+
import { createPluginLogger } from './sandbox.ts';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Plugin execution context (AsyncLocalStorage)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface PluginGuardContext {
|
|
23
|
+
readonly pluginId: string;
|
|
24
|
+
readonly permissions: PluginPermissions;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pluginGuardStorage = new AsyncLocalStorage<PluginGuardContext>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the currently-executing plugin context, if any.
|
|
31
|
+
* Returns undefined when called outside of a plugin method invocation.
|
|
32
|
+
*/
|
|
33
|
+
export function getActivePluginGuard(): PluginGuardContext | undefined {
|
|
34
|
+
return pluginGuardStorage.getStore();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run a function within a plugin's guard context.
|
|
39
|
+
* All code executed within `fn` (including async continuations) will
|
|
40
|
+
* have the plugin's permissions available via `getActivePluginGuard()`.
|
|
41
|
+
*/
|
|
42
|
+
export function runInPluginGuard<T>(
|
|
43
|
+
pluginId: string,
|
|
44
|
+
permissions: PluginPermissions,
|
|
45
|
+
fn: () => T,
|
|
46
|
+
): T {
|
|
47
|
+
return pluginGuardStorage.run({ pluginId, permissions }, fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Global fetch guard
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
let fetchGuardInstalled = false;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Install a global fetch interceptor that enforces plugin network permissions.
|
|
58
|
+
*
|
|
59
|
+
* When code runs inside `runInPluginGuard()`, all `fetch()` calls are checked
|
|
60
|
+
* against the plugin's `permissions.network.allowedDomains`. Calls outside of
|
|
61
|
+
* a plugin context pass through unmodified.
|
|
62
|
+
*
|
|
63
|
+
* This is idempotent — calling it multiple times is safe.
|
|
64
|
+
*/
|
|
65
|
+
export function installGlobalFetchGuard(): void {
|
|
66
|
+
if (fetchGuardInstalled) return;
|
|
67
|
+
|
|
68
|
+
const originalFetch = globalThis.fetch;
|
|
69
|
+
|
|
70
|
+
const guardedFetch: typeof fetch = Object.assign(
|
|
71
|
+
function guardedFetch(
|
|
72
|
+
input: string | URL | Request,
|
|
73
|
+
init?: RequestInit,
|
|
74
|
+
): Promise<Response> {
|
|
75
|
+
const guard = pluginGuardStorage.getStore();
|
|
76
|
+
|
|
77
|
+
if (!guard) {
|
|
78
|
+
return originalFetch(input, init);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { pluginId, permissions } = guard;
|
|
82
|
+
|
|
83
|
+
if (!permissions.network?.enabled) {
|
|
84
|
+
const log = createPluginLogger(pluginId);
|
|
85
|
+
log.error('Network access blocked — plugin has not declared network permissions');
|
|
86
|
+
throw new PluginPermissionError(
|
|
87
|
+
pluginId,
|
|
88
|
+
'network',
|
|
89
|
+
'Network access not permitted',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const allowedDomains = permissions.network.allowedDomains;
|
|
94
|
+
if (allowedDomains && allowedDomains.length > 0) {
|
|
95
|
+
const url = typeof input === 'string'
|
|
96
|
+
? new URL(input)
|
|
97
|
+
: input instanceof URL
|
|
98
|
+
? input
|
|
99
|
+
: new URL(input.url);
|
|
100
|
+
|
|
101
|
+
const isAllowed = allowedDomains.some(
|
|
102
|
+
(domain) =>
|
|
103
|
+
url.hostname === domain || url.hostname.endsWith(`.${domain}`),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (!isAllowed) {
|
|
107
|
+
const log = createPluginLogger(pluginId);
|
|
108
|
+
log.error(`Network blocked: domain "${url.hostname}" not in allowlist [${allowedDomains.join(', ')}]`);
|
|
109
|
+
throw new PluginPermissionError(
|
|
110
|
+
pluginId,
|
|
111
|
+
'network',
|
|
112
|
+
`Domain "${url.hostname}" not in allowlist: ${allowedDomains.join(', ')}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return originalFetch(input, init);
|
|
118
|
+
},
|
|
119
|
+
{ preconnect: originalFetch.preconnect },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
globalThis.fetch = guardedFetch;
|
|
123
|
+
fetchGuardInstalled = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Deep freeze
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const frozenObjects = new WeakSet<object>();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Recursively freeze an object and all of its enumerable properties.
|
|
134
|
+
* Safe against circular references via WeakSet tracking.
|
|
135
|
+
* Skips functions (they may need internal state) and already-frozen objects.
|
|
136
|
+
*/
|
|
137
|
+
export function deepFreeze<T>(obj: T): T {
|
|
138
|
+
if (obj === null || obj === undefined) return obj;
|
|
139
|
+
if (typeof obj !== 'object') return obj;
|
|
140
|
+
|
|
141
|
+
const o = obj as object;
|
|
142
|
+
|
|
143
|
+
if (frozenObjects.has(o) || Object.isFrozen(o)) return obj;
|
|
144
|
+
frozenObjects.add(o);
|
|
145
|
+
|
|
146
|
+
Object.freeze(o);
|
|
147
|
+
|
|
148
|
+
for (const value of Object.values(o)) {
|
|
149
|
+
if (value !== null && typeof value === 'object') {
|
|
150
|
+
deepFreeze(value);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return obj;
|
|
155
|
+
}
|