create-windy 0.6.9 → 0.6.10

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.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +860 -305
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +3 -3
  5. package/template/README.md +4 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +2 -0
  7. package/template/apps/agent-server/tests/test_multimodal.py +30 -0
  8. package/template/apps/agent-server/tests/test_postgres_store.py +1 -0
  9. package/template/apps/agent-server/tests/test_sqlite_store.py +1 -0
  10. package/template/apps/server/src/agent/remote-runtime.test.ts +1 -0
  11. package/template/apps/server/src/agent/run-audit.ts +1 -0
  12. package/template/apps/server/src/agent/run-contracts.ts +1 -0
  13. package/template/apps/server/src/agent/run-facade.ts +2 -2
  14. package/template/apps/server/src/agent/run-operation-registry.ts +7 -0
  15. package/template/apps/server/src/agent/run-output-schema.ts +15 -0
  16. package/template/apps/server/src/agent/run-release.ts +11 -0
  17. package/template/apps/server/src/agent/supervisor-output-schema.test.ts +95 -0
  18. package/template/apps/server/src/agent/vision-input.ts +37 -2
  19. package/template/apps/server/src/license/pairing-http.ts +138 -0
  20. package/template/apps/server/src/license/pairing-routes.test.ts +282 -0
  21. package/template/apps/server/src/license/pairing-routes.ts +257 -0
  22. package/template/apps/server/src/license/pairing-service.test.ts +320 -0
  23. package/template/apps/server/src/license/pairing-service.ts +321 -0
  24. package/template/apps/server/src/license/restriction.test.ts +11 -0
  25. package/template/apps/server/src/license/restriction.ts +5 -0
  26. package/template/apps/server/src/license/routes.ts +8 -1
  27. package/template/apps/server/src/license/runtime-service.ts +20 -0
  28. package/template/apps/web/package.json +6 -4
  29. package/template/apps/web/src/components/license/LicensePairingCard.vue +177 -0
  30. package/template/apps/web/src/components/license/LicensePairingCard.webtest.ts +113 -0
  31. package/template/apps/web/src/composables/useLicensePairing.ts +168 -0
  32. package/template/apps/web/src/composables/useLicenseRuntime.ts +11 -0
  33. package/template/apps/web/src/pages/license/LicensePage.vue +8 -0
  34. package/template/apps/web/src/services/license-api.ts +60 -0
  35. package/template/apps/web/src/style.webtest.ts +5 -6
  36. package/template/docs/architecture/ai-runtime.md +5 -0
  37. package/template/docs/license/license-pairing-activation.md +67 -0
  38. package/template/docs/operations/incremental-delivery.md +6 -6
  39. package/template/docs/platform/agent-supervisor-output-contract.md +80 -0
  40. package/template/packages/ai-client/package.json +1 -1
  41. package/template/packages/ai-client/src/agent/contracts/final-answer.test.ts +67 -0
  42. package/template/packages/ai-client/src/agent/contracts/final-answer.ts +53 -0
  43. package/template/packages/ai-client/src/agent/contracts/index.ts +1 -0
  44. package/template/packages/modules/index.ts +1 -0
  45. package/template/packages/modules/package.json +1 -1
  46. package/template/packages/modules/src/system-admin-ui-catalog.ts +5 -1
  47. package/template/packages/modules/src/system-agent-supervisor.test.ts +48 -0
  48. package/template/packages/modules/src/system-agent-supervisor.ts +101 -0
  49. package/template/packages/modules/src/system-api-control-plane.ts +37 -0
  50. package/template/packages/modules/src/system-license-pairing.test.ts +44 -0
  51. package/template/packages/modules/src/system-module-catalog.ts +8 -18
  52. package/template/packages/modules/src/system-module-menu-selection.ts +15 -0
  53. package/template/packages/modules/src/system-module-schema-catalog.ts +14 -0
  54. package/template/packages/modules/src/system-modules.ts +2 -0
  55. package/template/packages/server-sdk/index.ts +5 -0
  56. package/template/packages/server-sdk/package.json +3 -1
  57. package/template/packages/server-sdk/src/ai-operation-registration.ts +4 -0
  58. package/template/packages/server-sdk/src/system-agent-supervisor.test.ts +87 -0
  59. package/template/packages/server-sdk/src/system-agent-supervisor.ts +89 -0
