@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.
@@ -36,7 +36,10 @@
36
36
  "Exec(git mv)",
37
37
  "Exec(perl)",
38
38
  "Exec(ssh)",
39
- "Exec(scp)"
39
+ "Exec(scp)",
40
+ "Exec(sqlite3)",
41
+ "Exec(npm update)",
42
+ "Exec(npm ls)"
40
43
  ]
41
44
  }
42
45
  }
@@ -22,6 +22,9 @@ jobs:
22
22
  - name: Install dependencies
23
23
  run: npm ci
24
24
 
25
+ - name: Run typecheck
26
+ run: npm run typecheck
27
+
25
28
  - name: Run lint
26
29
  run: npm run lint
27
30
 
@@ -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');
@@ -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, i.e. if it is not a duplicate:
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
- await sendAlert('Kilotest: MCP feature request received', feature);
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
+ });
@@ -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
  }
@@ -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
+ });
@@ -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 public web pages for front-end quality, namely accessibility, usability, and standards conformity, and makes structured findings available for retrieval.
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 public 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.
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/v0/servers?search=kilotest) (`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.
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 public web page for accessibility,
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 public web page.
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 8 MCP tool handlers in `mcp.ts`, once per successful tool call, recorded under the `mcpToolCalls` category by tool name (for example `listReports`, `requestTest`).
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
- // Add the public worker name to the job, in a property Testaro does not read or alter.
489
- firstJob.sources.worker = workerName;
490
- console.log(
491
- `Job ${firstJob.id} (${firstJob.target.what}) is being sent to the worker.`
492
- );
493
- // Assign the job to the worker.
494
- response.writeHead(200, {
495
- 'content-type': 'application/json; charset=utf-8'
496
- });
497
- response.end(JSON.stringify(firstJob));
498
- const messageEnd
499
- = `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the worker`;
500
- console.log(`${messageStart}${messageEnd}`);
501
- // Save the job in the claimed-jobs directory.
502
- await fs.writeFile(
503
- path.join(claimedPath(), oldestJobName), getJSON(firstJob)
504
- );
505
- // Delete it from the queue.
506
- await fs.unlink(path.join(queuePath(), oldestJobName));
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 from the public Internet and that are not protected by authentication or other access controls. Kilotest has not implemented any mechanism for testing private, internal, password-protected, or otherwise restricted pages.
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 any public 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.
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 8 tools', () => {
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, 8);
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 8 tools via tools/list', async () => {
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, 8);
312
+ assert.equal(toolNames.length, 9);
277
313
  assert.deepEqual(toolNames, [
314
+ 'getKilotestOverview',
278
315
  'listReports',
279
316
  'listIssues',
280
317
  'listViolators',