create-windy 0.2.23 → 0.2.24
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/dist/cli.js +21 -1
- package/package.json +1 -1
- package/template/.agents/skills/windy-business-module/SKILL.md +43 -0
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +2 -0
- package/template/apps/server/package.json +3 -1
- package/template/apps/server/src/agent/data-scope-routes.test.ts +20 -20
- package/template/apps/server/src/agent/remote-runtime.test.ts +59 -0
- package/template/apps/server/src/agent/remote-runtime.ts +22 -4
- package/template/apps/server/src/agent/routes.test.ts +16 -8
- package/template/apps/server/src/agent/routes.ts +103 -169
- package/template/apps/server/src/agent/run-facade.test.ts +30 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +3 -3
- package/template/apps/server/src/agent/tool-host.test.ts +61 -8
- package/template/apps/server/src/agent/tool-host.ts +17 -73
- package/template/apps/server/src/agent/tool-registry.test.ts +117 -0
- package/template/apps/server/src/agent/tool-registry.ts +318 -0
- package/template/apps/server/src/http-app.test.ts +87 -0
- package/template/apps/server/src/http-app.ts +43 -25
- package/template/apps/server/src/index.ts +14 -2
- package/template/apps/server/src/license/runtime-service.test.ts +6 -3
- package/template/apps/server/src/mcp/adapter.test.ts +195 -0
- package/template/apps/server/src/mcp/adapter.ts +204 -0
- package/template/apps/server/src/mcp/routes.ts +21 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +6 -5
- package/template/apps/server/src/observability/http-observability.test.ts +21 -0
- package/template/apps/server/src/observability/http-observability.ts +1 -0
- package/template/apps/server/src/observability/metrics.ts +5 -2
- package/template/docs/operations/observability.md +9 -1
- package/template/docs/platform/agent-runtime.md +15 -3
- package/template/docs/platform/agent-tools.md +88 -28
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/src/ai-operation-composition.test.ts +30 -0
- package/template/packages/modules/src/ai-operation-composition.ts +13 -0
- package/template/packages/modules/src/composition.test.ts +42 -0
- package/template/packages/modules/src/composition.ts +3 -0
- package/template/packages/modules/src/manifest.ts +20 -1
- package/template/packages/modules/src/system-agent-tools.ts +53 -1
- package/template/packages/modules/src/system-api-permissions.ts +5 -0
- package/template/packages/modules/src/system-module-catalog.ts +1 -0
- package/template/packages/modules/src/system-modules.test.ts +32 -0
- package/template/packages/modules/src/system-modules.ts +9 -1
- package/template/packages/modules/src/tool-composition.ts +86 -0
- package/template/packages/server-sdk/index.ts +1 -0
- package/template/packages/server-sdk/package.json +1 -0
- package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -7
- package/template/packages/server-sdk/src/tool-registration.ts +6 -7
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/src/json.ts +8 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModuleToolHandlers } from "./tool-registry.js";
|
|
3
|
+
import {
|
|
4
|
+
createFoundationSnapshot,
|
|
5
|
+
loadFoundationModules,
|
|
6
|
+
} from "../foundation.js";
|
|
7
|
+
import { createServerRuntime } from "../runtime.js";
|
|
8
|
+
import { createSystemRepositories } from "../system/seed.js";
|
|
9
|
+
import { createSystemToolHandlers } from "./routes.js";
|
|
10
|
+
import { GovernedToolRegistry } from "./tool-registry.js";
|
|
11
|
+
|
|
12
|
+
describe("Governed Tool Registry", () => {
|
|
13
|
+
test("输入 Schema 在 Handler 前校验并去除非契约字段", async () => {
|
|
14
|
+
const fixture = await createFixture();
|
|
15
|
+
await expect(
|
|
16
|
+
fixture.registry.execute(
|
|
17
|
+
"system.features.list",
|
|
18
|
+
{ page: 0 },
|
|
19
|
+
fixture.context,
|
|
20
|
+
),
|
|
21
|
+
).rejects.toMatchObject({ code: "TOOL_INPUT_INVALID" });
|
|
22
|
+
|
|
23
|
+
let received: unknown;
|
|
24
|
+
const handlers = new Map(fixture.handlers);
|
|
25
|
+
handlers.set("system.features.list", {
|
|
26
|
+
scopeMode: "platform",
|
|
27
|
+
execute: (input) => {
|
|
28
|
+
received = input;
|
|
29
|
+
return { items: [], total: 0, page: 1, pageSize: 10 };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const registry = new GovernedToolRegistry(
|
|
33
|
+
loadFoundationModules(),
|
|
34
|
+
handlers,
|
|
35
|
+
fixture.runtime,
|
|
36
|
+
);
|
|
37
|
+
await registry.execute(
|
|
38
|
+
"system.features.list",
|
|
39
|
+
{ page: 1, permissionKeys: ["system.admin"] },
|
|
40
|
+
fixture.context,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(received).toEqual({ page: 1, pageSize: 10 });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("输出漂移和 Handler scopeMode 漂移均 fail closed", async () => {
|
|
47
|
+
const fixture = await createFixture();
|
|
48
|
+
const invalidOutput = new Map(fixture.handlers);
|
|
49
|
+
invalidOutput.set("system.features.list", {
|
|
50
|
+
scopeMode: "platform",
|
|
51
|
+
execute: () => ({
|
|
52
|
+
items: [],
|
|
53
|
+
total: "wrong",
|
|
54
|
+
page: 1,
|
|
55
|
+
pageSize: 10,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
const registry = new GovernedToolRegistry(
|
|
59
|
+
loadFoundationModules(),
|
|
60
|
+
invalidOutput,
|
|
61
|
+
fixture.runtime,
|
|
62
|
+
);
|
|
63
|
+
await expect(
|
|
64
|
+
registry.execute("system.features.list", {}, fixture.context),
|
|
65
|
+
).rejects.toMatchObject({ code: "TOOL_OUTPUT_INVALID" });
|
|
66
|
+
|
|
67
|
+
const mismatchedScope = new Map(fixture.handlers);
|
|
68
|
+
mismatchedScope.set("system.users.list", {
|
|
69
|
+
...mismatchedScope.get("system.users.list")!,
|
|
70
|
+
scopeMode: "platform",
|
|
71
|
+
});
|
|
72
|
+
expect(
|
|
73
|
+
() =>
|
|
74
|
+
new GovernedToolRegistry(
|
|
75
|
+
loadFoundationModules(),
|
|
76
|
+
mismatchedScope,
|
|
77
|
+
fixture.runtime,
|
|
78
|
+
),
|
|
79
|
+
).toThrow("scopeMode 与 Manifest 不一致");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("审计只记录受控字节计数,不记录 Tool 输入原值", async () => {
|
|
83
|
+
const fixture = await createFixture();
|
|
84
|
+
await fixture.registry.execute(
|
|
85
|
+
"system.features.list",
|
|
86
|
+
{ page: 1 },
|
|
87
|
+
fixture.context,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const event = fixture.runtime.auditSink.list().at(-1);
|
|
91
|
+
expect(event?.action).toBe("agent.tool.execute");
|
|
92
|
+
expect(event?.metadata).toMatchObject({
|
|
93
|
+
inputBytes: expect.any(Number),
|
|
94
|
+
outputBytes: expect.any(Number),
|
|
95
|
+
});
|
|
96
|
+
expect(JSON.stringify(event)).not.toContain("permissionKeys");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function createFixture() {
|
|
101
|
+
const runtime = createServerRuntime({ WINDY_DEV_ADMIN_TOKEN: "token_1" });
|
|
102
|
+
const handlers: ModuleToolHandlers = createSystemToolHandlers(
|
|
103
|
+
createSystemRepositories(createFoundationSnapshot()),
|
|
104
|
+
runtime,
|
|
105
|
+
);
|
|
106
|
+
const registry = new GovernedToolRegistry(
|
|
107
|
+
loadFoundationModules(),
|
|
108
|
+
handlers,
|
|
109
|
+
runtime,
|
|
110
|
+
);
|
|
111
|
+
const context = await runtime.createGuardContext(
|
|
112
|
+
new Request("http://localhost/api/agent/tools", {
|
|
113
|
+
headers: { authorization: "Bearer token_1" },
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
return { context, handlers, registry, runtime };
|
|
117
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModuleJsonSchemaDefinition,
|
|
3
|
+
ModuleManifest,
|
|
4
|
+
ModuleToolDefinition,
|
|
5
|
+
} from "@southwind-ai/modules";
|
|
6
|
+
import type {
|
|
7
|
+
ModuleToolInput,
|
|
8
|
+
ModuleToolResult,
|
|
9
|
+
} from "@southwind-ai/server-sdk";
|
|
10
|
+
import type { AuditEvent, FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
11
|
+
import Ajv, { type ValidateFunction } from "ajv";
|
|
12
|
+
import { evaluateAccessGuards } from "../access-guards.js";
|
|
13
|
+
import { isServerScopeContext } from "../data-access/context.js";
|
|
14
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
15
|
+
import { findFeature } from "../route-guards.js";
|
|
16
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
17
|
+
|
|
18
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
19
|
+
|
|
20
|
+
export interface PlatformToolExecutionContext {
|
|
21
|
+
readonly guardContext: RequestGuardContext;
|
|
22
|
+
readonly signal: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PlatformToolHandlerRegistration {
|
|
26
|
+
readonly scopeMode: "platform" | "request";
|
|
27
|
+
execute(
|
|
28
|
+
input: ModuleToolInput,
|
|
29
|
+
context: PlatformToolExecutionContext,
|
|
30
|
+
): Promise<ModuleToolResult> | ModuleToolResult;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ModuleToolHandlers = ReadonlyMap<
|
|
34
|
+
string,
|
|
35
|
+
PlatformToolHandlerRegistration
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
export interface RegisteredTool extends ModuleToolDefinition {
|
|
39
|
+
readonly module: string;
|
|
40
|
+
readonly feature: FeatureFlagDefinition;
|
|
41
|
+
readonly inputSchema: ModuleJsonSchemaDefinition["schema"];
|
|
42
|
+
readonly outputSchema: ModuleJsonSchemaDefinition["schema"];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CompiledTool {
|
|
46
|
+
readonly definition: RegisteredTool;
|
|
47
|
+
readonly handler: PlatformToolHandlerRegistration;
|
|
48
|
+
readonly validateInput: ValidateFunction<unknown>;
|
|
49
|
+
readonly validateOutput: ValidateFunction<unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class GovernedToolRegistry {
|
|
53
|
+
readonly #tools: ReadonlyMap<string, CompiledTool>;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
modules: readonly ModuleManifest[],
|
|
57
|
+
handlers: ModuleToolHandlers,
|
|
58
|
+
private readonly runtime: ServerRuntime,
|
|
59
|
+
) {
|
|
60
|
+
const definitions = collectTools(modules);
|
|
61
|
+
validateHandlerContract(definitions, handlers);
|
|
62
|
+
const inputAjv = new Ajv({
|
|
63
|
+
allErrors: true,
|
|
64
|
+
coerceTypes: true,
|
|
65
|
+
removeAdditional: "all",
|
|
66
|
+
strict: true,
|
|
67
|
+
useDefaults: true,
|
|
68
|
+
});
|
|
69
|
+
const outputAjv = new Ajv({ allErrors: true, strict: true });
|
|
70
|
+
this.#tools = new Map(
|
|
71
|
+
definitions.map((definition) => {
|
|
72
|
+
const handler = handlers.get(definition.key)!;
|
|
73
|
+
if (handler.scopeMode !== definition.scopeMode) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Agent Tool ${definition.key} 的 Handler scopeMode 与 Manifest 不一致`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return [
|
|
79
|
+
definition.key,
|
|
80
|
+
{
|
|
81
|
+
definition,
|
|
82
|
+
handler,
|
|
83
|
+
validateInput: inputAjv.compile(definition.inputSchema),
|
|
84
|
+
validateOutput: outputAjv.compile(definition.outputSchema),
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
definitions(): readonly RegisteredTool[] {
|
|
92
|
+
return [...this.#tools.values()].map(({ definition }) => definition);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async allowed(key: string, context: RequestGuardContext): Promise<boolean> {
|
|
96
|
+
const tool = this.#tools.get(key);
|
|
97
|
+
if (!tool) return false;
|
|
98
|
+
return (
|
|
99
|
+
await evaluateAccessGuards(
|
|
100
|
+
this.runtime,
|
|
101
|
+
context,
|
|
102
|
+
{
|
|
103
|
+
permission: { permissionKey: tool.definition.permissionKey },
|
|
104
|
+
feature: tool.definition.feature,
|
|
105
|
+
},
|
|
106
|
+
{ record: false },
|
|
107
|
+
)
|
|
108
|
+
).decision.allowed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async execute(
|
|
112
|
+
key: string,
|
|
113
|
+
rawInput: ModuleToolInput,
|
|
114
|
+
context: RequestGuardContext,
|
|
115
|
+
): Promise<ModuleToolResult> {
|
|
116
|
+
const tool = this.#tools.get(key);
|
|
117
|
+
if (!tool) {
|
|
118
|
+
throw new ToolExecutionError("TOOL_NOT_FOUND", "Agent Tool 不存在");
|
|
119
|
+
}
|
|
120
|
+
const access = await evaluateAccessGuards(this.runtime, context, {
|
|
121
|
+
permission: { permissionKey: tool.definition.permissionKey },
|
|
122
|
+
feature: tool.definition.feature,
|
|
123
|
+
});
|
|
124
|
+
if (!access.decision.allowed) {
|
|
125
|
+
throw new ToolExecutionError(
|
|
126
|
+
"TOOL_FORBIDDEN",
|
|
127
|
+
"Agent Tool 当前无权执行",
|
|
128
|
+
access.decision.reason,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
tool.definition.scopeMode === "request" &&
|
|
133
|
+
!isServerScopeContext(context.actor.scopeContext)
|
|
134
|
+
) {
|
|
135
|
+
throw new ToolExecutionError(
|
|
136
|
+
"TOOL_FORBIDDEN",
|
|
137
|
+
"Agent Tool 数据范围无效",
|
|
138
|
+
"data-scope-denied",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const input = cloneJsonObject(rawInput);
|
|
142
|
+
enforceSize(
|
|
143
|
+
input,
|
|
144
|
+
tool.definition.limits.maxInputBytes,
|
|
145
|
+
"TOOL_INPUT_INVALID",
|
|
146
|
+
);
|
|
147
|
+
if (!tool.validateInput(input)) {
|
|
148
|
+
throw new ToolExecutionError(
|
|
149
|
+
"TOOL_INPUT_INVALID",
|
|
150
|
+
validationMessage(tool.validateInput),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const result = await executeWithTimeout(
|
|
154
|
+
tool,
|
|
155
|
+
input,
|
|
156
|
+
context,
|
|
157
|
+
tool.definition.limits.maxDurationSeconds,
|
|
158
|
+
);
|
|
159
|
+
enforceSize(
|
|
160
|
+
result,
|
|
161
|
+
tool.definition.limits.maxOutputBytes,
|
|
162
|
+
"TOOL_OUTPUT_INVALID",
|
|
163
|
+
);
|
|
164
|
+
if (!tool.validateOutput(result)) {
|
|
165
|
+
throw new ToolExecutionError(
|
|
166
|
+
"TOOL_OUTPUT_INVALID",
|
|
167
|
+
validationMessage(tool.validateOutput),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
this.runtime.auditSink.recordEvent(
|
|
171
|
+
toolEvent(context, tool.definition, input, result),
|
|
172
|
+
);
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type ToolExecutionErrorCode =
|
|
178
|
+
| "TOOL_NOT_FOUND"
|
|
179
|
+
| "TOOL_FORBIDDEN"
|
|
180
|
+
| "TOOL_INPUT_INVALID"
|
|
181
|
+
| "TOOL_OUTPUT_INVALID"
|
|
182
|
+
| "TOOL_TIMEOUT";
|
|
183
|
+
|
|
184
|
+
export class ToolExecutionError extends Error {
|
|
185
|
+
constructor(
|
|
186
|
+
readonly code: ToolExecutionErrorCode,
|
|
187
|
+
message: string,
|
|
188
|
+
readonly reason?: string,
|
|
189
|
+
) {
|
|
190
|
+
super(message);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function collectTools(modules: readonly ModuleManifest[]): RegisteredTool[] {
|
|
195
|
+
const features = modules.flatMap(({ features }) => features);
|
|
196
|
+
const schemas = new Map(
|
|
197
|
+
modules.flatMap((module) =>
|
|
198
|
+
(module.jsonSchemas ?? []).map((schema) => [schema.key, schema] as const),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
return modules.flatMap((module) =>
|
|
202
|
+
module.tools.map((tool) => ({
|
|
203
|
+
...tool,
|
|
204
|
+
module: module.name,
|
|
205
|
+
feature: required(
|
|
206
|
+
findFeature(features, tool.featureKey),
|
|
207
|
+
`Agent Tool ${tool.key} 缺少 Feature`,
|
|
208
|
+
),
|
|
209
|
+
inputSchema: required(
|
|
210
|
+
schemas.get(tool.inputSchemaKey),
|
|
211
|
+
`Agent Tool ${tool.key} 缺少输入 Schema`,
|
|
212
|
+
).schema,
|
|
213
|
+
outputSchema: required(
|
|
214
|
+
schemas.get(tool.outputSchemaKey),
|
|
215
|
+
`Agent Tool ${tool.key} 缺少输出 Schema`,
|
|
216
|
+
).schema,
|
|
217
|
+
})),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function validateHandlerContract(
|
|
222
|
+
tools: readonly RegisteredTool[],
|
|
223
|
+
handlers: ModuleToolHandlers,
|
|
224
|
+
): void {
|
|
225
|
+
const declared = new Set(tools.map(({ key }) => key));
|
|
226
|
+
for (const key of declared) {
|
|
227
|
+
if (!handlers.has(key)) throw new Error(`Agent Tool ${key} 缺少 Handler`);
|
|
228
|
+
}
|
|
229
|
+
for (const key of handlers.keys()) {
|
|
230
|
+
if (!declared.has(key)) {
|
|
231
|
+
throw new Error(`Agent Tool Handler 未声明:${key}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function executeWithTimeout(
|
|
237
|
+
tool: CompiledTool,
|
|
238
|
+
input: ModuleToolInput,
|
|
239
|
+
context: RequestGuardContext,
|
|
240
|
+
seconds: number,
|
|
241
|
+
): Promise<ModuleToolResult> {
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
244
|
+
const expired = new Promise<never>((_, reject) => {
|
|
245
|
+
timeout = setTimeout(() => {
|
|
246
|
+
controller.abort();
|
|
247
|
+
reject(new ToolExecutionError("TOOL_TIMEOUT", "Agent Tool 执行超时"));
|
|
248
|
+
}, seconds * 1_000);
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
return await Promise.race([
|
|
252
|
+
tool.handler.execute(input, {
|
|
253
|
+
guardContext: context,
|
|
254
|
+
signal: controller.signal,
|
|
255
|
+
}),
|
|
256
|
+
expired,
|
|
257
|
+
]);
|
|
258
|
+
} finally {
|
|
259
|
+
if (timeout) clearTimeout(timeout);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cloneJsonObject(value: ModuleToolInput): ModuleToolInput {
|
|
264
|
+
return structuredClone(value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function enforceSize(
|
|
268
|
+
value: ModuleToolInput | ModuleToolResult,
|
|
269
|
+
maximum: number,
|
|
270
|
+
code: "TOOL_INPUT_INVALID" | "TOOL_OUTPUT_INVALID",
|
|
271
|
+
): void {
|
|
272
|
+
const bytes = jsonBytes(value);
|
|
273
|
+
if (bytes > maximum) {
|
|
274
|
+
throw new ToolExecutionError(code, `Agent Tool JSON 超过 ${maximum} 字节`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validationMessage(validate: ValidateFunction<unknown>): string {
|
|
279
|
+
const detail = validate.errors
|
|
280
|
+
?.map(({ instancePath, message }) => `${instancePath || "/"} ${message}`)
|
|
281
|
+
.join(";");
|
|
282
|
+
return `Agent Tool JSON 不符合 Schema${detail ? `:${detail}` : ""}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function toolEvent(
|
|
286
|
+
context: RequestGuardContext,
|
|
287
|
+
tool: RegisteredTool,
|
|
288
|
+
input: ModuleToolInput,
|
|
289
|
+
result: ModuleToolResult,
|
|
290
|
+
): AuditEvent {
|
|
291
|
+
return {
|
|
292
|
+
id: crypto.randomUUID(),
|
|
293
|
+
action: tool.auditAction,
|
|
294
|
+
outcome: "success",
|
|
295
|
+
actor: {
|
|
296
|
+
id: context.actor.id,
|
|
297
|
+
type: context.actor.type === "anonymous" ? "user" : context.actor.type,
|
|
298
|
+
name: context.actor.name,
|
|
299
|
+
},
|
|
300
|
+
target: { type: "agent-tool", id: tool.key, label: tool.label },
|
|
301
|
+
module: tool.module,
|
|
302
|
+
requestId: context.requestId,
|
|
303
|
+
metadata: {
|
|
304
|
+
inputBytes: jsonBytes(input),
|
|
305
|
+
outputBytes: jsonBytes(result),
|
|
306
|
+
},
|
|
307
|
+
occurredAt: new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function required<T>(value: T | undefined, message: string): T {
|
|
312
|
+
if (value === undefined) throw new Error(message);
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function jsonBytes(value: ModuleToolInput | ModuleToolResult): number {
|
|
317
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
318
|
+
}
|
|
@@ -4,6 +4,92 @@ import { createPlatformObservability } from "./observability/bootstrap.js";
|
|
|
4
4
|
import { createServerRuntime } from "./runtime.js";
|
|
5
5
|
|
|
6
6
|
describe("platform HTTP app observability", () => {
|
|
7
|
+
test("未匹配路由不会因缺少请求观测而崩溃", async () => {
|
|
8
|
+
const records: Record<string, unknown>[] = [];
|
|
9
|
+
const runtime = createServerRuntime({ NODE_ENV: "test" });
|
|
10
|
+
const observability = createPlatformObservability({
|
|
11
|
+
databaseRequired: false,
|
|
12
|
+
auditStatus: () => runtime.auditSink.status(),
|
|
13
|
+
logger: {
|
|
14
|
+
info(fields) {
|
|
15
|
+
records.push(fields);
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const app = createPlatformHttpApp(runtime, observability).get(
|
|
20
|
+
"/probe",
|
|
21
|
+
() => ({ ok: true }),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const outsidePrefix = await app.handle(
|
|
25
|
+
new Request("http://localhost/health/live"),
|
|
26
|
+
);
|
|
27
|
+
const insidePrefix = await app.handle(
|
|
28
|
+
new Request("http://localhost/api/missing"),
|
|
29
|
+
);
|
|
30
|
+
const subsequent = await app.handle(
|
|
31
|
+
new Request("http://localhost/api/probe"),
|
|
32
|
+
);
|
|
33
|
+
await Bun.sleep(0);
|
|
34
|
+
|
|
35
|
+
expect(outsidePrefix.status).toBe(404);
|
|
36
|
+
expect(insidePrefix.status).toBe(404);
|
|
37
|
+
expect(subsequent.status).toBe(200);
|
|
38
|
+
expect(outsidePrefix.headers.get("x-request-id")).toMatch(
|
|
39
|
+
/^[A-Za-z0-9._-]{1,128}$/,
|
|
40
|
+
);
|
|
41
|
+
expect(
|
|
42
|
+
records.filter(({ event }) => event === "http.request.completed"),
|
|
43
|
+
).toHaveLength(3);
|
|
44
|
+
expect(observability.metrics.render()).toContain(
|
|
45
|
+
'windy_http_requests_total{method="GET",status_class="4xx"} 2',
|
|
46
|
+
);
|
|
47
|
+
expect(observability.metrics.render()).toContain(
|
|
48
|
+
'windy_http_requests_total{method="GET",status_class="2xx"} 1',
|
|
49
|
+
);
|
|
50
|
+
expect(observability.metrics.render()).toContain(
|
|
51
|
+
"windy_http_requests_active 0",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("handler 异常只完成一次请求观测", async () => {
|
|
56
|
+
const records: Record<string, unknown>[] = [];
|
|
57
|
+
const runtime = createServerRuntime({ NODE_ENV: "test" });
|
|
58
|
+
const observability = createPlatformObservability({
|
|
59
|
+
databaseRequired: false,
|
|
60
|
+
auditStatus: () => runtime.auditSink.status(),
|
|
61
|
+
logger: {
|
|
62
|
+
info(fields) {
|
|
63
|
+
records.push(fields);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const app = createPlatformHttpApp(runtime, observability)
|
|
68
|
+
.onError(({ set }) => {
|
|
69
|
+
set.status = 500;
|
|
70
|
+
return { error: "INTERNAL_ERROR" };
|
|
71
|
+
})
|
|
72
|
+
.get("/failure", () => {
|
|
73
|
+
throw new Error("failure");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const response = await app.handle(
|
|
77
|
+
new Request("http://localhost/api/failure"),
|
|
78
|
+
);
|
|
79
|
+
await Bun.sleep(0);
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(500);
|
|
82
|
+
expect(
|
|
83
|
+
records.filter(({ event }) => event === "http.request.completed"),
|
|
84
|
+
).toHaveLength(1);
|
|
85
|
+
expect(observability.metrics.render()).toContain(
|
|
86
|
+
'windy_http_requests_total{method="GET",status_class="5xx"} 1',
|
|
87
|
+
);
|
|
88
|
+
expect(observability.metrics.render()).toContain(
|
|
89
|
+
"windy_http_requests_active 0",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
7
93
|
test("校验并回传 request id,日志与指标不包含 URL 和查询值", async () => {
|
|
8
94
|
const records: Record<string, unknown>[] = [];
|
|
9
95
|
const runtime = createServerRuntime({ NODE_ENV: "test" });
|
|
@@ -25,6 +111,7 @@ describe("platform HTTP app observability", () => {
|
|
|
25
111
|
headers: { "x-request-id": "invalid request id" },
|
|
26
112
|
}),
|
|
27
113
|
);
|
|
114
|
+
await Bun.sleep(0);
|
|
28
115
|
|
|
29
116
|
expect(response.status).toBe(200);
|
|
30
117
|
expect(response.headers.get("x-request-id")).toMatch(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { swagger } from "@elysiajs/swagger";
|
|
2
2
|
import { Elysia } from "elysia";
|
|
3
|
+
import type { RequestGuardContext } from "./guards.js";
|
|
3
4
|
import type { createPlatformObservability } from "./observability/bootstrap.js";
|
|
5
|
+
import type { RequestObservation } from "./observability/metrics.js";
|
|
4
6
|
// @windy-module system.license begin
|
|
5
7
|
import { restrictedRequestResponse } from "./request-restriction.js";
|
|
6
8
|
// @windy-module system.license end
|
|
@@ -8,38 +10,18 @@ import type { createServerRuntime } from "./runtime.js";
|
|
|
8
10
|
|
|
9
11
|
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
10
12
|
type PlatformObservability = ReturnType<typeof createPlatformObservability>;
|
|
13
|
+
interface HttpRequestState {
|
|
14
|
+
guardContext: RequestGuardContext;
|
|
15
|
+
requestObservation: RequestObservation;
|
|
16
|
+
}
|
|
11
17
|
|
|
12
18
|
export function createPlatformHttpApp(
|
|
13
19
|
runtime: ServerRuntime,
|
|
14
20
|
observability: PlatformObservability,
|
|
15
21
|
) {
|
|
22
|
+
const requestStates = new WeakMap<Request, HttpRequestState>();
|
|
16
23
|
return (
|
|
17
24
|
new Elysia({ prefix: "/api" })
|
|
18
|
-
.derive(async ({ request, server }) => ({
|
|
19
|
-
guardContext: await runtime.createGuardContext(request, {
|
|
20
|
-
clientIp: server?.requestIP(request)?.address,
|
|
21
|
-
}),
|
|
22
|
-
requestObservation: observability.http.begin(request.method),
|
|
23
|
-
}))
|
|
24
|
-
// @windy-module system.license begin
|
|
25
|
-
.onBeforeHandle(({ request, guardContext }) =>
|
|
26
|
-
restrictedRequestResponse(runtime, guardContext, request),
|
|
27
|
-
)
|
|
28
|
-
// @windy-module system.license end
|
|
29
|
-
.onAfterHandle(({ guardContext, set }) => {
|
|
30
|
-
set.headers["x-request-id"] = guardContext.requestId;
|
|
31
|
-
})
|
|
32
|
-
.onAfterResponse(
|
|
33
|
-
({ request, guardContext, requestObservation, responseValue, set }) => {
|
|
34
|
-
observability.http.complete({
|
|
35
|
-
request,
|
|
36
|
-
guardContext,
|
|
37
|
-
observation: requestObservation,
|
|
38
|
-
responseValue,
|
|
39
|
-
status: set.status,
|
|
40
|
-
});
|
|
41
|
-
},
|
|
42
|
-
)
|
|
43
25
|
.use(
|
|
44
26
|
swagger({
|
|
45
27
|
documentation: {
|
|
@@ -47,5 +29,41 @@ export function createPlatformHttpApp(
|
|
|
47
29
|
},
|
|
48
30
|
}),
|
|
49
31
|
)
|
|
32
|
+
.onRequest(async ({ request, server, set }) => {
|
|
33
|
+
if (requestStates.has(request)) return;
|
|
34
|
+
const guardContext = await runtime.createGuardContext(request, {
|
|
35
|
+
clientIp: server?.requestIP(request)?.address,
|
|
36
|
+
});
|
|
37
|
+
requestStates.set(request, {
|
|
38
|
+
guardContext,
|
|
39
|
+
requestObservation: observability.http.begin(request.method),
|
|
40
|
+
});
|
|
41
|
+
set.headers["x-request-id"] = guardContext.requestId;
|
|
42
|
+
})
|
|
43
|
+
.derive(({ request }) => {
|
|
44
|
+
const state = requestStates.get(request);
|
|
45
|
+
if (!state) throw new Error("HTTP_REQUEST_STATE_UNAVAILABLE");
|
|
46
|
+
return {
|
|
47
|
+
guardContext: state.guardContext,
|
|
48
|
+
requestObservation: state.requestObservation,
|
|
49
|
+
};
|
|
50
|
+
})
|
|
51
|
+
// @windy-module system.license begin
|
|
52
|
+
.onBeforeHandle(({ request, guardContext }) =>
|
|
53
|
+
restrictedRequestResponse(runtime, guardContext, request),
|
|
54
|
+
)
|
|
55
|
+
// @windy-module system.license end
|
|
56
|
+
.onAfterResponse(({ request, responseValue, set }) => {
|
|
57
|
+
const state = requestStates.get(request);
|
|
58
|
+
if (!state) return;
|
|
59
|
+
requestStates.delete(request);
|
|
60
|
+
observability.http.complete({
|
|
61
|
+
request,
|
|
62
|
+
guardContext: state.guardContext,
|
|
63
|
+
observation: state.requestObservation,
|
|
64
|
+
responseValue,
|
|
65
|
+
status: set.status,
|
|
66
|
+
});
|
|
67
|
+
})
|
|
50
68
|
);
|
|
51
69
|
}
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
registerAgentRoutes,
|
|
23
23
|
} from "./agent/routes.js";
|
|
24
24
|
import { registerAgentPlatformRuntime } from "./agent/runtime-bootstrap.js";
|
|
25
|
+
import { GovernedToolRegistry } from "./agent/tool-registry.js";
|
|
26
|
+
import { PlatformMcpAdapter } from "./mcp/adapter.js";
|
|
27
|
+
import { registerMcpRoutes } from "./mcp/routes.js";
|
|
25
28
|
// @windy-module system.agent end
|
|
26
29
|
import { registerAuthRoutes, registerAuthSessionRoute } from "./auth/routes.js";
|
|
27
30
|
import { createAuthRuntime } from "./auth/runtime.js";
|
|
@@ -292,14 +295,23 @@ const toolHandlers = new Map([
|
|
|
292
295
|
...createSystemToolHandlers(systemRepositories, runtime),
|
|
293
296
|
...createModuleToolHandlerMap(moduleHosts),
|
|
294
297
|
]);
|
|
295
|
-
|
|
298
|
+
const toolRegistry = new GovernedToolRegistry(
|
|
299
|
+
foundationModules,
|
|
300
|
+
toolHandlers,
|
|
301
|
+
runtime,
|
|
302
|
+
);
|
|
303
|
+
registerAgentRoutes(app, toolRegistry);
|
|
304
|
+
registerMcpRoutes(
|
|
305
|
+
app,
|
|
306
|
+
new PlatformMcpAdapter(toolRegistry, runtime, foundationSnapshot.features),
|
|
307
|
+
);
|
|
296
308
|
registerAgentPlatformRuntime({
|
|
297
309
|
app,
|
|
298
310
|
platformConfig,
|
|
299
311
|
modules: foundationModules,
|
|
300
312
|
hosts: moduleHosts,
|
|
301
313
|
runtime,
|
|
302
|
-
|
|
314
|
+
toolRegistry,
|
|
303
315
|
});
|
|
304
316
|
// @windy-module system.agent end
|
|
305
317
|
// @windy-module system.scheduler begin
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
createSystemToolHandlers,
|
|
17
17
|
registerAgentRoutes,
|
|
18
18
|
} from "../agent/routes.js";
|
|
19
|
+
import { GovernedToolRegistry } from "../agent/tool-registry.js";
|
|
19
20
|
import { runGuardedBackgroundTask } from "../background/task-guard.js";
|
|
20
21
|
import {
|
|
21
22
|
createFoundationSnapshot,
|
|
@@ -264,9 +265,11 @@ function createFixture(
|
|
|
264
265
|
registerSystemRoutes(app, repositories, runtime, snapshot.features);
|
|
265
266
|
registerAgentRoutes(
|
|
266
267
|
app,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
new GovernedToolRegistry(
|
|
269
|
+
loadFoundationModules(),
|
|
270
|
+
createSystemToolHandlers(repositories, runtime),
|
|
271
|
+
runtime,
|
|
272
|
+
),
|
|
270
273
|
);
|
|
271
274
|
return { app, runtime, licenseRuntime };
|
|
272
275
|
}
|