@@ -0,0 +1,282 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Elysia } from "elysia";
3
+ import type { StaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
4
+ import { registerLicensePairingRoutes } from "./pairing-routes.js";
5
+ import type { LicensePairingService } from "./pairing-service.js";
6
+ import type { PlatformLicenseRuntime } from "./runtime-service.js";
7
+
8
+ describe("License 配对 HTTP 边界", () => {
9
+ test("创建会话要求认证且必须通过 Manifest authorizer", async () => {
10
+ const paths: string[] = [];
11
+ const service = mockService();
12
+ const app = createApp(service, {
13
+ authorize: async (_context, _method, path) => {
14
+ paths.push(path);
15
+ return Response.json({ error: "FORBIDDEN" }, { status: 403 });
16
+ },
17
+ });
18
+
19
+ const anonymous = await app.handle(
20
+ request("/api/license/runtime/pairing/sessions", "POST"),
21
+ );
22
+ expect(anonymous.status).toBe(401);
23
+ expect(anonymous.headers.get("cache-control")).toBe("no-store");
24
+ expect(paths).toEqual([]);
25
+
26
+ const denied = await app.handle(
27
+ request("/api/license/runtime/pairing/sessions", "POST", true),
28
+ );
29
+ expect(denied.status).toBe(403);
30
+ expect(denied.headers.get("cache-control")).toBe("no-store");
31
+ expect(paths).toEqual(["/api/license/runtime/pairing/sessions"]);
32
+ expect(service.createCalls).toBe(0);
33
+ });
34
+
35
+ test("创建会话只接受与请求 Origin 一致的 callbackOrigin", async () => {
36
+ const service = mockService();
37
+ const app = createApp(service, allowAuthorizer);
38
+ const mismatch = await app.handle(
39
+ request(
40
+ "/api/license/runtime/pairing/sessions",
41
+ "POST",
42
+ true,
43
+ { callbackOrigin: "https://windy.internal" },
44
+ {
45
+ origin: "https://other.internal",
46
+ "content-type": "application/json",
47
+ },
48
+ ),
49
+ );
50
+ expect(mismatch.status).toBe(403);
51
+ expect(service.createCalls).toBe(0);
52
+
53
+ const accepted = await app.handle(
54
+ request(
55
+ "/api/license/runtime/pairing/sessions",
56
+ "POST",
57
+ true,
58
+ { callbackOrigin: "https://windy.internal" },
59
+ {
60
+ origin: "https://windy.internal",
61
+ "content-type": "application/json",
62
+ },
63
+ ),
64
+ );
65
+ expect(accepted.status).toBe(201);
66
+ expect(service.callbackUrl).toBe(
67
+ "https://windy.internal/api/license/runtime/pairing/sessions/session_1/deliver",
68
+ );
69
+ await expect(accepted.json()).resolves.toMatchObject({
70
+ id: "session_1",
71
+ token: "delivery-token",
72
+ nonce: "pairing-nonce",
73
+ status: "waiting",
74
+ });
75
+
76
+ const confirmed = await app.handle(
77
+ request(
78
+ "/api/license/runtime/pairing/sessions/session_1/confirm",
79
+ "POST",
80
+ true,
81
+ { pairingCode: "GTM8FFTX" },
82
+ { "content-type": "application/json" },
83
+ ),
84
+ );
85
+ expect(confirmed.status).toBe(200);
86
+ expect(service.confirmCalls).toEqual([["session_1", "GTM8FFTX"]]);
87
+ });
88
+
89
+ test("允许来源可用 text/plain 自动投递,响应固定 no-store 和精确 CORS", async () => {
90
+ const service = mockService();
91
+ const bindings: string[] = [];
92
+ const app = createApp(service, {
93
+ authorize: async (_context, method, path) => {
94
+ bindings.push(`${method} ${path}`);
95
+ return undefined;
96
+ },
97
+ });
98
+ const response = await app.handle(
99
+ request(
100
+ "/api/license/runtime/pairing/sessions/session_1/deliver",
101
+ "POST",
102
+ false,
103
+ { token: "delivery-token", license: "canonical-license" },
104
+ {
105
+ origin: "https://phone.example",
106
+ "content-type": "text/plain;charset=UTF-8",
107
+ },
108
+ ),
109
+ );
110
+
111
+ expect(response.status).toBe(200);
112
+ expect(response.headers.get("access-control-allow-origin")).toBe(
113
+ "https://phone.example",
114
+ );
115
+ expect(response.headers.get("access-control-allow-private-network")).toBe(
116
+ "true",
117
+ );
118
+ expect(response.headers.get("cache-control")).toBe("no-store");
119
+ expect(response.headers.get("vary")).toContain("Origin");
120
+ expect(service.deliverCalls).toEqual([
121
+ ["session_1", "delivery-token", "canonical-license"],
122
+ ]);
123
+ expect(bindings).toEqual([
124
+ "POST /api/license/runtime/pairing/sessions/:sessionId/deliver",
125
+ ]);
126
+ });
127
+
128
+ test("未知 Origin 在读取 token 前拒绝,严格预检只允许 POST Content-Type", async () => {
129
+ const service = mockService();
130
+ const app = createApp(service, allowAuthorizer);
131
+ const rejected = await app.handle(
132
+ request(
133
+ "/api/license/runtime/pairing/sessions/session_1/deliver",
134
+ "POST",
135
+ false,
136
+ { token: "secret-token", license: "secret-license" },
137
+ { origin: "https://attacker.example", "content-type": "text/plain" },
138
+ ),
139
+ );
140
+ expect(rejected.status).toBe(403);
141
+ expect(rejected.headers.get("access-control-allow-origin")).toBeNull();
142
+ expect(service.deliverCalls).toEqual([]);
143
+
144
+ const preflight = await app.handle(
145
+ request(
146
+ "/api/license/runtime/pairing/sessions/session_1/deliver",
147
+ "OPTIONS",
148
+ false,
149
+ undefined,
150
+ {
151
+ origin: "https://phone.example",
152
+ "access-control-request-method": "POST",
153
+ "access-control-request-headers": "content-type",
154
+ "access-control-request-private-network": "true",
155
+ },
156
+ ),
157
+ );
158
+ expect(preflight.status).toBe(204);
159
+ expect(preflight.headers.get("access-control-allow-methods")).toBe(
160
+ "POST, OPTIONS",
161
+ );
162
+ expect(preflight.headers.get("access-control-allow-headers")).toBe(
163
+ "Content-Type",
164
+ );
165
+ expect(preflight.headers.get("access-control-allow-private-network")).toBe(
166
+ "true",
167
+ );
168
+ });
169
+
170
+ test("生产未配置来源时关闭创建入口", async () => {
171
+ const service = mockService();
172
+ const app = createApp(service, allowAuthorizer, { NODE_ENV: "production" });
173
+ const response = await app.handle(
174
+ request("/api/license/runtime/pairing/sessions", "POST", true),
175
+ );
176
+ expect(response.status).toBe(503);
177
+ await expect(response.json()).resolves.toMatchObject({
178
+ error: "PAIRING_DISABLED",
179
+ });
180
+ expect(service.createCalls).toBe(0);
181
+ });
182
+ });
183
+
184
+ function createApp(
185
+ service: ReturnType<typeof mockService>,
186
+ authorizer: StaticRouteAuthorizer,
187
+ env: Record<string, string | undefined> = {
188
+ NODE_ENV: "production",
189
+ WINDY_LICENSE_PAIRING_ORIGINS: "https://phone.example",
190
+ },
191
+ ) {
192
+ const app = new Elysia({ prefix: "/api" }).derive(({ request: current }) => ({
193
+ guardContext: {
194
+ actor: current.headers.has("authorization")
195
+ ? {
196
+ id: "user_1",
197
+ type: "user" as const,
198
+ name: "管理员",
199
+ roleCodes: ["admin"],
200
+ permissionKeys: ["system.license.manage"],
201
+ }
202
+ : { type: "anonymous" as const, roleCodes: [], permissionKeys: [] },
203
+ requestId: "request_pairing",
204
+ },
205
+ }));
206
+ registerLicensePairingRoutes(
207
+ app,
208
+ {
209
+ presentSnapshot: (snapshot: unknown) => snapshot,
210
+ } as unknown as PlatformLicenseRuntime,
211
+ authorizer,
212
+ {
213
+ env,
214
+ service: service as unknown as LicensePairingService,
215
+ },
216
+ );
217
+ return app;
218
+ }
219
+
220
+ function mockService() {
221
+ return {
222
+ createCalls: 0,
223
+ callbackUrl: "",
224
+ deliverCalls: [] as string[][],
225
+ confirmCalls: [] as string[][],
226
+ create(callback: string | ((id: string) => string)) {
227
+ this.createCalls += 1;
228
+ this.callbackUrl =
229
+ typeof callback === "function" ? callback("session_1") : callback;
230
+ return {
231
+ request: {
232
+ sessionId: "session_1",
233
+ token: "delivery-token",
234
+ nonce: "pairing-nonce",
235
+ productCode: "windy-platform",
236
+ machineCode: "machine_1",
237
+ expiresAt: "2030-08-01T00:00:00.000Z",
238
+ callbackUrl: this.callbackUrl,
239
+ },
240
+ qrPayload: "qr",
241
+ status: "waiting",
242
+ };
243
+ },
244
+ status: () => ({ status: "waiting" }),
245
+ async deliver(id: string, token: string, license: string) {
246
+ this.deliverCalls.push([id, token, license]);
247
+ return { id, status: "delivered" };
248
+ },
249
+ async confirm(id: string, pairingCode: string) {
250
+ this.confirmCalls.push([id, pairingCode]);
251
+ return {
252
+ result: { status: "installed", failures: [] },
253
+ snapshot: {
254
+ status: "valid",
255
+ restricted: false,
256
+ reminder: { level: "none" },
257
+ canViewDetails: true,
258
+ },
259
+ };
260
+ },
261
+ };
262
+ }
263
+
264
+ const allowAuthorizer: StaticRouteAuthorizer = {
265
+ authorize: async () => undefined,
266
+ };
267
+
268
+ function request(
269
+ path: string,
270
+ method: string,
271
+ authenticated = false,
272
+ body?: unknown,
273
+ headers: Record<string, string> = {},
274
+ ) {
275
+ const nextHeaders = new Headers(headers);
276
+ if (authenticated) nextHeaders.set("authorization", "Bearer user-token");
277
+ return new Request(`http://localhost${path}`, {
278
+ method,
279
+ headers: nextHeaders,
280
+ body: body === undefined ? undefined : JSON.stringify(body),
281
+ });
282
+ }
@@ -0,0 +1,257 @@
1
+ import { readBoundedJsonObject } from "@southwind-ai/license";
2
+ import type { RequestGuardContext } from "../guards.js";
3
+ import type { StaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
4
+ import type { RouteApp } from "../system/routes.js";
5
+ import {
6
+ allowedPreflightHeaders,
7
+ corsRejected,
8
+ createPairingCorsPolicy,
9
+ json,
10
+ noStore,
11
+ pairingErrorResponse,
12
+ requireCallbackOrigin,
13
+ requireDeliveryContentType,
14
+ requiredText,
15
+ responseHeaders,
16
+ } from "./pairing-http.js";
17
+ import { LicensePairingService } from "./pairing-service.js";
18
+ import type { PlatformLicenseRuntime } from "./runtime-service.js";
19
+
20
+ interface PairingRouteContext {
21
+ guardContext: RequestGuardContext;
22
+ request: Request;
23
+ params: { sessionId: string };
24
+ }
25
+
26
+ type PairingRouteApp = Pick<RouteApp, "get" | "post"> & {
27
+ options(path: string, handler: (context: unknown) => unknown): unknown;
28
+ };
29
+
30
+ export interface PairingRouteOptions {
31
+ env?: Record<string, string | undefined>;
32
+ service?: LicensePairingService;
33
+ }
34
+
35
+ export function registerLicensePairingRoutes(
36
+ app: PairingRouteApp,
37
+ runtime: PlatformLicenseRuntime,
38
+ authorizer: StaticRouteAuthorizer,
39
+ options: PairingRouteOptions = {},
40
+ ) {
41
+ const env = options.env || process.env;
42
+ const service = options.service || new LicensePairingService(runtime);
43
+ const cors = createPairingCorsPolicy(env);
44
+
45
+ app.post(
46
+ "/license/runtime/pairing/sessions",
47
+ async (rawContext) => {
48
+ const context = rawContext as PairingRouteContext;
49
+ const denied = await authorizeUser(
50
+ context,
51
+ authorizer,
52
+ "POST",
53
+ "/api/license/runtime/pairing/sessions",
54
+ );
55
+ if (denied) return denied;
56
+ if (!cors.enabled) {
57
+ return json(
58
+ { error: "PAIRING_DISABLED", reason: "未配置手机 Web 允许来源" },
59
+ 503,
60
+ );
61
+ }
62
+ try {
63
+ const body = await readBoundedJsonObject(context.request);
64
+ const callbackOrigin = requireCallbackOrigin(
65
+ body.callbackOrigin,
66
+ context.request,
67
+ );
68
+ const created = service.create(
69
+ (sessionId) =>
70
+ `${callbackOrigin}/api/license/runtime/pairing/sessions/${sessionId}/deliver`,
71
+ );
72
+ return json(
73
+ {
74
+ id: created.request.sessionId,
75
+ token: created.request.token,
76
+ nonce: created.request.nonce,
77
+ productCode: created.request.productCode,
78
+ machineCode: created.request.machineCode,
79
+ expiresAt: created.request.expiresAt,
80
+ qrPayload: created.qrPayload,
81
+ status: created.status,
82
+ },
83
+ 201,
84
+ );
85
+ } catch (error) {
86
+ return pairingErrorResponse(error);
87
+ }
88
+ },
89
+ { parse: "none" },
90
+ );
91
+
92
+ app.get(
93
+ "/license/runtime/pairing/sessions/:sessionId",
94
+ async (rawContext) => {
95
+ const context = rawContext as PairingRouteContext;
96
+ const denied = await authorizeUser(
97
+ context,
98
+ authorizer,
99
+ "GET",
100
+ "/api/license/runtime/pairing/sessions/:sessionId",
101
+ );
102
+ if (denied) return denied;
103
+ try {
104
+ return json(service.status(context.params.sessionId));
105
+ } catch (error) {
106
+ return pairingErrorResponse(error);
107
+ }
108
+ },
109
+ );
110
+
111
+ app.options(
112
+ "/license/runtime/pairing/sessions/:sessionId/deliver",
113
+ async (rawContext) => {
114
+ const context = rawContext as PairingRouteContext;
115
+ const denied = await authorizeCallbackBinding(
116
+ context,
117
+ authorizer,
118
+ "OPTIONS",
119
+ "/api/license/runtime/pairing/sessions/:sessionId/deliver",
120
+ );
121
+ if (denied) return denied;
122
+ const origin = cors.accept(context.request);
123
+ if (!origin) return corsRejected();
124
+ if (
125
+ context.request.headers.get("access-control-request-method") !==
126
+ "POST" ||
127
+ !allowedPreflightHeaders(
128
+ context.request.headers.get("access-control-request-headers"),
129
+ )
130
+ ) {
131
+ return json(
132
+ { error: "PAIRING_CORS_REJECTED" },
133
+ 403,
134
+ cors.headers(origin),
135
+ );
136
+ }
137
+ return new Response(null, {
138
+ status: 204,
139
+ headers: responseHeaders(cors.headers(origin)),
140
+ });
141
+ },
142
+ );
143
+
144
+ app.post(
145
+ "/license/runtime/pairing/sessions/:sessionId/deliver",
146
+ async (rawContext) => {
147
+ const context = rawContext as PairingRouteContext;
148
+ const denied = await authorizeCallbackBinding(
149
+ context,
150
+ authorizer,
151
+ "POST",
152
+ "/api/license/runtime/pairing/sessions/:sessionId/deliver",
153
+ );
154
+ if (denied) return denied;
155
+ const origin = cors.accept(context.request);
156
+ if (!origin) return corsRejected();
157
+ const headers = cors.headers(origin);
158
+ try {
159
+ requireDeliveryContentType(context.request);
160
+ const body = await readBoundedJsonObject(context.request);
161
+ const token = requiredText(body.token, "token");
162
+ const license = requiredText(body.license, "license");
163
+ return json(
164
+ await service.deliver(context.params.sessionId, token, license),
165
+ 200,
166
+ headers,
167
+ );
168
+ } catch (error) {
169
+ return pairingErrorResponse(error, headers);
170
+ }
171
+ },
172
+ { parse: "none" },
173
+ );
174
+
175
+ app.post(
176
+ "/license/runtime/pairing/sessions/:sessionId/confirm",
177
+ async (rawContext) => {
178
+ const context = rawContext as PairingRouteContext;
179
+ const denied = await authorizeUser(
180
+ context,
181
+ authorizer,
182
+ "POST",
183
+ "/api/license/runtime/pairing/sessions/:sessionId/confirm",
184
+ );
185
+ if (denied) return denied;
186
+ try {
187
+ const body = await readBoundedJsonObject(context.request);
188
+ const installed = await service.confirm(
189
+ context.params.sessionId,
190
+ requiredText(body.pairingCode, "pairingCode"),
191
+ {
192
+ actor: context.guardContext.actor,
193
+ requestId: context.guardContext.requestId,
194
+ clientIp: context.guardContext.clientIp,
195
+ userAgent: context.guardContext.userAgent,
196
+ },
197
+ );
198
+ if (installed.result.status === "rejected") {
199
+ return json(
200
+ {
201
+ error: "LICENSE_INSTALL_REJECTED",
202
+ reasons: installed.result.failures,
203
+ snapshot: runtime.presentSnapshot(
204
+ installed.snapshot,
205
+ context.guardContext.actor,
206
+ ),
207
+ },
208
+ 400,
209
+ );
210
+ }
211
+ return json({
212
+ installStatus: installed.result.status,
213
+ snapshot: runtime.presentSnapshot(
214
+ installed.snapshot,
215
+ context.guardContext.actor,
216
+ ),
217
+ });
218
+ } catch (error) {
219
+ return pairingErrorResponse(error);
220
+ }
221
+ },
222
+ { parse: "none" },
223
+ );
224
+ }
225
+
226
+ async function authorizeCallbackBinding(
227
+ context: PairingRouteContext,
228
+ authorizer: StaticRouteAuthorizer,
229
+ method: string,
230
+ path: string,
231
+ ): Promise<Response | undefined> {
232
+ const denied = await authorizer.authorize(
233
+ context.guardContext,
234
+ method,
235
+ path,
236
+ { restrictedModeAccess: "admin-admission" },
237
+ );
238
+ return denied ? noStore(denied) : undefined;
239
+ }
240
+
241
+ async function authorizeUser(
242
+ context: PairingRouteContext,
243
+ authorizer: StaticRouteAuthorizer,
244
+ method: string,
245
+ path: string,
246
+ ): Promise<Response | undefined> {
247
+ if (context.guardContext.actor.type !== "user") {
248
+ return json({ error: "UNAUTHORIZED" }, 401);
249
+ }
250
+ const denied = await authorizer.authorize(
251
+ context.guardContext,
252
+ method,
253
+ path,
254
+ { restrictedModeAccess: "admin-admission" },
255
+ );
256
+ return denied ? noStore(denied) : undefined;
257
+ }