@jrpool/kilotest 52.0.0 → 55.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.devin/config.local.json +4 -1
- package/.github/workflows/ci.yml +3 -0
- package/api/requestFeature.test.ts +36 -0
- package/api/requestFeature.ts +26 -4
- package/api/requestRetest.test.ts +18 -0
- package/api/requestRetest.ts +4 -0
- package/api/requestTest.test.ts +34 -0
- package/api/requestTest.ts +19 -1
- package/capability.md +2 -2
- package/docs/AI-TOOLS.md +3 -3
- package/docs/SERVICE.md +14 -1
- package/env.example +24 -0
- package/index.test.ts +51 -0
- package/index.ts +49 -20
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/mcp.test.ts +41 -4
- package/mcp.ts +67 -17
- package/openapi.yaml +5 -5
- package/package.json +2 -1
- package/scripts/generate-openapi.ts +1 -1
- package/util.test.ts +272 -0
- package/util.ts +102 -39
- package/web/requestRetest/index.ts +9 -0
- package/web/requestRetest/requestRetest.test.ts +14 -0
- package/web/requestTest/index.ts +9 -0
- package/web/requestTest/requestTest.test.ts +16 -0
- package/web/tutorialAI/index.ts +25 -3
- package/web/tutorialAI/tutorialAI.test.ts +32 -0
- package/web/tutorialWeb/index.ts +25 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +32 -0
package/.devin/config.local.json
CHANGED
package/.github/workflows/ci.yml
CHANGED
|
@@ -81,9 +81,45 @@ test('requestFeature accepts a non-empty feature request and notifies the manage
|
|
|
81
81
|
assert.ok(logged.some(line =>
|
|
82
82
|
line.startsWith('WARNING (Kilotest: MCP feature request received)')
|
|
83
83
|
&& line.includes('Add a dark mode toggle')
|
|
84
|
+
// The alert reports the resulting queue size, so a maintainer who has been away can
|
|
85
|
+
// see at a glance how close the queue is to full.
|
|
86
|
+
&& line.includes('Feature requests now awaiting review: 1 of 20')
|
|
84
87
|
));
|
|
85
88
|
});
|
|
86
89
|
|
|
90
|
+
test('requestFeature rejects a request with a fallback channel when the queue is full', async () => {
|
|
91
|
+
// Fill featureRequests.json with 20 distinct requests, reaching the cap.
|
|
92
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
93
|
+
timeStamp: '260101T0000', content: `Filler feature request number ${i}, long enough to pass the length check.`
|
|
94
|
+
}));
|
|
95
|
+
await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify(filler));
|
|
96
|
+
const body = await response(['One feature request too many, long enough to pass the length check.']);
|
|
97
|
+
const details = body['response content']['details about your request'] as any;
|
|
98
|
+
assert.ok(details.error.includes('too many feature requests are awaiting review right now.'));
|
|
99
|
+
assert.ok(details.error.includes('https://github.com/jrpool/kilotest/issues'));
|
|
100
|
+
assert.ok(details.error.includes('info@kilotest.com'));
|
|
101
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
102
|
+
// The rejected request was not recorded.
|
|
103
|
+
assert.equal(featureRequests.length, 20);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('requestFeature never rejects for a full inbox when FEATURE_REQUESTS_MAX is 0 (no limit)', async () => {
|
|
107
|
+
process.env.FEATURE_REQUESTS_MAX = '0';
|
|
108
|
+
try {
|
|
109
|
+
// Fill featureRequests.json past the default cap.
|
|
110
|
+
const filler = Array.from({length: 25}, (_, i) => ({
|
|
111
|
+
timeStamp: '260101T0000', content: `Filler feature request number ${i}, long enough to pass the length check.`
|
|
112
|
+
}));
|
|
113
|
+
await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify(filler));
|
|
114
|
+
const body = await response(['One more feature request, long enough to pass the length check.']);
|
|
115
|
+
const details = body['response content']['details about your request'] as any;
|
|
116
|
+
assert.equal(details.error, undefined);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
delete process.env.FEATURE_REQUESTS_MAX;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
87
123
|
test('requestFeature includes tool name and metadata', async () => {
|
|
88
124
|
const body = await response(['A sufficiently long feature description']);
|
|
89
125
|
assert.equal(body['tool name'], 'requestFeature');
|
package/api/requestFeature.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
10
|
import {sendAlert} from '../alerts.ts';
|
|
11
|
-
import {checkCommentDuplicate, checkLength, getJSON, getNowStamp} from '../util.ts';
|
|
11
|
+
import {checkCommentDuplicate, checkLength, describeMax, getEnvMax, getJSON, getNowStamp} from '../util.ts';
|
|
12
12
|
import {requestFeatureResponseSchema} from './schemas.ts';
|
|
13
13
|
import fs from 'node:fs/promises';
|
|
14
14
|
import path from 'node:path';
|
|
@@ -21,6 +21,13 @@ type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response co
|
|
|
21
21
|
// FUNCTIONS (helpers)
|
|
22
22
|
|
|
23
23
|
const getFeatureRequestsPath = () => process.env.FEATURE_REQUESTS_PATH || path.join(import.meta.dirname, '../db/featureRequests.json');
|
|
24
|
+
// Maximum number of feature requests held at once. Once reached, a new, distinct request
|
|
25
|
+
// is rejected rather than stored, so the feature-requests file and the manager alert
|
|
26
|
+
// emails it generates cannot grow without bound (modeled on a full mailbox rejecting new
|
|
27
|
+
// mail; see GitHub issue #3, abuse type 3). Configurable via FEATURE_REQUESTS_MAX, since
|
|
28
|
+
// different Kilotest deployments' maintainers may want a different size; a value of 0
|
|
29
|
+
// means no limit.
|
|
30
|
+
const getFeatureRequestsMax = () => getEnvMax('FEATURE_REQUESTS_MAX', 20);
|
|
24
31
|
|
|
25
32
|
// FUNCTIONS
|
|
26
33
|
|
|
@@ -61,7 +68,18 @@ export const response = async (args: string[]) => {
|
|
|
61
68
|
}
|
|
62
69
|
};
|
|
63
70
|
}
|
|
64
|
-
// Otherwise,
|
|
71
|
+
// Otherwise, if the feature requests already on file have reached the cap (a cap of 0
|
|
72
|
+
// means no limit, so the queue is never full):
|
|
73
|
+
else if (getFeatureRequestsMax() > 0 && featureRequests.length >= getFeatureRequestsMax()) {
|
|
74
|
+
responseContent = {
|
|
75
|
+
'details about your request': {
|
|
76
|
+
error: 'request invalid: too many feature requests are awaiting review right now. ' +
|
|
77
|
+
'Please try again later, or post your request at https://github.com/jrpool/kilotest/issues ' +
|
|
78
|
+
'or email info@kilotest.com.'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Otherwise, i.e. if it is not a duplicate and the queue is not full:
|
|
65
83
|
else {
|
|
66
84
|
// Record the feature request alongside the existing ones.
|
|
67
85
|
featureRequests.push({
|
|
@@ -70,8 +88,12 @@ export const response = async (args: string[]) => {
|
|
|
70
88
|
});
|
|
71
89
|
await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
|
|
72
90
|
await fs.writeFile(featureRequestsPath, getJSON(featureRequests));
|
|
73
|
-
// Notify the manager
|
|
74
|
-
|
|
91
|
+
// Notify the manager, including the resulting count, so a maintainer who has been
|
|
92
|
+
// away sees at a glance how urgently the requests need review.
|
|
93
|
+
await sendAlert(
|
|
94
|
+
'Kilotest: MCP feature request received',
|
|
95
|
+
`${feature}\nFeature requests now awaiting review: ${featureRequests.length} of ${describeMax(getFeatureRequestsMax())}`
|
|
96
|
+
);
|
|
75
97
|
// Add the disposition to the response content.
|
|
76
98
|
responseContent = {
|
|
77
99
|
'details about your request': {
|
|
@@ -173,5 +173,23 @@ test('requestRetest accepts a valid retest request for the latest report of a pa
|
|
|
173
173
|
line.startsWith('WARNING (Kilotest: new retest request awaits approval)')
|
|
174
174
|
&& line.includes('Mixed Outcomes Page')
|
|
175
175
|
&& line.includes('https://example.com/mixed')
|
|
176
|
+
// The alert reports the resulting queue size, so a maintainer who has been away can
|
|
177
|
+
// see at a glance how close the queue is to full.
|
|
178
|
+
&& line.includes('Requests now awaiting approval: 1 of 20')
|
|
176
179
|
));
|
|
177
180
|
});
|
|
181
|
+
|
|
182
|
+
test('requestRetest rejects a request with a fallback channel when the queue is full', async () => {
|
|
183
|
+
// Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
|
|
184
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
185
|
+
timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Filler reason.'
|
|
186
|
+
}));
|
|
187
|
+
await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
|
|
188
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
189
|
+
const details = body['response content']['details about your request'] as any;
|
|
190
|
+
assert.equal(details.error, undefined);
|
|
191
|
+
const disposition = body['response content']['disposition of your request'] as any;
|
|
192
|
+
assert.ok(disposition['what happens next'].includes('too many requests are awaiting approval right now.'));
|
|
193
|
+
assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
|
|
194
|
+
assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
|
|
195
|
+
});
|
package/api/requestRetest.ts
CHANGED
|
@@ -79,6 +79,10 @@ export const response = async (args: string[]) => {
|
|
|
79
79
|
else if (requestResult === 'superseded') {
|
|
80
80
|
failureReason = 'a later report about a page with the same description is available.'
|
|
81
81
|
}
|
|
82
|
+
else if (requestResult === 'queueFull') {
|
|
83
|
+
failureReason = 'too many requests are awaiting approval right now. Please try again later, ' +
|
|
84
|
+
'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
85
|
+
}
|
|
82
86
|
else {
|
|
83
87
|
failureReason = 'an identical request is already awaiting approval.'
|
|
84
88
|
}
|
package/api/requestTest.test.ts
CHANGED
|
@@ -93,6 +93,22 @@ test('requestTest rejects a private-address URL unless internal targets are allo
|
|
|
93
93
|
}
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test('requestTest rejects a URL that redirects to a disallowed target, and alerts a manager', async (t) => {
|
|
97
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
98
|
+
t.mock.method(globalThis, 'fetch', async () => ({url: 'https://10.0.0.5/page'}) as Response);
|
|
99
|
+
try {
|
|
100
|
+
const body = await response(['Redirecting Page', 'https://example.com/redirector', 'A reason that is long enough.']);
|
|
101
|
+
const details = body['response content']['details about your request'] as any;
|
|
102
|
+
assert.ok(details.error.includes('does not resolve to a page that this deployment can test'));
|
|
103
|
+
assert.ok(logged.some(line =>
|
|
104
|
+
line.includes('WARNING (Kilotest: request rejected for redirecting to a disallowed target)')
|
|
105
|
+
&& line.includes('https://example.com/redirector')
|
|
106
|
+
));
|
|
107
|
+
} finally {
|
|
108
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
96
112
|
test('requestTest rejects an already-tested page', async () => {
|
|
97
113
|
const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
|
|
98
114
|
const details = body['response content']['details about your request'] as any;
|
|
@@ -165,6 +181,9 @@ test('requestTest accepts a valid new page request', async () => {
|
|
|
165
181
|
line.startsWith('WARNING (Kilotest: new test request awaits approval)')
|
|
166
182
|
&& line.includes('Brand New Page')
|
|
167
183
|
&& line.includes('https://example.com/brandnew')
|
|
184
|
+
// The alert reports the resulting queue size, so a maintainer who has been away can
|
|
185
|
+
// see at a glance how close the queue is to full.
|
|
186
|
+
&& line.includes('Requests now awaiting approval: 1 of 20')
|
|
168
187
|
));
|
|
169
188
|
});
|
|
170
189
|
|
|
@@ -174,3 +193,18 @@ test('requestTest includes disposition information for a valid request', async (
|
|
|
174
193
|
assert.ok(disposition);
|
|
175
194
|
assert.ok(disposition['what happens next']);
|
|
176
195
|
});
|
|
196
|
+
|
|
197
|
+
test('requestTest rejects a request with a fallback channel when the queue is full', async () => {
|
|
198
|
+
// Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
|
|
199
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
200
|
+
timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Filler reason.'
|
|
201
|
+
}));
|
|
202
|
+
await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
|
|
203
|
+
const body = await response(['One Too Many Page', 'https://example.com/one-too-many', 'A reason that is long enough.']);
|
|
204
|
+
const details = body['response content']['details about your request'] as any;
|
|
205
|
+
assert.equal(details.error, undefined);
|
|
206
|
+
const disposition = body['response content']['disposition of your request'] as any;
|
|
207
|
+
assert.ok(disposition['what happens next'].includes('too many requests are awaiting approval right now.'));
|
|
208
|
+
assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
|
|
209
|
+
assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
|
|
210
|
+
});
|
package/api/requestTest.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
-
import {checkLength, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
|
|
10
|
+
import {checkLength, isAllowedRedirectTarget, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
|
|
11
11
|
import {requestTestResponseSchema} from './schemas.ts';
|
|
12
|
+
import {sendAlert} from '../alerts.ts';
|
|
12
13
|
|
|
13
14
|
// TYPES
|
|
14
15
|
|
|
@@ -49,6 +50,19 @@ export const response = async (args: string[]) => {
|
|
|
49
50
|
error: 'request invalid: the URL you specified does not resolve to a page that this deployment can test'
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
// Otherwise, i.e. if the URL redirects to a target this deployment does not allow:
|
|
54
|
+
else if (!(await isAllowedRedirectTarget(url))) {
|
|
55
|
+
requestDetails = {
|
|
56
|
+
error: 'request invalid: the URL you specified does not resolve to a page that this deployment can test'
|
|
57
|
+
};
|
|
58
|
+
// Alert a manager, since a redirect to a disallowed target on a request that
|
|
59
|
+
// itself named an allowed hostname suggests an attempted or accidental SSRF,
|
|
60
|
+
// rather than an ordinary mistake by a legitimate submitter.
|
|
61
|
+
await sendAlert(
|
|
62
|
+
'Kilotest: request rejected for redirecting to a disallowed target',
|
|
63
|
+
`A request to test ${url} (described as "${description}") was rejected because it redirects to a target this deployment does not allow (e.g. a private, loopback, or link-local address).`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
52
66
|
// Otherwise, i.e. if the description and URL are valid:
|
|
53
67
|
else {
|
|
54
68
|
// Process the request.
|
|
@@ -82,6 +96,10 @@ export const response = async (args: string[]) => {
|
|
|
82
96
|
else if (requestResult === 'retest') {
|
|
83
97
|
failureReason = 'a report about a page with the same description and URL is available.'
|
|
84
98
|
}
|
|
99
|
+
else if (requestResult === 'queueFull') {
|
|
100
|
+
failureReason = 'too many requests are awaiting approval right now. Please try again later, ' +
|
|
101
|
+
'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
102
|
+
}
|
|
85
103
|
else {
|
|
86
104
|
failureReason = 'an identical request is already awaiting approval.'
|
|
87
105
|
}
|
package/capability.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Capability
|
|
4
4
|
|
|
5
|
-
Web-page front-end quality audit. Kilotest tests
|
|
5
|
+
Web-page front-end quality audit. Kilotest tests web pages for front-end quality, namely accessibility, usability, and standards conformity, and makes structured findings available for retrieval.
|
|
6
6
|
|
|
7
7
|
## Purpose
|
|
8
8
|
|
|
@@ -36,7 +36,7 @@ A list of available reports.
|
|
|
36
36
|
|
|
37
37
|
## Side effects
|
|
38
38
|
|
|
39
|
-
Kilotest reads the specified
|
|
39
|
+
Kilotest reads the specified web page during testing. It does not modify the target site. Testing is asynchronous and may take up to approximately one day before reports are available for newly requested pages.
|
|
40
40
|
|
|
41
41
|
## Cost
|
|
42
42
|
|
package/docs/AI-TOOLS.md
CHANGED
|
@@ -42,7 +42,7 @@ The external features that support the use of Kilotest as a collection of AI too
|
|
|
42
42
|
- A [pull request](https://github.com/public-apis/public-apis/pull/6346/changes) to add Kilotest to the list of public APIs in the `public-apis` repository.
|
|
43
43
|
- An [issue](https://github.com/APIs-guru/openapi-directory/issues/2677) to add Kilotest to `openapi-directory`.
|
|
44
44
|
- A [pull request](https://github.com/w3c/wai-evaluation-tools-list/pull/1153) to add Kilotest to the WAI evaluation tools list.
|
|
45
|
-
- Registration of Kilotest as an active server in the [official MCP Registry](https://registry.modelcontextprotocol.io
|
|
45
|
+
- Registration of Kilotest as an active server in the [official MCP Registry](https://registry.modelcontextprotocol.io) (`io.github.jrpool/kilotest`), maintained by the [Model Context Protocol](https://modelcontextprotocol.io/) project. The [PulseMCP](https://www.pulsemcp.com/servers) directory and the VS Code MCP gallery both crawl this registry, so Kilotest is discoverable through them with no separate submission.
|
|
46
46
|
- Registration of Kilotest with the [Smithery](https://smithery.ai/servers/pool/kilotest) MCP server registry.
|
|
47
47
|
- Registration of Kilotest with the [Glama](https://glama.ai/mcp/connectors/com.kilotest/kilotest) MCP server registry, including a claim of ownership of the MCP connector by the maintainer via a `TXT` DNS record for `_glama-claim.kilotest.com`, with the value `glama_claim_gVV4HrPdcXr9TEfj1p3icEUBJ3_OVJGX`.
|
|
48
48
|
- Registration of Kilotest with [Awesome MCP Servers](https://www.tensorblock.co/mcp/servers/github-jrpool-kilotest-f518ba36).
|
|
@@ -107,7 +107,7 @@ Other future work:
|
|
|
107
107
|
Capability: web-page-quality-audit
|
|
108
108
|
|
|
109
109
|
Purpose:
|
|
110
|
-
Test a
|
|
110
|
+
Test a web page for accessibility,
|
|
111
111
|
usability, HTML and web-standards problems.
|
|
112
112
|
|
|
113
113
|
Interfaces:
|
|
@@ -123,7 +123,7 @@ Other future work:
|
|
|
123
123
|
rule-engine diagnoses, WCAG mappings
|
|
124
124
|
|
|
125
125
|
Side effects:
|
|
126
|
-
Reads the specified
|
|
126
|
+
Reads the specified web page.
|
|
127
127
|
Does not modify the target site.
|
|
128
128
|
|
|
129
129
|
Cost:
|
package/docs/SERVICE.md
CHANGED
|
@@ -417,6 +417,19 @@ Alerts are sent via the `sendAlert()` function in [alerts.ts](../alerts.ts), whi
|
|
|
417
417
|
|
|
418
418
|
If any of these five variables is missing or empty at alert time, the alert is not sent. Instead, a warning message is logged to the console, and the application continues normal operation. The warning includes the alert subject and body so the maintainer can manually review what would have been sent.
|
|
419
419
|
|
|
420
|
+
### Inbox size limits
|
|
421
|
+
|
|
422
|
+
Four submission types (test requests and retest requests together, web tutorial comments, AI tutorial comments, and MCP feature requests) are each held in a pending "inbox" (`db/jobs/testRequests.json`, `db/comments/tutorialWeb.json`, `db/comments/tutorialAI.json`, and `db/featureRequests.json` respectively) awaiting manual review by the maintainer. Each inbox has a maximum size; once reached, a new, distinct submission is rejected (with a message suggesting the requester instead post to the [GitHub issues page](https://github.com/jrpool/kilotest/issues) or email `info@kilotest.com`) rather than added, so neither the inbox file nor the manager alert emails it generates can grow without bound (see GitHub issue #3, abuse type 3, repeated/automated submissions). This is modeled on a full mailbox rejecting new mail, rather than on a submission rate limit: it is the backlog size itself, not how quickly it grows, that costs the maintainer review time and risks unbounded storage. Every alert sent for an accepted submission reports the resulting inbox count against its cap, so a maintainer returning after an absence can immediately see how close an inbox is to full.
|
|
423
|
+
|
|
424
|
+
Each cap defaults to 20 and can be set independently via an environment variable:
|
|
425
|
+
|
|
426
|
+
1. **`TEST_REQUEST_QUEUE_MAX`**: The maximum number of test and retest requests awaiting approval at once, summed across all requested URLs.
|
|
427
|
+
2. **`TUTORIAL_WEB_COMMENTS_MAX`**: The maximum number of comments held on the web tutorial page (`/tutorialWeb.html`).
|
|
428
|
+
3. **`TUTORIAL_AI_COMMENTS_MAX`**: The maximum number of comments held on the AI agent tutorial page (`/tutorialAI.html`).
|
|
429
|
+
4. **`FEATURE_REQUESTS_MAX`**: The maximum number of feature requests held via the MCP server's `requestFeature` tool.
|
|
430
|
+
|
|
431
|
+
Each variable, if set, must be a non-negative integer; if unset, empty, or not a non-negative integer, the default of 20 is used instead. A value of `0` means no limit: that inbox is never treated as full, regardless of size. Unlike the alert-configuration variables above, these are not required: an unset value never disables the limit, it only selects the default size.
|
|
432
|
+
|
|
420
433
|
### Resend integration
|
|
421
434
|
|
|
422
435
|
Kilotest uses [Resend](https://resend.com/) as its transactional email delivery service. Resend is configured through the environment variables above.
|
|
@@ -489,7 +502,7 @@ Kilotest records basic counts of how it is used, so the maintainer can answer qu
|
|
|
489
502
|
|
|
490
503
|
- The generic `.html` GET dispatch in `index.ts`, once per successfully served web page, recorded under the `pageViews` category by page name (for example `tutorialWeb`, `listReports`), unless the page is a manager-only page (see "Manager page activity" below), in which case it is recorded there instead.
|
|
491
504
|
- The home page (`/` and `/index.html`), served by its own branch in `index.ts` rather than through the generic `.html` dispatch, recorded under `pageViews` as `index`.
|
|
492
|
-
- Each of the
|
|
505
|
+
- Each of the 9 MCP tool handlers in `mcp.ts`, once per successful tool call, recorded under the `mcpToolCalls` category by tool name (for example `listReports`, `requestTest`).
|
|
493
506
|
- The `/api/*` GET and POST service branches in `index.ts`, once per call, recorded under the `apiOperations` category by operation name.
|
|
494
507
|
|
|
495
508
|
`db/metrics.json` also stores a `since` time stamp, set when the file is first created, so the counts can be read as "since this date" rather than assumed to cover Kilotest's entire history. `getMetrics()` backfills any category absent from an existing `db/metrics.json` (for example one written before a category such as `managerActivity` existed), so an older file remains readable rather than causing every subsequent request to error.
|
package/env.example
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
ENVIRONMENT=__placeholder__
|
|
3
3
|
# http (local or reverse-proxied server) or https.
|
|
4
4
|
PROTOCOL=__placeholder__
|
|
5
|
+
# Port the server listens on (defaults to 3000).
|
|
6
|
+
PORT=__placeholder__
|
|
7
|
+
# Path of the private key file. Required only when PROTOCOL is https.
|
|
8
|
+
KEY=__placeholder__
|
|
9
|
+
# Path of the certificate file. Required only when PROTOCOL is https.
|
|
10
|
+
CERT=__placeholder__
|
|
5
11
|
# 3-character password for manager operations.
|
|
6
12
|
AUTH_CODE=__placeholder__
|
|
7
13
|
# ID of each Testaro worker and, for each, its secret and published name.
|
|
@@ -23,6 +29,24 @@ DB_DIR=__placeholder__
|
|
|
23
29
|
# since such a worker could otherwise be induced to fetch a target it has no legitimate reason
|
|
24
30
|
# to reach, such as its own host’s cloud-metadata service.
|
|
25
31
|
ALLOW_INTERNAL_TARGETS=__placeholder__
|
|
32
|
+
# Path of the web tutorial comments file (defaults to db/comments/tutorialWeb.json).
|
|
33
|
+
TUTORIAL_WEB_COMMENTS_PATH=__placeholder__
|
|
34
|
+
# Path of the AI tutorial comments file (defaults to db/comments/tutorialAI.json).
|
|
35
|
+
TUTORIAL_AI_COMMENTS_PATH=__placeholder__
|
|
36
|
+
# Path of the feature requests file (defaults to db/featureRequests.json).
|
|
37
|
+
FEATURE_REQUESTS_PATH=__placeholder__
|
|
38
|
+
|
|
39
|
+
# Inbox size limits: each defaults to 20 if unset, empty, or not a non-negative integer.
|
|
40
|
+
# A value of 0 means no limit.
|
|
41
|
+
|
|
42
|
+
# Maximum number of test/retest requests awaiting approval at once, across all URLs.
|
|
43
|
+
TEST_REQUEST_QUEUE_MAX=__placeholder__
|
|
44
|
+
# Maximum number of comments held on the web tutorial page.
|
|
45
|
+
TUTORIAL_WEB_COMMENTS_MAX=__placeholder__
|
|
46
|
+
# Maximum number of comments held on the AI agent tutorial page.
|
|
47
|
+
TUTORIAL_AI_COMMENTS_MAX=__placeholder__
|
|
48
|
+
# Maximum number of feature requests held via the MCP server's requestFeature tool.
|
|
49
|
+
FEATURE_REQUESTS_MAX=__placeholder__
|
|
26
50
|
|
|
27
51
|
# Alert configuration:
|
|
28
52
|
|
package/index.test.ts
CHANGED
|
@@ -1166,6 +1166,57 @@ test('POST /worker/job with a queued job assigns it to the worker', {timeout: 50
|
|
|
1166
1166
|
await fs.unlink(path.join(claimedDir, jobFile)).catch(() => {});
|
|
1167
1167
|
});
|
|
1168
1168
|
|
|
1169
|
+
test('POST /worker/job with a queued job whose host now redirects to a disallowed target rejects it without assigning it', {timeout: 500}, async (t) => {
|
|
1170
|
+
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
1171
|
+
// Clean up claimed, queue, and failed directories.
|
|
1172
|
+
const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
|
|
1173
|
+
const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
|
|
1174
|
+
const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
|
|
1175
|
+
for (const dir of [claimedDir, queueDir]) {
|
|
1176
|
+
await fs.mkdir(dir, {recursive: true});
|
|
1177
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
1178
|
+
for (const file of files) {
|
|
1179
|
+
await fs.unlink(path.join(dir, file)).catch(() => {});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
const jobFile = '260101T0000-mix.json';
|
|
1183
|
+
const failedJobPath = path.join(failedDir, jobFile);
|
|
1184
|
+
await fs.unlink(failedJobPath).catch(() => {});
|
|
1185
|
+
// The job's host still resolves publicly, but by the time it is dequeued for
|
|
1186
|
+
// assignment it has been reconfigured (e.g. by an attacker) to redirect to a
|
|
1187
|
+
// private address, which only a fetch-based check, not a DNS-only one, can see.
|
|
1188
|
+
t.mock.method(globalThis, 'fetch', async () => ({url: 'https://192.168.1.1/test'}) as Response);
|
|
1189
|
+
try {
|
|
1190
|
+
await fs.writeFile(path.join(queueDir, jobFile), JSON.stringify({
|
|
1191
|
+
id: '260101T0000-mix',
|
|
1192
|
+
target: {what: 'Test Page', url: 'https://example.com/test'},
|
|
1193
|
+
sources: {}
|
|
1194
|
+
}));
|
|
1195
|
+
const auth = Buffer.from('worker1:secret1').toString('base64');
|
|
1196
|
+
const res = await request('POST', '/worker/job', {}, {
|
|
1197
|
+
authorization: `Basic ${auth}`
|
|
1198
|
+
});
|
|
1199
|
+
// The worker should be told there is no job, not given the disallowed job.
|
|
1200
|
+
assert.equal(res.statusCode, 200);
|
|
1201
|
+
const body = jsonBody(res);
|
|
1202
|
+
assert.equal(body.id, undefined);
|
|
1203
|
+
// Wait for the async rename to complete.
|
|
1204
|
+
await new Promise<void>(resolve => setTimeout(resolve, 100));
|
|
1205
|
+
// The job should have been moved from queue to failed, never to claimed.
|
|
1206
|
+
const queueExists = await fs.access(path.join(queueDir, jobFile)).then(() => true).catch(() => false);
|
|
1207
|
+
assert.equal(queueExists, false, 'Job should be removed from queue');
|
|
1208
|
+
const claimedExists = await fs.access(path.join(claimedDir, jobFile)).then(() => true).catch(() => false);
|
|
1209
|
+
assert.equal(claimedExists, false, 'Job should not be moved to claimed directory');
|
|
1210
|
+
const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
|
|
1211
|
+
assert.ok(failedExists, 'Job should be moved to the failed directory');
|
|
1212
|
+
} finally {
|
|
1213
|
+
t.mock.reset();
|
|
1214
|
+
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
1215
|
+
// Clean up.
|
|
1216
|
+
await fs.unlink(failedJobPath).catch(() => {});
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1169
1220
|
// TESTS: worker/report valid submission
|
|
1170
1221
|
|
|
1171
1222
|
test('POST /worker/report with valid authentication and valid claimed job processes the report', {timeout: 500}, async () => {
|
package/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
getObject,
|
|
18
18
|
getPOSTData,
|
|
19
19
|
getReport,
|
|
20
|
+
isAllowedRedirectTarget,
|
|
20
21
|
isAllowedReport,
|
|
21
22
|
isAllowedTarget,
|
|
22
23
|
isReportError,
|
|
@@ -484,26 +485,54 @@ const processJobRequest = async (request: IncomingMessage, response: ServerRespo
|
|
|
484
485
|
if (queuedJobNames.length) {
|
|
485
486
|
const oldestJobName = queuedJobNames[0]!;
|
|
486
487
|
// Get the first one.
|
|
487
|
-
const firstJob = await getObject(path.join(queuePath(), oldestJobName)) as {id: string, sources: {worker: string}, target: {what: string}};
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
488
|
+
const firstJob = await getObject(path.join(queuePath(), oldestJobName)) as {id: string, sources: {worker: string}, target: {what: string, url: string}};
|
|
489
|
+
// If the job's target is no longer allowed, whether because its host now resolves
|
|
490
|
+
// to a disallowed address or, more likely, because the host now redirects to one
|
|
491
|
+
// (both possible if the target host was reconfigured, e.g. by an attacker, after
|
|
492
|
+
// the request was accepted; a fetch-based check is used, rather than the cheaper
|
|
493
|
+
// DNS-only isAllowedTarget, because this runs only when a job is actually queued,
|
|
494
|
+
// which is rare, and a redirect introduced after acceptance is the likelier attack):
|
|
495
|
+
if (!(await isAllowedRedirectTarget(firstJob.target.url))) {
|
|
496
|
+
console.error(`ERROR: Queued job ${firstJob.id} is on a target this deployment does not allow`);
|
|
497
|
+
// Alert a manager, since this indicates an attempted or accidental SSRF
|
|
498
|
+
// rather than an ordinary usability problem with the target.
|
|
499
|
+
await sendAlert(
|
|
500
|
+
'Kilotest: queued job on disallowed target rejected',
|
|
501
|
+
`Job ${firstJob.id} (${firstJob.target.what}) was never assigned to a worker because its target no longer resolves, directly or via a redirect, to an address this deployment allows (e.g. a private, loopback, or link-local address). The job was reclassified as failed.`
|
|
502
|
+
);
|
|
503
|
+
// Reclassify the job as failed, instead of assigning it or leaving it queued.
|
|
504
|
+
await fs.rename(
|
|
505
|
+
path.join(queuePath(), oldestJobName), path.join(failedPath(), oldestJobName)
|
|
506
|
+
);
|
|
507
|
+
// Respond as if no job were queued, rather than revealing anything about the rejected job.
|
|
508
|
+
response.writeHead(200, {
|
|
509
|
+
'content-type': 'application/json; charset=utf-8'
|
|
510
|
+
});
|
|
511
|
+
response.end(JSON.stringify({}));
|
|
512
|
+
console.log(`${messageStart}but the queued job was on a disallowed target`);
|
|
513
|
+
}
|
|
514
|
+
// Otherwise, i.e. if the job's target is still allowed:
|
|
515
|
+
else {
|
|
516
|
+
// Add the public worker name to the job, in a property Testaro does not read or alter.
|
|
517
|
+
firstJob.sources.worker = workerName;
|
|
518
|
+
console.log(
|
|
519
|
+
`Job ${firstJob.id} (${firstJob.target.what}) is being sent to the worker.`
|
|
520
|
+
);
|
|
521
|
+
// Assign the job to the worker.
|
|
522
|
+
response.writeHead(200, {
|
|
523
|
+
'content-type': 'application/json; charset=utf-8'
|
|
524
|
+
});
|
|
525
|
+
response.end(JSON.stringify(firstJob));
|
|
526
|
+
const messageEnd
|
|
527
|
+
= `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the worker`;
|
|
528
|
+
console.log(`${messageStart}${messageEnd}`);
|
|
529
|
+
// Save the job in the claimed-jobs directory.
|
|
530
|
+
await fs.writeFile(
|
|
531
|
+
path.join(claimedPath(), oldestJobName), getJSON(firstJob)
|
|
532
|
+
);
|
|
533
|
+
// Delete it from the queue.
|
|
534
|
+
await fs.unlink(path.join(queuePath(), oldestJobName));
|
|
535
|
+
}
|
|
507
536
|
}
|
|
508
537
|
// Otherwise, i.e. if no jobs are queued:
|
|
509
538
|
else {
|
package/llms-full.txt
CHANGED
|
@@ -6,7 +6,7 @@ Kilotest is an application that performs ensemble testing of web pages for front
|
|
|
6
6
|
|
|
7
7
|
## What Kilotest does for language models
|
|
8
8
|
|
|
9
|
-
Kilotest is deployed on the public Internet as a service at `https://kilotest.com`. Kilotest can test only web pages that can be accessed
|
|
9
|
+
Kilotest is deployed on the public Internet as a service at `https://kilotest.com`. That deployment of Kilotest can test only web pages that can be accessed by its Testaro worker with a URL. Generally those are pages on the public Internet that are not protected by authentication or other access controls.
|
|
10
10
|
|
|
11
11
|
Without calling any tool, an LLM can, under some conditions, load web pages and use its knowledge to assess successes and failures of front-end quality. But such an assessment is almost always:
|
|
12
12
|
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Kilotest
|
|
2
2
|
|
|
3
|
-
Kilotest helps language models and AI platforms to give thorough, accurate and truthful answers when users ask about the front-end quality (i.e. accessibility, usability, and standards conformity) of web pages. Kilotest integrates an ensemble of twelve independent rule engines to test
|
|
3
|
+
Kilotest helps language models and AI platforms to give thorough, accurate and truthful answers when users ask about the front-end quality (i.e. accessibility, usability, and standards conformity) of web pages. Kilotest integrates an ensemble of twelve independent rule engines to test a web page. Kilotest creates a structured report of the results and saves the report for future reference. Kilotest API endpoints and MCP tools can provide multipage, single-page, and element-level test results.
|
|
4
4
|
|
|
5
5
|
## API
|
|
6
6
|
|
package/mcp.test.ts
CHANGED
|
@@ -11,6 +11,8 @@ import http from 'node:http';
|
|
|
11
11
|
import {mcpPath, createMCPServer, handleMCP} from './mcp.ts';
|
|
12
12
|
import {metricsPath} from './util.ts';
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
|
15
|
+
import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js';
|
|
14
16
|
|
|
15
17
|
// CONSTANTS
|
|
16
18
|
|
|
@@ -35,11 +37,12 @@ test('mcpPath is /mcp', () => {
|
|
|
35
37
|
assert.equal(mcpPath, '/mcp');
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
test('createMCPServer registers all
|
|
40
|
+
test('createMCPServer registers all 9 tools', () => {
|
|
39
41
|
const server = createMCPServer();
|
|
40
42
|
const toolNames = Object.keys((server as any)._registeredTools);
|
|
41
|
-
assert.equal(toolNames.length,
|
|
43
|
+
assert.equal(toolNames.length, 9);
|
|
42
44
|
assert.deepEqual(toolNames, [
|
|
45
|
+
'getKilotestOverview',
|
|
43
46
|
'listReports',
|
|
44
47
|
'listIssues',
|
|
45
48
|
'listViolators',
|
|
@@ -56,10 +59,41 @@ test('each tool has a description and a handler function', () => {
|
|
|
56
59
|
const tools = (server as any)._registeredTools;
|
|
57
60
|
for (const [name, tool] of Object.entries(tools) as [string, any][]) {
|
|
58
61
|
assert.ok(tool.description, `${name} has a description`);
|
|
62
|
+
assert.ok(
|
|
63
|
+
tool.description.includes('front-end quality'),
|
|
64
|
+
`${name} description includes the shared domain context`
|
|
65
|
+
);
|
|
59
66
|
assert.equal(typeof tool.handler, 'function', `${name} has a handler function`);
|
|
60
67
|
}
|
|
61
68
|
});
|
|
62
69
|
|
|
70
|
+
test('getKilotestOverview handler returns overview text and records a metric', async () => {
|
|
71
|
+
const server = createMCPServer();
|
|
72
|
+
const countBefore = getToolCallCount('getKilotestOverview');
|
|
73
|
+
const result = await (server as any)._registeredTools.getKilotestOverview.handler({});
|
|
74
|
+
assert.ok(result.content);
|
|
75
|
+
assert.equal(result.content[0].type, 'text');
|
|
76
|
+
assert.ok(result.content[0].text.includes('front-end quality'));
|
|
77
|
+
assert.equal(getToolCallCount('getKilotestOverview'), countBefore + 1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('client receives instructions, server description, and the overview resource', async () => {
|
|
81
|
+
const server = createMCPServer();
|
|
82
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
83
|
+
await server.connect(serverTransport);
|
|
84
|
+
const client = new Client({name: 'test-client', version: '1.0.0'});
|
|
85
|
+
await client.connect(clientTransport);
|
|
86
|
+
assert.ok(client.getInstructions()?.includes('front-end quality'));
|
|
87
|
+
assert.ok(client.getServerVersion()?.description?.includes('front-end quality'));
|
|
88
|
+
const resources = await client.listResources();
|
|
89
|
+
assert.deepEqual(resources.resources.map((r: any) => r.uri), ['docs://kilotest/overview']);
|
|
90
|
+
const read = await client.readResource({uri: 'docs://kilotest/overview'});
|
|
91
|
+
assert.equal(read.contents[0]?.mimeType, 'text/plain');
|
|
92
|
+
assert.ok((read.contents[0] as any).text.includes('front-end quality'));
|
|
93
|
+
await client.close();
|
|
94
|
+
await server.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
63
97
|
test('listReports handler returns content and structuredContent', async () => {
|
|
64
98
|
const server = createMCPServer();
|
|
65
99
|
const countBefore = getToolCallCount('listReports');
|
|
@@ -258,6 +292,8 @@ test('handleMCP responds to initialize with server info', async () => {
|
|
|
258
292
|
assert.equal(res.statusCode, 200);
|
|
259
293
|
const result = parseSSEResult(res.body);
|
|
260
294
|
assert.equal(result.result.serverInfo.name, 'Kilotest');
|
|
295
|
+
assert.ok(result.result.serverInfo.description.includes('front-end quality'));
|
|
296
|
+
assert.ok(result.result.instructions.includes('front-end quality'));
|
|
261
297
|
assert.equal(result.result.protocolVersion, '2025-06-18');
|
|
262
298
|
}
|
|
263
299
|
finally {
|
|
@@ -265,7 +301,7 @@ test('handleMCP responds to initialize with server info', async () => {
|
|
|
265
301
|
}
|
|
266
302
|
});
|
|
267
303
|
|
|
268
|
-
test('handleMCP lists all
|
|
304
|
+
test('handleMCP lists all 9 tools via tools/list', async () => {
|
|
269
305
|
const server = await startMCPServer();
|
|
270
306
|
try {
|
|
271
307
|
const port = server.address().port;
|
|
@@ -273,8 +309,9 @@ test('handleMCP lists all 8 tools via tools/list', async () => {
|
|
|
273
309
|
assert.equal(res.statusCode, 200);
|
|
274
310
|
const result = parseSSEResult(res.body);
|
|
275
311
|
const toolNames = result.result.tools.map((t: any) => t.name);
|
|
276
|
-
assert.equal(toolNames.length,
|
|
312
|
+
assert.equal(toolNames.length, 9);
|
|
277
313
|
assert.deepEqual(toolNames, [
|
|
314
|
+
'getKilotestOverview',
|
|
278
315
|
'listReports',
|
|
279
316
|
'listIssues',
|
|
280
317
|
'listViolators',
|