Files
FastGPT/projects/app/server.ts
T
Jon 57a505f837 Agent skill dev (#6668)
* chore: Rename service & container names for consistency in Docker configs (#6710)

* chore: Rename container names for consistency in Docker configs

* chore: Rename service names for consistency in Docker configs

chore: Update OpenSandbox versions and image repositories (#6709)

* chore: Update OpenSandbox versions and image repositories

* yml version

* images

* init yml

* port

---------

Co-authored-by: archer <545436317@qq.com>

refactor(chat): optimize sandbox status logic and decouple UI/Status hooks (#6713)

* refactor(chat): optimize sandbox status logic and decouple UI/Status hooks

* fix: useRef, rename onClose to afterClose

Update .env.template (#6720)

aiproxy默认的请求地址改成http协议

feat: comprehensive agent skill management and sandbox infrastructure optimization

- Skill System: Implemented a full skill management module including CRUD operations, folder organization, AI-driven skill generation, and versioning (switch/update).
- Sandbox Infrastructure: Introduced 'volume-manager' for PVC and Docker volume lifecycle management, replacing the MinIO sync-agent for better data persistence.
- Workflow Integration: Enhanced the Agent node to support skill selection and configuration, including new UI components and data normalization.
- Permission Management: Added granular permission controls for skills, supporting collaborators, owner transfers, and permission inheritance.
- UI/UX: Added a dedicated Skill dashboard, sandbox debug interface (terminal, logs, and iframe proxy), and comprehensive i18n support.
- Maintenance: Migrated Docker services to named volumes, optimized sandbox instance limits, and improved error handling for sandbox providers.

Co-authored-by: chanzhi82020 <chenzhi@sangfor.com.cn>
Co-authored-by: lavine77
Signed-off-by: Jon <ljp@sangfor.com.cn>

feat: hide skill

prettier

* perf: hide skill code

* fix: ts

* lock

* perf: tool code

* fix: ts

* lock

* fix: test

* fix: openapi

* lock

* fix: test

* null model

---------

Co-authored-by: archer <545436317@qq.com>
2026-04-07 22:52:03 +08:00

520 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IncomingMessage } from 'http';
import { createServer, ServerResponse } from 'http';
import { parse } from 'url';
import next from 'next';
import httpProxy from 'http-proxy';
import { Readable } from 'stream';
import net from 'net';
import crypto from 'crypto';
const dev = process.env.NODE_ENV !== 'production';
const port = parseInt(process.env.PORT || '3000', 10);
// sandboxId: alphanumeric + hyphens, 864 chars, must start/end with alnum.
// Explicitly excludes '.', '/', '%', '..', and other path-traversal characters.
const SANDBOX_ID_RE = /[a-zA-Z0-9][a-zA-Z0-9-]{6,62}[a-zA-Z0-9]/;
// Match /proxy/{sandboxId}/{port} or /absproxy/{sandboxId}/{port}
const PATH_PROXY_RE = new RegExp(`^\\/(proxy|absproxy)\\/(${SANDBOX_ID_RE.source})\\/(\\d+)`);
// Match /tcptunnel/{sandboxId}/{port} — WebSocket upgrade only
const TCPTUNNEL_RE = new RegExp(`^\\/tcptunnel\\/(${SANDBOX_ID_RE.source})\\/(\\d+)`);
// Strip subdomain prefix from a host string (may include :port).
// "port--uuid.localhost:3000" → "localhost:3000"
function deriveBaseHost(subdomainHost: string): string {
const dotIdx = subdomainHost.indexOf('.');
return dotIdx >= 0 ? subdomainHost.substring(dotIdx + 1) : subdomainHost;
}
async function main() {
const app = next({ dev });
const handle = app.getRequestHandler();
await app.prepare();
// Import pure utilities from sandboxProxyUtils — no service-layer deps, safe in tsx CJS mode.
// getSandboxProxyTarget is NOT imported here; auth is delegated to the proxyAuth API route.
const { parseSubdomainProxy, rewriteHtml, redeemRelayToken } = (await import(
'./src/service/core/sandbox/proxyUtils'
)) as typeof import('./src/service/core/sandbox/proxyUtils');
const proxy = httpProxy.createProxyServer({ xfwd: true, changeOrigin: true });
proxy.on(
'error',
(err: Error, _req: IncomingMessage, res: ServerResponse | import('stream').Duplex) => {
if (res instanceof ServerResponse && !res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Proxy error: ${err.message}`);
}
}
);
// absproxy: fetch upstream then rewrite HTML paths with base prefix
async function handleAbsProxy(
req: IncomingMessage,
res: ServerResponse,
target: string,
sandboxId: string,
targetPort: string
) {
const upstreamUrl = `${target}${req.url || '/'}`;
const response = await fetch(upstreamUrl, {
method: req.method,
headers: buildProxyHeaders(req.headers),
// @ts-ignore — Node 18+ supports duplex on fetch body streams
duplex: 'half',
body: req.method !== 'GET' && req.method !== 'HEAD' ? (req as any) : undefined
});
const skipHeaders = new Set([
'content-encoding',
'transfer-encoding',
'x-frame-options',
'content-security-policy'
]);
response.headers.forEach((value, key) => {
if (!skipHeaders.has(key.toLowerCase())) res.setHeader(key, value);
});
res.statusCode = response.status;
const contentType = response.headers.get('content-type') || '';
const contentLength = Number(response.headers.get('content-length') || 0);
// Only rewrite HTML; stream large or binary responses directly
if (contentType.includes('text/html') && response.body && contentLength < 10 * 1024 * 1024) {
const html = await response.text();
const basePath = `/absproxy/${sandboxId}/${targetPort}`;
const rewritten = rewriteHtml(html, basePath);
res.setHeader('content-length', Buffer.byteLength(rewritten));
res.end(rewritten);
} else if (response.body) {
Readable.fromWeb(response.body as any).pipe(res);
} else {
res.end();
}
}
async function handleProxy(
req: IncomingMessage,
res: ServerResponse,
sandboxId: string,
portNum: number,
proxyType: string
) {
try {
const target = await authProxyTarget(req.headers, sandboxId, portNum);
if (proxyType === 'absproxy') {
await handleAbsProxy(req, res, target, sandboxId, String(portNum));
} else {
// Rewrite Origin so code-server's CSRF check passes (changeOrigin only rewrites Host).
const targetUrl = new URL(target);
proxy.web(req, res, {
target,
headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` }
});
}
} catch (err: any) {
const status = err.statusCode || 502;
if (!res.headersSent) {
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(err.message || 'Proxy error');
}
}
}
// Subdomain proxy handler: on auth failure (401/403) redirect to proxyAuth for cross-domain cookie hand-off.
async function handleSubdomainProxy(
req: IncomingMessage,
res: ServerResponse,
sandboxId: string,
portNum: number
) {
// Check for relay token in query string (?__pt=<nonce>).
// proxyAuth GET redirects here after storing fastgptToken server-side.
// We set the cookie from this subdomain so Chrome scopes it correctly.
const urlObj = new URL(`http://placeholder${req.url || '/'}`);
const relayToken = urlObj.searchParams.get('__pt');
if (relayToken) {
const fastgptToken = redeemRelayToken(relayToken);
if (fastgptToken) {
urlObj.searchParams.delete('__pt');
const cleanUrl = urlObj.pathname + (urlObj.search !== '?' ? urlObj.search : '');
dev &&
console.log(
`[proxy:subdomain] relay token redeemed, setting cookie and redirecting to ${cleanUrl}`
);
res.setHeader(
'Set-Cookie',
`fastgpt_token=${fastgptToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`
);
res.writeHead(302, { Location: cleanUrl || '/' });
res.end();
return;
}
console.warn(`[proxy:subdomain] relay token invalid or expired: ${relayToken}`);
}
try {
const target = await authProxyTarget(req.headers, sandboxId, portNum);
const targetUrl = new URL(target);
proxy.web(req, res, {
target,
headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` }
});
} catch (err: any) {
const status = err.statusCode || 502;
// Auth failure — redirect to proxyAuth on the base origin for cookie hand-off
if (status === 401 || status === 403) {
const host = req.headers.host!;
const proto = (req.headers['x-forwarded-proto'] as string) || 'http';
const originalUrl = `${proto}://${host}${req.url || '/'}`;
const authBase = `${proto}://${deriveBaseHost(host)}`;
const authUrl = new URL(`${authBase}/api/core/sandbox/proxyAuth`);
authUrl.searchParams.set('sandboxId', sandboxId);
authUrl.searchParams.set('port', String(portNum));
authUrl.searchParams.set('next', originalUrl);
console.warn(
`[proxy:subdomain] auth failed (${status}), redirecting to proxyAuth. next=${originalUrl}`
);
res.writeHead(302, { Location: authUrl.toString() });
res.end();
return;
}
if (!res.headersSent) {
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(err.message || 'Proxy error');
}
}
}
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const parsedUrl = parse(req.url || '');
// ① Check subdomain proxy first: {port}--{sandboxId}.{baseDomain}
const subdomain = parseSubdomainProxy(req.headers.host);
if (subdomain) {
await handleSubdomainProxy(req, res, subdomain.sandboxId, subdomain.port);
return;
}
// ② Path-based proxy: /proxy/{sandboxId}/{port} or /absproxy/{sandboxId}/{port}
const match = parsedUrl.pathname?.match(PATH_PROXY_RE);
if (match) {
const [, proxyType, sandboxId, portStr] = match;
// Strip proxy prefix so upstream sees the real path
req.url = req.url!.replace(`/${proxyType}/${sandboxId}/${portStr}`, '') || '/';
await handleProxy(req, res, sandboxId, Number(portStr), proxyType);
return;
}
// ③ Fall through to Next.js handler
handle(req, res, parsedUrl as any);
});
// WebSocket upgrade handler — supports all three proxy modes
server.on('upgrade', async (req: IncomingMessage, socket, head) => {
// ① tcptunnel: raw TCP-over-WebSocket, handled before all other upgrade logic
const tunnelMatch = req.url?.match(TCPTUNNEL_RE);
if (tunnelMatch) {
const tunnelSandboxId = tunnelMatch[1];
const tunnelPort = Number(tunnelMatch[2]);
dev &&
console.log(
`[proxy:tcptunnel] upgrade sandboxId=${tunnelSandboxId} port=${tunnelPort} hasCookie=${!!req.headers.cookie}`
);
let target: string;
try {
target = await authProxyTarget(req.headers, tunnelSandboxId, tunnelPort);
dev && console.log(`[proxy:tcptunnel] auth ok target=${target}`);
} catch (err: any) {
const status = err.statusCode || 502;
console.error(`[proxy:tcptunnel] auth failed status=${status} message=${err.message}`);
socket.write(`HTTP/1.1 ${status} ${err.message || 'Auth error'}\r\n\r\n`);
socket.destroy();
return;
}
// Parse host from auth target URL
const targetUrl = new URL(target);
const containerHost = targetUrl.hostname;
const containerPort = tunnelPort;
// Complete WebSocket handshake (RFC 6455 §4.2.2)
const wsKey = req.headers['sec-websocket-key'];
if (!wsKey) {
socket.write('HTTP/1.1 400 Missing Sec-WebSocket-Key\r\n\r\n');
socket.destroy();
return;
}
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const acceptKey = crypto
.createHash('sha1')
.update(wsKey + WS_GUID)
.digest('base64');
// Connect to TCP target first, then send 101 after connect
const tcpSocket = net.createConnection({ host: containerHost, port: containerPort });
let closed = false;
function cleanup() {
if (closed) return;
closed = true;
tcpSocket.destroy();
socket.destroy();
}
tcpSocket.once('connect', () => {
dev &&
console.log(
`[proxy:tcptunnel] TCP connected host=${containerHost} port=${containerPort}`
);
// Send 101 after TCP is ready
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptKey}\r\n` +
'\r\n'
);
// Flush any buffered data that came in with the upgrade request
const decoder = new WsFrameDecoder();
if (head && head.length > 0) {
for (const payload of decoder.push(head)) {
if (payload.length === 0) {
cleanup();
return;
}
tcpSocket.write(payload);
}
}
// Browser → TCP: decode WS frames, write raw bytes to tcpSocket
socket.on('data', (chunk: Buffer) => {
if (closed) return;
for (const payload of decoder.push(chunk)) {
if (payload.length === 0) {
cleanup();
return;
}
tcpSocket.write(payload);
}
});
// TCP → Browser: wrap raw bytes in WS binary frames
tcpSocket.on('data', (chunk: Buffer) => {
if (closed) return;
socket.write(encodeWsFrame(chunk));
});
});
tcpSocket.once('error', (err) => {
console.error(`[proxy:tcptunnel] TCP error: ${err.message}`);
cleanup();
});
tcpSocket.once('close', cleanup);
socket.once('error', cleanup);
socket.once('close', cleanup);
return;
}
let sandboxId: string;
let portNum: number;
let proxyType = 'proxy';
const subdomain = parseSubdomainProxy(req.headers.host);
if (subdomain) {
sandboxId = subdomain.sandboxId;
portNum = subdomain.port;
} else {
const match = req.url?.match(PATH_PROXY_RE);
if (!match) {
dev && console.log(`[proxy:ws] no match, destroying socket. url=${req.url}`);
socket.destroy();
return;
}
proxyType = match[1];
sandboxId = match[2];
portNum = Number(match[3]);
req.url = req.url!.replace(`/${proxyType}/${sandboxId}/${portNum}`, '') || '/';
}
dev &&
console.log(
`[proxy:ws] upgrade sandboxId=${sandboxId} port=${portNum} url=${req.url} hasCookie=${!!req.headers.cookie}`
);
try {
const target = await authProxyTarget(req.headers, sandboxId, portNum);
dev && console.log(`[proxy:ws] auth ok, forwarding to target=${target}`);
// Rewrite Origin to match the target host so code-server's CSRF check passes.
// changeOrigin:true only rewrites Host, not Origin.
const targetUrl = new URL(target);
proxy.ws(req, socket, head, {
target,
headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` }
});
} catch (err: any) {
const status = err.statusCode || 502;
console.error(`[proxy:ws] auth failed status=${status} message=${err.message}`);
socket.write(`HTTP/1.1 ${status} ${err.message || 'Proxy error'}\r\n\r\n`);
socket.destroy();
}
});
server.listen(port, () => {
console.log(`> Ready on http://localhost:${port} [${dev ? 'dev' : 'production'}]`);
});
}
// Authenticate a sandbox proxy request via the internal Next.js API route.
// This avoids importing @fastgpt/service (ESM-only deps) directly in server.ts.
async function authProxyTarget(
reqHeaders: IncomingMessage['headers'],
sandboxId: string,
targetPort: number
): Promise<string> {
dev &&
console.log(
`[proxy:auth] POST proxyAuth sandboxId=${sandboxId} port=${targetPort} hasCookie=${!!reqHeaders.cookie}`
);
const authResp = await fetch(`http://127.0.0.1:${port}/api/core/sandbox/proxyAuth`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(reqHeaders.cookie ? { cookie: reqHeaders.cookie as string } : {}),
...(reqHeaders.authorization ? { authorization: reqHeaders.authorization as string } : {})
},
body: JSON.stringify({ sandboxId, targetPort })
});
if (!authResp.ok) {
// NextAPI always returns HTTP 500 for errors; read the real code from JSON body
const body = await authResp.json().catch(() => ({ code: authResp.status }));
const code = body?.code || authResp.status;
const msg = body?.message || body?.error || 'Auth failed';
console.error(
`[proxy:auth] proxyAuth failed httpStatus=${authResp.status} code=${code} message=${msg}`
);
throw Object.assign(new Error(msg), { statusCode: code });
}
const { target } = await authResp.json();
dev && console.log(`[proxy:auth] proxyAuth ok target=${target}`);
return target as string;
}
// Build upstream request headers, dropping hop-by-hop headers
function buildProxyHeaders(headers: IncomingMessage['headers']): Record<string, string> {
const hopByHop = new Set([
'host',
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade'
]);
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (hopByHop.has(key.toLowerCase())) continue;
if (value) result[key] = Array.isArray(value) ? value.join(', ') : value;
}
return result;
}
// RFC 6455 WebSocket frame decoder with buffer accumulation.
// Handles fragmented frames that arrive across multiple TCP chunks.
class WsFrameDecoder {
private buf: Buffer = Buffer.alloc(0);
// Push a new chunk; returns list of decoded payloads.
// An empty Buffer in the list signals a close frame (opcode 0x8).
push(chunk: Buffer): Buffer[] {
this.buf = Buffer.concat([this.buf, chunk]);
const payloads: Buffer[] = [];
while (this.buf.length >= 2) {
const b0 = this.buf[0];
const b1 = this.buf[1];
const opcode = b0 & 0x0f;
const masked = (b1 & 0x80) !== 0;
let payloadLen = b1 & 0x7f;
let headerLen = 2;
if (payloadLen === 126) {
if (this.buf.length < 4) break;
payloadLen = this.buf.readUInt16BE(2);
headerLen = 4;
} else if (payloadLen === 127) {
if (this.buf.length < 10) break;
// Only handle payloads up to 2^32; high 4 bytes are expected to be 0
payloadLen = this.buf.readUInt32BE(6);
headerLen = 10;
}
if (masked) headerLen += 4;
const totalLen = headerLen + payloadLen;
if (this.buf.length < totalLen) break;
const payload = Buffer.allocUnsafe(payloadLen);
if (masked) {
const maskOffset = headerLen - 4;
for (let i = 0; i < payloadLen; i++) {
payload[i] = this.buf[headerLen + i] ^ this.buf[maskOffset + (i & 3)];
}
} else {
this.buf.copy(payload, 0, headerLen, totalLen);
}
this.buf = this.buf.subarray(totalLen);
if (opcode === 0x8) {
// Close frame — signal EOF
payloads.push(Buffer.alloc(0));
break;
}
// data frame (text=0x1, binary=0x2, continuation=0x0) or ping(0x9)/pong(0xa) ignored
if (opcode === 0x1 || opcode === 0x2 || opcode === 0x0) {
payloads.push(payload);
}
}
return payloads;
}
}
// Encode raw bytes as a WebSocket binary frame (server→client, no masking).
function encodeWsFrame(data: Buffer): Buffer {
const len = data.length;
let header: Buffer;
if (len <= 125) {
header = Buffer.allocUnsafe(2);
header[0] = 0x82; // FIN=1, opcode=0x2 (binary)
header[1] = len;
} else if (len <= 65535) {
header = Buffer.allocUnsafe(4);
header[0] = 0x82;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.allocUnsafe(10);
header[0] = 0x82;
header[1] = 127;
header.writeUInt32BE(0, 2);
header.writeUInt32BE(len, 6);
}
return Buffer.concat([header, data]);
}
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});