Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/cli/README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ Command-line tool for FastGPT plugin development. It is used to create, build, t

### Remote Debugging

Local plugins can connect to a test-environment plugin-server through Connection Gateway. The CLI needs the gateway TCP endpoint for the long-lived channel; the HTTP endpoint is used to create and clean up the session.
Local plugins can connect to a test-environment plugin-server through a FastGPT connect link. The recommended path is for FastGPT to authenticate the user and create the debug session, while the CLI only exchanges a one-time ticket for short-lived connection info.

```bash
fastgpt-plugin debug ./plugins/getTime ./plugins/dbops \
--connect "https://fastgpt.example.com/debug-plugin/connect?ticket=..."
```

The connect link returns the gateway TCP endpoint, `debug:tmbId:{tmbId}:session:{debugSessionId}` source, precreated session, and scoped connect token. The CLI does not need `CONNECTION_GATEWAY_AUTH_TOKEN` or `JWT_SECRET`.

For low-level local integration, the CLI can still connect to Connection Gateway directly:

```bash
fastgpt-plugin debug ./plugins/getTime ./plugins/dbops \
Expand Down
11 changes: 10 additions & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ FastGPT 插件开发的命令行工具,用于创建、构建和测试 FastGPT

### 远程调试

本地插件可以通过 Connection Gateway 接入测试环境的 plugin-server。CLI 只需要能访问 gateway 的 TCP 地址;HTTP 地址用于创建/清理 session。
本地插件可以通过 FastGPT 生成的 connect link 接入测试环境的 plugin-server。推荐路径是由 FastGPT 完成用户鉴权并创建 debug session,CLI 只使用一次性 ticket 换取短期连接信息。

```bash
fastgpt-plugin debug ./plugins/getTime ./plugins/dbops \
--connect "https://fastgpt.example.com/debug-plugin/connect?ticket=..."
```

connect link 会返回 gateway TCP 地址、`debug:tmbId:{tmbId}:session:{debugSessionId}` source、预创建 session 和 scoped connect token。CLI 不需要 `CONNECTION_GATEWAY_AUTH_TOKEN` 或 `JWT_SECRET`。

本地底层联调仍可直接连接 Connection Gateway:

```bash
fastgpt-plugin debug ./plugins/getTime ./plugins/dbops \
Expand Down
70 changes: 70 additions & 0 deletions apps/cli/src/commands/debug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('debug command', () => {
loggerSpy.success.mockReset();
loggerSpy.info.mockReset();
loggerSpy.error.mockReset();
vi.unstubAllGlobals();
delete process.env.CONNECTION_GATEWAY_AUTH_TOKEN;
await rm(tempUploadDir, { recursive: true, force: true });
});
Expand Down Expand Up @@ -178,6 +179,75 @@ describe('debug command', () => {
expect(exitSpy).not.toHaveBeenCalled();
});

it('应能通过 connect link 换取预创建远程调试连接信息', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
data: {
tcpUrl: 'tcp://tcp.example.com:39430',
source: 'debug:tmbId:tmb-1:session:debug-1',
sessionId: 'session-debug',
connectToken: 'scoped-token',
expiresAt: Date.now() + 60_000,
session: {
id: 'session-debug',
consumerType: 'plugin-debug',
subject: 'tmb-1',
sessionScope: {
userId: 'tmb-1',
source: 'debug:tmbId:tmb-1:session:debug-1'
},
transport: 'tcp',
capabilities: ['gateway.bind', 'invoke'],
generation: 0,
ownerNodeId: 'node-a',
status: 'connecting',
connectedAt: Date.now(),
lastSeenAt: Date.now(),
expiresAt: Date.now() + 60_000
}
}
})
)
)
);

await run([
process.execPath,
'cli',
'debug',
GETTIME_TOOL_DIR,
'--connect',
'https://fastgpt.example.com/debug/connect?ticket=t1'
]);

