@axiom-lattice/gateway 2.1.116 → 3.0.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/.turbo/turbo-build.log +15 -15
- package/CHANGELOG.md +37 -0
- package/dist/{a2a-ERG5RMUW.mjs → a2a-QMHEH3ZT.mjs} +77 -29
- package/dist/a2a-QMHEH3ZT.mjs.map +1 -0
- package/dist/index.js +1144 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1013 -92
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -5
- package/src/__tests__/a2a.test.ts +82 -0
- package/src/__tests__/workspace.test.ts +13 -3
- package/src/config/local-a2a-templates.ts +59 -0
- package/src/controllers/__tests__/assistant-local-a2a.test.ts +147 -0
- package/src/controllers/__tests__/local-a2a-templates.test.ts +112 -0
- package/src/controllers/assistant.ts +126 -4
- package/src/controllers/local-a2a-templates.ts +18 -0
- package/src/controllers/local-a2a.ts +149 -0
- package/src/controllers/tasks.ts +2 -3
- package/src/controllers/workspace.ts +144 -17
- package/src/index.ts +2 -10
- package/src/routes/__tests__/a2a-test-connection.test.ts +177 -0
- package/src/routes/a2a-test-connection.ts +126 -0
- package/src/routes/a2a.ts +100 -28
- package/src/routes/index.ts +27 -0
- package/src/routes/stt.ts +102 -0
- package/src/services/__tests__/local-a2a.test.ts +124 -0
- package/src/services/eval-runner.ts +6 -0
- package/src/services/local-a2a.ts +352 -0
- package/src/utils/__tests__/local-a2a-runtime.test.ts +77 -0
- package/src/utils/__tests__/path-containment.test.ts +35 -0
- package/src/utils/a2a-url.ts +20 -0
- package/src/utils/local-a2a-runtime.ts +107 -0
- package/src/utils/path-containment.ts +38 -0
- package/dist/a2a-ERG5RMUW.mjs.map +0 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import { getStoreLattice } from "@axiom-lattice/core";
|
|
3
|
+
import { localA2AService } from "../services/local-a2a";
|
|
4
|
+
import {
|
|
5
|
+
AgentType,
|
|
6
|
+
type Assistant,
|
|
7
|
+
type A2ARemoteAgentConfig,
|
|
8
|
+
} from "@axiom-lattice/protocols";
|
|
9
|
+
|
|
10
|
+
function getTenantId(request: FastifyRequest): string {
|
|
11
|
+
const userTenantId = (request as any).user?.tenantId;
|
|
12
|
+
if (userTenantId) return userTenantId;
|
|
13
|
+
return (request.headers["x-tenant-id"] as string) || "default";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getLocalRuntime(assistant: Assistant): A2ARemoteAgentConfig["localRuntime"] | undefined {
|
|
17
|
+
const graphDefinition = assistant.graphDefinition;
|
|
18
|
+
if (!graphDefinition || typeof graphDefinition !== "object") return undefined;
|
|
19
|
+
if ((graphDefinition as { type?: unknown }).type !== AgentType.A2A_REMOTE) return undefined;
|
|
20
|
+
return (graphDefinition as A2ARemoteAgentConfig).localRuntime;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getLocalA2AAssistants(
|
|
24
|
+
tenantId: string,
|
|
25
|
+
): Promise<Array<{ assistant: Assistant; localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]> }>> {
|
|
26
|
+
const storeLattice = getStoreLattice("default", "assistant");
|
|
27
|
+
const assistants = await storeLattice.store.getAllAssistants(tenantId);
|
|
28
|
+
const results: Array<{
|
|
29
|
+
assistant: Assistant;
|
|
30
|
+
localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]>;
|
|
31
|
+
}> = [];
|
|
32
|
+
|
|
33
|
+
for (const assistant of assistants) {
|
|
34
|
+
const runtime = getLocalRuntime(assistant);
|
|
35
|
+
if (runtime) {
|
|
36
|
+
results.push({ assistant, localRuntime: runtime });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function getLocalA2AAssistant(
|
|
44
|
+
tenantId: string,
|
|
45
|
+
assistantId: string,
|
|
46
|
+
): Promise<{
|
|
47
|
+
assistant: Assistant;
|
|
48
|
+
localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]>;
|
|
49
|
+
} | null> {
|
|
50
|
+
const storeLattice = getStoreLattice("default", "assistant");
|
|
51
|
+
const assistant = await storeLattice.store.getAssistantById(tenantId, assistantId);
|
|
52
|
+
if (!assistant) return null;
|
|
53
|
+
|
|
54
|
+
const runtime = getLocalRuntime(assistant);
|
|
55
|
+
if (!runtime) return null;
|
|
56
|
+
|
|
57
|
+
return { assistant, localRuntime: runtime };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function requireManagedAssistant(
|
|
61
|
+
request: FastifyRequest<{ Params: { assistantId: string } }>,
|
|
62
|
+
reply: FastifyReply,
|
|
63
|
+
): Promise<{
|
|
64
|
+
assistantId: string;
|
|
65
|
+
localRuntime: NonNullable<A2ARemoteAgentConfig["localRuntime"]>;
|
|
66
|
+
} | null> {
|
|
67
|
+
const assistantId = request.params.assistantId;
|
|
68
|
+
if (!assistantId) {
|
|
69
|
+
reply.status(400).send({ success: false, error: "assistantId is required" });
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const registered = await getLocalA2AAssistant(getTenantId(request), assistantId);
|
|
74
|
+
if (!registered) {
|
|
75
|
+
reply.status(404).send({
|
|
76
|
+
success: false,
|
|
77
|
+
error: `Assistant ${assistantId} is not a local managed A2A assistant`,
|
|
78
|
+
});
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { assistantId, localRuntime: registered.localRuntime };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getLocalA2AStatus(
|
|
86
|
+
request: FastifyRequest,
|
|
87
|
+
reply: FastifyReply,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
const tenantId = getTenantId(request);
|
|
90
|
+
const managed = await getLocalA2AAssistants(tenantId);
|
|
91
|
+
const states = await Promise.all(
|
|
92
|
+
managed.map(({ assistant, localRuntime }) =>
|
|
93
|
+
localA2AService.getRegisteredState(assistant.id, localRuntime),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
reply.send({ success: true, data: { records: states, total: states.length } });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function getLocalA2AProvider(
|
|
100
|
+
request: FastifyRequest<{ Params: { assistantId: string } }>,
|
|
101
|
+
reply: FastifyReply,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const registered = await requireManagedAssistant(request, reply);
|
|
104
|
+
if (!registered) return;
|
|
105
|
+
const state = await localA2AService.getRegisteredState(
|
|
106
|
+
registered.assistantId,
|
|
107
|
+
registered.localRuntime,
|
|
108
|
+
);
|
|
109
|
+
reply.send({ success: true, data: state });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function startLocalA2AProvider(
|
|
113
|
+
request: FastifyRequest<{ Params: { assistantId: string } }>,
|
|
114
|
+
reply: FastifyReply,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const registered = await requireManagedAssistant(request, reply);
|
|
117
|
+
if (!registered) return;
|
|
118
|
+
|
|
119
|
+
const state = await localA2AService.startProvider(
|
|
120
|
+
registered.assistantId,
|
|
121
|
+
registered.localRuntime,
|
|
122
|
+
);
|
|
123
|
+
reply.send({ success: true, data: state });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function stopLocalA2AProvider(
|
|
127
|
+
request: FastifyRequest<{ Params: { assistantId: string } }>,
|
|
128
|
+
reply: FastifyReply,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const registered = await requireManagedAssistant(request, reply);
|
|
131
|
+
if (!registered) return;
|
|
132
|
+
|
|
133
|
+
const state = await localA2AService.stopProvider(registered.assistantId);
|
|
134
|
+
reply.send({ success: true, data: state });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function restartLocalA2AProvider(
|
|
138
|
+
request: FastifyRequest<{ Params: { assistantId: string } }>,
|
|
139
|
+
reply: FastifyReply,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const registered = await requireManagedAssistant(request, reply);
|
|
142
|
+
if (!registered) return;
|
|
143
|
+
|
|
144
|
+
const state = await localA2AService.restartProvider(
|
|
145
|
+
registered.assistantId,
|
|
146
|
+
registered.localRuntime,
|
|
147
|
+
);
|
|
148
|
+
reply.send({ success: true, data: state });
|
|
149
|
+
}
|
package/src/controllers/tasks.ts
CHANGED
|
@@ -25,16 +25,15 @@ export async function listTasks(
|
|
|
25
25
|
): Promise<{ success: boolean; data?: TaskItem[]; count?: number; error?: string }> {
|
|
26
26
|
try {
|
|
27
27
|
const tenantId = getTenantId(request);
|
|
28
|
-
const userId = getUserId(request);
|
|
29
28
|
const query = request.query as Record<string, string>;
|
|
30
29
|
|
|
31
30
|
const store = getStore();
|
|
32
31
|
const tasks = await store.list({
|
|
33
32
|
tenantId,
|
|
34
|
-
ownerId: query.ownerId || userId,
|
|
35
33
|
status: query.status,
|
|
36
34
|
priority: query.priority,
|
|
37
|
-
|
|
35
|
+
...(query.ownerId && { ownerId: query.ownerId }),
|
|
36
|
+
...(query.ownerType && { ownerType: query.ownerType as 'user' | 'agent' }),
|
|
38
37
|
metadata: query.metadata ? JSON.parse(query.metadata) : undefined,
|
|
39
38
|
projectId: query.projectId,
|
|
40
39
|
limit: query.limit ? parseInt(query.limit) : undefined,
|
|
@@ -8,11 +8,18 @@
|
|
|
8
8
|
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
|
9
9
|
import * as fs from "fs/promises";
|
|
10
10
|
import * as path from "path";
|
|
11
|
+
import { constants as fsConstants } from "fs";
|
|
12
|
+
import * as os from "os";
|
|
11
13
|
import { getStoreLattice } from "@axiom-lattice/core";
|
|
12
14
|
import { SandboxFilesystem, FilesystemBackend } from "@axiom-lattice/core";
|
|
13
15
|
import { getSandBoxManager } from "@axiom-lattice/core";
|
|
14
16
|
import { v4 as uuidv4 } from "uuid";
|
|
15
17
|
import { getContentTypeFromFilename, getFilenameFromPath } from "../utils/mime";
|
|
18
|
+
import {
|
|
19
|
+
isPathWithinRoot,
|
|
20
|
+
PathOutsideRootError,
|
|
21
|
+
resolvePathWithinRoot,
|
|
22
|
+
} from "../utils/path-containment";
|
|
16
23
|
import type {
|
|
17
24
|
CreateWorkspaceRequest,
|
|
18
25
|
UpdateWorkspaceRequest,
|
|
@@ -291,15 +298,30 @@ export class WorkspaceController {
|
|
|
291
298
|
sandboxInstance: sandbox,
|
|
292
299
|
}), workspace
|
|
293
300
|
};
|
|
294
|
-
} else {
|
|
295
|
-
console.log(`[getBackend] using FilesystemBackend rootDir=/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`);
|
|
296
|
-
return {
|
|
297
|
-
backend: new FilesystemBackend({
|
|
298
|
-
rootDir: `/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`,
|
|
299
|
-
virtualMode: true,
|
|
300
|
-
}), workspace
|
|
301
|
-
};
|
|
302
301
|
}
|
|
302
|
+
|
|
303
|
+
// Filesystem storage: use default /lattice_store path
|
|
304
|
+
const rootDir = await this.getFilesystemRootDirectory(
|
|
305
|
+
tenantId,
|
|
306
|
+
workspaceId,
|
|
307
|
+
projectId,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
console.log(`[getBackend] using FilesystemBackend rootDir=${rootDir}`);
|
|
311
|
+
return {
|
|
312
|
+
backend: new FilesystemBackend({
|
|
313
|
+
rootDir,
|
|
314
|
+
virtualMode: true,
|
|
315
|
+
}), workspace
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async getFilesystemRootDirectory(
|
|
320
|
+
tenantId: string,
|
|
321
|
+
workspaceId: string,
|
|
322
|
+
projectId: string,
|
|
323
|
+
): Promise<string> {
|
|
324
|
+
return `/lattice_store/tenants/${tenantId}/workspaces/${workspaceId}/${projectId}`;
|
|
303
325
|
}
|
|
304
326
|
|
|
305
327
|
private isBinaryContentType(filename: string): boolean {
|
|
@@ -572,7 +594,7 @@ export class WorkspaceController {
|
|
|
572
594
|
* - `path`: optional directory path relative to project root (e.g., "docs", "src/utils")
|
|
573
595
|
* - `assistantId`: optional query parameter for sandbox identity matching
|
|
574
596
|
* For sandbox storage, delegates to the sandbox upload API.
|
|
575
|
-
* For filesystem storage, writes
|
|
597
|
+
* For filesystem storage, writes under the same project root used by the file backend.
|
|
576
598
|
*/
|
|
577
599
|
async uploadFile(
|
|
578
600
|
request: FastifyRequest<{
|
|
@@ -661,16 +683,46 @@ export class WorkspaceController {
|
|
|
661
683
|
return reply.status(200).send({ success: true, data: result });
|
|
662
684
|
}
|
|
663
685
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
686
|
+
const rootDir = await this.getFilesystemRootDirectory(
|
|
687
|
+
tenantId,
|
|
688
|
+
workspaceId,
|
|
689
|
+
projectId,
|
|
690
|
+
);
|
|
691
|
+
await fs.mkdir(rootDir, { recursive: true });
|
|
692
|
+
const relativeDirectory = pathValue?.replace(/^[/\\]+/, "") || ".";
|
|
693
|
+
const targetDir = path.resolve(rootDir, relativeDirectory);
|
|
694
|
+
if (!isPathWithinRoot(path.resolve(rootDir), targetDir)) {
|
|
695
|
+
return reply.status(400).send({
|
|
696
|
+
success: false,
|
|
697
|
+
error: "Upload path must stay within the project directory",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
701
|
+
|
|
702
|
+
const [resolvedRoot, resolvedTargetDir] = await Promise.all([
|
|
703
|
+
fs.realpath(rootDir),
|
|
704
|
+
fs.realpath(targetDir),
|
|
705
|
+
]);
|
|
706
|
+
if (!isPathWithinRoot(resolvedRoot, resolvedTargetDir)) {
|
|
707
|
+
return reply.status(400).send({
|
|
708
|
+
success: false,
|
|
709
|
+
error: "Upload path must stay within the project directory",
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const safeFilename = path.basename(filename);
|
|
714
|
+
if (safeFilename !== filename) {
|
|
715
|
+
return reply.status(400).send({
|
|
716
|
+
success: false,
|
|
717
|
+
error: "Invalid filename",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
const targetPath = path.join(resolvedTargetDir, safeFilename);
|
|
669
721
|
await fs.writeFile(targetPath, buffer);
|
|
670
722
|
const stat = await fs.stat(targetPath);
|
|
671
723
|
|
|
672
724
|
const result = {
|
|
673
|
-
path:
|
|
725
|
+
path: path.posix.join("/", relativeDirectory, safeFilename),
|
|
674
726
|
name: filename,
|
|
675
727
|
is_dir: false,
|
|
676
728
|
size: stat.size,
|
|
@@ -678,10 +730,79 @@ export class WorkspaceController {
|
|
|
678
730
|
};
|
|
679
731
|
|
|
680
732
|
return reply.status(200).send({ success: true, data: result });
|
|
681
|
-
} catch (error:
|
|
733
|
+
} catch (error: unknown) {
|
|
734
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
682
735
|
return reply.status(500).send({
|
|
683
736
|
success: false,
|
|
684
|
-
error: `Upload handler error: ${
|
|
737
|
+
error: `Upload handler error: ${message}`,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Browse local filesystem directories for the folder picker.
|
|
743
|
+
*
|
|
744
|
+
* Security constraints:
|
|
745
|
+
* - Restricted to os.homedir()
|
|
746
|
+
* - Blacklists system directories
|
|
747
|
+
* - Returns only directory names, no file contents or hidden files
|
|
748
|
+
*/
|
|
749
|
+
async browseFilesystem(
|
|
750
|
+
request: FastifyRequest<{
|
|
751
|
+
Querystring: { path?: string };
|
|
752
|
+
}>,
|
|
753
|
+
reply: FastifyReply
|
|
754
|
+
) {
|
|
755
|
+
const homeDir = os.homedir();
|
|
756
|
+
const requestedPath = request.query.path || homeDir;
|
|
757
|
+
|
|
758
|
+
const SYSTEM_BLACKLIST = ["/etc", "/sys", "/proc", "/var", "/tmp", "/dev"];
|
|
759
|
+
|
|
760
|
+
try {
|
|
761
|
+
const resolved = await resolvePathWithinRoot(homeDir, requestedPath);
|
|
762
|
+
const normalized = resolved.targetPath;
|
|
763
|
+
|
|
764
|
+
for (const blacklisted of SYSTEM_BLACKLIST) {
|
|
765
|
+
if (isPathWithinRoot(blacklisted, normalized)) {
|
|
766
|
+
return reply.status(403).send({
|
|
767
|
+
success: false,
|
|
768
|
+
error: `Cannot browse system directory: ${blacklisted}`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const entries = await fs.readdir(normalized, { withFileTypes: true });
|
|
774
|
+
const dirEntries = entries
|
|
775
|
+
.filter((entry) => !entry.name.startsWith(".") && entry.isDirectory())
|
|
776
|
+
.map((entry) => ({
|
|
777
|
+
name: entry.name,
|
|
778
|
+
path: path.join(normalized, entry.name),
|
|
779
|
+
isDirectory: true,
|
|
780
|
+
}));
|
|
781
|
+
|
|
782
|
+
const parentPath = path.dirname(normalized);
|
|
783
|
+
const hasParent =
|
|
784
|
+
parentPath !== normalized &&
|
|
785
|
+
isPathWithinRoot(resolved.rootPath, parentPath);
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
success: true,
|
|
789
|
+
data: {
|
|
790
|
+
path: normalized,
|
|
791
|
+
parentPath: hasParent ? parentPath : undefined,
|
|
792
|
+
entries: dirEntries,
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
} catch (error: unknown) {
|
|
796
|
+
if (error instanceof PathOutsideRootError) {
|
|
797
|
+
return reply.status(403).send({
|
|
798
|
+
success: false,
|
|
799
|
+
error: "Browse restricted to home directory",
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
803
|
+
return reply.status(400).send({
|
|
804
|
+
success: false,
|
|
805
|
+
error: `Cannot read directory: ${message}`,
|
|
685
806
|
});
|
|
686
807
|
}
|
|
687
808
|
}
|
|
@@ -749,4 +870,10 @@ export function registerWorkspaceRoutes(app: FastifyInstance) {
|
|
|
749
870
|
"/api/workspaces/:workspaceId/projects/:projectId/viewfile",
|
|
750
871
|
controller.viewFile.bind(controller)
|
|
751
872
|
);
|
|
873
|
+
|
|
874
|
+
// Filesystem browsing for folder picker
|
|
875
|
+
app.get(
|
|
876
|
+
"/api/filesystem/browse",
|
|
877
|
+
controller.browseFilesystem.bind(controller)
|
|
878
|
+
);
|
|
752
879
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
1
2
|
import fastify from "fastify";
|
|
2
3
|
import cors from "@fastify/cors";
|
|
3
4
|
import multipart from "@fastify/multipart";
|
|
@@ -42,8 +43,6 @@ import {
|
|
|
42
43
|
PinoFileOptions,
|
|
43
44
|
McpServerConfigStore,
|
|
44
45
|
} from "@axiom-lattice/protocols";
|
|
45
|
-
|
|
46
|
-
|
|
47
46
|
process.on("unhandledRejection", (reason, promise) => {
|
|
48
47
|
console.error("未处理的Promise拒绝:", reason);
|
|
49
48
|
// 可以在这里进行日志记录或其他处理
|
|
@@ -112,7 +111,7 @@ const getHeaderValue = (
|
|
|
112
111
|
};
|
|
113
112
|
|
|
114
113
|
// Routes that don't require authentication even when AUTH_REQUIRED=true
|
|
115
|
-
const PUBLIC_ROUTES = ["/api/auth/login", "/api/auth/register", "/health"];
|
|
114
|
+
const PUBLIC_ROUTES = ["/api/auth/login", "/api/auth/register", "/health", "/api/stt/models"];
|
|
116
115
|
|
|
117
116
|
// Global auth preHandler: extract user from Authorization header
|
|
118
117
|
// When AUTH_REQUIRED=true, reject unauthenticated requests (except public routes)
|
|
@@ -455,13 +454,6 @@ const start = async (config?: LatticeGatewayConfig) => {
|
|
|
455
454
|
await app.listen({ port: target_port, host: "0.0.0.0" });
|
|
456
455
|
logger.info(`Lattice Gateway is running on port: ${target_port}`);
|
|
457
456
|
|
|
458
|
-
// Initialize AgentLifecycleManager
|
|
459
|
-
try {
|
|
460
|
-
logger.info("AgentLifecycleManager initialized");
|
|
461
|
-
} catch (error) {
|
|
462
|
-
logger.warn("Failed to initialize AgentLifecycleManager", { error });
|
|
463
|
-
}
|
|
464
|
-
|
|
465
457
|
// Configure queue service
|
|
466
458
|
const queueServiceConfig = config?.queueServiceConfig;
|
|
467
459
|
if (queueServiceConfig) {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fastify, { FastifyInstance } from "fastify";
|
|
2
|
+
import type { LocalRuntimeConfig } from "@axiom-lattice/protocols";
|
|
3
|
+
import { registerA2ATestConnectionRoute } from "../a2a-test-connection";
|
|
4
|
+
|
|
5
|
+
const mockStartProvider = jest.fn();
|
|
6
|
+
const mockRemoveState = jest.fn();
|
|
7
|
+
|
|
8
|
+
jest.mock("../../services/local-a2a", () => ({
|
|
9
|
+
localA2AService: {
|
|
10
|
+
startProvider: (...args: unknown[]) => mockStartProvider(...args),
|
|
11
|
+
removeState: (...args: unknown[]) => mockRemoveState(...args),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("A2A test connection route", () => {
|
|
16
|
+
let app: FastifyInstance;
|
|
17
|
+
const originalFetch = global.fetch;
|
|
18
|
+
const mockFetch = jest.fn<(...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>>();
|
|
19
|
+
|
|
20
|
+
const localRuntime: LocalRuntimeConfig = {
|
|
21
|
+
label: "Local Test",
|
|
22
|
+
agentCardUrl: "http://old-host/.well-known/agent-card.json",
|
|
23
|
+
command: {
|
|
24
|
+
executable: "local-a2a",
|
|
25
|
+
args: ["--port", "4301"],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
global.fetch = mockFetch as unknown as typeof fetch;
|
|
32
|
+
app = fastify();
|
|
33
|
+
registerA2ATestConnectionRoute(app);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await app.close();
|
|
38
|
+
global.fetch = originalFetch;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("starts a temporary local runtime before fetching the local agent card", async () => {
|
|
42
|
+
mockStartProvider.mockResolvedValueOnce({
|
|
43
|
+
runtimeId: "test-runtime",
|
|
44
|
+
label: "Local Test",
|
|
45
|
+
status: "running",
|
|
46
|
+
enabled: true,
|
|
47
|
+
});
|
|
48
|
+
mockFetch.mockResolvedValueOnce(
|
|
49
|
+
new Response(JSON.stringify({ name: "Local Agent" }), { status: 200 }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const response = await app.inject({
|
|
53
|
+
method: "POST",
|
|
54
|
+
url: "/api/a2a/test-connection",
|
|
55
|
+
payload: {
|
|
56
|
+
agentCardUrl: "localhost:4301",
|
|
57
|
+
localRuntime,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(response.statusCode).toBe(200);
|
|
62
|
+
const body = JSON.parse(response.body);
|
|
63
|
+
expect(body.data.reachable).toBe(true);
|
|
64
|
+
expect(body.data.agentName).toBe("Local Agent");
|
|
65
|
+
|
|
66
|
+
expect(mockStartProvider).toHaveBeenCalledTimes(1);
|
|
67
|
+
const [runtimeId, startedRuntime] = mockStartProvider.mock.calls[0] as [
|
|
68
|
+
string,
|
|
69
|
+
LocalRuntimeConfig,
|
|
70
|
+
];
|
|
71
|
+
expect(runtimeId).toMatch(/^test-/);
|
|
72
|
+
expect(startedRuntime.agentCardUrl).toBe(
|
|
73
|
+
"http://localhost:4301/.well-known/agent-card.json",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
77
|
+
"http://localhost:4301/.well-known/agent-card.json",
|
|
78
|
+
expect.objectContaining({ headers: {} }),
|
|
79
|
+
);
|
|
80
|
+
expect(mockRemoveState).toHaveBeenCalledWith("test-runtime");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("uses https by default for remote URL tests and does not start a local runtime", async () => {
|
|
84
|
+
mockFetch.mockResolvedValueOnce(
|
|
85
|
+
new Response(JSON.stringify({ name: "Remote Agent" }), { status: 200 }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const response = await app.inject({
|
|
89
|
+
method: "POST",
|
|
90
|
+
url: "/api/a2a/test-connection",
|
|
91
|
+
payload: {
|
|
92
|
+
agentCardUrl: "example.com/a2a",
|
|
93
|
+
apiKey: "secret",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(response.statusCode).toBe(200);
|
|
98
|
+
const body = JSON.parse(response.body);
|
|
99
|
+
expect(body.data.reachable).toBe(true);
|
|
100
|
+
expect(body.data.agentName).toBe("Remote Agent");
|
|
101
|
+
expect(mockStartProvider).not.toHaveBeenCalled();
|
|
102
|
+
expect(mockRemoveState).not.toHaveBeenCalled();
|
|
103
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
104
|
+
"https://example.com/a2a/.well-known/agent-card.json",
|
|
105
|
+
expect.objectContaining({
|
|
106
|
+
headers: { Authorization: "Bearer secret" },
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not fetch the agent card when the local runtime fails to start", async () => {
|
|
112
|
+
mockStartProvider.mockResolvedValueOnce({
|
|
113
|
+
runtimeId: "test-failed",
|
|
114
|
+
label: "Local Test",
|
|
115
|
+
status: "failed",
|
|
116
|
+
enabled: true,
|
|
117
|
+
message: "Server did not become ready",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const response = await app.inject({
|
|
121
|
+
method: "POST",
|
|
122
|
+
url: "/api/a2a/test-connection",
|
|
123
|
+
payload: {
|
|
124
|
+
agentCardUrl: "localhost:4301",
|
|
125
|
+
localRuntime,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(response.statusCode).toBe(200);
|
|
130
|
+
const body = JSON.parse(response.body);
|
|
131
|
+
expect(body.data.reachable).toBe(false);
|
|
132
|
+
expect(body.data.error).toContain("Server did not become ready");
|
|
133
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
134
|
+
expect(mockRemoveState).toHaveBeenCalledWith("test-failed");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it.each([
|
|
138
|
+
["--port with next arg", ["--port", "4302"]],
|
|
139
|
+
["--port inline", ["--port=4302"]],
|
|
140
|
+
["-p with next arg", ["-p", "4302"]],
|
|
141
|
+
["-p inline", ["-p=4302"]],
|
|
142
|
+
])("does not start local runtime when %s mismatches the agent card URL port", async (_label, args) => {
|
|
143
|
+
const response = await app.inject({
|
|
144
|
+
method: "POST",
|
|
145
|
+
url: "/api/a2a/test-connection",
|
|
146
|
+
payload: {
|
|
147
|
+
agentCardUrl: "http://localhost:4301/.well-known/agent-card.json",
|
|
148
|
+
localRuntime: {
|
|
149
|
+
...localRuntime,
|
|
150
|
+
command: {
|
|
151
|
+
executable: "local-a2a",
|
|
152
|
+
args,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(response.statusCode).toBe(200);
|
|
159
|
+
const body = JSON.parse(response.body);
|
|
160
|
+
expect(body.data.reachable).toBe(false);
|
|
161
|
+
expect(body.data.error).toContain("command port (4302) does not match agentCardUrl port (4301)");
|
|
162
|
+
expect(mockStartProvider).not.toHaveBeenCalled();
|
|
163
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns 400 when agentCardUrl is missing", async () => {
|
|
167
|
+
const response = await app.inject({
|
|
168
|
+
method: "POST",
|
|
169
|
+
url: "/api/a2a/test-connection",
|
|
170
|
+
payload: {},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(response.statusCode).toBe(400);
|
|
174
|
+
expect(JSON.parse(response.body).error).toBe("agentCardUrl is required");
|
|
175
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
});
|