@desplega.ai/agent-swarm 1.90.0 → 1.91.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/openapi.json +74 -1
- package/package.json +5 -5
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/scripts/typecheck.ts +132 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
- package/src/be/seed-scripts/index.ts +36 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +91 -1
- package/src/hooks/hook.ts +32 -9
- package/src/http/index.ts +47 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/scripts.ts +24 -1
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/providers/claude-adapter.ts +26 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +121 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +227 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/utils/constants.ts +58 -8
- package/templates/skills/swarm-scripts/content.md +46 -7
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { closeDb, createMcpServer, initDb } from "../be/db";
|
|
6
|
+
import { getMcpOAuthToken } from "../be/db-queries/mcp-oauth";
|
|
7
|
+
import { handleCore } from "../http/core";
|
|
8
|
+
import { handleMcpOAuth } from "../http/mcp-oauth";
|
|
9
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
10
|
+
|
|
11
|
+
const API_KEY = "test-secret-key";
|
|
12
|
+
const TEST_DB_PATH = "./test-mcp-oauth-manual-client.sqlite";
|
|
13
|
+
|
|
14
|
+
async function removeDbFiles(): Promise<void> {
|
|
15
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
16
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type TestResponse = {
|
|
21
|
+
status: number;
|
|
22
|
+
text: string;
|
|
23
|
+
headers: Record<string, string>;
|
|
24
|
+
json: () => Promise<unknown>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function dispatch(path: string, init: RequestInit = {}): Promise<TestResponse> {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
30
|
+
};
|
|
31
|
+
if (init.body !== undefined && !headers["Content-Type"])
|
|
32
|
+
headers["Content-Type"] = "application/json";
|
|
33
|
+
|
|
34
|
+
const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
|
|
35
|
+
req.method = init.method ?? "GET";
|
|
36
|
+
req.url = path;
|
|
37
|
+
req.headers = Object.fromEntries(
|
|
38
|
+
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
let status = 200;
|
|
42
|
+
let text = "";
|
|
43
|
+
const responseHeaders: Record<string, string> = {};
|
|
44
|
+
const res = {
|
|
45
|
+
headersSent: false,
|
|
46
|
+
writableEnded: false,
|
|
47
|
+
setHeader(name: string, value: number | string | readonly string[]) {
|
|
48
|
+
responseHeaders[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : String(value);
|
|
49
|
+
return this;
|
|
50
|
+
},
|
|
51
|
+
writeHead(code: number, headersArg?: Record<string, number | string | readonly string[]>) {
|
|
52
|
+
status = code;
|
|
53
|
+
if (headersArg) {
|
|
54
|
+
for (const [key, value] of Object.entries(headersArg)) {
|
|
55
|
+
responseHeaders[key.toLowerCase()] = Array.isArray(value)
|
|
56
|
+
? value.join(", ")
|
|
57
|
+
: String(value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.headersSent = true;
|
|
61
|
+
return this;
|
|
62
|
+
},
|
|
63
|
+
end(chunk?: unknown) {
|
|
64
|
+
if (chunk !== undefined) text += String(chunk);
|
|
65
|
+
this.writableEnded = true;
|
|
66
|
+
return this;
|
|
67
|
+
},
|
|
68
|
+
} as unknown as ServerResponse;
|
|
69
|
+
|
|
70
|
+
const handledCore = await handleCore(req, res, undefined, API_KEY);
|
|
71
|
+
if (!handledCore) {
|
|
72
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
73
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
74
|
+
const handled = await handleMcpOAuth(req, res, pathSegments, queryParams);
|
|
75
|
+
if (!handled) {
|
|
76
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
77
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
status,
|
|
83
|
+
text,
|
|
84
|
+
headers: responseHeaders,
|
|
85
|
+
json: async () => JSON.parse(text),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("MCP OAuth manual client flow", () => {
|
|
90
|
+
let originalFetch: typeof fetch;
|
|
91
|
+
let capturedTokenBody: string | null;
|
|
92
|
+
let originalPublicMcpBaseUrl: string | undefined;
|
|
93
|
+
let originalAppUrl: string | undefined;
|
|
94
|
+
let originalDashboardUrl: string | undefined;
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
originalFetch = globalThis.fetch;
|
|
98
|
+
capturedTokenBody = null;
|
|
99
|
+
originalPublicMcpBaseUrl = process.env.PUBLIC_MCP_BASE_URL;
|
|
100
|
+
originalAppUrl = process.env.APP_URL;
|
|
101
|
+
originalDashboardUrl = process.env.DASHBOARD_URL;
|
|
102
|
+
process.env.SECRETS_ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
|
|
103
|
+
|
|
104
|
+
await removeDbFiles();
|
|
105
|
+
initDb(TEST_DB_PATH);
|
|
106
|
+
process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.test";
|
|
107
|
+
process.env.APP_URL = "https://dashboard.example.test";
|
|
108
|
+
delete process.env.DASHBOARD_URL;
|
|
109
|
+
|
|
110
|
+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
111
|
+
const href = input.toString();
|
|
112
|
+
if (href === "https://login.salesforce.com/services/oauth2/token") {
|
|
113
|
+
capturedTokenBody = init?.body?.toString() ?? null;
|
|
114
|
+
return new Response(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
access_token: "sf-access-token",
|
|
117
|
+
token_type: "Bearer",
|
|
118
|
+
expires_in: 3600,
|
|
119
|
+
refresh_token: "sf-refresh-token",
|
|
120
|
+
scope: "mcp_api refresh_token",
|
|
121
|
+
}),
|
|
122
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return new Response("not found", { status: 404 });
|
|
126
|
+
}) as typeof fetch;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(async () => {
|
|
130
|
+
globalThis.fetch = originalFetch;
|
|
131
|
+
closeDb();
|
|
132
|
+
await removeDbFiles();
|
|
133
|
+
if (originalPublicMcpBaseUrl === undefined) delete process.env.PUBLIC_MCP_BASE_URL;
|
|
134
|
+
else process.env.PUBLIC_MCP_BASE_URL = originalPublicMcpBaseUrl;
|
|
135
|
+
if (originalAppUrl === undefined) delete process.env.APP_URL;
|
|
136
|
+
else process.env.APP_URL = originalAppUrl;
|
|
137
|
+
if (originalDashboardUrl === undefined) delete process.env.DASHBOARD_URL;
|
|
138
|
+
else process.env.DASHBOARD_URL = originalDashboardUrl;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("authorize-url uses a stored manual client when DCR is not available", async () => {
|
|
142
|
+
const mcpServer = createMcpServer({
|
|
143
|
+
name: "salesforce-sobjects",
|
|
144
|
+
transport: "http",
|
|
145
|
+
url: "https://api.salesforce.com/platform/mcp/v1/platform/sobject-all",
|
|
146
|
+
scope: "swarm",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const manualRes = await dispatch(`/api/mcp-oauth/${mcpServer.id}/manual-client`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
clientId: "sf-client-id",
|
|
156
|
+
clientSecret: "sf-client-secret",
|
|
157
|
+
authorizationServerIssuer: "https://login.salesforce.com",
|
|
158
|
+
authorizeUrl: "https://login.salesforce.com/services/oauth2/authorize",
|
|
159
|
+
tokenUrl: "https://login.salesforce.com/services/oauth2/token",
|
|
160
|
+
scopes: ["mcp_api", "refresh_token"],
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
expect(manualRes.status).toBe(200);
|
|
164
|
+
|
|
165
|
+
const provisionalToken = getMcpOAuthToken(mcpServer.id);
|
|
166
|
+
expect(provisionalToken?.clientSource).toBe("manual");
|
|
167
|
+
expect(provisionalToken?.status).toBe("error");
|
|
168
|
+
|
|
169
|
+
const authorizeRes = await dispatch(`/api/mcp-oauth/${mcpServer.id}/authorize-url`, {
|
|
170
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
171
|
+
});
|
|
172
|
+
expect(authorizeRes.status).toBe(200);
|
|
173
|
+
const { providerUrl } = (await authorizeRes.json()) as { providerUrl: string };
|
|
174
|
+
const provider = new URL(providerUrl);
|
|
175
|
+
|
|
176
|
+
expect(provider.origin + provider.pathname).toBe(
|
|
177
|
+
"https://login.salesforce.com/services/oauth2/authorize",
|
|
178
|
+
);
|
|
179
|
+
expect(provider.searchParams.get("client_id")).toBe("sf-client-id");
|
|
180
|
+
expect(provider.searchParams.get("scope")).toBe("mcp_api refresh_token");
|
|
181
|
+
expect(provider.searchParams.get("resource")).toBe(mcpServer.url);
|
|
182
|
+
expect(provider.searchParams.get("redirect_uri")).toBe(
|
|
183
|
+
"https://swarm.example.test/api/mcp-oauth/callback",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const state = provider.searchParams.get("state");
|
|
187
|
+
expect(state).toBeTruthy();
|
|
188
|
+
|
|
189
|
+
const callbackRes = await dispatch(
|
|
190
|
+
`/api/mcp-oauth/callback?state=${encodeURIComponent(state!)}&code=sf-auth-code`,
|
|
191
|
+
);
|
|
192
|
+
expect(callbackRes.status).toBe(302);
|
|
193
|
+
expect(callbackRes.headers.location).toBe(
|
|
194
|
+
`${process.env.APP_URL}/mcp-servers/${mcpServer.id}?oauth=success`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const tokenRequest = new URLSearchParams(capturedTokenBody ?? "");
|
|
198
|
+
expect(tokenRequest.get("client_id")).toBe("sf-client-id");
|
|
199
|
+
expect(tokenRequest.get("client_secret")).toBe("sf-client-secret");
|
|
200
|
+
expect(tokenRequest.get("resource")).toBe(mcpServer.url);
|
|
201
|
+
expect(tokenRequest.get("redirect_uri")).toBe(
|
|
202
|
+
"https://swarm.example.test/api/mcp-oauth/callback",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const connectedToken = getMcpOAuthToken(mcpServer.id);
|
|
206
|
+
expect(connectedToken?.clientSource).toBe("manual");
|
|
207
|
+
expect(connectedToken?.status).toBe("connected");
|
|
208
|
+
expect(connectedToken?.accessToken).toBe("sf-access-token");
|
|
209
|
+
expect(connectedToken?.refreshToken).toBe("sf-refresh-token");
|
|
210
|
+
expect(connectedToken?.dcrClientId).toBe("sf-client-id");
|
|
211
|
+
expect(connectedToken?.dcrClientSecret).toBe("sf-client-secret");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -41,6 +41,8 @@ describe("GET /p/:id — HTML public path", () => {
|
|
|
41
41
|
let server: Server;
|
|
42
42
|
const agentId = crypto.randomUUID();
|
|
43
43
|
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
44
|
+
const ORIG_APP = process.env.APP_URL;
|
|
45
|
+
const ORIG_DASHBOARD = process.env.DASHBOARD_URL;
|
|
44
46
|
|
|
45
47
|
beforeAll(async () => {
|
|
46
48
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
@@ -56,6 +58,10 @@ describe("GET /p/:id — HTML public path", () => {
|
|
|
56
58
|
afterAll(async () => {
|
|
57
59
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
58
60
|
closeDb();
|
|
61
|
+
if (ORIG_APP === undefined) delete process.env.APP_URL;
|
|
62
|
+
else process.env.APP_URL = ORIG_APP;
|
|
63
|
+
if (ORIG_DASHBOARD === undefined) delete process.env.DASHBOARD_URL;
|
|
64
|
+
else process.env.DASHBOARD_URL = ORIG_DASHBOARD;
|
|
59
65
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
60
66
|
try {
|
|
61
67
|
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
@@ -98,6 +104,41 @@ describe("GET /p/:id — HTML public path", () => {
|
|
|
98
104
|
expect(text).toContain("class SwarmSDK"); // BROWSER_SDK_JS sentinel
|
|
99
105
|
});
|
|
100
106
|
|
|
107
|
+
test("CSP frame ancestors include deprecated DASHBOARD_URL alias", async () => {
|
|
108
|
+
const prevApp = process.env.APP_URL;
|
|
109
|
+
const prevDashboard = process.env.DASHBOARD_URL;
|
|
110
|
+
delete process.env.APP_URL;
|
|
111
|
+
process.env.DASHBOARD_URL = "https://dashboard.example.test/";
|
|
112
|
+
try {
|
|
113
|
+
const html = "<!doctype html><html><head><title>CSP</title></head><body></body></html>";
|
|
114
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
slug: "dashboard-url-csp",
|
|
119
|
+
title: "Dashboard URL CSP",
|
|
120
|
+
contentType: "text/html",
|
|
121
|
+
authMode: "public",
|
|
122
|
+
body: html,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
expect(post.status).toBe(201);
|
|
126
|
+
const { id } = (await post.json()) as { id: string };
|
|
127
|
+
|
|
128
|
+
const res = await fetch(`${BASE}/p/${id}`);
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
const csp = res.headers.get("content-security-policy");
|
|
131
|
+
const frameAncestors =
|
|
132
|
+
csp?.split(";").find((d) => d.trim().startsWith("frame-ancestors ")) ?? "";
|
|
133
|
+
expect(frameAncestors).toContain("https://dashboard.example.test");
|
|
134
|
+
} finally {
|
|
135
|
+
if (prevApp === undefined) delete process.env.APP_URL;
|
|
136
|
+
else process.env.APP_URL = prevApp;
|
|
137
|
+
if (prevDashboard === undefined) delete process.env.DASHBOARD_URL;
|
|
138
|
+
else process.env.DASHBOARD_URL = prevDashboard;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
101
142
|
test("public JSON page 302-redirects to SPA artifact route", async () => {
|
|
102
143
|
const post = await fetch(`${BASE}/api/pages`, {
|
|
103
144
|
method: "POST",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public `/p/:id` for JSON content. JSON pages do NOT render at the API —
|
|
3
3
|
* the renderer lives in the SPA at `/pages/:id` (step-6/7). The API
|
|
4
|
-
* responds with a 302 to
|
|
4
|
+
* responds with a 302 to the configured app URL's `/pages/:id`.
|
|
5
5
|
*/
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import crypto from "node:crypto";
|
|
@@ -38,6 +38,7 @@ describe("GET /p/:id — JSON page redirect", () => {
|
|
|
38
38
|
const agentId = crypto.randomUUID();
|
|
39
39
|
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
40
40
|
const ORIG_APP = process.env.APP_URL;
|
|
41
|
+
const ORIG_DASHBOARD = process.env.DASHBOARD_URL;
|
|
41
42
|
|
|
42
43
|
beforeAll(async () => {
|
|
43
44
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
@@ -48,6 +49,7 @@ describe("GET /p/:id — JSON page redirect", () => {
|
|
|
48
49
|
initDb(TEST_DB_PATH);
|
|
49
50
|
// Pin APP_URL so the redirect is deterministic across hosts.
|
|
50
51
|
process.env.APP_URL = "http://localhost:5274";
|
|
52
|
+
delete process.env.DASHBOARD_URL;
|
|
51
53
|
server = createTestServer();
|
|
52
54
|
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
53
55
|
});
|
|
@@ -57,6 +59,8 @@ describe("GET /p/:id — JSON page redirect", () => {
|
|
|
57
59
|
closeDb();
|
|
58
60
|
if (ORIG_APP === undefined) delete process.env.APP_URL;
|
|
59
61
|
else process.env.APP_URL = ORIG_APP;
|
|
62
|
+
if (ORIG_DASHBOARD === undefined) delete process.env.DASHBOARD_URL;
|
|
63
|
+
else process.env.DASHBOARD_URL = ORIG_DASHBOARD;
|
|
60
64
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
61
65
|
try {
|
|
62
66
|
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
@@ -64,7 +68,7 @@ describe("GET /p/:id — JSON page redirect", () => {
|
|
|
64
68
|
}
|
|
65
69
|
});
|
|
66
70
|
|
|
67
|
-
test("JSON content redirects to
|
|
71
|
+
test("JSON content redirects to configured app URL pages route", async () => {
|
|
68
72
|
const post = await fetch(`${BASE}/api/pages`, {
|
|
69
73
|
method: "POST",
|
|
70
74
|
headers,
|
|
@@ -83,4 +87,35 @@ describe("GET /p/:id — JSON page redirect", () => {
|
|
|
83
87
|
expect(res.status).toBe(302);
|
|
84
88
|
expect(res.headers.get("location")).toBe(`http://localhost:5274/pages/${id}`);
|
|
85
89
|
});
|
|
90
|
+
|
|
91
|
+
test("JSON content falls back to local SPA when app URL envs are unset", async () => {
|
|
92
|
+
const prevApp = process.env.APP_URL;
|
|
93
|
+
const prevDashboard = process.env.DASHBOARD_URL;
|
|
94
|
+
process.env.APP_URL = "";
|
|
95
|
+
delete process.env.DASHBOARD_URL;
|
|
96
|
+
try {
|
|
97
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers,
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
slug: "redir-local-fallback",
|
|
102
|
+
title: "Redirect Locally",
|
|
103
|
+
contentType: "application/json",
|
|
104
|
+
authMode: "public",
|
|
105
|
+
body: JSON.stringify({ kind: "spec", nodes: [] }),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
expect(post.status).toBe(201);
|
|
109
|
+
const { id } = (await post.json()) as { id: string };
|
|
110
|
+
|
|
111
|
+
const res = await fetch(`${BASE}/p/${id}`, { redirect: "manual" });
|
|
112
|
+
expect(res.status).toBe(302);
|
|
113
|
+
expect(res.headers.get("location")).toBe(`http://localhost:5274/pages/${id}`);
|
|
114
|
+
} finally {
|
|
115
|
+
if (prevApp === undefined) delete process.env.APP_URL;
|
|
116
|
+
else process.env.APP_URL = prevApp;
|
|
117
|
+
if (prevDashboard === undefined) delete process.env.DASHBOARD_URL;
|
|
118
|
+
else process.env.DASHBOARD_URL = prevDashboard;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
86
121
|
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildIdentityPayload,
|
|
4
|
+
CLAUDE_MD_PATH,
|
|
5
|
+
collectProfilePayloads,
|
|
6
|
+
extractSetupScriptContent,
|
|
7
|
+
type FileReader,
|
|
8
|
+
IDENTITY_MD_PATH,
|
|
9
|
+
postProfileUpdate,
|
|
10
|
+
resolveClaudeMdPath,
|
|
11
|
+
SETUP_SCRIPT_PATH,
|
|
12
|
+
SOUL_MD_PATH,
|
|
13
|
+
syncProfileFilesToServer,
|
|
14
|
+
TOOLS_MD_PATH,
|
|
15
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
16
|
+
} from "../commands/profile-sync";
|
|
17
|
+
|
|
18
|
+
const MARKER_START = "# === Agent-managed setup (from DB) ===";
|
|
19
|
+
const MARKER_END = "# === End agent-managed setup ===";
|
|
20
|
+
|
|
21
|
+
// A SOUL/IDENTITY body long enough to clear the 500-char min-length guard.
|
|
22
|
+
const LONG = "x".repeat(600);
|
|
23
|
+
|
|
24
|
+
describe("extractSetupScriptContent (marker extraction)", () => {
|
|
25
|
+
test("extracts ONLY the content between the agent-managed markers", () => {
|
|
26
|
+
const raw = [
|
|
27
|
+
"#!/bin/bash",
|
|
28
|
+
"echo operator-prelude",
|
|
29
|
+
MARKER_START,
|
|
30
|
+
"export FOO=bar",
|
|
31
|
+
'echo "agent line"',
|
|
32
|
+
MARKER_END,
|
|
33
|
+
"echo operator-postlude",
|
|
34
|
+
].join("\n");
|
|
35
|
+
|
|
36
|
+
expect(extractSetupScriptContent(raw)).toBe('export FOO=bar\necho "agent line"');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("strips a leading shebang when no markers are present", () => {
|
|
40
|
+
const raw = '#!/bin/bash\necho "whole file is agent-managed"';
|
|
41
|
+
expect(extractSetupScriptContent(raw)).toBe('echo "whole file is agent-managed"');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns null for an empty / whitespace-only file", () => {
|
|
45
|
+
expect(extractSetupScriptContent("")).toBeNull();
|
|
46
|
+
expect(extractSetupScriptContent(" \n\t ")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns null when the marker section is empty", () => {
|
|
50
|
+
const raw = `prelude\n${MARKER_START}\n \n${MARKER_END}\npostlude`;
|
|
51
|
+
expect(extractSetupScriptContent(raw)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns null when content exceeds the max length", () => {
|
|
55
|
+
const raw = `${MARKER_START}\n${"a".repeat(65537)}\n${MARKER_END}`;
|
|
56
|
+
expect(extractSetupScriptContent(raw)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("buildIdentityPayload (min-length guard)", () => {
|
|
61
|
+
test("includes SOUL/IDENTITY only when they clear the 500-char minimum", () => {
|
|
62
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
63
|
+
try {
|
|
64
|
+
const ok = buildIdentityPayload({ soulMd: LONG, identityMd: LONG });
|
|
65
|
+
expect(ok.soulMd).toBe(LONG);
|
|
66
|
+
expect(ok.identityMd).toBe(LONG);
|
|
67
|
+
|
|
68
|
+
const short = buildIdentityPayload({ soulMd: "too short", identityMd: "also short" });
|
|
69
|
+
expect(short.soulMd).toBeUndefined();
|
|
70
|
+
expect(short.identityMd).toBeUndefined();
|
|
71
|
+
// The guard must be VISIBLE — it logs why it skipped.
|
|
72
|
+
expect(errSpy).toHaveBeenCalled();
|
|
73
|
+
} finally {
|
|
74
|
+
errSpy.mockRestore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("TOOLS.md has no min-length guard (any non-empty content syncs)", () => {
|
|
79
|
+
const payload = buildIdentityPayload({ toolsMd: "short tools" });
|
|
80
|
+
expect(payload.toolsMd).toBe("short tools");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("HEARTBEAT.md syncs even when empty (no trim/min-length guard)", () => {
|
|
84
|
+
const payload = buildIdentityPayload({ heartbeatMd: "" });
|
|
85
|
+
expect(payload.heartbeatMd).toBe("");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("skips files that exceed the max length", () => {
|
|
89
|
+
const huge = "z".repeat(65537);
|
|
90
|
+
const payload = buildIdentityPayload({ soulMd: huge, toolsMd: huge });
|
|
91
|
+
expect(payload.soulMd).toBeUndefined();
|
|
92
|
+
expect(payload.toolsMd).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("absent files (undefined) produce no keys", () => {
|
|
96
|
+
expect(buildIdentityPayload({})).toEqual({});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("collectProfilePayloads (field gate)", () => {
|
|
101
|
+
const reader = (files: Record<string, string>): FileReader => {
|
|
102
|
+
return async (path: string) => files[path];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test("only the selected field group is collected", async () => {
|
|
106
|
+
const files = reader({
|
|
107
|
+
[SOUL_MD_PATH]: LONG,
|
|
108
|
+
[IDENTITY_MD_PATH]: LONG,
|
|
109
|
+
[TOOLS_MD_PATH]: "tools",
|
|
110
|
+
[CLAUDE_MD_PATH]: "claude md content",
|
|
111
|
+
[SETUP_SCRIPT_PATH]: `${MARKER_START}\nexport X=1\n${MARKER_END}`,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const setupOnly = await collectProfilePayloads(["setup"], "session_sync", files);
|
|
115
|
+
expect(setupOnly.map((p) => p.label)).toEqual(["setup"]);
|
|
116
|
+
expect(setupOnly[0]?.body).toEqual({ setupScript: "export X=1", changeSource: "session_sync" });
|
|
117
|
+
|
|
118
|
+
const claudeOnly = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
119
|
+
expect(claudeOnly.map((p) => p.label)).toEqual(["claude"]);
|
|
120
|
+
|
|
121
|
+
const all = await collectProfilePayloads(
|
|
122
|
+
["identity", "claude", "setup"],
|
|
123
|
+
"session_sync",
|
|
124
|
+
files,
|
|
125
|
+
);
|
|
126
|
+
expect(all.map((p) => p.label).sort()).toEqual(["claude", "identity", "setup"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a missing file yields no payload for that group (no empty POST)", async () => {
|
|
130
|
+
const files = reader({}); // nothing on disk
|
|
131
|
+
const payloads = await collectProfilePayloads(
|
|
132
|
+
["identity", "claude", "setup"],
|
|
133
|
+
"session_sync",
|
|
134
|
+
files,
|
|
135
|
+
);
|
|
136
|
+
expect(payloads).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("propagates the changeSource into every body", async () => {
|
|
140
|
+
const files = reader({ [TOOLS_MD_PATH]: "tools" });
|
|
141
|
+
const payloads = await collectProfilePayloads(["identity"], "self_edit", files);
|
|
142
|
+
expect(payloads[0]?.body.changeSource).toBe("self_edit");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("non-Claude providers sync /workspace/CLAUDE.md, not the personal file", async () => {
|
|
146
|
+
// A codex/pi/opencode session edits the runner-materialized workspace file;
|
|
147
|
+
// the Claude personal file (~/.claude/CLAUDE.md) is absent for them.
|
|
148
|
+
const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "workspace claude md edit" });
|
|
149
|
+
|
|
150
|
+
const payloads = await collectProfilePayloads(
|
|
151
|
+
["claude"],
|
|
152
|
+
"session_sync",
|
|
153
|
+
files,
|
|
154
|
+
WORKSPACE_CLAUDE_MD_PATH,
|
|
155
|
+
);
|
|
156
|
+
expect(payloads.map((p) => p.label)).toEqual(["claude"]);
|
|
157
|
+
expect(payloads[0]?.body).toEqual({
|
|
158
|
+
claudeMd: "workspace claude md edit",
|
|
159
|
+
changeSource: "session_sync",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("Claude's default path never reads the workspace materialization", async () => {
|
|
164
|
+
// Guard against reverting a real Claude personal-file edit: with the default
|
|
165
|
+
// (personal-file) path, content sitting only at /workspace/CLAUDE.md — the
|
|
166
|
+
// stale boot materialization — must NOT be picked up as a claude payload.
|
|
167
|
+
const files = reader({ [WORKSPACE_CLAUDE_MD_PATH]: "stale workspace materialization" });
|
|
168
|
+
|
|
169
|
+
const payloads = await collectProfilePayloads(["claude"], "session_sync", files);
|
|
170
|
+
expect(payloads).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("resolveClaudeMdPath (per-batch provider routing)", () => {
|
|
175
|
+
test("an all-Claude batch uses the personal-file path (Stop-hook backstop)", () => {
|
|
176
|
+
expect(resolveClaudeMdPath(["claude"])).toBe(CLAUDE_MD_PATH);
|
|
177
|
+
expect(resolveClaudeMdPath(["claude", "claude"])).toBe(CLAUDE_MD_PATH);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("any non-Claude local session routes to the workspace file", () => {
|
|
181
|
+
expect(resolveClaudeMdPath(["codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
182
|
+
expect(resolveClaudeMdPath(["pi"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
183
|
+
expect(resolveClaudeMdPath(["opencode"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
184
|
+
// Mixed batch: a non-Claude edit means the workspace file is authoritative.
|
|
185
|
+
expect(resolveClaudeMdPath(["claude", "codex"])).toBe(WORKSPACE_CLAUDE_MD_PATH);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("postProfileUpdate (non-2xx is surfaced, not swallowed)", () => {
|
|
190
|
+
const opts = {
|
|
191
|
+
agentId: "agent-1",
|
|
192
|
+
apiUrl: "https://api.example.test",
|
|
193
|
+
apiKey: "secret-key",
|
|
194
|
+
};
|
|
195
|
+
const payload = {
|
|
196
|
+
label: "setup",
|
|
197
|
+
body: { setupScript: "export X=1", changeSource: "session_sync" },
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
test("a successful 2xx response logs no warning", async () => {
|
|
201
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
202
|
+
const fetchImpl = (async () => new Response("{}", { status: 200 })) as typeof fetch;
|
|
203
|
+
try {
|
|
204
|
+
await postProfileUpdate({ ...opts, fetchImpl }, payload);
|
|
205
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
206
|
+
} finally {
|
|
207
|
+
warnSpy.mockRestore();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a non-2xx response surfaces a warning but does NOT throw", async () => {
|
|
212
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
213
|
+
const fetchImpl = (async () =>
|
|
214
|
+
new Response("boom", { status: 500, statusText: "Server Error" })) as typeof fetch;
|
|
215
|
+
try {
|
|
216
|
+
// Must resolve (non-fatal), not reject.
|
|
217
|
+
await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
|
|
218
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
219
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
220
|
+
expect(msg).toContain("setup sync failed");
|
|
221
|
+
expect(msg).toContain("500");
|
|
222
|
+
} finally {
|
|
223
|
+
warnSpy.mockRestore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a thrown fetch error surfaces a warning but does NOT throw", async () => {
|
|
228
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
229
|
+
const fetchImpl = (async () => {
|
|
230
|
+
throw new Error("network down");
|
|
231
|
+
}) as typeof fetch;
|
|
232
|
+
try {
|
|
233
|
+
await expect(postProfileUpdate({ ...opts, fetchImpl }, payload)).resolves.toBeUndefined();
|
|
234
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("setup sync errored");
|
|
236
|
+
} finally {
|
|
237
|
+
warnSpy.mockRestore();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("sends a PUT to the profile route with auth + agent headers", async () => {
|
|
242
|
+
let capturedUrl = "";
|
|
243
|
+
let capturedInit: RequestInit | undefined;
|
|
244
|
+
const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
245
|
+
capturedUrl = String(url);
|
|
246
|
+
capturedInit = init;
|
|
247
|
+
return new Response("{}", { status: 200 });
|
|
248
|
+
}) as typeof fetch;
|
|
249
|
+
|
|
250
|
+
await postProfileUpdate({ ...opts, fetchImpl }, payload);
|
|
251
|
+
|
|
252
|
+
expect(capturedUrl).toBe("https://api.example.test/api/agents/agent-1/profile");
|
|
253
|
+
expect(capturedInit?.method).toBe("PUT");
|
|
254
|
+
const headers = capturedInit?.headers as Record<string, string>;
|
|
255
|
+
expect(headers.Authorization).toBe("Bearer secret-key");
|
|
256
|
+
expect(headers["X-Agent-ID"]).toBe("agent-1");
|
|
257
|
+
expect(JSON.parse(String(capturedInit?.body))).toEqual(payload.body);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("syncProfileFilesToServer (orchestration is non-fatal)", () => {
|
|
262
|
+
test("resolves without throwing even when every POST fails", async () => {
|
|
263
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
264
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
265
|
+
const fetchImpl = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
|
266
|
+
try {
|
|
267
|
+
await expect(
|
|
268
|
+
syncProfileFilesToServer({
|
|
269
|
+
agentId: "agent-1",
|
|
270
|
+
apiUrl: "https://api.example.test",
|
|
271
|
+
apiKey: "secret-key",
|
|
272
|
+
changeSource: "session_sync",
|
|
273
|
+
// No files on a CI box → typically no payloads; still must never throw.
|
|
274
|
+
fetchImpl,
|
|
275
|
+
}),
|
|
276
|
+
).resolves.toBeUndefined();
|
|
277
|
+
} finally {
|
|
278
|
+
warnSpy.mockRestore();
|
|
279
|
+
errSpy.mockRestore();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -288,6 +288,39 @@ describe("runScript", () => {
|
|
|
288
288
|
}
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
test("zod import works in compiled binary mode (SCRIPT_RUNTIME_DIR)", async () => {
|
|
292
|
+
const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/script-runtime-test-${crypto.randomUUID()}`;
|
|
293
|
+
await Bun.$`mkdir -p ${tmpdir}`;
|
|
294
|
+
try {
|
|
295
|
+
const runtimeSrc = new URL("../scripts-runtime", import.meta.url).pathname;
|
|
296
|
+
await Bun.$`bun build ${runtimeSrc}/eval-harness.ts --target bun --no-splitting --outfile ${tmpdir}/eval-harness.bundle.js`.quiet();
|
|
297
|
+
await Bun.$`bun build ${runtimeSrc}/stdlib/index.ts --target bun --no-splitting --outfile ${tmpdir}/stdlib.bundle.js`.quiet();
|
|
298
|
+
await Bun.$`bun build ${runtimeSrc}/swarm-sdk.ts --target bun --no-splitting --outfile ${tmpdir}/swarm-sdk.bundle.js`.quiet();
|
|
299
|
+
const zodEntry = Bun.resolveSync("zod", import.meta.dir);
|
|
300
|
+
await Bun.$`bun build ${zodEntry} --target bun --no-splitting --outfile ${tmpdir}/zod.bundle.js`.quiet();
|
|
301
|
+
|
|
302
|
+
process.env.SCRIPT_RUNTIME_DIR = tmpdir;
|
|
303
|
+
|
|
304
|
+
const output = await runScript({
|
|
305
|
+
agentId: "agent-1",
|
|
306
|
+
args: { name: "test" },
|
|
307
|
+
resources,
|
|
308
|
+
source: `
|
|
309
|
+
import { z } from "zod";
|
|
310
|
+
export const argsSchema = z.object({ name: z.string() });
|
|
311
|
+
export default async (args: z.infer<typeof argsSchema>) => ({ greeting: "hello " + args.name });
|
|
312
|
+
`,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(output.error).toBeUndefined();
|
|
316
|
+
expect(output.result).toEqual({ greeting: "hello test" });
|
|
317
|
+
expect(output.exitCode).toBe(0);
|
|
318
|
+
} finally {
|
|
319
|
+
delete process.env.SCRIPT_RUNTIME_DIR;
|
|
320
|
+
await Bun.$`rm -rf ${tmpdir}`;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
291
324
|
test("argsSchema rejects invalid args with a formatted Zod error", async () => {
|
|
292
325
|
const output = await runScript({
|
|
293
326
|
agentId: "agent-1",
|
|
@@ -48,8 +48,8 @@ afterAll(async () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
describe("seed-scripts catalog", () => {
|
|
51
|
-
test("manifest holds
|
|
52
|
-
expect(SEED_SCRIPTS.length).toBe(
|
|
51
|
+
test("manifest holds 14 unique, well-described scripts", () => {
|
|
52
|
+
expect(SEED_SCRIPTS.length).toBe(14);
|
|
53
53
|
const names = SEED_SCRIPTS.map((s) => s.name);
|
|
54
54
|
expect(new Set(names).size).toBe(names.length);
|
|
55
55
|
for (const s of SEED_SCRIPTS) {
|