expect(globalThis.fetch).toHaveBeenCalledWith(
'https://fastgpt.example.com/debug/connect?ticket=t1'
);
expect(vi.mocked(connectDebugGateway).mock.calls[0]?.[0].options).toMatchObject({
tcpHost: 'tcp.example.com',
tcpPort: 39430,
source: 'debug:tmbId:tmb-1:session:debug-1',
precreatedSession: {
connectToken: 'scoped-token',
session: expect.objectContaining({
id: 'session-debug'
})
}
});
expect(vi.mocked(connectDebugGateway).mock.calls[0]?.[0].options).not.toHaveProperty(
'authToken'
);
expect(vi.mocked(connectDebugGateway).mock.calls[0]?.[0].options).not.toHaveProperty(
'jwtSecret'
);
expect(getLoggerOutput(loggerSpy.success)).toContain(
'远程调试已就绪: debug:tmbId:tmb-1:session:debug-1 getTime'
);
});

it('应能通过 CONNECTION_GATEWAY_AUTH_TOKEN 配置 gateway token', async () => {
process.env.CONNECTION_GATEWAY_AUTH_TOKEN = 'gateway-token-from-env';

Expand Down
137 changes: 120 additions & 17 deletions apps/cli/src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
} from '@fastgpt-plugin/cli/debug/session';
import { logger } from '@fastgpt-plugin/cli/helpers';
import type { Command } from 'commander';
import z from 'zod';

import { ConnectionGatewaySessionSchema } from '@domain/value-objects/connection-gateway.vo';
import type { SystemVarType } from '@domain/value-objects/system-var.vo';
import type { ToolStreamMessageType } from '@domain/value-objects/tool.vo';

Expand All @@ -30,6 +32,7 @@ type DebugCommandOptions = {
systemVarFile?: string;
uploadDir?: string;
gateway?: boolean;
connect?: string;
gatewayBaseUrl?: string;
gatewayAuthToken?: string;
gatewayJwtSecret?: string;
Expand Down Expand Up @@ -61,6 +64,7 @@ export class DebugCommand extends BaseCommand {
.option('--system-var-file <path>', 'systemVar JSON 文件路径')
.option('--upload-dir <path>', '虚拟 uploadFile 的输出目录')
.option('--gateway', '连接 Connection Gateway,等待远程调试请求', false)
.option('--connect <url>', 'FastGPT debug connect link,通过 ticket 换取远程调试连接信息')
.option('--gateway-base-url <url>', 'Connection Gateway HTTP 地址')
.option('--gateway-auth-token <token>', 'Connection Gateway AUTH_TOKEN')
.option('--gateway-jwt-secret <secret>', 'Connection Gateway JWT_SECRET')
Expand All @@ -86,8 +90,12 @@ export class DebugCommand extends BaseCommand {
);
const isMultiEntry = entries.length > 1;

if (isMultiEntry && !options.gateway) {
throw new Error('多个插件同时调试需要使用 --gateway。');
if (options.connect) {
options.gateway = true;
}

if (isMultiEntry && !options.gateway && !options.connect) {
throw new Error('多个插件同时调试需要使用 --gateway 或 --connect。');
}

if (isMultiEntry && options.run) {
Expand Down Expand Up @@ -240,7 +248,11 @@ export class DebugCommand extends BaseCommand {
private resolveGatewayOptions(
options: DebugCommandOptions,
snapshot: DebugPluginSnapshot
): DebugGatewayClientOptions {
): Promise<DebugGatewayClientOptions> | DebugGatewayClientOptions {
if (options.connect) {
return this.resolveConnectGatewayOptions(options);
}

const userId = options.gatewayUserId ?? process.env.CONNECTION_GATEWAY_USER_ID ?? 'debug-user';
const tcpEndpoint = resolveGatewayTcpEndpoint(options);

Expand Down Expand Up @@ -275,6 +287,33 @@ export class DebugCommand extends BaseCommand {
};
}

private async resolveConnectGatewayOptions(
options: DebugCommandOptions
): Promise<DebugGatewayClientOptions> {
const info = await exchangeConnectLink(options.connect as string);
const tcpEndpoint = parseGatewayTcpUrl(info.tcpUrl);

return {
baseUrl: '',
tcpHost: tcpEndpoint.host,
tcpPort: tcpEndpoint.port,
userId: info.tmbId,
source: info.source,
tokenTtlMs: Math.max(1, info.expiresAt - Date.now()),
reconnect: options.gatewayNoReconnect ? false : options.gatewayReconnect ?? true,
reconnectIntervalMs: toPositiveInt(
options.gatewayReconnectIntervalMs ??
process.env.CONNECTION_GATEWAY_RECONNECT_INTERVAL_MS ??
'2000',
'gateway-reconnect-interval-ms'
),
precreatedSession: {
session: info.session,
connectToken: info.connectToken
}
};
}

private resolveGatewaySource(
options: DebugCommandOptions,
_snapshot: DebugPluginSnapshot
Expand Down Expand Up @@ -303,16 +342,17 @@ export class DebugCommand extends BaseCommand {
}>;
options: DebugCommandOptions;
}): Promise<void> {
const source = this.resolveGatewaySource(options, sessions[0].snapshot);
const targets: DebugGatewayTarget[] = sessions.map((session) => ({
runtime: session.runtime,
snapshot: session.snapshot
}));
const gatewayOptions = await this.resolveGatewayOptions(options, sessions[0].snapshot);
const gateway = await connectDebugGateway({
targets,
options: this.resolveGatewayOptions(options, sessions[0].snapshot),
options: gatewayOptions,
onLog: (message) => logger.info(message)
});
const source = gateway.session.sessionScope.source ?? gatewayOptions.source ?? '-';

targets.forEach((target) => {
logger.success(`远程调试已就绪: ${source} ${target.snapshot.pluginId}`);
Expand Down Expand Up @@ -451,18 +491,7 @@ function resolveGatewayTcpEndpoint(options: DebugCommandOptions): { host: string
const tcpUrl = options.gatewayTcpUrl ?? process.env.CONNECTION_GATEWAY_TCP_URL;

if (tcpUrl) {
const parsed = new URL(tcpUrl);
if (parsed.protocol !== 'tcp:') {
throw new Error('gateway-tcp-url 必须使用 tcp:// 协议。');
}
if (!parsed.hostname || !parsed.port) {
throw new Error('gateway-tcp-url 必须包含 host 和 port。');
}

return {
host: parsed.hostname,
port: toPositiveInt(parsed.port, 'gateway-tcp-url port')
};
return parseGatewayTcpUrl(tcpUrl);
}

return {
Expand All @@ -474,6 +503,80 @@ function resolveGatewayTcpEndpoint(options: DebugCommandOptions): { host: string
};
}

const ConnectInfoSchema = z.object({
tcpUrl: z.string().min(1),
source: z.string().min(1),
sessionId: z.string().min(1),
connectToken: z.string().min(1),
expiresAt: z.number().int().positive(),
session: ConnectionGatewaySessionSchema.optional()
});

async function exchangeConnectLink(connectUrl: string) {
const response = await fetch(connectUrl);
const text = await response.text();
const payload = text ? JSON.parse(text) : {};

if (!response.ok) {
throw new Error(`connect link 请求失败: ${response.status} ${text}`);
}

const info = ConnectInfoSchema.parse(payload.data ?? payload);
const session =
info.session ??
ConnectionGatewaySessionSchema.parse({
id: info.sessionId,
consumerType: 'plugin-debug',
subject: parseTmbIdFromDebugSource(info.source),
sessionScope: {
userId: parseTmbIdFromDebugSource(info.source),
source: info.source
},
transport: 'tcp',
capabilities: ['gateway.bind', 'invoke'],
generation: 0,
ownerNodeId: 'remote',
status: 'connecting',
connectedAt: Date.now(),
lastSeenAt: Date.now(),
expiresAt: info.expiresAt,
metadata: {
connectToken: info.connectToken
}
});

return {
...info,
tmbId: parseTmbIdFromDebugSource(info.source),
session
};
}

function parseTmbIdFromDebugSource(source: string): string {
const parts = source.split(':');
const index = parts.indexOf('tmbId');
const tmbId = index >= 0 ? parts[index + 1] : undefined;
if (!tmbId) {
throw new Error(`debug source 缺少 tmbId: ${source}`);
}
return tmbId;
}

function parseGatewayTcpUrl(tcpUrl: string): { host: string; port: number } {
const parsed = new URL(tcpUrl);
if (parsed.protocol !== 'tcp:') {
throw new Error('gateway-tcp-url 必须使用 tcp:// 协议。');
}
if (!parsed.hostname || !parsed.port) {
throw new Error('gateway-tcp-url 必须包含 host 和 port。');
}

return {
host: parsed.hostname,
port: toPositiveInt(parsed.port, 'gateway-tcp-url port')
};
}

function findDuplicateValues(values: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
Expand Down
Loading
Loading