swallowkit 1.0.0-beta.54 → 1.0.0-beta.56
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/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +154 -157
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/init.d.ts +5 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +178 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +7 -2
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +111 -123
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +127 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +8 -4
- package/dist/core/config.js.map +1 -1
- package/dist/core/operations/auth-operations.d.ts +57 -0
- package/dist/core/operations/auth-operations.d.ts.map +1 -0
- package/dist/core/operations/auth-operations.js +269 -0
- package/dist/core/operations/auth-operations.js.map +1 -0
- package/dist/core/operations/create-model.d.ts.map +1 -1
- package/dist/core/operations/create-model.js +23 -13
- package/dist/core/operations/create-model.js.map +1 -1
- package/dist/core/operations/file-session.d.ts +53 -0
- package/dist/core/operations/file-session.d.ts.map +1 -0
- package/dist/core/operations/file-session.js +195 -0
- package/dist/core/operations/file-session.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +52 -0
- package/dist/core/operations/provision-operations.d.ts.map +1 -0
- package/dist/core/operations/provision-operations.js +211 -0
- package/dist/core/operations/provision-operations.js.map +1 -0
- package/dist/core/operations/runtime.d.ts +5 -0
- package/dist/core/operations/runtime.d.ts.map +1 -1
- package/dist/core/operations/runtime.js +23 -0
- package/dist/core/operations/runtime.js.map +1 -1
- package/dist/core/operations/scaffold-apply.d.ts +21 -0
- package/dist/core/operations/scaffold-apply.d.ts.map +1 -0
- package/dist/core/operations/scaffold-apply.js +57 -0
- package/dist/core/operations/scaffold-apply.js.map +1 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
- package/dist/core/operations/scaffold-machine.js +1 -13
- package/dist/core/operations/scaffold-machine.js.map +1 -1
- package/dist/core/operations/scaffold-plan.d.ts +43 -0
- package/dist/core/operations/scaffold-plan.d.ts.map +1 -0
- package/dist/core/operations/scaffold-plan.js +195 -0
- package/dist/core/operations/scaffold-plan.js.map +1 -0
- package/dist/core/project/artifacts.d.ts +58 -0
- package/dist/core/project/artifacts.d.ts.map +1 -0
- package/dist/core/project/artifacts.js +179 -0
- package/dist/core/project/artifacts.js.map +1 -0
- package/dist/core/project/boundaries.d.ts +50 -0
- package/dist/core/project/boundaries.d.ts.map +1 -0
- package/dist/core/project/boundaries.js +117 -0
- package/dist/core/project/boundaries.js.map +1 -0
- package/dist/core/project/drift.d.ts +29 -0
- package/dist/core/project/drift.d.ts.map +1 -0
- package/dist/core/project/drift.js +188 -0
- package/dist/core/project/drift.js.map +1 -0
- package/dist/core/project/infra.d.ts +49 -0
- package/dist/core/project/infra.d.ts.map +1 -0
- package/dist/core/project/infra.js +167 -0
- package/dist/core/project/infra.js.map +1 -0
- package/dist/core/project/state.d.ts +15 -0
- package/dist/core/project/state.d.ts.map +1 -0
- package/dist/core/project/state.js +106 -0
- package/dist/core/project/state.js.map +1 -0
- package/dist/core/project/workflows.d.ts +43 -0
- package/dist/core/project/workflows.d.ts.map +1 -0
- package/dist/core/project/workflows.js +255 -0
- package/dist/core/project/workflows.js.map +1 -0
- package/dist/core/scaffold/auth-generator.d.ts +1 -1
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +108 -52
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/functions-generator.js +9 -9
- package/dist/core/scaffold/nextjs-generator.js +6 -6
- package/dist/core/scaffold/ui-generator.js +19 -19
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/core/verify/index.d.ts +62 -0
- package/dist/core/verify/index.d.ts.map +1 -0
- package/dist/core/verify/index.js +343 -0
- package/dist/core/verify/index.js.map +1 -0
- package/dist/machine/contracts.d.ts +8 -0
- package/dist/machine/contracts.d.ts.map +1 -1
- package/dist/machine/errors.d.ts +4 -1
- package/dist/machine/errors.d.ts.map +1 -1
- package/dist/machine/errors.js +9 -1
- package/dist/machine/errors.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +350 -16
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +202 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/types/index.d.ts +14 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +17 -17
- package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +6 -6
- package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +11 -11
- package/src/__tests__/agent-loop-phase34.test.ts +449 -0
- package/src/__tests__/bff-swa-auth.test.ts +275 -0
- package/src/__tests__/file-session.test.ts +98 -0
- package/src/__tests__/init.test.ts +50 -0
- package/src/__tests__/mcp.test.ts +113 -5
- package/src/__tests__/multi-auth.test.ts +1 -1
- package/src/__tests__/plan-apply.test.ts +294 -0
- package/src/cli/commands/add-auth.ts +164 -164
- package/src/cli/commands/init.ts +186 -24
- package/src/cli/commands/scaffold.ts +128 -129
- package/src/cli/index.ts +148 -2
- package/src/core/config.ts +5 -4
- package/src/core/operations/auth-operations.ts +353 -0
- package/src/core/operations/create-model.ts +25 -13
- package/src/core/operations/file-session.ts +210 -0
- package/src/core/operations/provision-operations.ts +297 -0
- package/src/core/operations/runtime.ts +23 -0
- package/src/core/operations/scaffold-apply.ts +106 -0
- package/src/core/operations/scaffold-machine.ts +2 -16
- package/src/core/operations/scaffold-plan.ts +224 -0
- package/src/core/project/artifacts.ts +205 -0
- package/src/core/project/boundaries.ts +170 -0
- package/src/core/project/drift.ts +202 -0
- package/src/core/project/infra.ts +196 -0
- package/src/core/project/state.ts +77 -0
- package/src/core/project/workflows.ts +249 -0
- package/src/core/scaffold/auth-generator.ts +119 -60
- package/src/core/scaffold/functions-generator.ts +9 -9
- package/src/core/scaffold/nextjs-generator.ts +6 -6
- package/src/core/scaffold/ui-generator.ts +19 -19
- package/src/core/verify/index.ts +392 -0
- package/src/machine/contracts.ts +16 -0
- package/src/machine/errors.ts +12 -1
- package/src/machine/index.ts +444 -15
- package/src/mcp/index.ts +198 -2
- package/src/types/index.ts +17 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import {
|
|
3
|
+
generateBFFCallFunctionWithMultipleAuth,
|
|
4
|
+
generateBFFCallFunctionWithSwaAuth,
|
|
5
|
+
} from "../core/scaffold/auth-generator";
|
|
6
|
+
|
|
7
|
+
type GeneratedModule = {
|
|
8
|
+
callFunction(config: { method: "GET"; path: string }): Promise<unknown>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type FetchCall = {
|
|
12
|
+
url: string;
|
|
13
|
+
init: RequestInit | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const originalFetch = global.fetch;
|
|
17
|
+
const originalBaseUrl = process.env.BACKEND_FUNCTIONS_BASE_URL;
|
|
18
|
+
const originalFunctionsKey = process.env.BACKEND_FUNCTIONS_KEY;
|
|
19
|
+
|
|
20
|
+
function encodePrincipal(value: unknown): string {
|
|
21
|
+
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function decodeForwardedPrincipal(call: FetchCall): Record<string, unknown> | undefined {
|
|
25
|
+
const value = (call.init?.headers as Record<string, string>)["x-ms-client-principal"];
|
|
26
|
+
return value
|
|
27
|
+
? JSON.parse(Buffer.from(value, "base64").toString("utf8"))
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadGeneratedModule(
|
|
32
|
+
code: string,
|
|
33
|
+
requestHeaders: Record<string, string>,
|
|
34
|
+
): GeneratedModule {
|
|
35
|
+
const output = ts.transpileModule(code, {
|
|
36
|
+
compilerOptions: {
|
|
37
|
+
esModuleInterop: true,
|
|
38
|
+
module: ts.ModuleKind.CommonJS,
|
|
39
|
+
target: ts.ScriptTarget.ES2020,
|
|
40
|
+
},
|
|
41
|
+
reportDiagnostics: true,
|
|
42
|
+
});
|
|
43
|
+
expect(output.diagnostics).toEqual([]);
|
|
44
|
+
|
|
45
|
+
class MockNextResponse {
|
|
46
|
+
static json(body: unknown, init?: { status?: number }) {
|
|
47
|
+
return { body, status: init?.status ?? 200 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
public body: unknown,
|
|
52
|
+
public init?: { status?: number },
|
|
53
|
+
) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const generatedModule = { exports: {} as GeneratedModule };
|
|
57
|
+
const mockRequire = (id: string): unknown => {
|
|
58
|
+
if (id === "next/server") return { NextResponse: MockNextResponse };
|
|
59
|
+
if (id === "next/headers") {
|
|
60
|
+
return { headers: async () => new Headers(requestHeaders) };
|
|
61
|
+
}
|
|
62
|
+
if (id === "zod/v4") return require("zod/v4");
|
|
63
|
+
throw new Error(`Unexpected generated import: ${id}`);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
new Function("require", "module", "exports", output.outputText)(
|
|
67
|
+
mockRequire,
|
|
68
|
+
generatedModule,
|
|
69
|
+
generatedModule.exports,
|
|
70
|
+
);
|
|
71
|
+
return generatedModule.exports;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runGeneratedCall(options: {
|
|
75
|
+
code: string;
|
|
76
|
+
requestHeaders: Record<string, string>;
|
|
77
|
+
mePrincipal?: unknown;
|
|
78
|
+
functionsBaseUrl?: string;
|
|
79
|
+
}): Promise<FetchCall[]> {
|
|
80
|
+
process.env.BACKEND_FUNCTIONS_BASE_URL =
|
|
81
|
+
options.functionsBaseUrl ?? "https://functions.example.test";
|
|
82
|
+
process.env.BACKEND_FUNCTIONS_KEY = "functions-key-secret";
|
|
83
|
+
|
|
84
|
+
const calls: FetchCall[] = [];
|
|
85
|
+
global.fetch = jest.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
86
|
+
const url = input.toString();
|
|
87
|
+
calls.push({ url, init });
|
|
88
|
+
if (url.endsWith("/.auth/me")) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
status: 200,
|
|
92
|
+
json: async () => ({ clientPrincipal: options.mePrincipal }),
|
|
93
|
+
} as Response;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
status: 200,
|
|
98
|
+
json: async () => ({ ok: true }),
|
|
99
|
+
text: async () => "",
|
|
100
|
+
} as Response;
|
|
101
|
+
}) as typeof fetch;
|
|
102
|
+
|
|
103
|
+
const generated = loadGeneratedModule(options.code, options.requestHeaders);
|
|
104
|
+
await generated.callFunction({ method: "GET", path: "/api/resource" });
|
|
105
|
+
return calls;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function functionsCall(calls: FetchCall[]): FetchCall {
|
|
109
|
+
const call = calls.find(({ url }) => url.endsWith("/api/resource"));
|
|
110
|
+
expect(call).toBeDefined();
|
|
111
|
+
return call!;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
global.fetch = originalFetch;
|
|
116
|
+
if (originalBaseUrl === undefined) delete process.env.BACKEND_FUNCTIONS_BASE_URL;
|
|
117
|
+
else process.env.BACKEND_FUNCTIONS_BASE_URL = originalBaseUrl;
|
|
118
|
+
if (originalFunctionsKey === undefined) delete process.env.BACKEND_FUNCTIONS_KEY;
|
|
119
|
+
else process.env.BACKEND_FUNCTIONS_KEY = originalFunctionsKey;
|
|
120
|
+
jest.restoreAllMocks();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("generated BFF SWA credential forwarding", () => {
|
|
124
|
+
const validPrincipal = {
|
|
125
|
+
identityProvider: "github",
|
|
126
|
+
userId: "user-123",
|
|
127
|
+
userDetails: "octocat",
|
|
128
|
+
userRoles: ["anonymous", "authenticated", 123],
|
|
129
|
+
untrustedExtra: "must-not-be-forwarded",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
it("normalizes an incoming principal and does not call /.auth/me", async () => {
|
|
133
|
+
const calls = await runGeneratedCall({
|
|
134
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
135
|
+
requestHeaders: {
|
|
136
|
+
cookie: "session=cookie-secret",
|
|
137
|
+
host: "app.example.test",
|
|
138
|
+
"x-ms-client-principal": encodePrincipal(validPrincipal),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(calls.map(({ url }) => url)).not.toContain("https://app.example.test/.auth/me");
|
|
143
|
+
expect(decodeForwardedPrincipal(functionsCall(calls))).toEqual({
|
|
144
|
+
identityProvider: "github",
|
|
145
|
+
userId: "user-123",
|
|
146
|
+
userDetails: "octocat",
|
|
147
|
+
userRoles: ["anonymous", "authenticated"],
|
|
148
|
+
});
|
|
149
|
+
expect(
|
|
150
|
+
(functionsCall(calls).init?.headers as Record<string, string>)[
|
|
151
|
+
"x-functions-key"
|
|
152
|
+
],
|
|
153
|
+
).toBe("functions-key-secret");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("uses x-forwarded-host for the /.auth/me fallback", async () => {
|
|
157
|
+
const calls = await runGeneratedCall({
|
|
158
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
159
|
+
requestHeaders: {
|
|
160
|
+
cookie: "session=valid",
|
|
161
|
+
host: "internal.example.test",
|
|
162
|
+
"x-forwarded-host": "public.example.test",
|
|
163
|
+
},
|
|
164
|
+
mePrincipal: validPrincipal,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(calls[0].url).toBe("https://public.example.test/.auth/me");
|
|
168
|
+
expect(decodeForwardedPrincipal(functionsCall(calls))).toEqual({
|
|
169
|
+
identityProvider: "github",
|
|
170
|
+
userId: "user-123",
|
|
171
|
+
userDetails: "octocat",
|
|
172
|
+
userRoles: ["anonymous", "authenticated"],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("falls back to host when x-forwarded-host is absent", async () => {
|
|
177
|
+
const calls = await runGeneratedCall({
|
|
178
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
179
|
+
requestHeaders: { cookie: "session=valid", host: "app.example.test" },
|
|
180
|
+
mePrincipal: validPrincipal,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(calls[0].url).toBe("https://app.example.test/.auth/me");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("uses the first comma-separated forwarded host and protocol", async () => {
|
|
187
|
+
const calls = await runGeneratedCall({
|
|
188
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
189
|
+
requestHeaders: {
|
|
190
|
+
cookie: "session=valid",
|
|
191
|
+
host: "internal.example.test",
|
|
192
|
+
"x-forwarded-host": "public.example.test, proxy.example.test",
|
|
193
|
+
"x-forwarded-proto": "http, https",
|
|
194
|
+
},
|
|
195
|
+
mePrincipal: validPrincipal,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(calls[0].url).toBe("http://public.example.test/.auth/me");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it.each([
|
|
202
|
+
["a missing userId", { ...validPrincipal, userId: undefined }],
|
|
203
|
+
["a missing authenticated role", { ...validPrincipal, userRoles: ["anonymous"] }],
|
|
204
|
+
["a missing identityProvider", { ...validPrincipal, identityProvider: "" }],
|
|
205
|
+
])("does not forward a principal with %s", async (_case, principal) => {
|
|
206
|
+
const calls = await runGeneratedCall({
|
|
207
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
208
|
+
requestHeaders: { "x-ms-client-principal": encodePrincipal(principal) },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(decodeForwardedPrincipal(functionsCall(calls))).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it.each([
|
|
215
|
+
"not-base64!",
|
|
216
|
+
`${encodePrincipal(validPrincipal)}!`,
|
|
217
|
+
Buffer.from("not-json", "utf8").toString("base64"),
|
|
218
|
+
])(
|
|
219
|
+
"fails closed without logging credentials for malformed principal %s",
|
|
220
|
+
async (principal) => {
|
|
221
|
+
const error = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
222
|
+
const cookie = "session=highly-sensitive-cookie";
|
|
223
|
+
const calls = await runGeneratedCall({
|
|
224
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
225
|
+
requestHeaders: { cookie, "x-ms-client-principal": principal },
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(decodeForwardedPrincipal(functionsCall(calls))).toBeUndefined();
|
|
229
|
+
const logged = JSON.stringify(error.mock.calls);
|
|
230
|
+
expect(logged).not.toContain(cookie);
|
|
231
|
+
expect(logged).not.toContain(principal);
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
it("preserves Bearer and applies the same SWA rules for multiple auth", async () => {
|
|
236
|
+
const calls = await runGeneratedCall({
|
|
237
|
+
code: generateBFFCallFunctionWithMultipleAuth(),
|
|
238
|
+
requestHeaders: {
|
|
239
|
+
authorization: "Bearer line-token-secret",
|
|
240
|
+
"x-ms-client-principal": encodePrincipal(validPrincipal),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const headers = functionsCall(calls).init?.headers as Record<string, string>;
|
|
244
|
+
|
|
245
|
+
expect(headers.Authorization).toBe("Bearer line-token-secret");
|
|
246
|
+
expect(decodeForwardedPrincipal(functionsCall(calls))?.userId).toBe("user-123");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does not add Bearer forwarding to the SWA-only variant", async () => {
|
|
250
|
+
const code = generateBFFCallFunctionWithSwaAuth();
|
|
251
|
+
const calls = await runGeneratedCall({
|
|
252
|
+
code,
|
|
253
|
+
requestHeaders: {
|
|
254
|
+
authorization: "Bearer must-not-be-forwarded",
|
|
255
|
+
"x-ms-client-principal": encodePrincipal(validPrincipal),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const headers = functionsCall(calls).init?.headers as Record<string, string>;
|
|
259
|
+
|
|
260
|
+
expect(headers.Authorization).toBeUndefined();
|
|
261
|
+
expect(code).not.toContain("reqHeaders.get('authorization')");
|
|
262
|
+
expect(decodeForwardedPrincipal(functionsCall(calls))?.userId).toBe("user-123");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("defaults /.auth/me to http for local Functions development", async () => {
|
|
266
|
+
const calls = await runGeneratedCall({
|
|
267
|
+
code: generateBFFCallFunctionWithSwaAuth(),
|
|
268
|
+
requestHeaders: { cookie: "session=valid", host: "localhost:3000" },
|
|
269
|
+
mePrincipal: validPrincipal,
|
|
270
|
+
functionsBaseUrl: "http://localhost:7071",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(calls[0].url).toBe("http://localhost:3000/.auth/me");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import {
|
|
5
|
+
FileOperationSession,
|
|
6
|
+
hashContent,
|
|
7
|
+
runWithFileSession,
|
|
8
|
+
getActiveFileSession,
|
|
9
|
+
} from "../core/operations/file-session";
|
|
10
|
+
|
|
11
|
+
describe("FileOperationSession", () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-session-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("writes files to disk in commit mode and records create operations", () => {
|
|
23
|
+
const session = new FileOperationSession("commit", tempDir);
|
|
24
|
+
const target = path.join(tempDir, "nested", "dir", "file.ts");
|
|
25
|
+
|
|
26
|
+
const op = session.writeFile(target, "export const a = 1;\n", {
|
|
27
|
+
ownership: "managed",
|
|
28
|
+
generator: "scaffold",
|
|
29
|
+
sourceModel: "Todo",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(fs.readFileSync(target, "utf-8")).toBe("export const a = 1;\n");
|
|
33
|
+
expect(op.action).toBe("create");
|
|
34
|
+
expect(op.path).toBe("nested/dir/file.ts");
|
|
35
|
+
expect(op.previousHash).toBeNull();
|
|
36
|
+
expect(op.newHash).toBe(hashContent("export const a = 1;\n"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not touch the disk in collect mode", () => {
|
|
40
|
+
const session = new FileOperationSession("collect", tempDir);
|
|
41
|
+
const target = path.join(tempDir, "virtual.ts");
|
|
42
|
+
|
|
43
|
+
session.writeFile(target, "content", { ownership: "managed", generator: "scaffold" });
|
|
44
|
+
|
|
45
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
46
|
+
expect(session.operations).toHaveLength(1);
|
|
47
|
+
expect(session.operations[0].action).toBe("create");
|
|
48
|
+
// overlay read-back
|
|
49
|
+
expect(session.readFile(target)).toBe("content");
|
|
50
|
+
expect(session.fileExists(target)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("records skip when content is unchanged", () => {
|
|
54
|
+
const target = path.join(tempDir, "same.ts");
|
|
55
|
+
fs.writeFileSync(target, "unchanged\n", "utf-8");
|
|
56
|
+
|
|
57
|
+
const session = new FileOperationSession("commit", tempDir);
|
|
58
|
+
const op = session.writeFile(target, "unchanged\n", { ownership: "managed", generator: "scaffold" });
|
|
59
|
+
|
|
60
|
+
expect(op.action).toBe("skip");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("records append for extension-point modifications and modify for managed", () => {
|
|
64
|
+
const extensionTarget = path.join(tempDir, "ext.ts");
|
|
65
|
+
const managedTarget = path.join(tempDir, "managed.ts");
|
|
66
|
+
fs.writeFileSync(extensionTarget, "base\n", "utf-8");
|
|
67
|
+
fs.writeFileSync(managedTarget, "base\n", "utf-8");
|
|
68
|
+
|
|
69
|
+
const session = new FileOperationSession("commit", tempDir);
|
|
70
|
+
const appendOp = session.writeFile(extensionTarget, "base\nmore\n", {
|
|
71
|
+
ownership: "extension-point",
|
|
72
|
+
generator: "scaffold",
|
|
73
|
+
});
|
|
74
|
+
const modifyOp = session.writeFile(managedTarget, "regenerated\n", {
|
|
75
|
+
ownership: "managed",
|
|
76
|
+
generator: "scaffold",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(appendOp.action).toBe("append");
|
|
80
|
+
expect(modifyOp.action).toBe("modify");
|
|
81
|
+
expect(modifyOp.previousHash).toBe(hashContent("base\n"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("normalizes CRLF for hashing", () => {
|
|
85
|
+
expect(hashContent("a\r\nb\r\n")).toBe(hashContent("a\nb\n"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("scopes the active session with runWithFileSession", async () => {
|
|
89
|
+
expect(getActiveFileSession()).toBeNull();
|
|
90
|
+
const session = new FileOperationSession("collect", tempDir);
|
|
91
|
+
|
|
92
|
+
await runWithFileSession(session, async () => {
|
|
93
|
+
expect(getActiveFileSession()).toBe(session);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(getActiveFileSession()).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getGitHubFunctionsWorkflow,
|
|
17
17
|
injectSwallowKitNextConfig,
|
|
18
18
|
parseIgnoredBuilds,
|
|
19
|
+
approveBuildsViaWorkspaceYaml,
|
|
19
20
|
buildCosmosDbFreeTierBicepSource,
|
|
20
21
|
buildFunctionsHostKeyBicepExpression,
|
|
21
22
|
buildStaticWebAppConfigBicepSource,
|
|
@@ -236,6 +237,55 @@ describe("parseIgnoredBuilds", () => {
|
|
|
236
237
|
});
|
|
237
238
|
});
|
|
238
239
|
|
|
240
|
+
describe("approveBuildsViaWorkspaceYaml", () => {
|
|
241
|
+
let tempDir: string;
|
|
242
|
+
|
|
243
|
+
beforeEach(() => {
|
|
244
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sk-allowbuilds-"));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const yamlPath = () => path.join(tempDir, "pnpm-workspace.yaml");
|
|
252
|
+
|
|
253
|
+
it("appends an allowBuilds map to an existing workspace file", () => {
|
|
254
|
+
fs.writeFileSync(yamlPath(), "packages:\n - shared\n - functions\n", "utf-8");
|
|
255
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["esbuild", "protobufjs"]);
|
|
256
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
257
|
+
expect(content).toContain("packages:");
|
|
258
|
+
expect(content).toContain("allowBuilds:\n esbuild: true\n protobufjs: true");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("replaces pnpm's placeholder entries with true", () => {
|
|
262
|
+
fs.writeFileSync(
|
|
263
|
+
yamlPath(),
|
|
264
|
+
"packages:\n - shared\nallowBuilds:\n esbuild: set this to true or false\n",
|
|
265
|
+
"utf-8"
|
|
266
|
+
);
|
|
267
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["esbuild", "protobufjs"]);
|
|
268
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
269
|
+
expect(content).toContain("esbuild: true");
|
|
270
|
+
expect(content).toContain("protobufjs: true");
|
|
271
|
+
expect(content).not.toContain("set this to true or false");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("quotes scoped package names and creates the file when missing", () => {
|
|
275
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["@scope/pkg"]);
|
|
276
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
277
|
+
expect(content).toContain("allowBuilds:\n '@scope/pkg': true");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("keeps existing entries untouched", () => {
|
|
281
|
+
fs.writeFileSync(yamlPath(), "allowBuilds:\n core-js: false\n", "utf-8");
|
|
282
|
+
approveBuildsViaWorkspaceYaml(tempDir, ["esbuild"]);
|
|
283
|
+
const content = fs.readFileSync(yamlPath(), "utf-8");
|
|
284
|
+
expect(content).toContain("core-js: false");
|
|
285
|
+
expect(content).toContain("esbuild: true");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
239
289
|
describe("buildCosmosDbFreeTierBicepSource", () => {
|
|
240
290
|
it("keeps database-level shared throughput at 1000 RU/s for Free Tier", () => {
|
|
241
291
|
const source = buildCosmosDbFreeTierBicepSource();
|
|
@@ -81,6 +81,110 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
81
81
|
expect(JSON.parse(result.content[0].text)).toEqual({ createdFiles: ["functions/src/todo.ts"] });
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it("exposes the agent-loop tools (plan/apply/artifacts/drift/verify/explain)", () => {
|
|
85
|
+
const names = buildSwallowKitToolDefinitions(jest.fn() as unknown as MachineCliRunner).map((tool) => tool.name);
|
|
86
|
+
expect(names).toEqual(
|
|
87
|
+
expect.arrayContaining([
|
|
88
|
+
"swallowkit_plan_scaffold",
|
|
89
|
+
"swallowkit_apply_scaffold",
|
|
90
|
+
"swallowkit_inspect_artifacts",
|
|
91
|
+
"swallowkit_inspect_drift",
|
|
92
|
+
"swallowkit_verify_project",
|
|
93
|
+
"swallowkit_explain_failure",
|
|
94
|
+
])
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("delegates plan_scaffold and passes through status and nextActions", async () => {
|
|
99
|
+
const runner = jest.fn(async () => ({
|
|
100
|
+
stdout: JSON.stringify({
|
|
101
|
+
ok: true,
|
|
102
|
+
command: "plan-scaffold",
|
|
103
|
+
status: "requires-human",
|
|
104
|
+
nextActions: [{ command: "swallowkit machine apply scaffold --plan abc --approve", description: "Apply." }],
|
|
105
|
+
data: { planId: "abc", requiresApproval: true, operations: [] },
|
|
106
|
+
}),
|
|
107
|
+
stderr: "",
|
|
108
|
+
exitCode: 0,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
112
|
+
(candidate) => candidate.name === "swallowkit_plan_scaffold"
|
|
113
|
+
);
|
|
114
|
+
const result = await tool!.handler({ model: "todo", apiOnly: true });
|
|
115
|
+
|
|
116
|
+
expect(runner).toHaveBeenCalledWith(["plan", "scaffold", "todo", "--api-only"]);
|
|
117
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
118
|
+
expect(parsed.status).toBe("requires-human");
|
|
119
|
+
expect(parsed.planId).toBe("abc");
|
|
120
|
+
expect(parsed.nextActions).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("delegates apply_scaffold with plan and approve flags", async () => {
|
|
124
|
+
const runner = jest.fn(async () => ({
|
|
125
|
+
stdout: JSON.stringify({
|
|
126
|
+
ok: true,
|
|
127
|
+
command: "apply-scaffold",
|
|
128
|
+
status: "complete",
|
|
129
|
+
data: { createdFiles: [] },
|
|
130
|
+
}),
|
|
131
|
+
stderr: "",
|
|
132
|
+
exitCode: 0,
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
136
|
+
(candidate) => candidate.name === "swallowkit_apply_scaffold"
|
|
137
|
+
);
|
|
138
|
+
await tool!.handler({ planId: "abc", approve: true });
|
|
139
|
+
|
|
140
|
+
expect(runner).toHaveBeenCalledWith(["apply", "scaffold", "--plan", "abc", "--approve"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("surfaces machine error code, status, and details to the agent", async () => {
|
|
144
|
+
const runner = jest.fn(async () => ({
|
|
145
|
+
stdout: JSON.stringify({
|
|
146
|
+
ok: false,
|
|
147
|
+
command: "apply-scaffold",
|
|
148
|
+
status: "blocked",
|
|
149
|
+
error: {
|
|
150
|
+
code: "stale-plan",
|
|
151
|
+
message: "Plan is stale.",
|
|
152
|
+
details: { changedFiles: ["shared/models/todo.ts"] },
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
stderr: "",
|
|
156
|
+
exitCode: 1,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
160
|
+
(candidate) => candidate.name === "swallowkit_apply_scaffold"
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await expect(tool!.handler({ planId: "abc" })).rejects.toThrow(
|
|
164
|
+
/\[stale-plan\] Plan is stale\. \(status: blocked\)[\s\S]*changedFiles/
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("delegates verify_project with selected checks", async () => {
|
|
169
|
+
const runner = jest.fn(async () => ({
|
|
170
|
+
stdout: JSON.stringify({
|
|
171
|
+
ok: true,
|
|
172
|
+
command: "verify-project",
|
|
173
|
+
status: "complete",
|
|
174
|
+
data: { summary: { done: true } },
|
|
175
|
+
}),
|
|
176
|
+
stderr: "",
|
|
177
|
+
exitCode: 0,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
const tool = buildSwallowKitToolDefinitions(runner as MachineCliRunner).find(
|
|
181
|
+
(candidate) => candidate.name === "swallowkit_verify_project"
|
|
182
|
+
);
|
|
183
|
+
await tool!.handler({ checks: ["structure", "drift"] });
|
|
184
|
+
|
|
185
|
+
expect(runner).toHaveBeenCalledWith(["verify", "project", "--checks", "structure,drift"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
84
188
|
itWhenBuiltEntrypointExists("keeps the built MCP entrypoint alive long enough to complete the handshake", async () => {
|
|
85
189
|
await new Promise<void>((resolve, reject) => {
|
|
86
190
|
const child = spawn(process.execPath, [builtMcpEntrypoint], {
|
|
@@ -105,13 +209,17 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
105
209
|
}
|
|
106
210
|
};
|
|
107
211
|
|
|
108
|
-
|
|
109
|
-
expectedShutdown = true;
|
|
110
|
-
child.kill();
|
|
111
|
-
}, 1000);
|
|
212
|
+
let timer: NodeJS.Timeout | undefined;
|
|
112
213
|
|
|
113
214
|
child.stderr.on("data", (chunk) => {
|
|
114
215
|
stderr += chunk.toString();
|
|
216
|
+
// Start the aliveness window once the entrypoint has actually booted.
|
|
217
|
+
if (!timer && /\[swallowkit-mcp\] version: /.test(stderr)) {
|
|
218
|
+
timer = setTimeout(() => {
|
|
219
|
+
expectedShutdown = true;
|
|
220
|
+
child.kill();
|
|
221
|
+
}, 1000);
|
|
222
|
+
}
|
|
115
223
|
});
|
|
116
224
|
|
|
117
225
|
child.on("error", (error) => {
|
|
@@ -135,7 +243,7 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
135
243
|
);
|
|
136
244
|
});
|
|
137
245
|
});
|
|
138
|
-
});
|
|
246
|
+
}, 20000);
|
|
139
247
|
|
|
140
248
|
itWhenBuiltEntrypointExists("preserves a runtime dynamic execa import in the built MCP entrypoint", () => {
|
|
141
249
|
const source = fs.readFileSync(builtMcpEntrypoint, "utf8");
|
|
@@ -81,7 +81,7 @@ describe("named authentication routers", () => {
|
|
|
81
81
|
|
|
82
82
|
it("forwards both credential sources and the Functions host key", () => {
|
|
83
83
|
const code = generateBFFCallFunctionWithMultipleAuth();
|
|
84
|
-
expect(code).toContain("fetchHeaders
|
|
84
|
+
expect(code).toContain("fetchHeaders.Authorization");
|
|
85
85
|
expect(code).toContain("fetchHeaders['x-ms-client-principal']");
|
|
86
86
|
expect(code).toContain("'x-functions-key'");
|
|
87
87
|
expect(code).not.toMatch(/console\.log\([^\n]*(authorization|principal|cookie)/i);
|