@tokentop/ttop 0.2.2 → 0.4.0
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/README.md +2 -5
- package/package.json +8 -5
- package/src/agents/aggregator.ts +39 -19
- package/src/agents/costing.ts +11 -9
- package/src/agents/index.ts +7 -9
- package/src/agents/types.ts +2 -2
- package/src/cli.ts +28 -23
- package/src/config/schema.test.ts +42 -40
- package/src/config/schema.ts +26 -26
- package/src/demo/simulator.ts +161 -122
- package/src/plugins/agents/index.ts +2 -2
- package/src/plugins/auth-sources.ts +42 -38
- package/src/plugins/lifecycle.ts +42 -38
- package/src/plugins/loader.test.ts +49 -49
- package/src/plugins/loader.ts +67 -63
- package/src/plugins/notification-bus.ts +21 -16
- package/src/plugins/notifications/index.ts +2 -2
- package/src/plugins/notifications/terminal-bell.ts +36 -23
- package/src/plugins/notifications/visual-flash.ts +31 -19
- package/src/plugins/npm-installer.ts +23 -25
- package/src/plugins/plugin-context-factory.ts +10 -10
- package/src/plugins/plugin-host.ts +12 -5
- package/src/plugins/providers/anthropic.ts +64 -57
- package/src/plugins/providers/antigravity.ts +81 -69
- package/src/plugins/providers/codex.ts +42 -42
- package/src/plugins/providers/gemini.ts +106 -87
- package/src/plugins/providers/github-copilot.ts +49 -45
- package/src/plugins/providers/index.ts +10 -12
- package/src/plugins/providers/minimax.ts +50 -44
- package/src/plugins/providers/openai-api.ts +82 -62
- package/src/plugins/providers/opencode-zen.ts +46 -39
- package/src/plugins/providers/perplexity.ts +55 -46
- package/src/plugins/providers/zai.ts +68 -57
- package/src/plugins/registry.ts +80 -71
- package/src/plugins/sandbox-guard.test.ts +45 -48
- package/src/plugins/sandbox-guard.ts +20 -25
- package/src/plugins/sandbox.ts +26 -48
- package/src/plugins/themes/catppuccin-latte.ts +29 -29
- package/src/plugins/themes/catppuccin-mocha.ts +29 -29
- package/src/plugins/themes/claude-code.ts +29 -29
- package/src/plugins/themes/dracula.ts +29 -29
- package/src/plugins/themes/github-light.ts +29 -29
- package/src/plugins/themes/gruvbox-dark.ts +29 -29
- package/src/plugins/themes/gruvbox-light.ts +29 -29
- package/src/plugins/themes/index.ts +15 -15
- package/src/plugins/themes/kanagawa.ts +29 -29
- package/src/plugins/themes/nord.ts +29 -29
- package/src/plugins/themes/one-dark.ts +29 -29
- package/src/plugins/themes/opencode.ts +31 -31
- package/src/plugins/themes/rose-pine-dawn.ts +29 -29
- package/src/plugins/themes/rose-pine.ts +29 -29
- package/src/plugins/themes/solarized-light.ts +29 -29
- package/src/plugins/themes/tokyo-night.ts +29 -29
- package/src/plugins/types/agent.ts +5 -5
- package/src/plugins/types/base.ts +35 -24
- package/src/plugins/types/index.ts +9 -9
- package/src/plugins/types/notification.ts +12 -12
- package/src/plugins/types/provider.ts +12 -8
- package/src/plugins/types/theme.ts +31 -23
- package/src/plugins/update-checker.ts +6 -6
- package/src/pricing/estimator.test.ts +39 -44
- package/src/pricing/estimator.ts +7 -13
- package/src/pricing/fallback.test.ts +30 -34
- package/src/pricing/fallback.ts +54 -27
- package/src/pricing/index.ts +18 -12
- package/src/pricing/models-dev.ts +6 -6
- package/src/storage/database.ts +56 -46
- package/src/storage/db.ts +12 -12
- package/src/storage/index.ts +44 -45
- package/src/storage/migrations/index.ts +2 -2
- package/src/storage/paths.ts +13 -13
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/repos/agentSessions.ts +33 -23
- package/src/storage/repos/providerSnapshots.ts +5 -5
- package/src/storage/repos/usageEvents.ts +40 -28
- package/src/storage/retention.ts +146 -0
- package/src/storage/types.ts +5 -5
- package/src/tui/App.tsx +180 -137
- package/src/tui/components/CommandPalette.tsx +46 -34
- package/src/tui/components/DebugConsole.tsx +39 -33
- package/src/tui/components/DebugPanel.tsx +314 -183
- package/src/tui/components/FooterHints.tsx +2 -2
- package/src/tui/components/GhostProviderCard.tsx +37 -35
- package/src/tui/components/Header.tsx +68 -35
- package/src/tui/components/HelpOverlay.tsx +99 -29
- package/src/tui/components/InlineGauge.tsx +10 -7
- package/src/tui/components/InlineSparkline.tsx +17 -11
- package/src/tui/components/KpiStrip.tsx +128 -98
- package/src/tui/components/LimitGauge.tsx +96 -59
- package/src/tui/components/ModalBackdrop.tsx +4 -4
- package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
- package/src/tui/components/ProviderCard.tsx +232 -226
- package/src/tui/components/ProviderDetailPanel.tsx +133 -58
- package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
- package/src/tui/components/ProvidersList.tsx +215 -126
- package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
- package/src/tui/components/SessionsTable.tsx +320 -169
- package/src/tui/components/SettingsModal.tsx +593 -269
- package/src/tui/components/SidebarBreakdown.tsx +30 -16
- package/src/tui/components/Skeleton.tsx +24 -17
- package/src/tui/components/SmartSidebar.tsx +360 -258
- package/src/tui/components/Sparkline.tsx +64 -52
- package/src/tui/components/Spinner.tsx +3 -3
- package/src/tui/components/StatusBar.tsx +27 -27
- package/src/tui/components/ThemePicker.tsx +67 -66
- package/src/tui/components/Toast.tsx +9 -6
- package/src/tui/components/UsageGauge.tsx +10 -13
- package/src/tui/components/index.ts +5 -5
- package/src/tui/contexts/AgentSessionContext.tsx +164 -151
- package/src/tui/contexts/ConfigContext.tsx +15 -13
- package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
- package/src/tui/contexts/DemoModeContext.tsx +22 -15
- package/src/tui/contexts/DrawerContext.tsx +11 -9
- package/src/tui/contexts/InputContext.tsx +2 -2
- package/src/tui/contexts/LogContext.tsx +62 -47
- package/src/tui/contexts/PluginContext.tsx +238 -183
- package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
- package/src/tui/contexts/StorageContext.tsx +95 -149
- package/src/tui/contexts/ThemeContext.tsx +21 -16
- package/src/tui/contexts/TimeWindowContext.tsx +72 -61
- package/src/tui/contexts/ToastContext.tsx +10 -7
- package/src/tui/contexts/index.ts +9 -5
- package/src/tui/createApp.tsx +8 -12
- package/src/tui/debug/captureFrame.ts +25 -28
- package/src/tui/debug/snapshot.tsx +179 -162
- package/src/tui/driver/assertions.ts +30 -22
- package/src/tui/driver/cli.ts +231 -221
- package/src/tui/driver/coverage.ts +27 -44
- package/src/tui/driver/demo-snapshot.ts +33 -33
- package/src/tui/driver/diff.ts +26 -32
- package/src/tui/driver/driver.ts +43 -48
- package/src/tui/driver/index.ts +36 -40
- package/src/tui/driver/recorder.ts +37 -48
- package/src/tui/driver/test-workflow.ts +51 -51
- package/src/tui/driver/test.ts +25 -25
- package/src/tui/hooks/index.ts +24 -20
- package/src/tui/hooks/useAnimatedValue.ts +13 -18
- package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
- package/src/tui/hooks/useDashboardState.ts +26 -24
- package/src/tui/hooks/useEmaActivity.ts +82 -74
- package/src/tui/hooks/useEntranceAnimation.ts +11 -11
- package/src/tui/hooks/useExitAnimation.ts +7 -3
- package/src/tui/hooks/usePulse.ts +4 -4
- package/src/tui/hooks/useSafeRenderer.ts +5 -5
- package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
- package/src/tui/hooks/useValueFlash.ts +10 -14
- package/src/tui/index.tsx +29 -6
- package/src/tui/shutdown.ts +19 -0
- package/src/tui/utils/providerColor.ts +1 -1
- package/src/tui/views/Dashboard.tsx +164 -102
- package/src/tui/views/HistoricalTrendsView.tsx +579 -250
- package/src/tui/views/ProjectsView.tsx +495 -243
- package/src/tui/views/RealTimeDashboard.tsx +176 -116
- package/src/tui/views/SettingsView.tsx +225 -189
- package/src/tui/views/index.ts +2 -2
- package/src/types/opentui-test-utils.d.ts +7 -7
- package/src/utils/clipboard.ts +29 -29
- package/src/utils/currency.test.ts +53 -53
- package/src/utils/currency.ts +35 -32
- package/src/version.ts +1 -1
package/src/plugins/registry.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
+
import type { PluginsConfig } from "@/config/schema.ts";
|
|
2
|
+
import {
|
|
3
|
+
discoverLocalPlugins,
|
|
4
|
+
loadLocalPlugin,
|
|
5
|
+
loadNpmPlugin,
|
|
6
|
+
resolvePluginPath,
|
|
7
|
+
} from "./loader.ts";
|
|
8
|
+
import { installAllNpmPlugins, resolveNpmPluginPath } from "./npm-installer.ts";
|
|
1
9
|
import type {
|
|
10
|
+
AgentPlugin,
|
|
2
11
|
AnyPlugin,
|
|
3
|
-
|
|
12
|
+
NotificationPlugin,
|
|
4
13
|
PluginByType,
|
|
14
|
+
PluginType,
|
|
5
15
|
ProviderPlugin,
|
|
6
|
-
AgentPlugin,
|
|
7
16
|
ThemePlugin,
|
|
8
|
-
|
|
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';
|
|
17
|
+
} from "./types/index.ts";
|
|
13
18
|
|
|
14
|
-
export type PluginSource =
|
|
19
|
+
export type PluginSource = "builtin" | "local" | "npm";
|
|
15
20
|
|
|
16
21
|
type PluginStore = {
|
|
17
22
|
provider: Map<string, ProviderPlugin>;
|
|
@@ -30,46 +35,30 @@ class PluginRegistryImpl {
|
|
|
30
35
|
|
|
31
36
|
private sources = new Map<string, PluginSource>();
|
|
32
37
|
private initialized = false;
|
|
38
|
+
private officialIds = new Set<string>();
|
|
33
39
|
|
|
34
40
|
private sourceKey(type: string, id: string): string {
|
|
35
41
|
return `${type}-${id}`;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
register(plugin: AnyPlugin, source: PluginSource =
|
|
44
|
+
register(plugin: AnyPlugin, source: PluginSource = "builtin"): void {
|
|
39
45
|
const key = this.sourceKey(plugin.type, plugin.id);
|
|
40
46
|
const existingSource = this.sources.get(key);
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
// Priority: local > npm > builtin
|
|
49
|
+
// If a higher-priority source already registered this plugin, skip.
|
|
50
|
+
const priority: Record<PluginSource, number> = { local: 2, npm: 1, builtin: 0 };
|
|
51
|
+
if (existingSource && priority[existingSource] > priority[source]) {
|
|
43
52
|
return;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
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;
|
|
55
|
+
if (existingSource && existingSource !== source) {
|
|
56
|
+
console.info(`Plugin "${plugin.id}" overridden by ${source} (was ${existingSource})`);
|
|
72
57
|
}
|
|
58
|
+
|
|
59
|
+
this.sources.set(key, source);
|
|
60
|
+
const map = this.plugins[plugin.type] as Map<string, AnyPlugin>;
|
|
61
|
+
map.set(plugin.id, plugin);
|
|
73
62
|
}
|
|
74
63
|
|
|
75
64
|
unregister(type: PluginType, id: string): boolean {
|
|
@@ -113,43 +102,59 @@ class PluginRegistryImpl {
|
|
|
113
102
|
return this.sources.get(this.sourceKey(type, id));
|
|
114
103
|
}
|
|
115
104
|
|
|
105
|
+
isOfficial(type: PluginType, id: string): boolean {
|
|
106
|
+
return this.officialIds.has(this.sourceKey(type, id));
|
|
107
|
+
}
|
|
108
|
+
|
|
116
109
|
async loadBuiltinPlugins(): Promise<void> {
|
|
117
110
|
const [providers, agents, themes, notifications] = await Promise.all([
|
|
118
|
-
import(
|
|
119
|
-
import(
|
|
120
|
-
import(
|
|
121
|
-
import(
|
|
111
|
+
import("./providers/index.ts"),
|
|
112
|
+
import("./agents/index.ts"),
|
|
113
|
+
import("./themes/index.ts"),
|
|
114
|
+
import("./notifications/index.ts"),
|
|
122
115
|
]);
|
|
123
116
|
|
|
124
117
|
for (const plugin of Object.values(providers)) {
|
|
125
|
-
if (isProviderPlugin(plugin))
|
|
118
|
+
if (isProviderPlugin(plugin)) {
|
|
119
|
+
this.register(plugin, "builtin");
|
|
120
|
+
this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
|
|
121
|
+
}
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
for (const plugin of Object.values(agents)) {
|
|
129
|
-
if (isAgentPlugin(plugin))
|
|
125
|
+
if (isAgentPlugin(plugin)) {
|
|
126
|
+
this.register(plugin, "builtin");
|
|
127
|
+
this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
|
|
128
|
+
}
|
|
130
129
|
}
|
|
131
130
|
|
|
132
131
|
for (const plugin of Object.values(themes)) {
|
|
133
|
-
if (isThemePlugin(plugin))
|
|
132
|
+
if (isThemePlugin(plugin)) {
|
|
133
|
+
this.register(plugin, "builtin");
|
|
134
|
+
this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
|
|
135
|
+
}
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
for (const plugin of Object.values(notifications)) {
|
|
137
|
-
if (isNotificationPlugin(plugin))
|
|
139
|
+
if (isNotificationPlugin(plugin)) {
|
|
140
|
+
this.register(plugin, "builtin");
|
|
141
|
+
this.officialIds.add(this.sourceKey(plugin.type, plugin.id));
|
|
142
|
+
}
|
|
138
143
|
}
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
async loadLocalPlugins(extraPaths: string[] = []): Promise<void> {
|
|
142
147
|
const discoveredPaths = await discoverLocalPlugins();
|
|
143
|
-
const resolvedExtraPaths = extraPaths.map(p => resolvePluginPath(p));
|
|
148
|
+
const resolvedExtraPaths = extraPaths.map((p) => resolvePluginPath(p));
|
|
144
149
|
const allPaths = [...discoveredPaths, ...resolvedExtraPaths];
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
const results = await Promise.all(allPaths.map((p) => loadLocalPlugin(p)));
|
|
152
|
+
for (const [i, result] of results.entries()) {
|
|
148
153
|
if (result.success && result.plugin) {
|
|
149
|
-
this.register(result.plugin,
|
|
154
|
+
this.register(result.plugin, "local");
|
|
150
155
|
console.info(`Loaded local plugin: ${result.plugin.name} (${result.plugin.id})`);
|
|
151
156
|
} else {
|
|
152
|
-
console.warn(`Failed to load plugin from ${
|
|
157
|
+
console.warn(`Failed to load plugin from ${allPaths[i]}: ${result.error}`);
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
}
|
|
@@ -166,11 +171,16 @@ class PluginRegistryImpl {
|
|
|
166
171
|
}
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
const loadResults = await Promise.all(
|
|
175
|
+
packages.map(async (packageName) => {
|
|
176
|
+
const resolvedPath = resolveNpmPluginPath(packageName);
|
|
177
|
+
const result = await loadNpmPlugin(packageName, resolvedPath);
|
|
178
|
+
return { packageName, result };
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
for (const { packageName, result } of loadResults) {
|
|
172
182
|
if (result.success && result.plugin) {
|
|
173
|
-
this.register(result.plugin,
|
|
183
|
+
this.register(result.plugin, "npm");
|
|
174
184
|
console.info(`Loaded npm plugin: ${result.plugin.name} (${packageName})`);
|
|
175
185
|
} else {
|
|
176
186
|
console.warn(`Failed to load npm plugin ${packageName}: ${result.error}`);
|
|
@@ -190,10 +200,7 @@ class PluginRegistryImpl {
|
|
|
190
200
|
|
|
191
201
|
await this.loadBuiltinPlugins();
|
|
192
202
|
|
|
193
|
-
const localPaths = [
|
|
194
|
-
...(config?.plugins?.local ?? []),
|
|
195
|
-
...(config?.cliPlugins ?? []),
|
|
196
|
-
];
|
|
203
|
+
const localPaths = [...(config?.plugins?.local ?? []), ...(config?.cliPlugins ?? [])];
|
|
197
204
|
await this.loadLocalPlugins(localPaths);
|
|
198
205
|
|
|
199
206
|
const npmPackages = config?.plugins?.npm ?? [];
|
|
@@ -203,7 +210,7 @@ class PluginRegistryImpl {
|
|
|
203
210
|
|
|
204
211
|
const disabled = config?.plugins?.disabled ?? [];
|
|
205
212
|
for (const pluginId of disabled) {
|
|
206
|
-
for (const type of [
|
|
213
|
+
for (const type of ["provider", "agent", "theme", "notification"] as PluginType[]) {
|
|
207
214
|
if (this.has(type, pluginId)) {
|
|
208
215
|
this.disablePlugin(type, pluginId);
|
|
209
216
|
console.info(`Disabled plugin: ${pluginId}`);
|
|
@@ -213,38 +220,40 @@ class PluginRegistryImpl {
|
|
|
213
220
|
|
|
214
221
|
this.initialized = true;
|
|
215
222
|
console.info(
|
|
216
|
-
`Plugin registry initialized: ${this.count(
|
|
217
|
-
`${this.count(
|
|
218
|
-
`${this.count(
|
|
223
|
+
`Plugin registry initialized: ${this.count("provider")} providers, ` +
|
|
224
|
+
`${this.count("agent")} agents, ${this.count("theme")} themes, ` +
|
|
225
|
+
`${this.count("notification")} notifications`,
|
|
219
226
|
);
|
|
220
227
|
}
|
|
221
228
|
}
|
|
222
229
|
|
|
223
|
-
function hasPluginShape(
|
|
230
|
+
function hasPluginShape(
|
|
231
|
+
obj: unknown,
|
|
232
|
+
): obj is { id: string; type: string; name: string; version: string } {
|
|
224
233
|
return (
|
|
225
234
|
obj !== null &&
|
|
226
|
-
typeof obj ===
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
typeof obj === "object" &&
|
|
236
|
+
"id" in obj &&
|
|
237
|
+
"type" in obj &&
|
|
238
|
+
"name" in obj &&
|
|
239
|
+
"version" in obj
|
|
231
240
|
);
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
function isProviderPlugin(obj: unknown): obj is ProviderPlugin {
|
|
235
|
-
return hasPluginShape(obj) && obj.type ===
|
|
244
|
+
return hasPluginShape(obj) && obj.type === "provider";
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
function isAgentPlugin(obj: unknown): obj is AgentPlugin {
|
|
239
|
-
return hasPluginShape(obj) && obj.type ===
|
|
248
|
+
return hasPluginShape(obj) && obj.type === "agent";
|
|
240
249
|
}
|
|
241
250
|
|
|
242
251
|
function isThemePlugin(obj: unknown): obj is ThemePlugin {
|
|
243
|
-
return hasPluginShape(obj) && obj.type ===
|
|
252
|
+
return hasPluginShape(obj) && obj.type === "theme";
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
function isNotificationPlugin(obj: unknown): obj is NotificationPlugin {
|
|
247
|
-
return hasPluginShape(obj) && obj.type ===
|
|
256
|
+
return hasPluginShape(obj) && obj.type === "notification";
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
export const pluginRegistry = new PluginRegistryImpl();
|
|
@@ -1,37 +1,34 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
deepFreeze,
|
|
4
4
|
getActivePluginGuard,
|
|
5
5
|
installGlobalFetchGuard,
|
|
6
6
|
runInPluginGuard,
|
|
7
|
-
} from
|
|
8
|
-
import { PluginPermissionError } from
|
|
7
|
+
} from "./sandbox-guard.ts";
|
|
8
|
+
import { PluginPermissionError } from "./types/base.ts";
|
|
9
9
|
|
|
10
10
|
const originalFetch = globalThis.fetch;
|
|
11
11
|
const fetchCalls: string[] = [];
|
|
12
12
|
|
|
13
13
|
const mockFetch: typeof fetch = Object.assign(
|
|
14
14
|
(input: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
15
|
-
const url =
|
|
16
|
-
? input
|
|
17
|
-
: input instanceof URL
|
|
18
|
-
? input.toString()
|
|
19
|
-
: input.url;
|
|
15
|
+
const url =
|
|
16
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
20
17
|
fetchCalls.push(url);
|
|
21
|
-
return Promise.resolve(new Response(
|
|
18
|
+
return Promise.resolve(new Response("ok", { status: 200 }));
|
|
22
19
|
},
|
|
23
20
|
{ preconnect: originalFetch.preconnect },
|
|
24
21
|
);
|
|
25
22
|
|
|
26
|
-
describe(
|
|
27
|
-
test(
|
|
23
|
+
describe("deepFreeze", () => {
|
|
24
|
+
test("freezes a plain object", () => {
|
|
28
25
|
const obj = { a: 1 };
|
|
29
26
|
deepFreeze(obj);
|
|
30
27
|
|
|
31
28
|
expect(Object.isFrozen(obj)).toBe(true);
|
|
32
29
|
});
|
|
33
30
|
|
|
34
|
-
test(
|
|
31
|
+
test("freezes nested objects recursively", () => {
|
|
35
32
|
const obj = {
|
|
36
33
|
top: {
|
|
37
34
|
child: {
|
|
@@ -47,19 +44,19 @@ describe('deepFreeze', () => {
|
|
|
47
44
|
expect(Object.isFrozen(obj.top.child)).toBe(true);
|
|
48
45
|
});
|
|
49
46
|
|
|
50
|
-
test(
|
|
47
|
+
test("returns the same reference", () => {
|
|
51
48
|
const obj = { nested: { value: 1 } };
|
|
52
49
|
const frozen = deepFreeze(obj);
|
|
53
50
|
|
|
54
51
|
expect(frozen).toBe(obj);
|
|
55
52
|
});
|
|
56
53
|
|
|
57
|
-
test(
|
|
54
|
+
test("handles null and undefined gracefully", () => {
|
|
58
55
|
expect(deepFreeze(null)).toBeNull();
|
|
59
56
|
expect(deepFreeze(undefined)).toBeUndefined();
|
|
60
57
|
});
|
|
61
58
|
|
|
62
|
-
test(
|
|
59
|
+
test("handles circular references without infinite loop", () => {
|
|
63
60
|
const obj: { self?: unknown; nested: { parent?: unknown } } = {
|
|
64
61
|
nested: {},
|
|
65
62
|
};
|
|
@@ -73,7 +70,7 @@ describe('deepFreeze', () => {
|
|
|
73
70
|
expect(Object.isFrozen(obj.nested)).toBe(true);
|
|
74
71
|
});
|
|
75
72
|
|
|
76
|
-
test(
|
|
73
|
+
test("handles already-frozen objects", () => {
|
|
77
74
|
const obj = Object.freeze({ already: true });
|
|
78
75
|
|
|
79
76
|
const frozen = deepFreeze(obj);
|
|
@@ -83,44 +80,44 @@ describe('deepFreeze', () => {
|
|
|
83
80
|
});
|
|
84
81
|
});
|
|
85
82
|
|
|
86
|
-
describe(
|
|
87
|
-
test(
|
|
83
|
+
describe("runInPluginGuard and getActivePluginGuard", () => {
|
|
84
|
+
test("returns undefined outside guard", () => {
|
|
88
85
|
expect(getActivePluginGuard()).toBeUndefined();
|
|
89
86
|
});
|
|
90
87
|
|
|
91
|
-
test(
|
|
92
|
-
const context = runInPluginGuard(
|
|
88
|
+
test("returns plugin context inside guard", () => {
|
|
89
|
+
const context = runInPluginGuard("plugin-a", {}, () => getActivePluginGuard());
|
|
93
90
|
|
|
94
91
|
expect(context).toBeDefined();
|
|
95
|
-
expect(context?.pluginId).toBe(
|
|
92
|
+
expect(context?.pluginId).toBe("plugin-a");
|
|
96
93
|
});
|
|
97
94
|
|
|
98
|
-
test(
|
|
99
|
-
const context = await runInPluginGuard(
|
|
95
|
+
test("preserves context across async/await", async () => {
|
|
96
|
+
const context = await runInPluginGuard("plugin-async", {}, async () => {
|
|
100
97
|
await Promise.resolve();
|
|
101
98
|
return getActivePluginGuard();
|
|
102
99
|
});
|
|
103
100
|
|
|
104
101
|
expect(context).toBeDefined();
|
|
105
|
-
expect(context?.pluginId).toBe(
|
|
102
|
+
expect(context?.pluginId).toBe("plugin-async");
|
|
106
103
|
});
|
|
107
104
|
|
|
108
|
-
test(
|
|
109
|
-
const contexts = runInPluginGuard(
|
|
105
|
+
test("nested guards use the innermost context", () => {
|
|
106
|
+
const contexts = runInPluginGuard("outer-plugin", {}, () => {
|
|
110
107
|
const outerBefore = getActivePluginGuard();
|
|
111
|
-
const inner = runInPluginGuard(
|
|
108
|
+
const inner = runInPluginGuard("inner-plugin", {}, () => getActivePluginGuard());
|
|
112
109
|
const outerAfter = getActivePluginGuard();
|
|
113
110
|
|
|
114
111
|
return { outerBefore, inner, outerAfter };
|
|
115
112
|
});
|
|
116
113
|
|
|
117
|
-
expect(contexts.outerBefore?.pluginId).toBe(
|
|
118
|
-
expect(contexts.inner?.pluginId).toBe(
|
|
119
|
-
expect(contexts.outerAfter?.pluginId).toBe(
|
|
114
|
+
expect(contexts.outerBefore?.pluginId).toBe("outer-plugin");
|
|
115
|
+
expect(contexts.inner?.pluginId).toBe("inner-plugin");
|
|
116
|
+
expect(contexts.outerAfter?.pluginId).toBe("outer-plugin");
|
|
120
117
|
});
|
|
121
118
|
});
|
|
122
119
|
|
|
123
|
-
describe(
|
|
120
|
+
describe("installGlobalFetchGuard", () => {
|
|
124
121
|
beforeAll(() => {
|
|
125
122
|
globalThis.fetch = mockFetch;
|
|
126
123
|
installGlobalFetchGuard();
|
|
@@ -130,62 +127,62 @@ describe('installGlobalFetchGuard', () => {
|
|
|
130
127
|
globalThis.fetch = originalFetch;
|
|
131
128
|
});
|
|
132
129
|
|
|
133
|
-
test(
|
|
130
|
+
test("outside guard, fetch calls pass through", async () => {
|
|
134
131
|
fetchCalls.length = 0;
|
|
135
132
|
|
|
136
|
-
const response = await fetch(
|
|
133
|
+
const response = await fetch("https://outside-guard.example");
|
|
137
134
|
|
|
138
135
|
expect(response.status).toBe(200);
|
|
139
136
|
expect(fetchCalls).toHaveLength(1);
|
|
140
|
-
expect(fetchCalls[0]).toContain(
|
|
137
|
+
expect(fetchCalls[0]).toContain("outside-guard.example");
|
|
141
138
|
});
|
|
142
139
|
|
|
143
|
-
test(
|
|
140
|
+
test("inside guard with no network permission throws PluginPermissionError", () => {
|
|
144
141
|
expect(() => {
|
|
145
|
-
runInPluginGuard(
|
|
146
|
-
void fetch(
|
|
142
|
+
runInPluginGuard("plugin-no-network", {}, () => {
|
|
143
|
+
void fetch("https://blocked.example");
|
|
147
144
|
});
|
|
148
145
|
}).toThrow(PluginPermissionError);
|
|
149
146
|
});
|
|
150
147
|
|
|
151
|
-
test(
|
|
148
|
+
test("inside guard with allowedDomains blocks unlisted domains", () => {
|
|
152
149
|
expect(() => {
|
|
153
150
|
runInPluginGuard(
|
|
154
|
-
|
|
151
|
+
"plugin-allowlist",
|
|
155
152
|
{
|
|
156
153
|
network: {
|
|
157
154
|
enabled: true,
|
|
158
|
-
allowedDomains: [
|
|
155
|
+
allowedDomains: ["allowed.example"],
|
|
159
156
|
},
|
|
160
157
|
},
|
|
161
158
|
() => {
|
|
162
|
-
void fetch(
|
|
159
|
+
void fetch("https://not-allowed.example/path");
|
|
163
160
|
},
|
|
164
161
|
);
|
|
165
162
|
}).toThrow(PluginPermissionError);
|
|
166
163
|
});
|
|
167
164
|
|
|
168
|
-
test(
|
|
165
|
+
test("inside guard with allowedDomains allows listed domains and subdomains", async () => {
|
|
169
166
|
fetchCalls.length = 0;
|
|
170
167
|
|
|
171
168
|
const response = await runInPluginGuard(
|
|
172
|
-
|
|
169
|
+
"plugin-allowlist",
|
|
173
170
|
{
|
|
174
171
|
network: {
|
|
175
172
|
enabled: true,
|
|
176
|
-
allowedDomains: [
|
|
173
|
+
allowedDomains: ["allowed.example"],
|
|
177
174
|
},
|
|
178
175
|
},
|
|
179
176
|
async () => {
|
|
180
|
-
const rootResponse = await fetch(
|
|
181
|
-
await fetch(
|
|
177
|
+
const rootResponse = await fetch("https://allowed.example/path");
|
|
178
|
+
await fetch("https://api.allowed.example/path");
|
|
182
179
|
return rootResponse;
|
|
183
180
|
},
|
|
184
181
|
);
|
|
185
182
|
|
|
186
183
|
expect(response.status).toBe(200);
|
|
187
184
|
expect(fetchCalls).toHaveLength(2);
|
|
188
|
-
expect(fetchCalls[0]).toContain(
|
|
189
|
-
expect(fetchCalls[1]).toContain(
|
|
185
|
+
expect(fetchCalls[0]).toContain("allowed.example/path");
|
|
186
|
+
expect(fetchCalls[1]).toContain("api.allowed.example/path");
|
|
190
187
|
});
|
|
191
188
|
});
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* 2. **Deep-freeze** — prevents plugins from mutating the PluginContext.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { AsyncLocalStorage } from
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
14
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
15
|
+
import { createPluginLogger } from "./sandbox.ts";
|
|
16
|
+
import { PluginPermissionError, type PluginPermissions } from "./types/base.ts";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Plugin execution context (AsyncLocalStorage)
|
|
@@ -68,10 +68,7 @@ export function installGlobalFetchGuard(): void {
|
|
|
68
68
|
const originalFetch = globalThis.fetch;
|
|
69
69
|
|
|
70
70
|
const guardedFetch: typeof fetch = Object.assign(
|
|
71
|
-
function guardedFetch(
|
|
72
|
-
input: string | URL | Request,
|
|
73
|
-
init?: RequestInit,
|
|
74
|
-
): Promise<Response> {
|
|
71
|
+
function guardedFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
75
72
|
const guard = pluginGuardStorage.getStore();
|
|
76
73
|
|
|
77
74
|
if (!guard) {
|
|
@@ -82,34 +79,32 @@ export function installGlobalFetchGuard(): void {
|
|
|
82
79
|
|
|
83
80
|
if (!permissions.network?.enabled) {
|
|
84
81
|
const log = createPluginLogger(pluginId);
|
|
85
|
-
log.error(
|
|
86
|
-
throw new PluginPermissionError(
|
|
87
|
-
pluginId,
|
|
88
|
-
'network',
|
|
89
|
-
'Network access not permitted',
|
|
90
|
-
);
|
|
82
|
+
log.error("Network access blocked — plugin has not declared network permissions");
|
|
83
|
+
throw new PluginPermissionError(pluginId, "network", "Network access not permitted");
|
|
91
84
|
}
|
|
92
85
|
|
|
93
86
|
const allowedDomains = permissions.network.allowedDomains;
|
|
94
87
|
if (allowedDomains && allowedDomains.length > 0) {
|
|
95
|
-
const url =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
const url =
|
|
89
|
+
typeof input === "string"
|
|
90
|
+
? new URL(input)
|
|
91
|
+
: input instanceof URL
|
|
92
|
+
? input
|
|
93
|
+
: new URL(input.url);
|
|
100
94
|
|
|
101
95
|
const isAllowed = allowedDomains.some(
|
|
102
|
-
(domain) =>
|
|
103
|
-
url.hostname === domain || url.hostname.endsWith(`.${domain}`),
|
|
96
|
+
(domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`),
|
|
104
97
|
);
|
|
105
98
|
|
|
106
99
|
if (!isAllowed) {
|
|
107
100
|
const log = createPluginLogger(pluginId);
|
|
108
|
-
log.error(
|
|
101
|
+
log.error(
|
|
102
|
+
`Network blocked: domain "${url.hostname}" not in allowlist [${allowedDomains.join(", ")}]`,
|
|
103
|
+
);
|
|
109
104
|
throw new PluginPermissionError(
|
|
110
105
|
pluginId,
|
|
111
|
-
|
|
112
|
-
`Domain "${url.hostname}" not in allowlist: ${allowedDomains.join(
|
|
106
|
+
"network",
|
|
107
|
+
`Domain "${url.hostname}" not in allowlist: ${allowedDomains.join(", ")}`,
|
|
113
108
|
);
|
|
114
109
|
}
|
|
115
110
|
}
|
|
@@ -136,7 +131,7 @@ const frozenObjects = new WeakSet<object>();
|
|
|
136
131
|
*/
|
|
137
132
|
export function deepFreeze<T>(obj: T): T {
|
|
138
133
|
if (obj === null || obj === undefined) return obj;
|
|
139
|
-
if (typeof obj !==
|
|
134
|
+
if (typeof obj !== "object") return obj;
|
|
140
135
|
|
|
141
136
|
const o = obj as object;
|
|
142
137
|
|
|
@@ -146,7 +141,7 @@ export function deepFreeze<T>(obj: T): T {
|
|
|
146
141
|
Object.freeze(o);
|
|
147
142
|
|
|
148
143
|
for (const value of Object.values(o)) {
|
|
149
|
-
if (value !== null && typeof value ===
|
|
144
|
+
if (value !== null && typeof value === "object") {
|
|
150
145
|
deepFreeze(value);
|
|
151
146
|
}
|
|
152
147
|
}
|