streamtrust 0.1.0 → 0.2.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/README.md +77 -11
- package/examples/librechat/README.md +91 -0
- package/examples/librechat/adapter.mjs +515 -0
- package/examples/librechat/provider.mjs +79 -0
- package/examples/librechat/setup.mjs +42 -0
- package/package.json +5 -1
- package/src/checks.d.ts +36 -0
- package/src/checks.js +105 -0
- package/src/cli.js +23 -8
- package/src/index.js +13 -8
package/README.md
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# StreamTrust CLI
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/streamtrust)
|
|
4
|
+
|
|
5
|
+
**[View StreamTrust on npm](https://www.npmjs.com/package/streamtrust)**
|
|
6
|
+
|
|
3
7
|
Adapter-driven CI checks for streaming applications: compare observed output with authoritative state, exercise cancellation, and verify retry identity.
|
|
4
8
|
|
|
5
|
-
**Status:** `0.
|
|
9
|
+
**Status:** `0.2.0`, an early 0.x release for independent teams. APIs and report formats may change before 1.0. The CLI is dependency-free, ESM, and requires Node.js 22+. MIT licensed. Browser and database libraries belong to your adapter and are not installed by StreamTrust.
|
|
10
|
+
|
|
11
|
+
## Watch the 66-second demo
|
|
12
|
+
|
|
13
|
+
[](https://santoshsibyala.github.io/streamtrust/)
|
|
14
|
+
|
|
15
|
+
**[Watch the narrated demo in your browser](https://santoshsibyala.github.io/streamtrust/)** · [Read the transcript](docs/media/streamtrust-demo-transcript.txt) · [Download subtitles](docs/media/streamtrust-demo-0.1.1-brian.srt) · [Silent version](https://raw.githubusercontent.com/santoshsibyala/streamtrust/main/docs/media/streamtrust-demo-0.1.1.mp4)
|
|
16
|
+
|
|
17
|
+
A 66-second walkthrough narrated with ElevenLabs’ Brian voice (synthetic English narration), on-screen captions, and an optional English subtitle track: install StreamTrust, run the example checks, inspect the diagnostic report, and see a deliberate save failure return a nonzero CI exit code. Recorded with version 0.1.1. The bundled application and injected failure are demonstrations; connect an adapter to your own application to verify its behavior. The browser player includes playback controls, captions, and an MP4 download option.
|
|
6
18
|
|
|
7
19
|
## Install
|
|
8
20
|
|
|
@@ -12,7 +24,13 @@ npx streamtrust init
|
|
|
12
24
|
npx streamtrust verify --adapter ./streamtrust/adapter.mjs
|
|
13
25
|
```
|
|
14
26
|
|
|
15
|
-
For reproducible CI, pin `streamtrust@0.
|
|
27
|
+
For reproducible CI, pin `streamtrust@0.2.0` and commit your lockfile. The generated adapter initially exercises the bundled demonstration; map it to your application before using its results as application evidence.
|
|
28
|
+
|
|
29
|
+
## LibreChat integration
|
|
30
|
+
|
|
31
|
+
A LibreChat preset now exercises persistence, native Stop, regeneration and error-retry through its browser UI and disposable MongoDB. It uses reusable `streamtrust/checks` assertions and summary-only evidence; no chat text, screenshots or application logs are included in its reports.
|
|
32
|
+
|
|
33
|
+
**Available in StreamTrust 0.2.0.** Install from npm using the [LibreChat quick start and CI guide](examples/librechat/README.md). It requires Node 24 and optional Playwright/MongoDB dependencies. An [integration workflow](.github/workflows/librechat.yml) exercises the packed CLI against the pinned upstream version. The default `init` demonstration remains available.
|
|
16
34
|
|
|
17
35
|
## Run from source
|
|
18
36
|
|
|
@@ -32,7 +50,7 @@ Build the tarball with `npm pack` in this repository.
|
|
|
32
50
|
Put the supplied tarball in your project's `vendor/` directory, then:
|
|
33
51
|
|
|
34
52
|
```sh
|
|
35
|
-
npm install --save-dev ./vendor/streamtrust-0.
|
|
53
|
+
npm install --save-dev ./vendor/streamtrust-0.2.0.tgz
|
|
36
54
|
npx streamtrust init
|
|
37
55
|
npx streamtrust verify --adapter ./streamtrust/adapter.mjs
|
|
38
56
|
```
|
|
@@ -123,34 +141,82 @@ node --import tsx ./node_modules/streamtrust/bin/streamtrust.js verify \
|
|
|
123
141
|
|
|
124
142
|
## CI
|
|
125
143
|
|
|
126
|
-
|
|
144
|
+
Add StreamTrust as a test step in your existing CI. For GitHub Actions, follow these steps:
|
|
145
|
+
|
|
146
|
+
1. Install StreamTrust and create the starting adapter:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
npm install --save-dev --save-exact streamtrust@0.2.0
|
|
150
|
+
npx streamtrust init
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
2. Adapt `streamtrust/adapter.mjs` to your application's native start, stream, cancel, retry, and saved-state operations. **The generated adapter initially tests a bundled demo. Setting a base URL alone does not adapt it to arbitrary application APIs.** See [Connect your application](#connect-your-application) above.
|
|
154
|
+
3. Commit the adapter, `package.json`, and `package-lock.json`. If the adapter imports browser or database libraries, add those dependencies and their required CI setup too.
|
|
155
|
+
4. Save the workflow below as `.github/workflows/streamtrust.yml`, or copy its relevant steps into an existing job.
|
|
156
|
+
|
|
157
|
+
**Replace `npm run start:test`, port `3000`, and `/health` with your application's test-server command and readiness endpoint.** These are placeholders, not commands or routes supplied by StreamTrust. Add any required build, database, migration, fixture, or browser-install steps before starting your app. Use a disposable test environment because the checks create and modify application state.
|
|
127
158
|
|
|
128
159
|
```yaml
|
|
129
160
|
name: Streaming application verification
|
|
130
161
|
on: [push, pull_request]
|
|
162
|
+
permissions:
|
|
163
|
+
contents: read
|
|
131
164
|
jobs:
|
|
132
165
|
verify:
|
|
133
166
|
runs-on: ubuntu-latest
|
|
134
167
|
timeout-minutes: 10
|
|
135
168
|
steps:
|
|
136
169
|
- uses: actions/checkout@v4
|
|
170
|
+
|
|
137
171
|
- uses: actions/setup-node@v4
|
|
138
172
|
with:
|
|
139
173
|
node-version: 22
|
|
140
174
|
cache: npm
|
|
175
|
+
|
|
141
176
|
- run: npm ci
|
|
142
|
-
|
|
143
|
-
|
|
177
|
+
|
|
178
|
+
# Add your application's build and test-service setup here.
|
|
179
|
+
- name: Start application and wait for readiness
|
|
180
|
+
shell: bash
|
|
181
|
+
run: |
|
|
182
|
+
npm run start:test > app.log 2>&1 &
|
|
183
|
+
app_pid=$!
|
|
184
|
+
for attempt in {1..30}; do
|
|
185
|
+
if ! kill -0 "$app_pid" 2>/dev/null; then
|
|
186
|
+
cat app.log
|
|
187
|
+
exit 1
|
|
188
|
+
fi
|
|
189
|
+
if curl --fail --silent --max-time 2 http://localhost:3000/health; then
|
|
190
|
+
exit 0
|
|
191
|
+
fi
|
|
192
|
+
sleep 2
|
|
193
|
+
done
|
|
194
|
+
cat app.log
|
|
195
|
+
echo "Application did not become ready in time."
|
|
196
|
+
exit 1
|
|
197
|
+
|
|
198
|
+
- name: Verify streaming behavior
|
|
199
|
+
run: |
|
|
200
|
+
npx --no-install streamtrust verify \
|
|
201
|
+
--adapter ./streamtrust/adapter.mjs \
|
|
202
|
+
--output ./reports/streamtrust
|
|
144
203
|
env:
|
|
145
|
-
STREAMTRUST_BASE_URL: http://
|
|
146
|
-
|
|
147
|
-
-
|
|
204
|
+
STREAMTRUST_BASE_URL: http://localhost:3000
|
|
205
|
+
|
|
206
|
+
- name: Save diagnostic reports
|
|
148
207
|
if: always()
|
|
208
|
+
uses: actions/upload-artifact@v4
|
|
149
209
|
with:
|
|
150
|
-
name: streamtrust-
|
|
210
|
+
name: streamtrust-reports
|
|
151
211
|
path: reports/streamtrust/
|
|
152
212
|
```
|
|
153
213
|
|
|
214
|
+
If your application is already running in the job, omit the startup step and point your adapter at its test URL. For the bundled HTTP adapter's optional bearer authentication, add `STREAMTRUST_API_TOKEN: ${{ secrets.TEST_APP_TOKEN }}` to the verification step's `env`, and configure that secret in GitHub. Fork pull requests generally do not receive repository secrets; use a suitable unauthenticated test fixture or an explicitly trusted workflow for those runs. Never put credentials in the adapter or uploaded evidence.
|
|
215
|
+
|
|
216
|
+
A failed assertion or harness error fails the verification step. The report-upload step runs even after a failure, so teams can inspect diagnostics when they were generated; startup failures may have no StreamTrust report. To block merging, configure the repository's branch protection or ruleset to require this workflow's `verify` check. StreamTrust does not configure merge rules itself.
|
|
217
|
+
|
|
218
|
+
If you use a locally built tarball instead of npm, keep it in `vendor/` and commit the corresponding file dependency and lockfile so `npm ci` can resolve it.
|
|
219
|
+
|
|
154
220
|
| Exit | Meaning |
|
|
155
221
|
|---|---|
|
|
156
222
|
| 0 | Every declared scenario and assertion passed. |
|
|
@@ -163,6 +229,6 @@ Use a unique output directory per concurrent job. The CLI takes an exclusive loc
|
|
|
163
229
|
|
|
164
230
|
## Scope and attribution
|
|
165
231
|
|
|
166
|
-
StreamTrust checks what an adapter asserts. It does not prove exactly-once external effects, AI factuality, automatic recovery, production incident rates or adoption. The HTTP demo is synthetic. Separate development experiments have tested pinned Vercel Chatbot and LibreChat applications;
|
|
232
|
+
StreamTrust checks what an adapter asserts. It does not prove exactly-once external effects, AI factuality, automatic recovery, production incident rates or adoption. The HTTP demo is synthetic. Separate development experiments have tested pinned Vercel Chatbot and LibreChat applications; the LibreChat adapter is now available as an npm preset with optional dependencies. The Vercel experiment remains separate.
|
|
167
233
|
|
|
168
234
|
Local preparation: `npm pack` in this package directory runs the package tests and creates the tarball. The public repository runs tests and a clean tarball installation on Node.js 22 and 24. This release is published interactively; npm build provenance is not configured.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# LibreChat CI integration
|
|
2
|
+
|
|
3
|
+
This preset runs a **built local LibreChat checkout**, native browser UI and real disposable MongoDB. A controlled OpenAI-compatible HTTP provider supplies synthetic text. No live model key or existing database is used. Four scenarios cover saved answers, Stop, regeneration branches, and retry after a provider disconnect. This is not an official LibreChat integration.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Use Node.js 24 and npm 11. In a separate verification project, install StreamTrust 0.2.0 from npm:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm init -y
|
|
11
|
+
npm install --save-dev --save-exact streamtrust@0.2.0
|
|
12
|
+
npm install --save-dev --save-exact playwright@1.64.1 mongodb@6.20.0 mongodb-memory-server@11.3.0
|
|
13
|
+
npx playwright install chromium
|
|
14
|
+
npx streamtrust init streamtrust-librechat --preset librechat
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use Node.js 24 and npm 11 for this preset. Commit the generated adapter and your package lock. The core CLI still has no runtime dependencies; browser/database dependencies belong to the verification project. Browser, MongoDB binary and LibreChat installation require network access and disk space. The first build takes several minutes; this is not a five-minute cold installation.
|
|
18
|
+
|
|
19
|
+
## Run the supported upstream version
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
export STREAMTRUST_LIBRECHAT_ROOT="$PWD/.cache/librechat"
|
|
23
|
+
node streamtrust-librechat/setup.mjs
|
|
24
|
+
npx streamtrust verify --adapter streamtrust-librechat/adapter.mjs \
|
|
25
|
+
--output reports/librechat --repetitions 3 --timeout-ms 600000
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The setup script clones and builds commit `a378657583567310c841d652b0f6b00c83e2bd6b`. It refuses to reset an existing or dirty checkout. The verifier starts its own loopback-only server, chooses an available port, creates a synthetic test user, and tears down its browser, provider and disposable database. The preset refuses checkouts containing `.env`, `.env.local` or `.env.production`. Do not place production configuration in this checkout.
|
|
29
|
+
|
|
30
|
+
To test your team's committed changes, point `STREAMTRUST_LIBRECHAT_ROOT` at your **built, clean checkout** and set `STREAMTRUST_LIBRECHAT_COMMIT` to its full SHA. Skip setup if already built. This executes the actual application under test; it does not copy a test app into your repository. Compatibility with other commits is not guaranteed: changed selectors/routes/schema can cause HARNESS_ERROR rather than a product failure.
|
|
31
|
+
|
|
32
|
+
Optional settings:
|
|
33
|
+
|
|
34
|
+
| Variable | Purpose |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `STREAMTRUST_LIBRECHAT_ROOT` | Required path to the built checkout |
|
|
37
|
+
| `STREAMTRUST_LIBRECHAT_COMMIT` | Expected full SHA; defaults to the tested upstream commit |
|
|
38
|
+
| `STREAMTRUST_LIBRECHAT_PORT` | Optional fixed loopback port; defaults to an ephemeral port |
|
|
39
|
+
| `STREAMTRUST_BROWSER_CHANNEL` | Optional installed browser channel, such as `chrome`; default is Playwright Chromium |
|
|
40
|
+
|
|
41
|
+
## CI
|
|
42
|
+
|
|
43
|
+
In your verification project, install once and commit package.json/package-lock.json and the generated preset. Add these steps to a Node 24 GitHub Actions job:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
- uses: actions/checkout@v4
|
|
47
|
+
- uses: actions/setup-node@v4
|
|
48
|
+
with:
|
|
49
|
+
node-version: '24'
|
|
50
|
+
cache: npm
|
|
51
|
+
- run: npm ci
|
|
52
|
+
- run: npx --no-install playwright install --with-deps chromium
|
|
53
|
+
- name: Build isolated LibreChat checkout
|
|
54
|
+
run: node streamtrust-librechat/setup.mjs
|
|
55
|
+
env:
|
|
56
|
+
STREAMTRUST_LIBRECHAT_ROOT: ${{ runner.temp }}/librechat
|
|
57
|
+
- name: Verify LibreChat
|
|
58
|
+
run: npx --no-install streamtrust verify --adapter streamtrust-librechat/adapter.mjs --output reports/librechat --repetitions 3 --timeout-ms 600000
|
|
59
|
+
env:
|
|
60
|
+
STREAMTRUST_LIBRECHAT_ROOT: ${{ runner.temp }}/librechat
|
|
61
|
+
- uses: actions/upload-artifact@v4
|
|
62
|
+
if: always()
|
|
63
|
+
with:
|
|
64
|
+
name: librechat-verification
|
|
65
|
+
path: |
|
|
66
|
+
reports/librechat/report.json
|
|
67
|
+
reports/librechat/report.md
|
|
68
|
+
reports/librechat/junit.xml
|
|
69
|
+
reports/librechat/index.html
|
|
70
|
+
retention-days: 7
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use a fresh report directory for each job. Restrict workflow permissions to `contents: read`, set a 30-minute job timeout, and use normal pull-request workflows without production secrets. To verify your fork, replace the build step with checkout/build of the intended commit and pass its SHA to the verifier.
|
|
74
|
+
|
|
75
|
+
Exit 0 means all declared checks passed; 1 means failed or unsupported checks; 2 means setup, timeout, or harness error. Keep the exit status: do not use `continue-on-error`. Inspect reports before calling a failure an upstream defect.
|
|
76
|
+
|
|
77
|
+
## Evidence and limits
|
|
78
|
+
|
|
79
|
+
`summary-only-v1` returns stable check identifiers, booleans, message counts and provider-call counts. Raw chat text, database records, user identifiers, network URLs and tokens remain in memory and are not returned as report evidence. The preset does not write screenshots, browser traces or application logs. Browser/database exceptions are replaced with fixed errors. App subprocess output is discarded. Upload only the four report files, not the entire working directory.
|
|
80
|
+
|
|
81
|
+
This is data minimization, not a universal PII detector or sandbox. Other adapters, changes to this adapter, setup/build logs and third-party processes are outside that guarantee. The core verifier still runs trusted local code. The preset deliberately does not forward application environment variables, but LibreChat can load files in its checkout: use an isolated checkout with no production configuration.
|
|
82
|
+
|
|
83
|
+
The model provider is synthetic. The test uses one server and an in-memory stream store: no Redis, real model behavior, database crash, worker kill, multi-replica or production incident-rate coverage. A single matching saved answer is not proof of exactly-once external side effects. Regeneration is a distinct sibling branch, not reuse of a request idempotency key.
|
|
84
|
+
|
|
85
|
+
The exported `streamtrust/checks` evaluator can be reused by other adapters. Map observations to `LifecycleObservation`; the evaluator validates required types, computes checks, and returns summary-only evidence. Missing or malformed observations throw, becoming HARNESS_ERROR rather than a green result.
|
|
86
|
+
|
|
87
|
+
## Validation
|
|
88
|
+
|
|
89
|
+
Local validation used macOS arm64, Node 24.22.1, Playwright 1.64.1 Chromium 156.0.8078.4, MongoDB 8.2.6, and the pinned LibreChat commit. A clean tarball consumer passed all four scenarios with three repetitions (12 executions, 99 assertions). A checkout containing an environment file was rejected with exit 2. Success and error artifact checks found only the four intended files and no tested secret/chat-text markers. The package suite has 13 tests, including negative controls for shared checks and stale-screenshot exclusion.
|
|
90
|
+
|
|
91
|
+
The [Linux GitHub Actions run](https://github.com/santoshsibyala/streamtrust/actions/runs/40451851475) also passed a cold clone/install/build, clean tarball installation, all 12 scenario executions, and upload of exactly the four report files. The verified implementation commit is `b1b2b27a19b512c6abce164502901d1716bf86a3`. Passing results do not establish an upstream defect or production reliability.
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { evaluateLifecycle } from 'streamtrust/checks';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
5
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { createServer } from 'node:net';
|
|
8
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve, dirname } from 'node:path';
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
import { startProvider } from './provider.mjs';
|
|
13
|
+
|
|
14
|
+
const PIN = 'a378657583567310c841d652b0f6b00c83e2bd6b';
|
|
15
|
+
export async function until(check, label, ms = 20000) {
|
|
16
|
+
const end = Date.now() + ms;
|
|
17
|
+
while (Date.now() < end) {
|
|
18
|
+
if (await check()) return;
|
|
19
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Timeout: ${label}`);
|
|
22
|
+
}
|
|
23
|
+
let phase = 'preflight';
|
|
24
|
+
async function createAdapter() {
|
|
25
|
+
if (!process.env.STREAMTRUST_LIBRECHAT_ROOT)
|
|
26
|
+
throw new Error(
|
|
27
|
+
'Set STREAMTRUST_LIBRECHAT_ROOT to a built LibreChat checkout',
|
|
28
|
+
);
|
|
29
|
+
if (Number(process.versions.node.split('.')[0]) < 24)
|
|
30
|
+
throw new Error('LibreChat preset requires Node.js 24+');
|
|
31
|
+
const root = resolve(process.env.STREAMTRUST_LIBRECHAT_ROOT);
|
|
32
|
+
if (
|
|
33
|
+
['.env', '.env.local', '.env.production'].some((name) =>
|
|
34
|
+
existsSync(resolve(root, name)),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
throw new Error('Use an isolated checkout without environment files');
|
|
38
|
+
const expectedCommit = process.env.STREAMTRUST_LIBRECHAT_COMMIT || PIN;
|
|
39
|
+
if (!/^[a-f0-9]{40}$/.test(expectedCommit))
|
|
40
|
+
throw new Error('Expected commit must be a full Git SHA');
|
|
41
|
+
const configPort = Number(process.env.STREAMTRUST_LIBRECHAT_PORT || 0);
|
|
42
|
+
if (!Number.isInteger(configPort) || configPort < 0 || configPort > 65535)
|
|
43
|
+
throw new Error('Invalid LibreChat port');
|
|
44
|
+
const port =
|
|
45
|
+
configPort ||
|
|
46
|
+
(await new Promise((ok, fail) => {
|
|
47
|
+
const server = createServer();
|
|
48
|
+
server.once('error', fail);
|
|
49
|
+
server.listen(0, '127.0.0.1', () => {
|
|
50
|
+
const value = server.address().port;
|
|
51
|
+
server.close(() => ok(value));
|
|
52
|
+
});
|
|
53
|
+
}));
|
|
54
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
55
|
+
const commit = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
56
|
+
cwd: root,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
}).trim();
|
|
59
|
+
const dirty = () =>
|
|
60
|
+
execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {
|
|
61
|
+
cwd: root,
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
}).trim();
|
|
64
|
+
if (commit !== expectedCommit || dirty())
|
|
65
|
+
throw new Error('Expected clean pinned LibreChat checkout');
|
|
66
|
+
const require = createRequire(resolve(process.cwd(), 'package.json'));
|
|
67
|
+
const { chromium } = require('playwright');
|
|
68
|
+
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
69
|
+
const { MongoClient } = require('mongodb');
|
|
70
|
+
const cleanups = [];
|
|
71
|
+
let cleaned = false;
|
|
72
|
+
const cleanup = async () => {
|
|
73
|
+
if (cleaned) return;
|
|
74
|
+
cleaned = true;
|
|
75
|
+
let cleanupFailed = false;
|
|
76
|
+
for (const close of cleanups.reverse()) {
|
|
77
|
+
try {
|
|
78
|
+
await close();
|
|
79
|
+
} catch {
|
|
80
|
+
cleanupFailed = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (cleanupFailed) throw new Error('LibreChat cleanup failed');
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
phase = 'disposable database startup';
|
|
87
|
+
const mongo = await MongoMemoryServer.create({
|
|
88
|
+
instance: { ip: '127.0.0.1', dbName: 'streamtrust' },
|
|
89
|
+
});
|
|
90
|
+
cleanups.push(() => mongo.stop());
|
|
91
|
+
const client = new MongoClient(mongo.getUri());
|
|
92
|
+
cleanups.push(() => client.close());
|
|
93
|
+
await client.connect();
|
|
94
|
+
const db = client.db('streamtrust');
|
|
95
|
+
const provider = await startProvider();
|
|
96
|
+
cleanups.push(() => provider.close());
|
|
97
|
+
const temporary = await mkdtemp(
|
|
98
|
+
resolve(tmpdir(), 'streamtrust-librechat-'),
|
|
99
|
+
);
|
|
100
|
+
cleanups.unshift(() => rm(temporary, { recursive: true, force: true }));
|
|
101
|
+
const config = resolve(temporary, 'librechat.yaml');
|
|
102
|
+
await writeFile(
|
|
103
|
+
config,
|
|
104
|
+
`version: 1.3.11\ncache: true\nendpoints:\n custom:\n - name: StreamTrust Fixture\n apiKey: fixture-not-a-real-key\n baseURL: ${provider.url}\n models:\n default: [fixture-model]\n fetch: false\n titleConvo: false\n modelDisplayLabel: Fixture\n`,
|
|
105
|
+
);
|
|
106
|
+
const runtime = process.execPath;
|
|
107
|
+
const env = {
|
|
108
|
+
// Do not forward provider keys, database URLs or application secrets.
|
|
109
|
+
...Object.fromEntries(
|
|
110
|
+
['PATH', 'HOME', 'TMPDIR', 'TEMP', 'SystemRoot']
|
|
111
|
+
.filter((k) => process.env[k])
|
|
112
|
+
.map((k) => [k, process.env[k]]),
|
|
113
|
+
),
|
|
114
|
+
PATH: `${dirname(runtime)}:${process.env.PATH}`,
|
|
115
|
+
NODE_ENV: 'production',
|
|
116
|
+
HOST: '127.0.0.1',
|
|
117
|
+
PORT: String(port),
|
|
118
|
+
DOMAIN_CLIENT: baseUrl,
|
|
119
|
+
DOMAIN_SERVER: baseUrl,
|
|
120
|
+
MONGO_URI: mongo.getUri('streamtrust'),
|
|
121
|
+
CONFIG_PATH: config,
|
|
122
|
+
ENDPOINTS: 'custom',
|
|
123
|
+
OPENAI_API_KEY: '',
|
|
124
|
+
SEARCH: 'false',
|
|
125
|
+
NO_INDEX: 'true',
|
|
126
|
+
ALLOW_REGISTRATION: 'true',
|
|
127
|
+
ALLOW_SOCIAL_LOGIN: 'false',
|
|
128
|
+
ALLOW_EMAIL_LOGIN: 'true',
|
|
129
|
+
JWT_SECRET: randomBytes(32).toString('hex'),
|
|
130
|
+
JWT_REFRESH_SECRET: randomBytes(32).toString('hex'),
|
|
131
|
+
CREDS_KEY: randomBytes(32).toString('hex'),
|
|
132
|
+
CREDS_IV: randomBytes(16).toString('hex'),
|
|
133
|
+
SCHEDULES_SINGLE_PROCESS: 'true',
|
|
134
|
+
USE_REDIS: 'false',
|
|
135
|
+
USE_REDIS_STREAMS: 'false',
|
|
136
|
+
REGISTER_MAX: '100',
|
|
137
|
+
LOGIN_MAX: '100',
|
|
138
|
+
};
|
|
139
|
+
phase = 'application startup';
|
|
140
|
+
const app = spawn(runtime, ['api/server/index.js'], {
|
|
141
|
+
cwd: root,
|
|
142
|
+
env,
|
|
143
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
144
|
+
});
|
|
145
|
+
let appError = false;
|
|
146
|
+
app.on('error', () => {
|
|
147
|
+
appError = true;
|
|
148
|
+
});
|
|
149
|
+
cleanups.push(async () => {
|
|
150
|
+
if (!appError && app.exitCode === null && app.signalCode === null) {
|
|
151
|
+
const stopped = new Promise((r) => app.once('exit', () => r()));
|
|
152
|
+
app.kill('SIGTERM');
|
|
153
|
+
const timer = setTimeout(() => app.kill('SIGKILL'), 10000);
|
|
154
|
+
await stopped;
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
phase = 'browser startup';
|
|
159
|
+
const browser = await chromium.launch(
|
|
160
|
+
!process.env.STREAMTRUST_BROWSER_CHANNEL
|
|
161
|
+
? { headless: true }
|
|
162
|
+
: { channel: process.env.STREAMTRUST_BROWSER_CHANNEL, headless: true },
|
|
163
|
+
);
|
|
164
|
+
cleanups.push(() => browser.close());
|
|
165
|
+
phase = 'application readiness';
|
|
166
|
+
await until(
|
|
167
|
+
async () => {
|
|
168
|
+
if (appError || app.exitCode !== null)
|
|
169
|
+
throw new Error(
|
|
170
|
+
'LibreChat startup failed; check the build and port configuration',
|
|
171
|
+
);
|
|
172
|
+
try {
|
|
173
|
+
return (await fetch(baseUrl, { signal: AbortSignal.timeout(2000) }))
|
|
174
|
+
.ok;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
'app ready',
|
|
180
|
+
120000,
|
|
181
|
+
);
|
|
182
|
+
phase = 'synthetic user registration';
|
|
183
|
+
const email = `verifier-${Date.now()}@example.com`,
|
|
184
|
+
password = `Fixture-${randomBytes(12).toString('hex')}!`;
|
|
185
|
+
const registration = await browser.newContext();
|
|
186
|
+
const page = await registration.newPage();
|
|
187
|
+
await page.goto(`${baseUrl}/register`);
|
|
188
|
+
await page.getByLabel('Full name').fill('StreamTrust Verifier');
|
|
189
|
+
await page.getByLabel('Email', { exact: true }).fill(email);
|
|
190
|
+
await page.getByTestId('password').fill(password);
|
|
191
|
+
await page.getByTestId('confirm_password').fill(password);
|
|
192
|
+
await page.getByLabel('Submit registration').click();
|
|
193
|
+
await page.waitForURL('**/login**');
|
|
194
|
+
await page.getByLabel('Email', { exact: true }).fill(email);
|
|
195
|
+
await page.getByLabel('Password', { exact: true }).fill(password);
|
|
196
|
+
await page.getByTestId('login-button').click();
|
|
197
|
+
await page
|
|
198
|
+
.getByRole('textbox', { name: 'Message input' })
|
|
199
|
+
.waitFor({ timeout: 30000 });
|
|
200
|
+
await page.close();
|
|
201
|
+
async function messages(id) {
|
|
202
|
+
return db
|
|
203
|
+
.collection('messages')
|
|
204
|
+
.find(
|
|
205
|
+
{ conversationId: id },
|
|
206
|
+
{
|
|
207
|
+
projection: {
|
|
208
|
+
_id: 0,
|
|
209
|
+
messageId: 1,
|
|
210
|
+
parentMessageId: 1,
|
|
211
|
+
conversationId: 1,
|
|
212
|
+
text: 1,
|
|
213
|
+
content: 1,
|
|
214
|
+
isCreatedByUser: 1,
|
|
215
|
+
error: 1,
|
|
216
|
+
unfinished: 1,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
.toArray();
|
|
221
|
+
}
|
|
222
|
+
const textOf = (m) =>
|
|
223
|
+
m.text ||
|
|
224
|
+
(m.content ?? [])
|
|
225
|
+
.filter((p) => p.type === 'text')
|
|
226
|
+
.map((p) =>
|
|
227
|
+
typeof p.text === 'string' ? p.text : (p.text?.value ?? ''),
|
|
228
|
+
)
|
|
229
|
+
.join('');
|
|
230
|
+
const hasError = (m) =>
|
|
231
|
+
(m.content ?? []).some((part) => part.type === 'error');
|
|
232
|
+
const observed = async (check) => {
|
|
233
|
+
const deadline = Date.now() + 15000;
|
|
234
|
+
while (Date.now() < deadline) {
|
|
235
|
+
if (await check()) return true;
|
|
236
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
};
|
|
240
|
+
const visible = (p) => p.getByTestId('messages-view').innerText();
|
|
241
|
+
async function waitGeneration(p, index) {
|
|
242
|
+
await until(
|
|
243
|
+
() => provider.generations.length > index,
|
|
244
|
+
'provider request',
|
|
245
|
+
);
|
|
246
|
+
const g = provider.generations[index];
|
|
247
|
+
await until(
|
|
248
|
+
async () => (await visible(p)).includes(g.prefix),
|
|
249
|
+
'first rendered chunk',
|
|
250
|
+
);
|
|
251
|
+
return g;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
name: 'LibreChat',
|
|
255
|
+
scenarios: ['persistence', 'cancellation', 'regeneration', 'error-retry'],
|
|
256
|
+
async metadata() {
|
|
257
|
+
return {
|
|
258
|
+
evidencePolicy: 'summary-only-v1',
|
|
259
|
+
checkVersion: 1,
|
|
260
|
+
mongodb: (await db.admin().command({ buildInfo: 1 })).version,
|
|
261
|
+
upstream: 'https://github.com/LibreChat-AI/LibreChat',
|
|
262
|
+
commit,
|
|
263
|
+
trackedSourceModified: !!dirty(),
|
|
264
|
+
node: execFileSync(runtime, ['--version'], {
|
|
265
|
+
encoding: 'utf8',
|
|
266
|
+
}).trim(),
|
|
267
|
+
browser: browser.version(),
|
|
268
|
+
lockSha256: createHash('sha256')
|
|
269
|
+
.update(await readFile(resolve(root, 'package-lock.json')))
|
|
270
|
+
.digest('hex'),
|
|
271
|
+
scope:
|
|
272
|
+
'Real native UI, authentication, application routes, generation jobs and MongoDB. Only external OpenAI-compatible provider responses are synthetic. Single server, in-memory stream store; no Redis, worker-kill, database-crash or multi-replica coverage. Regeneration is a branch, not overwriting history. No coordinator installed.',
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
async run(scenario, { repetition }) {
|
|
276
|
+
const context = registration;
|
|
277
|
+
const p = await context.newPage();
|
|
278
|
+
const network = [];
|
|
279
|
+
|
|
280
|
+
p.on('response', (r) => {
|
|
281
|
+
const path = new URL(r.url()).pathname;
|
|
282
|
+
if (path.startsWith('/api/') && /chat|abort|message/.test(path))
|
|
283
|
+
network.push({
|
|
284
|
+
path,
|
|
285
|
+
method: r.request().method(),
|
|
286
|
+
status: r.status(),
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
let first;
|
|
290
|
+
let second;
|
|
291
|
+
try {
|
|
292
|
+
await p.goto(`${baseUrl}/c/new`);
|
|
293
|
+
await p.getByRole('textbox', { name: 'Message input' }).waitFor();
|
|
294
|
+
const selector = p
|
|
295
|
+
.getByRole('button', { name: 'Select a model' })
|
|
296
|
+
.first();
|
|
297
|
+
if (
|
|
298
|
+
(await selector.count()) &&
|
|
299
|
+
!(await selector.innerText()).includes('fixture-model')
|
|
300
|
+
) {
|
|
301
|
+
await selector.click();
|
|
302
|
+
await p
|
|
303
|
+
.getByRole('option', { name: 'StreamTrust Fixture', exact: true })
|
|
304
|
+
.click();
|
|
305
|
+
const model = p.getByRole('option', {
|
|
306
|
+
name: 'fixture-model',
|
|
307
|
+
exact: true,
|
|
308
|
+
});
|
|
309
|
+
if (await model.isVisible().catch(() => false)) await model.click();
|
|
310
|
+
}
|
|
311
|
+
const before = provider.generations.length;
|
|
312
|
+
await p
|
|
313
|
+
.getByRole('textbox', { name: 'Message input' })
|
|
314
|
+
.fill(
|
|
315
|
+
`StreamTrust ${scenario} ${repetition}. Write two sentences.`,
|
|
316
|
+
);
|
|
317
|
+
const admission = p.waitForResponse(
|
|
318
|
+
(r) =>
|
|
319
|
+
r.request().method() === 'POST' &&
|
|
320
|
+
new URL(r.url()).pathname.startsWith('/api/agents/chat') &&
|
|
321
|
+
!r.url().endsWith('/abort'),
|
|
322
|
+
);
|
|
323
|
+
await p
|
|
324
|
+
.getByRole('textbox', { name: 'Message input' })
|
|
325
|
+
.press('Enter');
|
|
326
|
+
first = await waitGeneration(p, before);
|
|
327
|
+
const accepted = await (await admission).json();
|
|
328
|
+
const id = accepted.conversationId;
|
|
329
|
+
if (!id || id === 'new')
|
|
330
|
+
throw new Error('Missing native admission conversation identity');
|
|
331
|
+
const checks = [];
|
|
332
|
+
let expected = first.prefix + first.suffix;
|
|
333
|
+
if (scenario === 'cancellation') {
|
|
334
|
+
await p
|
|
335
|
+
.getByRole('button', { name: 'Stop generating', exact: true })
|
|
336
|
+
.click();
|
|
337
|
+
await observed(() => first.closed);
|
|
338
|
+
expected = first.prefix;
|
|
339
|
+
checks.push({
|
|
340
|
+
name: 'Native server acknowledges Stop',
|
|
341
|
+
passed: await observed(() =>
|
|
342
|
+
network.some(
|
|
343
|
+
(e) =>
|
|
344
|
+
e.method === 'POST' &&
|
|
345
|
+
e.path.endsWith('/abort') &&
|
|
346
|
+
e.status === 200,
|
|
347
|
+
),
|
|
348
|
+
),
|
|
349
|
+
});
|
|
350
|
+
checks.push({
|
|
351
|
+
name: 'Stop aborts upstream HTTP response before completion',
|
|
352
|
+
passed: first.closed && !first.finished,
|
|
353
|
+
});
|
|
354
|
+
checks.push({
|
|
355
|
+
name: 'Late provider completion cannot be delivered',
|
|
356
|
+
passed: !provider.finish(first),
|
|
357
|
+
});
|
|
358
|
+
} else if (scenario === 'error-retry') {
|
|
359
|
+
provider.fail(first);
|
|
360
|
+
} else provider.finish(first);
|
|
361
|
+
await until(
|
|
362
|
+
async () =>
|
|
363
|
+
!(await p
|
|
364
|
+
.getByRole('button', { name: 'Stop generating', exact: true })
|
|
365
|
+
.count()),
|
|
366
|
+
'generation settles',
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
if (scenario === 'error-retry') {
|
|
370
|
+
const errorSaved = await observed(async () =>
|
|
371
|
+
(await messages(id)).some(hasError),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
checks.push({
|
|
375
|
+
name: 'Provider failure retained as a native error part',
|
|
376
|
+
passed: errorSaved,
|
|
377
|
+
});
|
|
378
|
+
await p.reload();
|
|
379
|
+
await p.getByRole('textbox', { name: 'Message input' }).waitFor();
|
|
380
|
+
const errorVisible = await observed(async () =>
|
|
381
|
+
(await visible(p)).includes(
|
|
382
|
+
'model provider closed the connection',
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
checks.push({
|
|
386
|
+
name: 'Provider error context survives reload before retry',
|
|
387
|
+
passed: errorVisible,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
if (scenario === 'regeneration' || scenario === 'error-retry') {
|
|
391
|
+
const nextIndex = provider.generations.length;
|
|
392
|
+
await p
|
|
393
|
+
.getByRole('button', { name: 'Regenerate', exact: true })
|
|
394
|
+
.last()
|
|
395
|
+
.click();
|
|
396
|
+
second = await waitGeneration(p, nextIndex);
|
|
397
|
+
provider.finish(second);
|
|
398
|
+
expected = second.prefix + second.suffix;
|
|
399
|
+
await until(
|
|
400
|
+
async () =>
|
|
401
|
+
!(await p
|
|
402
|
+
.getByRole('button', { name: 'Stop generating', exact: true })
|
|
403
|
+
.count()),
|
|
404
|
+
'retry settles',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
await observed(async () =>
|
|
408
|
+
(await messages(id)).some(
|
|
409
|
+
(m) => !m.isCreatedByUser && textOf(m).trim() === expected.trim(),
|
|
410
|
+
),
|
|
411
|
+
);
|
|
412
|
+
const durable = await messages(id);
|
|
413
|
+
const beforeReload = await visible(p);
|
|
414
|
+
await p.reload();
|
|
415
|
+
await p.getByRole('textbox', { name: 'Message input' }).waitFor();
|
|
416
|
+
await observed(async () => (await visible(p)).includes(expected));
|
|
417
|
+
const reloaded = await visible(p);
|
|
418
|
+
return evaluateLifecycle({
|
|
419
|
+
scenario,
|
|
420
|
+
expected,
|
|
421
|
+
original: first.prefix + first.suffix,
|
|
422
|
+
previousPrefix: first.prefix,
|
|
423
|
+
beforeReload,
|
|
424
|
+
reloaded,
|
|
425
|
+
messages: durable
|
|
426
|
+
.filter((m) => !m.isCreatedByUser)
|
|
427
|
+
.map((m) => ({
|
|
428
|
+
id: m.messageId,
|
|
429
|
+
parentId: m.parentMessageId,
|
|
430
|
+
text: textOf(m),
|
|
431
|
+
assistant: !m.isCreatedByUser,
|
|
432
|
+
unfinished: m.unfinished,
|
|
433
|
+
error: !!m.error || hasError(m),
|
|
434
|
+
})),
|
|
435
|
+
callsBeforeReload: second ? second.id : first.id,
|
|
436
|
+
callsAfterReload: provider.generations.length,
|
|
437
|
+
cancellation:
|
|
438
|
+
scenario === 'cancellation'
|
|
439
|
+
? {
|
|
440
|
+
acknowledged: checks.find(
|
|
441
|
+
(c) => c.name === 'Native server acknowledges Stop',
|
|
442
|
+
).passed,
|
|
443
|
+
providerClosed: first.closed,
|
|
444
|
+
providerFinished: first.finished,
|
|
445
|
+
lateCompletionRejected: checks.find(
|
|
446
|
+
(c) =>
|
|
447
|
+
c.name ===
|
|
448
|
+
'Late provider completion cannot be delivered',
|
|
449
|
+
).passed,
|
|
450
|
+
}
|
|
451
|
+
: undefined,
|
|
452
|
+
failure:
|
|
453
|
+
scenario === 'error-retry'
|
|
454
|
+
? {
|
|
455
|
+
saved: checks.find(
|
|
456
|
+
(c) =>
|
|
457
|
+
c.name ===
|
|
458
|
+
'Provider failure retained as a native error part',
|
|
459
|
+
).passed,
|
|
460
|
+
visibleAfterReload: checks.find(
|
|
461
|
+
(c) =>
|
|
462
|
+
c.name ===
|
|
463
|
+
'Provider error context survives reload before retry',
|
|
464
|
+
).passed,
|
|
465
|
+
}
|
|
466
|
+
: undefined,
|
|
467
|
+
});
|
|
468
|
+
} catch {
|
|
469
|
+
// Browser/DB exceptions can contain prompts, selectors, URLs and credentials.
|
|
470
|
+
throw new Error(
|
|
471
|
+
'LibreChat scenario could not complete (details withheld by summary-only policy)',
|
|
472
|
+
);
|
|
473
|
+
} finally {
|
|
474
|
+
if (first && !first.closed) provider.finish(first);
|
|
475
|
+
if (second && !second.closed) provider.finish(second);
|
|
476
|
+
await p.close();
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
close: cleanup,
|
|
480
|
+
};
|
|
481
|
+
} catch (error) {
|
|
482
|
+
await cleanup();
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export default async function safeFactory(context) {
|
|
488
|
+
try {
|
|
489
|
+
phase = 'preflight';
|
|
490
|
+
const adapter = await createAdapter();
|
|
491
|
+
const run = adapter.run.bind(adapter);
|
|
492
|
+
adapter.run = async (...args) => {
|
|
493
|
+
try {
|
|
494
|
+
return await run(...args);
|
|
495
|
+
} catch {
|
|
496
|
+
throw new Error(
|
|
497
|
+
'LibreChat scenario could not complete (details withheld by summary-only policy)',
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const metadata = adapter.metadata.bind(adapter);
|
|
502
|
+
adapter.metadata = async () => {
|
|
503
|
+
try {
|
|
504
|
+
return await metadata();
|
|
505
|
+
} catch {
|
|
506
|
+
throw new Error('LibreChat metadata unavailable (details withheld)');
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
return adapter;
|
|
510
|
+
} catch {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`LibreChat setup failed during ${phase}. Check Node 24+, optional dependencies, built clean checkout without .env files, expected commit, and port. Raw details withheld.`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export async function startProvider() {
|
|
4
|
+
const generations = [];
|
|
5
|
+
const chunk = (g, delta, finish = null) =>
|
|
6
|
+
g.response.write(
|
|
7
|
+
`data: ${JSON.stringify({ id: `chatcmpl-fixture-${g.id}`, object: 'chat.completion.chunk', created: 1, model: 'fixture-model', choices: [{ index: 0, delta: { content: delta }, finish_reason: finish }] })}\n\n`,
|
|
8
|
+
);
|
|
9
|
+
const server = createServer(async (req, res) => {
|
|
10
|
+
let body = '';
|
|
11
|
+
for await (const part of req) body += part;
|
|
12
|
+
if (req.method !== 'POST') {
|
|
13
|
+
res.setHeader('content-type', 'application/json');
|
|
14
|
+
res.end(
|
|
15
|
+
JSON.stringify({ data: [{ id: 'fixture-model', object: 'model' }] }),
|
|
16
|
+
);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const input = JSON.parse(body);
|
|
20
|
+
if (!input.stream) {
|
|
21
|
+
res.setHeader('content-type', 'application/json');
|
|
22
|
+
res.end(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
choices: [
|
|
25
|
+
{
|
|
26
|
+
message: { role: 'assistant', content: 'Fixture conversation' },
|
|
27
|
+
finish_reason: 'stop',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const id = generations.length + 1;
|
|
35
|
+
const g = {
|
|
36
|
+
id,
|
|
37
|
+
prefix: `Response ${id} begins.`,
|
|
38
|
+
suffix: ` Response ${id} completes.`,
|
|
39
|
+
closed: false,
|
|
40
|
+
finished: false,
|
|
41
|
+
failed: false,
|
|
42
|
+
path: req.url,
|
|
43
|
+
response: res,
|
|
44
|
+
};
|
|
45
|
+
generations.push(g);
|
|
46
|
+
res.on('close', () => {
|
|
47
|
+
g.closed = true;
|
|
48
|
+
});
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'content-type': 'text/event-stream',
|
|
51
|
+
'cache-control': 'no-cache',
|
|
52
|
+
});
|
|
53
|
+
chunk(g, g.prefix);
|
|
54
|
+
});
|
|
55
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
56
|
+
return {
|
|
57
|
+
url: `http://127.0.0.1:${server.address().port}/v1`,
|
|
58
|
+
generations,
|
|
59
|
+
finish(g) {
|
|
60
|
+
if (g.closed) return false;
|
|
61
|
+
chunk(g, g.suffix);
|
|
62
|
+
chunk(g, '', 'stop');
|
|
63
|
+
g.finished = true;
|
|
64
|
+
g.response.end('data: [DONE]\n\n');
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
fail(g) {
|
|
68
|
+
g.failed = true;
|
|
69
|
+
g.response.destroy();
|
|
70
|
+
},
|
|
71
|
+
observations() {
|
|
72
|
+
return generations.map(({ response, ...g }) => g);
|
|
73
|
+
},
|
|
74
|
+
async close() {
|
|
75
|
+
server.closeAllConnections();
|
|
76
|
+
await new Promise((r) => server.close(() => r()));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
const root = process.env.STREAMTRUST_LIBRECHAT_ROOT;
|
|
5
|
+
const pin =
|
|
6
|
+
process.env.STREAMTRUST_LIBRECHAT_COMMIT ||
|
|
7
|
+
'a378657583567310c841d652b0f6b00c83e2bd6b';
|
|
8
|
+
if (
|
|
9
|
+
!root ||
|
|
10
|
+
Number(process.versions.node.split('.')[0]) < 24 ||
|
|
11
|
+
!/^[a-f0-9]{40}$/.test(pin)
|
|
12
|
+
)
|
|
13
|
+
throw new Error(
|
|
14
|
+
'Set STREAMTRUST_LIBRECHAT_ROOT; use Node 24+ and a full commit SHA',
|
|
15
|
+
);
|
|
16
|
+
const path = resolve(root);
|
|
17
|
+
const run = (cmd, args, cwd) =>
|
|
18
|
+
execFileSync(cmd, args, { cwd, stdio: 'inherit' });
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
run('git', [
|
|
21
|
+
'clone',
|
|
22
|
+
'--no-checkout',
|
|
23
|
+
'https://github.com/LibreChat-AI/LibreChat.git',
|
|
24
|
+
path,
|
|
25
|
+
]);
|
|
26
|
+
run('git', ['checkout', '--detach', pin], path);
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
30
|
+
cwd: path,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
}).trim() !== pin ||
|
|
33
|
+
execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], {
|
|
34
|
+
cwd: path,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
}).trim()
|
|
37
|
+
)
|
|
38
|
+
throw new Error(
|
|
39
|
+
'Checkout must be clean and match the expected commit; setup never resets existing source',
|
|
40
|
+
);
|
|
41
|
+
run('npm', ['ci', '--no-audit', '--no-fund'], path);
|
|
42
|
+
run('npm', ['run', 'frontend'], path);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamtrust",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Adapter-driven CI verification for streaming application lifecycle guarantees",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./src/index.d.ts",
|
|
16
16
|
"import": "./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./checks": {
|
|
19
|
+
"types": "./src/checks.d.ts",
|
|
20
|
+
"import": "./src/checks.js"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"types": "src/index.d.ts",
|
package/src/checks.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExternalObservation } from './index.js';
|
|
2
|
+
export type LifecycleScenario =
|
|
3
|
+
| 'persistence'
|
|
4
|
+
| 'cancellation'
|
|
5
|
+
| 'regeneration'
|
|
6
|
+
| 'error-retry';
|
|
7
|
+
export const LIFECYCLE_SCENARIOS: readonly LifecycleScenario[];
|
|
8
|
+
export interface LifecycleObservation {
|
|
9
|
+
scenario: LifecycleScenario;
|
|
10
|
+
expected: string;
|
|
11
|
+
original: string;
|
|
12
|
+
previousPrefix: string;
|
|
13
|
+
beforeReload: string;
|
|
14
|
+
reloaded: string;
|
|
15
|
+
messages: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
parentId: string;
|
|
18
|
+
text: string;
|
|
19
|
+
assistant: boolean;
|
|
20
|
+
unfinished: boolean;
|
|
21
|
+
error: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
callsBeforeReload: number;
|
|
24
|
+
callsAfterReload: number;
|
|
25
|
+
cancellation?: {
|
|
26
|
+
acknowledged: boolean;
|
|
27
|
+
providerClosed: boolean;
|
|
28
|
+
providerFinished: boolean;
|
|
29
|
+
lateCompletionRejected: boolean;
|
|
30
|
+
};
|
|
31
|
+
failure?: { saved: boolean; visibleAfterReload: boolean };
|
|
32
|
+
}
|
|
33
|
+
/** Evaluate in-memory observations; returns only fixed check names and allowlisted numeric evidence. */
|
|
34
|
+
export function evaluateLifecycle(
|
|
35
|
+
observation: LifecycleObservation,
|
|
36
|
+
): ExternalObservation;
|
package/src/checks.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Raw observations remain in memory. Returned evidence is deliberately allowlisted.
|
|
2
|
+
export const LIFECYCLE_SCENARIOS = Object.freeze([
|
|
3
|
+
'persistence',
|
|
4
|
+
'cancellation',
|
|
5
|
+
'regeneration',
|
|
6
|
+
'error-retry',
|
|
7
|
+
]);
|
|
8
|
+
export function evaluateLifecycle(o) {
|
|
9
|
+
const text = (v) => typeof v === 'string' && v.length > 0;
|
|
10
|
+
const bool = (v) => typeof v === 'boolean';
|
|
11
|
+
if (
|
|
12
|
+
!o ||
|
|
13
|
+
!LIFECYCLE_SCENARIOS.includes(o.scenario) ||
|
|
14
|
+
!text(o.expected) ||
|
|
15
|
+
!text(o.original) ||
|
|
16
|
+
!text(o.previousPrefix) ||
|
|
17
|
+
typeof o.beforeReload !== 'string' ||
|
|
18
|
+
typeof o.reloaded !== 'string' ||
|
|
19
|
+
!Array.isArray(o.messages) ||
|
|
20
|
+
o.messages.some(
|
|
21
|
+
(m) =>
|
|
22
|
+
!m ||
|
|
23
|
+
!text(m.id) ||
|
|
24
|
+
typeof m.parentId !== 'string' ||
|
|
25
|
+
typeof m.text !== 'string' ||
|
|
26
|
+
!bool(m.assistant) ||
|
|
27
|
+
!bool(m.unfinished) ||
|
|
28
|
+
!bool(m.error),
|
|
29
|
+
) ||
|
|
30
|
+
!Number.isSafeInteger(o.callsBeforeReload) ||
|
|
31
|
+
o.callsBeforeReload < 1 ||
|
|
32
|
+
!Number.isSafeInteger(o.callsAfterReload) ||
|
|
33
|
+
o.callsAfterReload < 0
|
|
34
|
+
)
|
|
35
|
+
throw new Error('Invalid lifecycle observation');
|
|
36
|
+
const checks = [];
|
|
37
|
+
const check = (name, passed) => checks.push({ name, passed });
|
|
38
|
+
const matching = o.messages.filter(
|
|
39
|
+
(m) => m.assistant && m.text.trim() === o.expected.trim(),
|
|
40
|
+
);
|
|
41
|
+
check('saved.answer-present', matching.length > 0);
|
|
42
|
+
check('saved.single-matching-answer', matching.length === 1);
|
|
43
|
+
check('ui.answer-visible', o.beforeReload.includes(o.expected));
|
|
44
|
+
check(
|
|
45
|
+
'saved.lifecycle-state',
|
|
46
|
+
matching.length === 1 &&
|
|
47
|
+
(o.scenario === 'cancellation'
|
|
48
|
+
? matching[0].unfinished === true
|
|
49
|
+
: matching[0].unfinished === false && !matching[0].error),
|
|
50
|
+
);
|
|
51
|
+
check('reload.answer-preserved', o.reloaded.includes(o.expected));
|
|
52
|
+
check('reload.no-new-generation', o.callsAfterReload === o.callsBeforeReload);
|
|
53
|
+
if (o.scenario === 'cancellation') {
|
|
54
|
+
const c = o.cancellation;
|
|
55
|
+
if (
|
|
56
|
+
!c ||
|
|
57
|
+
[
|
|
58
|
+
'acknowledged',
|
|
59
|
+
'providerClosed',
|
|
60
|
+
'providerFinished',
|
|
61
|
+
'lateCompletionRejected',
|
|
62
|
+
].some((k) => !bool(c[k]))
|
|
63
|
+
)
|
|
64
|
+
throw new Error('Invalid cancellation observation');
|
|
65
|
+
check('cancel.server-acknowledged', c.acknowledged);
|
|
66
|
+
check('cancel.provider-aborted', c.providerClosed && !c.providerFinished);
|
|
67
|
+
check('cancel.late-completion-rejected', c.lateCompletionRejected);
|
|
68
|
+
}
|
|
69
|
+
if (o.scenario === 'regeneration') {
|
|
70
|
+
const original = o.messages.filter(
|
|
71
|
+
(m) => m.assistant && m.text === o.original,
|
|
72
|
+
);
|
|
73
|
+
check('retry.original-branch-preserved', original.length === 1);
|
|
74
|
+
check(
|
|
75
|
+
'retry.distinct-sibling',
|
|
76
|
+
original.length === 1 &&
|
|
77
|
+
matching.length === 1 &&
|
|
78
|
+
original[0].id !== matching[0].id &&
|
|
79
|
+
original[0].parentId === matching[0].parentId,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (o.scenario === 'error-retry') {
|
|
83
|
+
const f = o.failure;
|
|
84
|
+
if (!f || !bool(f.saved) || !bool(f.visibleAfterReload))
|
|
85
|
+
throw new Error('Invalid failure observation');
|
|
86
|
+
check('error.saved-before-retry', f.saved);
|
|
87
|
+
check('error.visible-after-reload', f.visibleAfterReload);
|
|
88
|
+
}
|
|
89
|
+
if (['regeneration', 'error-retry'].includes(o.scenario))
|
|
90
|
+
check(
|
|
91
|
+
'retry.previous-answer-not-active',
|
|
92
|
+
!o.reloaded.includes(o.previousPrefix),
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
checks,
|
|
96
|
+
evidence: {
|
|
97
|
+
policy: 'summary-only-v1',
|
|
98
|
+
checkVersion: 1,
|
|
99
|
+
savedMessageCount: o.messages.length,
|
|
100
|
+
matchingAnswerCount: matching.length,
|
|
101
|
+
providerCallsBeforeReload: o.callsBeforeReload,
|
|
102
|
+
providerCallsAfterReload: o.callsAfterReload,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { mkdir, copyFile, open, unlink, readFile } from 'node:fs/promises';
|
|
6
6
|
import { constants } from 'node:fs';
|
|
7
7
|
import { VERSION, failureReport } from './index.js';
|
|
8
|
-
const HELP = `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory]\n streamtrust verify --adapter ./streamtrust/adapter.mjs [options]\n\nOptions:\n --output <directory> Reports (default: reports/streamtrust)\n --repetitions <1-1000> Repetitions per scenario (default: 3)\n --timeout-ms <integer> Total run deadline including setup/cleanup (default: 300000)\n --help Show help\n --version Show version\n\nExit codes: 0 all pass; 1 failed/unsupported checks; 2 setup, timeout or harness error.\nAdapters execute trusted local code. JavaScript ESM (.mjs) works without dependencies.\n`;
|
|
8
|
+
const HELP = `StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory] [--preset librechat]\n streamtrust verify --adapter ./streamtrust/adapter.mjs [options]\n\nOptions:\n --output <directory> Reports (default: reports/streamtrust)\n --repetitions <1-1000> Repetitions per scenario (default: 3)\n --timeout-ms <integer> Total run deadline including setup/cleanup (default: 300000)\n --help Show help\n --version Show version\n\nExit codes: 0 all pass; 1 failed/unsupported checks; 2 setup, timeout or harness error.\nAdapters execute trusted local code. JavaScript ESM (.mjs) works without dependencies.\n`;
|
|
9
9
|
export async function main(args) {
|
|
10
10
|
let outputDir;
|
|
11
11
|
try {
|
|
@@ -18,17 +18,32 @@ export async function main(args) {
|
|
|
18
18
|
return 0;
|
|
19
19
|
}
|
|
20
20
|
if (args[0] === 'init') {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const initArgs = args.slice(1);
|
|
22
|
+
const presetIndex = initArgs.indexOf('--preset');
|
|
23
|
+
let preset = 'http';
|
|
24
|
+
if (presetIndex !== -1) {
|
|
25
|
+
preset = initArgs[presetIndex + 1];
|
|
26
|
+
initArgs.splice(presetIndex, 2);
|
|
27
|
+
if (preset !== 'librechat') throw new Error('Unknown init preset');
|
|
28
|
+
}
|
|
29
|
+
if (initArgs.length > 1 || initArgs[0]?.startsWith('-'))
|
|
30
|
+
throw new Error('Invalid init arguments');
|
|
31
|
+
const dir = resolve(initArgs[0] ?? 'streamtrust');
|
|
24
32
|
await mkdir(dir, { recursive: true });
|
|
25
33
|
// Exclusive copies preserve existing adapters. No generated config overrides user files.
|
|
26
34
|
const created = [];
|
|
27
35
|
try {
|
|
28
|
-
for (const [source, target] of
|
|
29
|
-
[
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
for (const [source, target] of preset === 'librechat'
|
|
37
|
+
? [
|
|
38
|
+
['librechat/adapter.mjs', 'adapter.mjs'],
|
|
39
|
+
['librechat/provider.mjs', 'provider.mjs'],
|
|
40
|
+
['librechat/setup.mjs', 'setup.mjs'],
|
|
41
|
+
['librechat/README.md', 'README.md'],
|
|
42
|
+
]
|
|
43
|
+
: [
|
|
44
|
+
['http.adapter.mjs', 'adapter.mjs'],
|
|
45
|
+
['demo-app.mjs', 'demo-app.mjs'],
|
|
46
|
+
]) {
|
|
32
47
|
const destination = resolve(dir, target);
|
|
33
48
|
await copyFile(
|
|
34
49
|
new URL(`../examples/${source}`, import.meta.url),
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { mkdir, writeFile, rename, access } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
export const VERSION = JSON.parse(
|
|
6
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
7
|
+
).version;
|
|
5
8
|
const object = (value) =>
|
|
6
9
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
10
|
const nonempty = (value) =>
|
|
@@ -60,13 +63,15 @@ export async function writeReports(outputDir, report) {
|
|
|
60
63
|
const rows = report.results;
|
|
61
64
|
const screenshots = new Set();
|
|
62
65
|
await Promise.all(
|
|
63
|
-
rows.map(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
(report.metadata?.evidencePolicy === 'summary-only-v1' ? [] : rows).map(
|
|
67
|
+
async (row) => {
|
|
68
|
+
const path = `${row.scenario}-${row.repetition}.png`;
|
|
69
|
+
try {
|
|
70
|
+
await access(resolve(outputDir, path));
|
|
71
|
+
screenshots.add(path);
|
|
72
|
+
} catch {}
|
|
73
|
+
},
|
|
74
|
+
),
|
|
70
75
|
);
|
|
71
76
|
const suiteCases = report.suiteErrors
|
|
72
77
|
.map(
|