feat(plugins): expose generic AI and public HTTPS capabilities
This commit is contained in:
1 parent
4432f2ea47
commit
ee9632038f
28 files changed
+605
-14
No files matched your search
@@ -3,6 +3,8 @@ import type { CloudflareStorageBindings } from "./cloudflare-storage-adapter";
|
||||
import type { StorageAdapter } from "./storage-contract";
|
||||
|
||||
export type Bindings = {
|
||||
/** Runtime driver for public-only egress; no platform-specific business logic. */
|
||||
publicNetworkFetch?: (input: string, init: RequestInit) => Promise<Response>;
|
||||
/** The only persistence dependency exposed to application code. */
|
||||
storage: StorageAdapter;
|
||||
EDGE_EVER_AUTH_USERNAME?: string;
|
||||
@@ -24,7 +26,7 @@ export type Bindings = {
|
||||
EDGE_EVER_ALLOW_UNAUTHENTICATED?: string;
|
||||
};
|
||||
|
||||
export type WorkerBindings = Omit<Bindings, "storage"> & CloudflareStorageBindings;
|
||||
export type WorkerBindings = Omit<Bindings, "storage" | "publicNetworkFetch"> & CloudflareStorageBindings;
|
||||
|
||||
export type AuthContext = {
|
||||
kind: "user" | "agent";
|
||||
|
||||
@@ -74,6 +74,7 @@ import { registerAuthRoutes } from "./auth-routes";
|
||||
import { registerApiTokenRoutes } from "./api-token-routes";
|
||||
import { registerObjectStorageRoutes } from "./object-storage-routes";
|
||||
import { registerAiRoutes } from "./ai-routes";
|
||||
import { registerPluginCapabilityRoutes } from "./plugin-capability-routes";
|
||||
import { registerCompanionRoutes } from "./companion-routes";
|
||||
import { registerAiPromptRoutes } from "./ai-prompt-routes";
|
||||
import { registerResourceRoutes } from "./resource-routes";
|
||||
@@ -296,6 +297,7 @@ registerObjectStorageRoutes(app, {
|
||||
registerAiRoutes(app, {
|
||||
isDemoMode: (...args) => isDemoMode(...args),
|
||||
});
|
||||
registerPluginCapabilityRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
||||
registerCompanionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
|
||||
registerAiPromptRoutes(app, {
|
||||
isDemoMode: (...args) => isDemoMode(...args),
|
||||
@@ -422,6 +424,8 @@ const worker = {
|
||||
return fetchEdgeEverApp(request, {
|
||||
...env,
|
||||
storage: createCloudflareStorageAdapter(env),
|
||||
// workerd's default Internet egress checks resolved addresses against its public-only network policy.
|
||||
publicNetworkFetch: (url, init) => fetch(url, init),
|
||||
}, ctx);
|
||||
},
|
||||
async scheduled(controller: ScheduledController, env: WorkerBindings, ctx: ExecutionContext) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { lookup, type LookupAddress } from 'node:dns';
|
||||
import type { LookupFunction } from 'node:net';
|
||||
import { request } from 'node:https';
|
||||
import { createBrotliDecompress, createUnzip } from 'node:zlib';
|
||||
import { isPublicAddress, validatePublicUrl } from './public-network-policy';
|
||||
|
||||
/** Node/Bun runtime driver. Validated DNS addresses are handed directly to the TLS connection. */
|
||||
type ResolveAll = (hostname: string, options: { all: true; verbatim: true }, callback: (error: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void) => void;
|
||||
export function createPublicLookup(resolve: ResolveAll = lookup): LookupFunction { return (hostname, options, callback) => {
|
||||
resolve(hostname, { all: true, verbatim: true }, (error, addresses) => {
|
||||
if (error) { callback(error, '', 0); return; }
|
||||
if (!addresses.length || addresses.some(entry => !isPublicAddress(entry.address))) {
|
||||
callback(new Error('The destination does not resolve exclusively to public addresses.'), '', 0); return;
|
||||
}
|
||||
const candidates = options.family ? addresses.filter(entry => entry.family === options.family) : addresses;
|
||||
if (!candidates.length) { callback(new Error('No public address in the requested family.'), '', 0); return; }
|
||||
if (options.all) callback(null, candidates);
|
||||
else callback(null, candidates[0].address, candidates[0].family);
|
||||
});
|
||||
}; }
|
||||
export const publicLookup = createPublicLookup();
|
||||
|
||||
export async function nodePublicFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const url = validatePublicUrl(input);
|
||||
init.signal?.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = Object.fromEntries(new Headers(init.headers));
|
||||
const req = request(url, {
|
||||
method: init.method ?? 'GET', headers: { ...headers, 'accept-encoding': 'identity' },
|
||||
lookup: publicLookup, agent: false, signal: init.signal ?? undefined,
|
||||
}, incoming => {
|
||||
const encoding = incoming.headers['content-encoding'];
|
||||
const stream = encoding === 'gzip' || encoding === 'deflate' ? incoming.pipe(createUnzip()) : encoding === 'br' ? incoming.pipe(createBrotliDecompress()) : incoming;
|
||||
if (encoding && !['identity', 'gzip', 'deflate', 'br'].includes(encoding)) { incoming.destroy(); reject(new Error('Unsupported response encoding.')); return; }
|
||||
const responseHeaders = new Headers();
|
||||
for (const [key, value] of Object.entries(incoming.headers)) if (value !== undefined && !['content-encoding', 'content-length', 'transfer-encoding'].includes(key)) responseHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const abort = () => { incoming.destroy(); stream.destroy(); };
|
||||
init.signal?.addEventListener('abort', abort, { once: true });
|
||||
const cleanUp = () => { init.signal?.removeEventListener('abort', abort); };
|
||||
const status = incoming.statusCode ?? 502;
|
||||
if (init.method === 'HEAD' || [204, 205, 304].includes(status)) { abort(); cleanUp(); resolve(new Response(null, { status, headers: responseHeaders })); return; }
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
init.signal?.throwIfAborted(); const chunk = await iterator.next(); init.signal?.throwIfAborted();
|
||||
if (chunk.done) { cleanUp(); controller.close(); } else controller.enqueue(new Uint8Array(chunk.value));
|
||||
} catch (error) { cleanUp(); controller.error(error); abort(); }
|
||||
},
|
||||
cancel() { cleanUp(); abort(); },
|
||||
});
|
||||
resolve(new Response(body, { status, statusText: incoming.statusMessage, headers: responseHeaders }));
|
||||
});
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { registerPluginCapabilityRoutes } from './plugin-capability-routes';
|
||||
|
||||
function fixture({ kind = 'user', demo = false, fetch = async () => new Response('public data'), generate = async input => ({ text: input.prompt.toUpperCase() }) } = {}) {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => { if (kind) c.set('auth', { kind, workspaceId: 'workspace' }); await next(); });
|
||||
registerPluginCapabilityRoutes(app, { isDemoMode: () => demo, aiStatus: async () => ({ configured: true, modelName: 'Test model' }), generate });
|
||||
const env = { publicNetworkFetch: fetch };
|
||||
return (path, body, signal) => app.request(`/api/v1/plugins/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal } : {}, env);
|
||||
}
|
||||
|
||||
test('generic capabilities reject agent tokens, unauthenticated access and public demo calls', async () => {
|
||||
for (const options of [{ kind: 'agent' }, { kind: null }, { demo: true }]) {
|
||||
const call = fixture({ ...options, fetch: () => { throw new Error('must not run'); } });
|
||||
expect((await call('network/fetch', { url: 'https://example.org' })).status).toBe(403);
|
||||
expect((await call('ai/status')).status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
test('public egress refuses private targets, credentials, unsupported methods and sensitive headers before transport', async () => {
|
||||
let calls = 0; const call = fixture({ fetch: async () => { calls++; return new Response('bad'); } });
|
||||
for (const url of ['http://example.org', 'https://127.0.0.1', 'https://2130706433', 'https://[::1]', 'https://user:password@example.org', 'https://example.org:8443', 'https://metadata.google.internal', 'https://localhost']) expect((await call('network/fetch', { url })).status).toBe(400);
|
||||
for (const headers of [{ Authorization: 'Bearer dummy' }, { Cookie: 'dummy' }, { Host: 'localhost' }, { Accept: 'text/plain\r\nx-test: dummy' }]) expect((await call('network/fetch', { url: 'https://example.org', headers })).status).toBe(400);
|
||||
expect((await call('network/fetch', { url: 'https://example.org', method: 'POST' })).status).toBe(400);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
test('public transport preserves upstream status and useful headers without following redirects or returning cookies', async () => {
|
||||
let calls = 0;
|
||||
const call = fixture({ fetch: async (url, init) => {
|
||||
calls++; expect(url).toBe('https://example.org/feed'); expect(init.redirect).toBe('manual'); expect(init.credentials).toBe('omit'); expect(init.headers.accept).toBe('application/rss+xml');
|
||||
return new Response('moved', { status: 302, headers: { Location: 'https://127.0.0.1/private', 'Set-Cookie': 'secret=not-for-plugin', 'X-Internal': 'hidden' } });
|
||||
} });
|
||||
const response = await call('network/fetch', { url: 'https://example.org/feed', headers: { Accept: 'application/rss+xml' } }); const data = await response.json();
|
||||
expect(response.status).toBe(200); expect(data.status).toBe(302); expect(atob(data.bodyBase64)).toBe('moved'); expect(data.headers.location).toBe('https://127.0.0.1/private'); expect(data.headers).not.toHaveProperty('set-cookie'); expect(data.headers).not.toHaveProperty('x-internal'); expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test('oversized and cancelled public responses fail without exposing upstream error details', async () => {
|
||||
const large = fixture({ fetch: async () => new Response(new Uint8Array(2_000_001)) });
|
||||
expect((await large('network/fetch', { url: 'https://example.org' })).status).toBe(502);
|
||||
const cancelled = fixture({ fetch: async (_url, init) => { init.signal.throwIfAborted(); return new Response('unexpected'); } });
|
||||
const controller = new AbortController(); controller.abort();
|
||||
expect((await cancelled('network/fetch', { url: 'https://example.org' }, controller.signal)).status).toBe(502);
|
||||
});
|
||||
|
||||
test('generic AI validates ordinary prompts, returns text, and redacts provider errors', async () => {
|
||||
const call = fixture();
|
||||
expect(await (await call('ai/status')).json()).toEqual({ configured: true, modelName: 'Test model' });
|
||||
expect(await (await call('ai/generate', { system: 'Translate', prompt: 'hello', maxOutputTokens: 100 })).json()).toEqual({ text: 'HELLO' });
|
||||
expect((await call('ai/generate', { system: '', prompt: 'hello', source: 'github' })).status).toBe(400);
|
||||
expect((await call('ai/generate', { system: '', prompt: 'x'.repeat(90001) })).status).toBe(400);
|
||||
const failed = fixture({ generate: async () => { throw new Error('Bearer dummy-provider-secret'); } });
|
||||
const response = await failed('ai/generate', { system: '', prompt: 'hello' }); expect(response.status).toBe(502); expect(await response.text()).not.toContain('dummy-provider-secret');
|
||||
});
|
||||
|
||||
test('concurrent requests are bounded and released after completion', async () => {
|
||||
let started = 0; let release; const gate = new Promise(resolve => { release = resolve; });
|
||||
const call = fixture({ fetch: async () => { started++; await gate; return new Response('ok'); } });
|
||||
const pending = Array.from({ length: 4 }, () => call('network/fetch', { url: 'https://example.org' }));
|
||||
for (let i = 0; i < 100 && started < 4; i++) await new Promise(resolve => setTimeout(resolve, 1));
|
||||
expect(started).toBe(4); expect((await call('network/fetch', { url: 'https://example.org' })).status).toBe(429);
|
||||
release(); await Promise.all(pending); expect((await call('network/fetch', { url: 'https://example.org' })).status).toBe(200);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PluginAiGenerateSchema, PluginPublicFetchSchema, type PluginAiGenerateRequest, type PluginAiStatus } from '@edgeever/shared';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import type { Hono } from 'hono';
|
||||
import type { AppEnv, Bindings } from './api-context';
|
||||
import { getAiSettings, loadDefaultAiModel, resolvePrimaryAiCredentialEncryptionKey } from './ai-service';
|
||||
import { generateAiText } from './ai-runtime';
|
||||
import { apiError } from './http-errors';
|
||||
import { getWorkspaceId, requireUser } from './request-auth';
|
||||
import { PUBLIC_NETWORK_TIMEOUT_MS, publicRequestHeaders, publicResponseHeaders, readPublicBody, validatePublicUrl } from './public-network-policy';
|
||||
|
||||
export function registerPluginCapabilityRoutes(app: Hono<AppEnv>, dependencies: {
|
||||
isDemoMode: (env: Bindings) => boolean;
|
||||
aiStatus?: () => Promise<PluginAiStatus>;
|
||||
generate?: (input: PluginAiGenerateRequest, signal: AbortSignal) => Promise<{ text: string }>;
|
||||
}) {
|
||||
// Per app instance concurrency guard, not a cross-instance usage quota.
|
||||
const active = new Map<string, number>();
|
||||
for (const path of ['/api/v1/plugins/ai/*', '/api/v1/plugins/network/*']) {
|
||||
app.use(path, bodyLimit({ maxSize: 400_000 }));
|
||||
app.use(path, async (c, next) => {
|
||||
const denied = requireUser(c); if (denied) return denied;
|
||||
if (dependencies.isDemoMode(c.env)) return apiError(c, 'plugin_capability_demo_disabled', 'This capability requires your own authenticated workspace.', 403);
|
||||
const key = `${getWorkspaceId(c)}:${c.req.path.includes('/ai/') ? 'ai' : 'network'}`;
|
||||
const count = active.get(key) ?? 0;
|
||||
if (count >= 4) return apiError(c, 'plugin_capability_busy', 'Too many concurrent requests. Try again shortly.', 429);
|
||||
active.set(key, count + 1);
|
||||
try { await next(); } finally { const left = (active.get(key) ?? 1) - 1; if (left) active.set(key, left); else active.delete(key); }
|
||||
});
|
||||
}
|
||||
app.get('/api/v1/plugins/ai/status', async c => {
|
||||
if (dependencies.aiStatus) return c.json(await dependencies.aiStatus());
|
||||
const settings = await getAiSettings(c.env.storage.db, getWorkspaceId(c), Boolean(resolvePrimaryAiCredentialEncryptionKey(c.env)), false);
|
||||
const provider = settings.providers.find(p => p.isEnabled && p.models.some(m => m.id === settings.defaultModelId));
|
||||
const model = provider?.models.find(m => m.id === settings.defaultModelId);
|
||||
return c.json({ configured: Boolean(model), ...(model ? { modelName: model.displayName } : {}) });
|
||||
});
|
||||
app.post('/api/v1/plugins/ai/generate', zValidator('json', PluginAiGenerateSchema), async c => {
|
||||
try {
|
||||
const input = c.req.valid('json');
|
||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(120_000)]);
|
||||
if (dependencies.generate) return c.json(await dependencies.generate(input, signal));
|
||||
const model = await loadDefaultAiModel(c.env.storage.db, getWorkspaceId(c), c.env);
|
||||
const output = await generateAiText({ model, system: input.system, prompt: input.prompt, maxOutputTokens: input.maxOutputTokens ?? 3000, abortSignal: signal });
|
||||
return c.json({ text: output.text });
|
||||
} catch { return apiError(c, 'plugin_ai_failed', 'AI generation failed or timed out. Check the default AI model configuration.', 502); }
|
||||
});
|
||||
app.post('/api/v1/plugins/network/fetch', zValidator('json', PluginPublicFetchSchema), async c => {
|
||||
const input = c.req.valid('json');
|
||||
let url: URL; let headers: Record<string, string>;
|
||||
try { url = validatePublicUrl(input.url); headers = publicRequestHeaders(input.headers); }
|
||||
catch { return apiError(c, 'plugin_network_invalid', 'Use a public HTTPS hostname, GET/HEAD, and supported public request headers.', 400); }
|
||||
if (!c.env.publicNetworkFetch) return apiError(c, 'plugin_network_unavailable', 'This runtime has no public network transport.', 503);
|
||||
try {
|
||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(PUBLIC_NETWORK_TIMEOUT_MS)]);
|
||||
const response = await c.env.publicNetworkFetch(url.href, { method: input.method, headers: { ...headers, 'User-Agent': 'EdgeEver-Plugins/1.0' }, redirect: 'manual', credentials: 'omit', signal });
|
||||
const bytes = await readPublicBody(response, signal);
|
||||
let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json({ url: url.href, status: response.status, statusText: response.statusText, headers: publicResponseHeaders(response.headers), bodyBase64: btoa(binary) });
|
||||
} catch { return apiError(c, 'plugin_network_failed', 'Public request failed, was blocked, exceeded its size limit, or timed out.', 502); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { isPublicAddress } from './public-network-policy';
|
||||
import { createPublicLookup, nodePublicFetch } from './node-public-network';
|
||||
|
||||
test('non-public and mapped addresses are refused', () => {
|
||||
for (const address of ['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', '::', 'fe80::1', 'fc00::1', '::ffff:127.0.0.1', '2001:db8::1']) expect(isPublicAddress(address)).toBe(false);
|
||||
for (const address of ['1.1.1.1', '8.8.8.8', '2606:4700:4700::1111']) expect(isPublicAddress(address)).toBe(true);
|
||||
});
|
||||
test('TLS lookup uses the validated answer directly and rejects mixed public/private DNS results', async () => {
|
||||
const run = async addresses => new Promise(resolve => {
|
||||
const lookup = createPublicLookup((_hostname, _options, callback) => callback(null, addresses));
|
||||
lookup('example.org', { all: true }, (error, result) => resolve({ error, result }));
|
||||
});
|
||||
const valid = [{ address: '1.1.1.1', family: 4 }]; expect((await run(valid)).result).toEqual(valid);
|
||||
expect((await run([...valid, { address: '127.0.0.1', family: 4 }])).error).toBeInstanceOf(Error);
|
||||
});
|
||||
test('self-hosted transport rejects local targets and cancelled calls before opening a connection', async () => {
|
||||
await expect(nodePublicFetch('https://localhost')).rejects.toThrow();
|
||||
const controller = new AbortController(); controller.abort();
|
||||
await expect(nodePublicFetch('https://example.org', { signal: controller.signal })).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import ipaddr from 'ipaddr.js';
|
||||
|
||||
export const PUBLIC_NETWORK_MAX_BYTES = 2_000_000;
|
||||
export const PUBLIC_NETWORK_TIMEOUT_MS = 20_000;
|
||||
const REQUEST_HEADERS = new Set(['accept', 'accept-language', 'if-none-match', 'if-modified-since', 'range']);
|
||||
const RESPONSE_HEADERS = new Set(['content-type', 'cache-control', 'etag', 'last-modified', 'location', 'retry-after', 'content-range', 'accept-ranges']);
|
||||
|
||||
export function isPublicAddress(address: string): boolean {
|
||||
try { return ipaddr.process(address).range() === 'unicast'; } catch { return false; }
|
||||
}
|
||||
|
||||
export function validatePublicUrl(input: string): URL {
|
||||
const url = new URL(input);
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
if (url.protocol !== 'https:' || (url.port && url.port !== '443') || url.username || url.password || url.hash) throw new Error('Only public HTTPS URLs on port 443 without credentials or fragments are supported.');
|
||||
// Reject IP literals as well as local/special-use names. DNS is checked at connection time by the transport.
|
||||
if (ipaddr.isValid(hostname) || !hostname.includes('.') || hostname.endsWith('.') || /(?:^|\.)(localhost|local|internal|test|invalid|example|onion|home|lan|arpa)$/.test(hostname)) throw new Error('A public Internet hostname is required.');
|
||||
return url;
|
||||
}
|
||||
|
||||
export function publicRequestHeaders(input: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (Object.keys(input).length > 10) throw new Error('Too many request headers.');
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
const name = key.toLowerCase();
|
||||
if (!REQUEST_HEADERS.has(name) || /[\r\n\0]/.test(value)) throw new Error('Unsupported public request header.');
|
||||
result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function publicResponseHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of headers) if (RESPONSE_HEADERS.has(key.toLowerCase())) result[key.toLowerCase()] = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function readPublicBody(response: Response, signal: AbortSignal): Promise<Uint8Array> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return new Uint8Array();
|
||||
const chunks: Uint8Array[] = []; let size = 0;
|
||||
const cancel = () => { void reader.cancel().catch(() => undefined); };
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); signal.throwIfAborted();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > PUBLIC_NETWORK_MAX_BYTES) throw new Error('Public response exceeds the size limit.');
|
||||
chunks.push(value);
|
||||
}
|
||||
const bytes = new Uint8Array(size); let offset = 0;
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||||
return bytes;
|
||||
} finally { signal.removeEventListener('abort', cancel); await reader.cancel().catch(() => undefined); }
|
||||
}
|
||||
@@ -743,6 +743,8 @@ export const WorkspaceApp = ({
|
||||
const pluginHost = useMemo(() => new EdgeEverPluginHost({
|
||||
repository,
|
||||
scope: localDataScope,
|
||||
aiAdapter: api.pluginAi,
|
||||
publicNetworkAdapter: api.pluginNetwork,
|
||||
onNotice: (message) => setAppNoticeDialog({ title: t("plugins.noticeTitle"), description: message }),
|
||||
scheduleAdapter: pluginScheduleAdapter,
|
||||
onWorkspaceChanged: async () => {
|
||||
@@ -2437,6 +2439,7 @@ export const WorkspaceApp = ({
|
||||
}, [clearMemoSelection, clearPendingCreatedMemo, navigateWorkspaceHome, setSelectedMemoId, setSelectedNotebookId]);
|
||||
|
||||
const handleOpenPluginNote = useCallback((memoId: string, notebookId: string, options?: { search?: string }) => {
|
||||
setRequestedPluginPanel(null);
|
||||
navigateWorkspaceHome();
|
||||
setMemoView("notebook");
|
||||
setSelectedTag(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
|
||||
@@ -13,7 +13,7 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [mountError, setMountError] = useState<string | null>(null);
|
||||
const [closeConfirmation, setCloseConfirmation] = useState<Exclude<PluginPanelCloseDecision, boolean> | null>(null);
|
||||
const [checkingClose, setCheckingClose] = useState(false);
|
||||
@@ -21,7 +21,7 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
|
||||
const panelId = panel?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
// Container is reactive so delayed portal attachment mounts the plugin.
|
||||
if (!container || !panelPluginId || !panelId) return;
|
||||
let disposed = false;
|
||||
let disposePanel: (() => void) | null = null;
|
||||
@@ -38,7 +38,7 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
|
||||
disposePanel?.();
|
||||
container.replaceChildren();
|
||||
};
|
||||
}, [host, options, panelId, panelPluginId]);
|
||||
}, [container, host, options, panelId, panelPluginId]);
|
||||
|
||||
const requestClose = async () => {
|
||||
if (!panelPluginId || !panelId || checkingClose) return;
|
||||
@@ -67,7 +67,7 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
|
||||
<DialogDescription>{t("plugins.panelDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{mountError ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{mountError}</div> : null}
|
||||
<div ref={containerRef} className={cn(
|
||||
<div ref={setContainer} className={cn(
|
||||
"min-h-40 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700",
|
||||
panel?.presentation === "fullscreen" && "min-h-0 flex-1 overflow-hidden",
|
||||
)} />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { IDBKeyRange, indexedDB } from 'fake-indexeddb';
|
||||
globalThis.indexedDB = indexedDB; globalThis.IDBKeyRange = IDBKeyRange;
|
||||
const { localDb } = await import('./local-db');
|
||||
const { api } = await import('./api');
|
||||
const { createWebRepository } = await import('./repository');
|
||||
const { createLocalMemo, replaceLocalMemoId } = await import('./local-mirror');
|
||||
test('a plugin can reopen its saved temporary note ID after synchronization', async () => {
|
||||
const scope = `plugin-note-${crypto.randomUUID()}`;
|
||||
const savedGetMemo = api.getMemo;
|
||||
try {
|
||||
const local = await createLocalMemo(scope, { notebookId: 'nb_test', title: 'Research', contentMarkdown: '# Research' });
|
||||
const remote = { ...local, id: 'memo_synced_research' };
|
||||
await replaceLocalMemoId(scope, local.id, remote);
|
||||
const requested = [];
|
||||
api.getMemo = async id => { requested.push(id); if (id !== remote.id) throw new Error('Memo not found'); return { memo: remote }; };
|
||||
const reopened = await createWebRepository(scope).getMemo(local.id);
|
||||
expect(reopened.memo.id).toBe(remote.id); expect(requested).toEqual([remote.id]);
|
||||
} finally {
|
||||
api.getMemo = savedGetMemo;
|
||||
await localDb.memos.where('scope').equals(scope).delete();
|
||||
await localDb.idMappings.where('scope').equals(scope).delete();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const results = new Map();
|
||||
export default {
|
||||
activate(context) {
|
||||
for (const [id, action] of Object.entries({
|
||||
ai: () => context.ai.generate({ system: 'Translate', prompt: 'hello', maxOutputTokens: 100 }),
|
||||
public: () => context.network.fetch('https://example.org/feed', { transport: 'public', redirect: 'manual' }).then(async r => ({ status: r.status, text: await r.text(), url: r.url })),
|
||||
unlisted: () => context.network.fetch('https://unlisted.org/feed', { transport: 'public' }),
|
||||
post: () => context.network.fetch('https://example.org/feed', { transport: 'public', method: 'POST', body: 'data' }),
|
||||
})) context.commands.register({ id, title: id, run: async () => { results.set(context.pluginId, await action()); } });
|
||||
},
|
||||
};
|
||||
@@ -39,12 +39,14 @@ globalThis.MutationObserver = class {
|
||||
const { EdgeEverPluginHost, applyPluginMarkdownEdits } = await import("./plugin-host.ts");
|
||||
const { sha256Hex } = await import("./github-plugin-distribution.ts");
|
||||
const { withRepositoryMutationEvents } = await import("../repository-events.ts");
|
||||
const { results: capabilityResults } = await import('./plugin-capabilities.fixture.mjs');
|
||||
|
||||
const repository = {
|
||||
listMemos: async () => ({ memos: [], totalCount: 0, nextCursor: null }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
capabilityResults.clear();
|
||||
values.clear();
|
||||
styles.clear();
|
||||
eventListeners.clear();
|
||||
@@ -53,6 +55,27 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("EdgeEverPluginHost", () => {
|
||||
test('generic AI/public network calls require declared permissions and destination hosts', async () => {
|
||||
const calls = [];
|
||||
const host = new EdgeEverPluginHost({ repository, scope: 'test',
|
||||
aiAdapter: { status: async () => ({ configured: true }), generate: async input => { calls.push(input); return { text: 'HELLO' }; } },
|
||||
publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, bodyBase64: btoa('limited') }; } },
|
||||
});
|
||||
const install = async (id, permissions) => {
|
||||
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, permissions: ['ui:commands', ...permissions], networkHosts: ['example.org'] }, 'https://example.org/manifest.json');
|
||||
await host.setEnabled(id, true);
|
||||
};
|
||||
await install('org.test.denied', ['network']);
|
||||
await expect(host.runCommand('org.test.denied', 'ai')).rejects.toThrow();
|
||||
await expect(host.runCommand('org.test.denied', 'public')).rejects.toThrow(); expect(calls).toHaveLength(0);
|
||||
await install('org.test.allowed', ['network', 'network:public', 'ai:generate']);
|
||||
await expect(host.runCommand('org.test.allowed', 'unlisted')).rejects.toThrow();
|
||||
await expect(host.runCommand('org.test.allowed', 'post')).rejects.toThrow(); expect(calls).toHaveLength(0);
|
||||
await host.runCommand('org.test.allowed', 'ai'); expect(capabilityResults.get('org.test.allowed')).toEqual({ text: 'HELLO' });
|
||||
await host.runCommand('org.test.allowed', 'public'); expect(capabilityResults.get('org.test.allowed')).toEqual({ status: 429, text: 'limited', url: 'https://example.org/feed' });
|
||||
expect(calls[0].signal.aborted).toBe(false); await host.setEnabled('org.test.allowed', false); expect(calls[0].signal.aborted).toBe(true);
|
||||
await host.dispose();
|
||||
});
|
||||
test("lets a permitted plugin idempotently own schedules for its registered commands", async () => {
|
||||
const calls = [];
|
||||
const scheduleAdapter = {
|
||||
|
||||
@@ -178,6 +178,10 @@ export interface PluginHostSnapshot {
|
||||
interface PluginHostOptions {
|
||||
repository: EdgeEverRepository;
|
||||
scope: string;
|
||||
aiAdapter?: PluginContext['ai'];
|
||||
publicNetworkAdapter?: {
|
||||
fetchPublic(input: { url: string; method: 'GET' | 'HEAD'; headers: Record<string, string> }, options?: { signal?: AbortSignal }): Promise<{ status: number; statusText: string; url: string; headers: Record<string, string>; bodyBase64: string }>;
|
||||
};
|
||||
onWorkspaceChanged?: () => void | Promise<void>;
|
||||
onNotice?: (message: string) => void;
|
||||
secretStorage?: PluginSecretStorage;
|
||||
@@ -410,6 +414,8 @@ const normalizePanelState = (state: PluginPanelOpenOptions["state"] | undefined)
|
||||
};
|
||||
|
||||
export class EdgeEverPluginHost {
|
||||
private readonly aiAdapter?: PluginHostOptions['aiAdapter'];
|
||||
private readonly publicNetworkAdapter?: PluginHostOptions['publicNetworkAdapter'];
|
||||
private readonly repository: EdgeEverRepository;
|
||||
private readonly scope: string;
|
||||
private readonly onWorkspaceChanged?: () => void | Promise<void>;
|
||||
@@ -435,8 +441,18 @@ export class EdgeEverPluginHost {
|
||||
private themeObserver: MutationObserver | null = null;
|
||||
private repositoryEventDisposer: (() => void) | null = null;
|
||||
private started = false;
|
||||
private lifecycleQueue: Promise<void> = Promise.resolve();
|
||||
private readonly activatingPlugins = new Map<string, Promise<void>>();
|
||||
|
||||
private enqueueLifecycle(action: () => Promise<void>) {
|
||||
const pending = this.lifecycleQueue.then(action, action);
|
||||
this.lifecycleQueue = pending.catch(() => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
constructor(options: PluginHostOptions) {
|
||||
this.aiAdapter = options.aiAdapter;
|
||||
this.publicNetworkAdapter = options.publicNetworkAdapter;
|
||||
this.repository = options.repository;
|
||||
this.scope = options.scope;
|
||||
this.onWorkspaceChanged = options.onWorkspaceChanged;
|
||||
@@ -476,13 +492,15 @@ export class EdgeEverPluginHost {
|
||||
};
|
||||
}
|
||||
|
||||
async activateEnabled() {
|
||||
activateEnabled() {
|
||||
return this.enqueueLifecycle(async () => {
|
||||
this.start();
|
||||
for (const extension of this.extensions) {
|
||||
if (!extension.enabled || extension.manifest.type !== "plugin") continue;
|
||||
await this.activatePlugin(extension.manifest.id).catch(() => undefined);
|
||||
}
|
||||
this.applyActiveTheme();
|
||||
});
|
||||
}
|
||||
|
||||
async installFromSource(input: string) {
|
||||
@@ -768,14 +786,16 @@ export class EdgeEverPluginHost {
|
||||
return dispose;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
dispose() {
|
||||
return this.enqueueLifecycle(async () => {
|
||||
this.started = false;
|
||||
window.removeEventListener("edgeever:sync-queue-changed", this.handleSyncQueueChanged);
|
||||
this.themeObserver?.disconnect();
|
||||
this.themeObserver = null;
|
||||
this.repositoryEventDisposer?.();
|
||||
this.repositoryEventDisposer = null;
|
||||
for (const pluginId of [...this.activePlugins.keys()]) await this.deactivatePlugin(pluginId);
|
||||
for (const pluginId of new Set([...this.activePlugins.keys(), ...this.activatingPlugins.keys()])) await this.deactivatePlugin(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
private start() {
|
||||
@@ -816,7 +836,15 @@ export class EdgeEverPluginHost {
|
||||
return extension;
|
||||
}
|
||||
|
||||
private async activatePlugin(pluginId: string) {
|
||||
private activatePlugin(pluginId: string) {
|
||||
const current = this.activatingPlugins.get(pluginId);
|
||||
if (current) return current;
|
||||
const pending = this.activatePluginOnce(pluginId).finally(() => { this.activatingPlugins.delete(pluginId); });
|
||||
this.activatingPlugins.set(pluginId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async activatePluginOnce(pluginId: string) {
|
||||
if (this.activePlugins.has(pluginId)) return;
|
||||
const extension = this.requireExtension(pluginId);
|
||||
if (extension.manifest.type !== "plugin") return;
|
||||
@@ -870,6 +898,7 @@ export class EdgeEverPluginHost {
|
||||
}
|
||||
|
||||
private async deactivatePlugin(pluginId: string) {
|
||||
await this.activatingPlugins.get(pluginId)?.catch(() => undefined);
|
||||
const active = this.activePlugins.get(pluginId);
|
||||
if (!active) return;
|
||||
this.activePlugins.delete(pluginId);
|
||||
@@ -904,8 +933,23 @@ export class EdgeEverPluginHost {
|
||||
private createContext(manifest: PluginManifest, disposers: Array<() => void>): PluginContext {
|
||||
const storagePrefix = `${STORAGE_PREFIX}:${this.scope}:${manifest.id}:`;
|
||||
const secretNamespace = `${this.scope}:${manifest.id}`;
|
||||
const lifetime = new AbortController();
|
||||
disposers.push(() => lifetime.abort());
|
||||
return {
|
||||
pluginId: manifest.id,
|
||||
ai: {
|
||||
status: async () => {
|
||||
assertPermission(manifest, 'ai:generate'); lifetime.signal.throwIfAborted();
|
||||
if (!this.aiAdapter) return { configured: false };
|
||||
const result = await this.aiAdapter.status(); lifetime.signal.throwIfAborted(); return result;
|
||||
},
|
||||
generate: async input => {
|
||||
assertPermission(manifest, 'ai:generate'); lifetime.signal.throwIfAborted();
|
||||
if (!this.aiAdapter) throw new Error('AI generation is unavailable in this host.');
|
||||
const signal = AbortSignal.any([lifetime.signal, ...(input.signal ? [input.signal] : [])]);
|
||||
const result = await this.aiAdapter.generate({ ...input, signal }); signal.throwIfAborted(); return result;
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
query: async (input = {}) => {
|
||||
assertPermission(manifest, "notes:read");
|
||||
@@ -1337,6 +1381,7 @@ export class EdgeEverPluginHost {
|
||||
network: {
|
||||
fetch: async (input, init) => {
|
||||
assertPermission(manifest, "network");
|
||||
lifetime.signal.throwIfAborted();
|
||||
const url = new URL(input);
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) {
|
||||
throw new Error("Plugin network requests must use HTTPS, except for localhost development.");
|
||||
@@ -1344,7 +1389,23 @@ export class EdgeEverPluginHost {
|
||||
if (!manifest.networkHosts?.length || !isAllowedNetworkHost(url.hostname.toLocaleLowerCase(), manifest.networkHosts)) {
|
||||
throw new Error(`${url.hostname} is not declared in this plugin's networkHosts.`);
|
||||
}
|
||||
return window.fetch(url, { ...init, credentials: "omit" });
|
||||
const { transport = 'direct', ...requestInit } = init ?? {};
|
||||
const signal = AbortSignal.any([lifetime.signal, ...(requestInit.signal ? [requestInit.signal] : [])]);
|
||||
if (transport === 'public') {
|
||||
assertPermission(manifest, 'network:public');
|
||||
if (!this.publicNetworkAdapter) throw new Error('Public network transport is unavailable in this host.');
|
||||
const method = (requestInit.method ?? 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD'].includes(method) || requestInit.body != null || requestInit.credentials === 'include') throw new Error('Public transport supports credential-free GET/HEAD only.');
|
||||
const data = await this.publicNetworkAdapter.fetchPublic({ url: url.href, method: method as 'GET' | 'HEAD', headers: Object.fromEntries(new Headers(requestInit.headers)) }, { signal });
|
||||
signal.throwIfAborted();
|
||||
if (requestInit.redirect === 'error' && data.status >= 300 && data.status < 400) throw new Error('Public request returned a redirect.');
|
||||
const bytes = Uint8Array.from(atob(data.bodyBase64), c => c.charCodeAt(0));
|
||||
const response = new Response(method === 'HEAD' || [204, 205, 304].includes(data.status) ? null : bytes, { status: data.status, statusText: data.statusText, headers: data.headers });
|
||||
Object.defineProperty(response, 'url', { value: data.url });
|
||||
return response;
|
||||
}
|
||||
if (transport !== 'direct') throw new Error('Unsupported network transport.');
|
||||
return window.fetch(url, { ...requestInit, signal, credentials: "omit" });
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
async activate(context) {
|
||||
await context.notes.query();
|
||||
const panel = context.ui.panels.register({ id: 'delayed', title: 'Delayed', mount() {} });
|
||||
const command = context.commands.register({ id: 'open', title: 'Open', run() {} });
|
||||
return () => { command(); panel(); };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
const values = new Map();
|
||||
globalThis.window = { location: { href: 'https://example.test' }, localStorage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)), removeItem: key => values.delete(key) }, addEventListener() {}, removeEventListener() {} };
|
||||
globalThis.document = { documentElement: { classList: { contains: () => false }, dataset: {}, style: { setProperty() {}, removeProperty() {} }, removeAttribute() {} } };
|
||||
globalThis.MutationObserver = class { observe() {} disconnect() {} };
|
||||
const { EdgeEverPluginHost } = await import('./plugin-host');
|
||||
function setup() {
|
||||
values.clear();
|
||||
let release, entered, calls = 0;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const gate = new Promise(resolve => { release = resolve; });
|
||||
const host = new EdgeEverPluginHost({ scope: crypto.randomUUID(), packageStorage: { get: async () => null }, repository: { listMemos: async () => { calls++; entered(); await gate; return { memos: [], totalCount: 0, nextCursor: null }; } } });
|
||||
const id = `org.edgeever.lifecycle-${crypto.randomUUID()}`;
|
||||
host.installManifest({ type: 'plugin', id, name: 'Delayed activation', version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-lifecycle.fixture.mjs', import.meta.url).href, permissions: ['notes:read', 'ui:commands', 'ui:panels'] }, 'https://example.test/manifest.json');
|
||||
return { host, id, started, release, calls: () => calls };
|
||||
}
|
||||
test('concurrent enable requests share a single asynchronous activation', async () => {
|
||||
const { host, id, started, release, calls } = setup();
|
||||
const first = host.setEnabled(id, true); await started;
|
||||
const second = host.setEnabled(id, true); release(); await Promise.all([first, second]);
|
||||
expect(calls()).toBe(1); expect(host.getSnapshot().panels.filter(p => p.pluginId === id)).toHaveLength(1);
|
||||
await host.dispose();
|
||||
});
|
||||
test('effect cleanup and restart do not strand an async plugin activation', async () => {
|
||||
const { host, id, started, release, calls } = setup();
|
||||
host.extensions.find(e => e.manifest.id === id).enabled = true;
|
||||
const first = host.activateEnabled(); await started;
|
||||
const cleanup = host.dispose(); const restart = host.activateEnabled(); release();
|
||||
await Promise.all([first, cleanup, restart]);
|
||||
expect(calls()).toBe(2); expect(host.getSnapshot().panels.filter(p => p.pluginId === id)).toHaveLength(1);
|
||||
expect(host.getSnapshot().extensions.find(e => e.manifest.id === id).enabled).toBe(true);
|
||||
await host.dispose(); expect(host.getSnapshot().panels).toHaveLength(0);
|
||||
});
|
||||
@@ -432,6 +432,10 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
|
||||
},
|
||||
|
||||
async getMemo(memoId, includeDeleted = false) {
|
||||
if (memoId.startsWith("local_")) {
|
||||
const mapping = await localDb.idMappings.get([scope, memoId]);
|
||||
memoId = mapping?.remoteId ?? memoId;
|
||||
}
|
||||
const localPromise = getLocalMemoWithoutBlocking(scope, memoId);
|
||||
|
||||
const remotePromise = api.getMemo(memoId, { includeDeleted });
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"hono": "latest",
|
||||
"i18next": "^26.3.4",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"katex": "0.16.22",
|
||||
"lucide-react": "latest",
|
||||
"marked": "^18.0.5",
|
||||
@@ -2640,6 +2641,8 @@
|
||||
|
||||
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="],
|
||||
|
||||
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
@@ -468,3 +468,44 @@ The first demonstrates note queries, selection replacement, commands, and a cust
|
||||
- Declared permissions are API capability checks, not a hard sandbox for trusted JavaScript.
|
||||
- Custom panels open from the unified desktop plugin menu or extension settings and cannot yet be pinned to the main navigation or editor sidebar.
|
||||
- Secret storage is device-local and does not sync to other devices.
|
||||
|
||||
## Generic AI and public network capabilities (unreleased)
|
||||
|
||||
Plugins may declare `ai:generate` to use the current workspace's default AI model. These calls take ordinary prompts; source parsing, business workflows and prompts belong to the plugin. Credentials stay in the host.
|
||||
|
||||
```ts
|
||||
const status = await context.ai.status(); // { configured, modelName? }
|
||||
const result = await context.ai.generate({
|
||||
system: "Translate the supplied text into English.",
|
||||
prompt: "用户提供的文本",
|
||||
maxOutputTokens: 1000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
```
|
||||
|
||||
`system` is limited to 8,000 characters, `prompt` to 90,000, output to 5,000 tokens, and generation to 120 seconds. The backend requires an interactive user session, disables these capabilities in public demo mode, and redacts provider errors. Each workspace has up to four concurrent calls per AI/network group per app instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls.
|
||||
|
||||
Existing `network.fetch(url, init)` remains browser fetch with CORS and omitted credentials. Add both `network` and `network:public`, declare `networkHosts`, and explicitly select `transport: "public"` to use the generic public Internet transport:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["network", "network:public"],
|
||||
"networkHosts": ["example.org"]
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
const response = await context.network.fetch("https://example.org/feed.xml", {
|
||||
transport: "public",
|
||||
headers: { Accept: "application/rss+xml" },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const feed = await response.text(); // Parse inside the plugin.
|
||||
```
|
||||
|
||||
Public mode supports HTTPS GET/HEAD on port 443, no request body or credentials, a 20-second deadline, and at most 2,000,000 decoded response bytes. It always returns redirects without following them (`redirect: "error"` rejects them). Upstream 403/429 remain upstream status codes; this transport does not bypass platform restrictions. Allowed request headers: Accept, Accept-Language, If-None-Match, If-Modified-Since, Range. Only content/cache metadata, Location and Retry-After are returned; Set-Cookie is excluded. The response is buffered within the size limit, not an unlimited streaming proxy.
|
||||
|
||||
Web and desktop clients use the same authenticated backend transport. Cloudflare uses default workerd public-only Internet egress and no private-service bindings. Self-hosted Bun uses a TLS lookup callback which validates DNS answers and connects directly to those answers; private, special-use and mixed public/private answers are rejected. Synthetic VPN/fake-IP DNS answers in private or reserved ranges are also rejected; do not disable this check. Nonstandard workerd deployments must preserve public-only global egress.
|
||||
|
||||
Plugin permission/domain checks run in the trusted client host. Backend routes independently require user authentication and enforce public-only egress; they do not trust client-supplied plugin IDs or allowlists, and do not claim server-attested per-plugin isolation. The trusted-JavaScript limitation still applies. The backend never receives a source enum, search window, evidence schema or report workflow.
|
||||
@@ -468,3 +468,44 @@ await context.ui.panels.open("dashboard", { state: { resourceId } });
|
||||
- 权限声明属于 API 能力检查,不是针对受信任 JavaScript 的严格沙箱。
|
||||
- 自定义面板可以从桌面端统一插件菜单或插件管理页打开,尚未支持固定到主导航或编辑器侧栏。
|
||||
- Secret Storage 仅保存在当前设备,不会同步到其他设备。
|
||||
|
||||
## 通用 AI 与公开网络能力(尚未发布)
|
||||
|
||||
插件可声明 `ai:generate` 调用当前工作区的默认 AI 模型。接口只接受普通提示词;来源解析、业务流程和提示词属于插件,凭据保留在宿主。
|
||||
|
||||
```ts
|
||||
const status = await context.ai.status(); // { configured, modelName? }
|
||||
const result = await context.ai.generate({
|
||||
system: "把给定文本翻译成英文。",
|
||||
prompt: "用户提供的文本",
|
||||
maxOutputTokens: 1000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
```
|
||||
|
||||
`system` 最多 8,000 字符,`prompt` 最多 90,000 字符,输出最多 5,000 token,生成最长 120 秒。后端要求交互式用户会话,公开演示模式禁用这些能力,供应商错误脱敏。每工作区、每应用运行实例的 AI/网络两组调用各最多四路并发,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
|
||||
|
||||
已有 `network.fetch(url, init)` 保留浏览器 fetch 行为,受 CORS 限制并省略凭据。使用通用公开网络传输时,需要同时声明 `network`、`network:public` 和 `networkHosts`,并显式选择 `transport: "public"`:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": ["network", "network:public"],
|
||||
"networkHosts": ["example.org"]
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
const response = await context.network.fetch("https://example.org/feed.xml", {
|
||||
transport: "public",
|
||||
headers: { Accept: "application/rss+xml" },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const feed = await response.text(); // 插件自己解析。
|
||||
```
|
||||
|
||||
公开模式仅支持 443 端口的 HTTPS GET/HEAD,不携带请求体和凭据;超时 20 秒,解码后的响应正文最多 2,000,000 字节。重定向只返回、不跟随(`redirect: "error"` 会拒绝)。来源的 403/429 保留为来源状态,不绕过平台访问限制。允许的请求头为 Accept、Accept-Language、If-None-Match、If-Modified-Since、Range;仅返回内容/缓存元数据、Location 和 Retry-After,不返回 Set-Cookie。响应在上限内缓冲,不是无限流式代理。
|
||||
|
||||
Web 和桌面客户端共用认证后的后端传输。Cloudflare 使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。Bun 自托管使用 TLS lookup 回调,校验 DNS 结果后直接连接这些地址;私网、特殊用途和混合公私地址全部拒绝。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。
|
||||
|
||||
插件权限和域名检查在可信客户端宿主执行。后端独立要求用户认证并限制仅公开网络,不信任客户端提交的插件 ID 或白名单,也不声称提供服务端证明的插件隔离;仍遵循可信 JavaScript 边界。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
|
||||
@@ -151,6 +151,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"hono": "latest",
|
||||
"i18next": "^26.3.4",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"katex": "0.16.22",
|
||||
"lucide-react": "latest",
|
||||
"marked": "^18.0.5",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPluginCapabilities } from './plugin-capabilities';
|
||||
import type {
|
||||
CompanionMemory,
|
||||
CompanionTurn,
|
||||
@@ -558,6 +559,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
|
||||
return {
|
||||
getInstanceHealth: () => request<InstanceHealth>("/api/health"),
|
||||
...createPluginCapabilities(request),
|
||||
|
||||
getInstanceRelease: () => request<InstanceRelease>("/api/release"),
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PluginAiGenerateRequest, PluginAiStatus, PluginPublicFetchRequest, PluginPublicFetchResponse } from '@edgeever/shared';
|
||||
|
||||
type Request = <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
export function createPluginCapabilities(request: Request) {
|
||||
return {
|
||||
pluginAi: {
|
||||
status: () => request<PluginAiStatus>('/api/v1/plugins/ai/status'),
|
||||
generate: ({ signal, ...input }: PluginAiGenerateRequest & { signal?: AbortSignal }) => request<{ text: string }>('/api/v1/plugins/ai/generate', { method: 'POST', body: JSON.stringify(input), signal }),
|
||||
},
|
||||
pluginNetwork: {
|
||||
fetchPublic: (input: PluginPublicFetchRequest, options?: { signal?: AbortSignal }) => request<PluginPublicFetchResponse>('/api/v1/plugins/network/fetch', { method: 'POST', body: JSON.stringify(input), signal: options?.signal }),
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+19
-2
@@ -1,6 +1,6 @@
|
||||
export declare const PLUGIN_API_VERSION: "1";
|
||||
export declare const THEME_API_VERSION: "1";
|
||||
export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "storage", "secrets", "schedules", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"];
|
||||
export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "network:public", "ai:generate", "storage", "secrets", "schedules", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"];
|
||||
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
|
||||
export type ExtensionPlatform = "web" | "desktop" | "android" | "ios";
|
||||
export interface PluginManifest {
|
||||
@@ -332,6 +332,20 @@ export interface PluginPanel {
|
||||
}
|
||||
export interface PluginContext {
|
||||
pluginId: string;
|
||||
ai: {
|
||||
status(): Promise<{
|
||||
configured: boolean;
|
||||
modelName?: string;
|
||||
}>;
|
||||
generate(input: {
|
||||
system: string;
|
||||
prompt: string;
|
||||
maxOutputTokens?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
notes: {
|
||||
query(input?: PluginNoteQuery): Promise<PluginNoteQueryResult>;
|
||||
queryContent(input?: PluginNoteQuery): Promise<PluginNoteContentQueryResult>;
|
||||
@@ -438,7 +452,10 @@ export interface PluginContext {
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
network: {
|
||||
fetch(input: string, init?: RequestInit): Promise<Response>;
|
||||
/** direct preserves browser fetch. public uses authenticated, bounded HTTPS GET/HEAD transport. */
|
||||
fetch(input: string, init?: RequestInit & {
|
||||
transport?: "direct" | "public";
|
||||
}): Promise<Response>;
|
||||
};
|
||||
ui: {
|
||||
showNotice(message: string): void;
|
||||
|
||||
Vendored
+4
@@ -12,6 +12,8 @@ var PLUGIN_PERMISSIONS = [
|
||||
"templates:read",
|
||||
"templates:write",
|
||||
"network",
|
||||
"network:public",
|
||||
"ai:generate",
|
||||
"storage",
|
||||
"secrets",
|
||||
"schedules",
|
||||
@@ -204,6 +206,8 @@ var parseExtensionManifest = (value) => {
|
||||
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
|
||||
if (unsupported)
|
||||
throw new Error(`Unsupported plugin permission: ${unsupported}`);
|
||||
if (permissions.includes("network:public") && !permissions.includes("network"))
|
||||
throw new Error("Public network transport also requires the network permission.");
|
||||
const networkHosts = value.networkHosts === undefined ? undefined : Array.isArray(value.networkHosts) ? value.networkHosts.map(String) : (() => {
|
||||
throw new Error("networkHosts must be an array.");
|
||||
})();
|
||||
|
||||
@@ -12,6 +12,8 @@ export const PLUGIN_PERMISSIONS = [
|
||||
"templates:read",
|
||||
"templates:write",
|
||||
"network",
|
||||
"network:public",
|
||||
"ai:generate",
|
||||
"storage",
|
||||
"secrets",
|
||||
"schedules",
|
||||
@@ -369,6 +371,10 @@ export interface PluginPanel {
|
||||
|
||||
export interface PluginContext {
|
||||
pluginId: string;
|
||||
ai: {
|
||||
status(): Promise<{ configured: boolean; modelName?: string }>;
|
||||
generate(input: { system: string; prompt: string; maxOutputTokens?: number; signal?: AbortSignal }): Promise<{ text: string }>;
|
||||
};
|
||||
notes: {
|
||||
query(input?: PluginNoteQuery): Promise<PluginNoteQueryResult>;
|
||||
queryContent(input?: PluginNoteQuery): Promise<PluginNoteContentQueryResult>;
|
||||
@@ -450,7 +456,8 @@ export interface PluginContext {
|
||||
remove(key: string): Promise<void>;
|
||||
};
|
||||
network: {
|
||||
fetch(input: string, init?: RequestInit): Promise<Response>;
|
||||
/** direct preserves browser fetch. public uses authenticated, bounded HTTPS GET/HEAD transport. */
|
||||
fetch(input: string, init?: RequestInit & { transport?: "direct" | "public" }): Promise<Response>;
|
||||
};
|
||||
ui: {
|
||||
showNotice(message: string): void;
|
||||
@@ -606,6 +613,7 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
|
||||
const permissions = [...new Set(value.permissions.map(String))];
|
||||
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
|
||||
if (unsupported) throw new Error(`Unsupported plugin permission: ${unsupported}`);
|
||||
if (permissions.includes("network:public") && !permissions.includes("network")) throw new Error("Public network transport also requires the network permission.");
|
||||
const networkHosts = value.networkHosts === undefined
|
||||
? undefined
|
||||
: Array.isArray(value.networkHosts)
|
||||
|
||||
@@ -31,3 +31,4 @@ export * from "./file-attachment";
|
||||
export * from "./note-image-card";
|
||||
export * from "./pdf-attachment";
|
||||
export * from "./types";
|
||||
export * from './plugin-capabilities';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PluginAiGenerateSchema = z.object({
|
||||
system: z.string().max(8000),
|
||||
prompt: z.string().min(1).max(90000),
|
||||
maxOutputTokens: z.number().int().min(1).max(5000).optional(),
|
||||
}).strict();
|
||||
export type PluginAiGenerateRequest = z.infer<typeof PluginAiGenerateSchema>;
|
||||
export interface PluginAiStatus { configured: boolean; modelName?: string }
|
||||
|
||||
export const PluginPublicFetchSchema = z.object({
|
||||
url: z.string().min(1).max(8192),
|
||||
method: z.enum(['GET', 'HEAD']).default('GET'),
|
||||
headers: z.record(z.string().max(100), z.string().max(2000)).default({}),
|
||||
}).strict();
|
||||
export type PluginPublicFetchRequest = z.infer<typeof PluginPublicFetchSchema>;
|
||||
export interface PluginPublicFetchResponse {
|
||||
url: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { hasBootstrapCredential } from "../apps/api/src/auth-bootstrap.ts";
|
||||
import { isUnauthenticatedAccessEnabled } from "../apps/api/src/auth-state.ts";
|
||||
import { fetchEdgeEverApp } from "../apps/api/src/index.ts";
|
||||
import { nodePublicFetch } from "../apps/api/src/node-public-network.ts";
|
||||
import { createSelfHostedStorageAdapter } from "../apps/api/src/self-hosted-storage-adapter.ts";
|
||||
import { createS3CompatibleStorageAdapter } from "../apps/api/src/s3-compatible-storage-adapter.ts";
|
||||
import { resolveSelfHostedConfig } from "./self-hosted-config.mjs";
|
||||
@@ -75,6 +76,7 @@ const storage = config.storageBackend === "s3"
|
||||
: createSelfHostedStorageAdapter(sqlite, resourcesDirectory);
|
||||
const env = {
|
||||
storage,
|
||||
publicNetworkFetch: nodePublicFetch,
|
||||
EDGE_EVER_AUTH_USERNAME: runtimeEnvironment.EDGE_EVER_AUTH_USERNAME ?? "admin",
|
||||
EDGE_EVER_RUNTIME: "self-hosted-bun",
|
||||
EDGE_EVER_CONTAINER_IMAGE: runtimeEnvironment.EDGE_EVER_CONTAINER_IMAGE,
|
||||
|
||||
Reference in new issue
Block a user