relmio 0.6.0 → 0.7.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/CHANGELOG.md +26 -0
- package/README.md +42 -429
- package/docs/faq.md +45 -0
- package/docs/getting-started.md +38 -0
- package/docs/local-endpoints-spec.md +27 -0
- package/docs/local-endpoints.md +52 -11
- package/docs/reference.md +77 -0
- package/docs/security.md +34 -0
- package/docs/troubleshooting.md +33 -0
- package/docs/vps-and-n8n.md +30 -0
- package/package.json +1 -1
- package/src/services/local-chat-test.js +361 -0
- package/src/ui/local.css +115 -1
- package/src/ui/local.html +99 -0
- package/src/ui/local.js +263 -1
- package/src/web/server.js +123 -1
package/docs/local-endpoints.md
CHANGED
|
@@ -20,6 +20,16 @@ This is a documentation-backed engineering boundary, not legal advice or a
|
|
|
20
20
|
guarantee that a particular account or use case is permitted. Review the
|
|
21
21
|
agreements and policies that apply to your account.
|
|
22
22
|
|
|
23
|
+
## ChatGPT/Codex sign-in lifetime
|
|
24
|
+
|
|
25
|
+
ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
|
|
26
|
+
them automatically during active use before they expire, so active sessions
|
|
27
|
+
usually continue without another browser login. The official [OpenAI
|
|
28
|
+
authentication documentation](https://learn.chatgpt.com/docs/auth) does not
|
|
29
|
+
publish a fixed 10-day lifetime; do not plan around one. This provider
|
|
30
|
+
credential is separate from Relmio's local capability, which remains valid
|
|
31
|
+
until you rotate it.
|
|
32
|
+
|
|
23
33
|
## Requirements
|
|
24
34
|
|
|
25
35
|
- macOS, Linux, or Linux under WSL2. Native Windows is not supported because
|
|
@@ -40,7 +50,8 @@ project on the local computer.
|
|
|
40
50
|
## Install with the browser wizard
|
|
41
51
|
|
|
42
52
|
1. Start Relmio on the computer that will run the endpoint. Use one of the
|
|
43
|
-
commands
|
|
53
|
+
commands on the [hosted install page](https://relmio.vercel.app/install),
|
|
54
|
+
or run:
|
|
44
55
|
|
|
45
56
|
```bash
|
|
46
57
|
npx --yes --ignore-scripts relmio@latest
|
|
@@ -129,8 +140,9 @@ For a quick private test:
|
|
|
129
140
|
|
|
130
141
|
```bash
|
|
131
142
|
export RELMIO_LOCAL_KEY="<capability shown once by the wizard>"
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_LOCAL_KEY" |
|
|
144
|
+
curl http://127.0.0.1:12435/v1/models --header @-
|
|
145
|
+
unset RELMIO_LOCAL_KEY
|
|
134
146
|
```
|
|
135
147
|
|
|
136
148
|
The upstream key is passed only over stdin to a transient, network-disabled
|
|
@@ -179,12 +191,15 @@ official verification URL, enter the device code, and complete authentication.
|
|
|
179
191
|
Relmio starts the login through the official Codex App Server account method;
|
|
180
192
|
it never returns the resulting ChatGPT access or refresh tokens.
|
|
181
193
|
|
|
182
|
-
A compatible Codex CLI can connect like this
|
|
194
|
+
A compatible Codex CLI can connect like this. Read the capability without
|
|
195
|
+
putting it in the command line:
|
|
183
196
|
|
|
184
197
|
```bash
|
|
185
|
-
|
|
198
|
+
read -r -s CODEX_REMOTE_TOKEN
|
|
199
|
+
printf '\n'
|
|
186
200
|
codex --remote ws://127.0.0.1:14500 \
|
|
187
201
|
--remote-auth-token-env CODEX_REMOTE_TOKEN
|
|
202
|
+
unset CODEX_REMOTE_TOKEN
|
|
188
203
|
```
|
|
189
204
|
|
|
190
205
|
This is not an OpenAI `/v1` endpoint. A client must implement the official
|
|
@@ -221,14 +236,19 @@ Protocol: Relmio Codex Chat HTTP
|
|
|
221
236
|
```
|
|
222
237
|
|
|
223
238
|
After completing the same official Codex device-code sign-in, a local backend
|
|
224
|
-
can start a conversation with
|
|
239
|
+
can start a conversation with. Read the bearer rather than placing it in a
|
|
240
|
+
shell command:
|
|
225
241
|
|
|
226
242
|
```bash
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
-
|
|
231
|
-
|
|
243
|
+
read -r -s RELMIO_CODEX_CHAT_KEY
|
|
244
|
+
printf '\n'
|
|
245
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CODEX_CHAT_KEY" |
|
|
246
|
+
curl --fail-with-body --silent --show-error \
|
|
247
|
+
--request POST http://127.0.0.1:14501/chat \
|
|
248
|
+
--header @- \
|
|
249
|
+
--header "Content-Type: application/json" \
|
|
250
|
+
--data '{"input":"Reply with a short hello."}'
|
|
251
|
+
unset RELMIO_CODEX_CHAT_KEY
|
|
232
252
|
```
|
|
233
253
|
|
|
234
254
|
The response contains only the App Server thread ID and final conversational
|
|
@@ -262,6 +282,27 @@ LAN, multi-user, or production service. It enforces bounded request bodies,
|
|
|
262
282
|
output, concurrency, process lifetime, and sanitized failures, but those
|
|
263
283
|
controls do not create a general-purpose API entitlement.
|
|
264
284
|
|
|
285
|
+
### In-wizard Chat Adapter tester
|
|
286
|
+
|
|
287
|
+
The Ready screen for an installed Chat Adapter includes a narrow local tester.
|
|
288
|
+
It is intended for a literal `http://127.0.0.1:PORT` adapter address only. The
|
|
289
|
+
browser never calls the adapter: it calls the local wizard's existing
|
|
290
|
+
same-origin, `X-Setup-Token` protected APIs, and the wizard makes the
|
|
291
|
+
server-side `POST /chat` request without an `Origin` header.
|
|
292
|
+
|
|
293
|
+
When the user secures the displayed client credential, the browser clears the
|
|
294
|
+
input and encrypts it with the tester's short-lived RSA-OAEP SHA-256 public
|
|
295
|
+
key. The private key exists only in local server memory, expires after a few
|
|
296
|
+
minutes, has a bounded session count, and can be invalidated with **Forget
|
|
297
|
+
tester**. The browser retains only ciphertext and key ID for the test session;
|
|
298
|
+
it keeps prompts and transcript only in current-page memory and DOM.
|
|
299
|
+
|
|
300
|
+
This reduces accidental credential transit and storage exposure. It is not
|
|
301
|
+
encryption at rest or end-to-end encryption, and it cannot protect against a
|
|
302
|
+
compromised browser, extension, or local machine. The tester rejects redirects,
|
|
303
|
+
non-loopback URLs, malformed or oversized data, concurrent key use, and
|
|
304
|
+
adapter failures with redacted messages.
|
|
305
|
+
|
|
265
306
|
## Network and container boundary
|
|
266
307
|
|
|
267
308
|
Every local Compose project publishes exactly one host mapping:
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Reference
|
|
2
|
+
|
|
3
|
+
## Chat Adapter test commands
|
|
4
|
+
|
|
5
|
+
The experimental Chat Adapter is a loopback-only, Relmio-specific `POST /chat`
|
|
6
|
+
service for trusted local backends or development servers. It is not OpenAI
|
|
7
|
+
`/v1` and it rejects browser `Origin` headers.
|
|
8
|
+
|
|
9
|
+
Set the endpoint, then read the one-time credential without typing the literal
|
|
10
|
+
credential into shell history:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
export RELMIO_CHAT_BASE_URL="http://127.0.0.1:14501"
|
|
14
|
+
read -r -s RELMIO_CHAT_CLIENT_CREDENTIAL
|
|
15
|
+
printf '\n'
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Start a conversation:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
|
|
22
|
+
curl --fail-with-body --silent --show-error \
|
|
23
|
+
--request POST "$RELMIO_CHAT_BASE_URL/chat" \
|
|
24
|
+
--header @- \
|
|
25
|
+
--header "Content-Type: application/json" \
|
|
26
|
+
--data '{"input":"Reply with exactly: adapter works"}'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Copy the returned `conversationId`, then send a continuation:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export RELMIO_CONVERSATION_ID="CONVERSATION_ID_FROM_THE_PREVIOUS_RESPONSE"
|
|
33
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
|
|
34
|
+
curl --fail-with-body --silent --show-error \
|
|
35
|
+
--request POST "$RELMIO_CHAT_BASE_URL/chat" \
|
|
36
|
+
--header @- \
|
|
37
|
+
--header "Content-Type: application/json" \
|
|
38
|
+
--data "{\"input\":\"Continue with one short sentence.\",\"conversationId\":\"$RELMIO_CONVERSATION_ID\"}"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Unset the shell credential when finished:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
unset RELMIO_CHAT_CLIENT_CREDENTIAL
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Raw Codex App Server command
|
|
48
|
+
|
|
49
|
+
The raw Codex App Server transport is JSON-RPC over WebSocket, experimental,
|
|
50
|
+
and high-trust. It is for trusted native clients only; it is not an
|
|
51
|
+
OpenAI-compatible `/v1` endpoint. Read the one-time local capability into a
|
|
52
|
+
named environment variable rather than placing it in the command:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
read -r -s RELMIO_CODEX_CLIENT_CREDENTIAL
|
|
56
|
+
printf '\n'
|
|
57
|
+
codex --remote ws://127.0.0.1:14500 \
|
|
58
|
+
--remote-auth-token-env RELMIO_CODEX_CLIENT_CREDENTIAL
|
|
59
|
+
unset RELMIO_CODEX_CLIENT_CREDENTIAL
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Keep that capability private. A trusted Codex client can control the isolated
|
|
63
|
+
container and may be able to recover its ChatGPT session credential.
|
|
64
|
+
|
|
65
|
+
## Local wizard tester API
|
|
66
|
+
|
|
67
|
+
The browser never contacts the adapter directly. While the local wizard is
|
|
68
|
+
running, its same-origin, `X-Setup-Token` protected APIs are:
|
|
69
|
+
|
|
70
|
+
| Method | Route | Purpose |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| `POST` | `/api/local/chat-test/key` | Issue one ephemeral RSA-OAEP public key |
|
|
73
|
+
| `POST` | `/api/local/chat-test/message` | Send encrypted credential and one bounded chat turn |
|
|
74
|
+
| `POST` | `/api/local/chat-test/reset` | Invalidate the tester key and clear the browser transcript |
|
|
75
|
+
|
|
76
|
+
The local proxy accepts only a literal `http://127.0.0.1:PORT` adapter base
|
|
77
|
+
URL and appends `/chat` itself.
|
package/docs/security.md
CHANGED
|
@@ -6,6 +6,16 @@ key, a Codex/ChatGPT session, and generated local capabilities. Treat every one
|
|
|
6
6
|
of these values as password-equivalent. Read this page before offering the
|
|
7
7
|
wizard to another person.
|
|
8
8
|
|
|
9
|
+
## ChatGPT/Codex sign-in lifetime
|
|
10
|
+
|
|
11
|
+
ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
|
|
12
|
+
them automatically during active use before they expire, so active sessions
|
|
13
|
+
usually continue without another browser login. The official [OpenAI
|
|
14
|
+
authentication documentation](https://learn.chatgpt.com/docs/auth) does not
|
|
15
|
+
publish a fixed 10-day lifetime; do not plan around one. This provider
|
|
16
|
+
credential is separate from Relmio's local capability, which remains valid
|
|
17
|
+
until you rotate it.
|
|
18
|
+
|
|
9
19
|
## Trust model
|
|
10
20
|
|
|
11
21
|
The design assumes:
|
|
@@ -112,6 +122,30 @@ shared, or production service.
|
|
|
112
122
|
resource limits; and uses root plus only `CHOWN` long enough to atomically
|
|
113
123
|
make the stdin-seeded volume entry readable by the non-root gateway.
|
|
114
124
|
|
|
125
|
+
### In-wizard Chat Adapter tester
|
|
126
|
+
|
|
127
|
+
The Ready screen's Chat Adapter tester is a deliberately narrow convenience
|
|
128
|
+
path, not a browser CORS exception. Its browser calls stay same-origin to the
|
|
129
|
+
setup-token-protected wizard. Only the local wizard server calls the adapter,
|
|
130
|
+
using a server-side `POST /chat` request without an `Origin` header.
|
|
131
|
+
|
|
132
|
+
The tester accepts only a literal `http://127.0.0.1:PORT` base URL and appends
|
|
133
|
+
`/chat` itself. It refuses `localhost`, IPv6, LAN/private/public addresses,
|
|
134
|
+
credentials, query strings, fragments, redirects, malformed JSON, oversized
|
|
135
|
+
payloads, excessive IDs/ciphertext, concurrent key use, and preview mode. The
|
|
136
|
+
server bounds timeout and response size, validates the upstream shape, and
|
|
137
|
+
returns only a conversation ID plus output with generic redacted errors.
|
|
138
|
+
|
|
139
|
+
Before a test, the browser obtains an ephemeral RSA-OAEP SHA-256 public key
|
|
140
|
+
from the local wizard, clears the credential input, and retains only ciphertext
|
|
141
|
+
and key ID in page memory. The matching private key remains only in the local
|
|
142
|
+
server's in-memory, time-limited, bounded session map and can be invalidated
|
|
143
|
+
explicitly. Prompts and transcript are not persisted server-side.
|
|
144
|
+
|
|
145
|
+
This is not encryption at rest or end-to-end encryption. It reduces accidental
|
|
146
|
+
credential transit and storage exposure, but cannot protect a compromised
|
|
147
|
+
browser, extension, or local machine.
|
|
148
|
+
|
|
115
149
|
## What “private” means here
|
|
116
150
|
|
|
117
151
|
Port `10531` is not reachable from the public internet or VPS host through a
|
package/docs/troubleshooting.md
CHANGED
|
@@ -9,6 +9,39 @@ wizard or any manual VPS command. The documented commands are sidecar-only and
|
|
|
9
9
|
do not delete, restart, or rebuild n8n, but they still access your VPS and write
|
|
10
10
|
files there.
|
|
11
11
|
|
|
12
|
+
## Docker is not running
|
|
13
|
+
|
|
14
|
+
Start Docker Desktop or Docker Engine and wait until `docker info` and
|
|
15
|
+
`docker compose version` both succeed. Close any stale Relmio wizard tab, start
|
|
16
|
+
one fresh wizard session, and review the local plan again. Do not restart or
|
|
17
|
+
rebuild unrelated containers while checking the local endpoint.
|
|
18
|
+
|
|
19
|
+
## Authentication fails
|
|
20
|
+
|
|
21
|
+
Close stale wizard and device-code tabs, keep the newest Relmio terminal open,
|
|
22
|
+
and use only the complete wizard URL printed by that active process. Start one
|
|
23
|
+
fresh ChatGPT device-code attempt and complete the newest code. A ChatGPT
|
|
24
|
+
subscription credential is valid only for the Codex targets; the generic
|
|
25
|
+
OpenAI-compatible `/v1` target requires a separately billed Platform API key.
|
|
26
|
+
ChatGPT/Codex sign-in tokens expire, but the official Codex client refreshes
|
|
27
|
+
them automatically during active use before they expire, so active sessions
|
|
28
|
+
usually continue without another browser login. The official [OpenAI
|
|
29
|
+
authentication documentation](https://learn.chatgpt.com/docs/auth) does not
|
|
30
|
+
publish a fixed 10-day lifetime; do not plan around one. This provider
|
|
31
|
+
credential is separate from Relmio's local capability, which remains valid
|
|
32
|
+
until you rotate it. If Relmio reports the credential is invalid or refresh no
|
|
33
|
+
longer succeeds, select **Start ChatGPT sign-in** again in the active local
|
|
34
|
+
wizard. The VPS sidecar flow labels that action **Refresh ChatGPT sign-in**.
|
|
35
|
+
|
|
36
|
+
## Local image build failed
|
|
37
|
+
|
|
38
|
+
The local wizard intentionally does not show Docker build output, filesystem
|
|
39
|
+
paths, or stderr in the browser. Confirm Docker Desktop or Docker Engine is
|
|
40
|
+
running, check that the local disk has room for the image, and confirm your
|
|
41
|
+
network can reach the image registry. Then close the old wizard, start one new
|
|
42
|
+
wizard session, review a fresh plan, and retry. Do not delete an existing
|
|
43
|
+
managed endpoint or rebuild unrelated containers as a workaround.
|
|
44
|
+
|
|
12
45
|
## Hosted chat browser extension
|
|
13
46
|
|
|
14
47
|
The hosted demo at [relmio.vercel.app](https://relmio.vercel.app/) needs the
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# VPS and n8n
|
|
2
|
+
|
|
3
|
+
Relmio installs a separate sidecar project at
|
|
4
|
+
`/docker/n8n-openai-oauth`. It does not edit, rebuild, recreate, stop, or
|
|
5
|
+
restart your existing n8n Compose project or image. The sidecar has no host
|
|
6
|
+
port: n8n reaches it over the shared Docker network at
|
|
7
|
+
`http://n8n-openai-oauth:10531/v1`.
|
|
8
|
+
|
|
9
|
+
## Wizard route
|
|
10
|
+
|
|
11
|
+
1. Run the local wizard and complete the fresh ChatGPT sign-in on your own
|
|
12
|
+
computer.
|
|
13
|
+
2. Enter your VPS address and compare the presented SSH host fingerprint with
|
|
14
|
+
your provider before authorizing password authentication.
|
|
15
|
+
3. Select an already-running n8n container and one of its existing shared
|
|
16
|
+
networks.
|
|
17
|
+
4. Review the exact plan. Remote writes begin only after final confirmation.
|
|
18
|
+
5. In n8n, use the private sidecar hostname rather than `127.0.0.1`.
|
|
19
|
+
|
|
20
|
+
The n8n credential's required API-key field uses `local-only` only as a UI
|
|
21
|
+
placeholder; it is not an OpenAI Platform API key.
|
|
22
|
+
|
|
23
|
+
## Follow-on guides
|
|
24
|
+
|
|
25
|
+
- [Configure n8n nodes](./n8n-configuration.md) has copy-ready AI Agent and
|
|
26
|
+
HTTP Request recipes.
|
|
27
|
+
- [Beginner manual installation](./manual-install.md) is the auditable fallback
|
|
28
|
+
when the wizard cannot be used.
|
|
29
|
+
- [Troubleshooting](./troubleshooting.md) includes connection, Docker-network,
|
|
30
|
+
and browser sign-in recovery steps.
|
package/package.json
CHANGED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
generateKeyPair as generateKeyPairCallback,
|
|
4
|
+
privateDecrypt,
|
|
5
|
+
randomUUID,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const generateKeyPair = promisify(generateKeyPairCallback);
|
|
10
|
+
|
|
11
|
+
const MAX_SESSIONS = 8;
|
|
12
|
+
const KEY_TTL_MS = 5 * 60 * 1_000;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
14
|
+
const MAX_ENDPOINT_LENGTH = 64;
|
|
15
|
+
const MAX_CIPHERTEXT_LENGTH = 4_096;
|
|
16
|
+
const MAX_INPUT_LENGTH = 8_192;
|
|
17
|
+
const MAX_CONVERSATION_ID_LENGTH = 160;
|
|
18
|
+
const MAX_RESPONSE_BYTES = 16 * 1_024;
|
|
19
|
+
const MAX_OUTPUT_LENGTH = 12 * 1_024;
|
|
20
|
+
|
|
21
|
+
function requestError(message, statusCode = 400) {
|
|
22
|
+
return Object.assign(new Error(message), { statusCode });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expiredKeyError() {
|
|
26
|
+
return requestError(
|
|
27
|
+
"This test credential has expired or was forgotten. Secure it again.",
|
|
28
|
+
409,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function adapterError(statusCode = 502) {
|
|
33
|
+
return requestError("The local adapter test could not be completed.", statusCode);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isBoundedText(value, maximumLength) {
|
|
41
|
+
return (
|
|
42
|
+
typeof value === "string" &&
|
|
43
|
+
value.length > 0 &&
|
|
44
|
+
value.length <= maximumLength &&
|
|
45
|
+
!/[\u0000-\u001f\u007f]/u.test(value)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseLocalAdapterBaseUrl(value) {
|
|
50
|
+
if (typeof value !== "string" || value.length > MAX_ENDPOINT_LENGTH) {
|
|
51
|
+
throw requestError("Enter a valid local adapter address.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const match = /^http:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/?$/u.exec(value);
|
|
55
|
+
if (!match) {
|
|
56
|
+
throw requestError("Enter a valid local adapter address.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const port = Number(match[1]);
|
|
60
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
61
|
+
throw requestError("Enter a valid local adapter address.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `http://127.0.0.1:${port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateMessageRequest(request) {
|
|
68
|
+
if (!isPlainObject(request)) {
|
|
69
|
+
throw requestError("Enter a valid local adapter test request.");
|
|
70
|
+
}
|
|
71
|
+
const endpointBaseUrl = parseLocalAdapterBaseUrl(request.endpointBaseUrl);
|
|
72
|
+
if (!isBoundedText(request.keyId, 128)) {
|
|
73
|
+
throw expiredKeyError();
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
typeof request.encryptedCredential !== "string" ||
|
|
77
|
+
request.encryptedCredential.length < 32 ||
|
|
78
|
+
request.encryptedCredential.length > MAX_CIPHERTEXT_LENGTH ||
|
|
79
|
+
!/^[A-Za-z0-9+/]+={0,2}$/u.test(request.encryptedCredential)
|
|
80
|
+
) {
|
|
81
|
+
throw requestError("Secure the client credential again before testing.");
|
|
82
|
+
}
|
|
83
|
+
if (!isBoundedText(request.input, MAX_INPUT_LENGTH)) {
|
|
84
|
+
throw requestError("Enter a shorter chat message.");
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
request.conversationId !== undefined &&
|
|
88
|
+
!isBoundedText(request.conversationId, MAX_CONVERSATION_ID_LENGTH)
|
|
89
|
+
) {
|
|
90
|
+
throw requestError("Start a new conversation and try again.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
endpointBaseUrl,
|
|
95
|
+
keyId: request.keyId,
|
|
96
|
+
encryptedCredential: request.encryptedCredential,
|
|
97
|
+
input: request.input,
|
|
98
|
+
...(request.conversationId !== undefined
|
|
99
|
+
? { conversationId: request.conversationId }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function decodeCiphertext(value) {
|
|
105
|
+
const decoded = Buffer.from(value, "base64");
|
|
106
|
+
if (
|
|
107
|
+
decoded.length === 0 ||
|
|
108
|
+
decoded.toString("base64") !== value
|
|
109
|
+
) {
|
|
110
|
+
decoded.fill(0);
|
|
111
|
+
throw requestError("Secure the client credential again before testing.");
|
|
112
|
+
}
|
|
113
|
+
return decoded;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readBoundedResponse(response) {
|
|
117
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
118
|
+
if (
|
|
119
|
+
contentLength !== null &&
|
|
120
|
+
contentLength !== undefined &&
|
|
121
|
+
(!/^\d+$/u.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)
|
|
122
|
+
) {
|
|
123
|
+
throw adapterError();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const reader = response.body?.getReader?.();
|
|
127
|
+
if (!reader) {
|
|
128
|
+
throw adapterError();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const chunks = [];
|
|
132
|
+
let bytes = 0;
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
const chunk = Buffer.from(value);
|
|
140
|
+
bytes += chunk.length;
|
|
141
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
142
|
+
chunk.fill(0);
|
|
143
|
+
throw adapterError();
|
|
144
|
+
}
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
148
|
+
} finally {
|
|
149
|
+
for (const chunk of chunks) {
|
|
150
|
+
chunk.fill(0);
|
|
151
|
+
}
|
|
152
|
+
reader.releaseLock?.();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseAdapterResponse(text) {
|
|
157
|
+
let value;
|
|
158
|
+
try {
|
|
159
|
+
value = JSON.parse(text);
|
|
160
|
+
} catch {
|
|
161
|
+
throw adapterError();
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
!isPlainObject(value) ||
|
|
165
|
+
!isBoundedText(value.conversationId, MAX_CONVERSATION_ID_LENGTH) ||
|
|
166
|
+
!isBoundedText(value.output, MAX_OUTPUT_LENGTH)
|
|
167
|
+
) {
|
|
168
|
+
throw adapterError();
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
conversationId: value.conversationId,
|
|
172
|
+
output: value.output,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isTimeout(error) {
|
|
177
|
+
return error?.name === "AbortError" || error?.name === "TimeoutError";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function createLocalChatTestService({
|
|
181
|
+
fetchImpl = fetch,
|
|
182
|
+
now = () => Date.now(),
|
|
183
|
+
keyTtlMs = KEY_TTL_MS,
|
|
184
|
+
maxSessions = MAX_SESSIONS,
|
|
185
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
186
|
+
} = {}) {
|
|
187
|
+
const sessions = new Map();
|
|
188
|
+
let pendingKeyIssuances = 0;
|
|
189
|
+
|
|
190
|
+
function expireSession(keyId, session) {
|
|
191
|
+
if (sessions.get(keyId) !== session) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
clearTimeout(session.expiryTimer);
|
|
195
|
+
session.abortController?.abort();
|
|
196
|
+
session.privateKey = undefined;
|
|
197
|
+
sessions.delete(keyId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function discardExpiredSessions() {
|
|
201
|
+
const currentTime = now();
|
|
202
|
+
for (const [keyId, session] of sessions) {
|
|
203
|
+
if (session.expiresAt <= currentTime) {
|
|
204
|
+
expireSession(keyId, session);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resetAllSessions() {
|
|
210
|
+
for (const [keyId, session] of sessions) {
|
|
211
|
+
expireSession(keyId, session);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getLiveSession(keyId) {
|
|
216
|
+
discardExpiredSessions();
|
|
217
|
+
const session = sessions.get(keyId);
|
|
218
|
+
if (!session || session.expiresAt <= now()) {
|
|
219
|
+
if (session) {
|
|
220
|
+
expireSession(keyId, session);
|
|
221
|
+
}
|
|
222
|
+
throw expiredKeyError();
|
|
223
|
+
}
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
async issueKey() {
|
|
229
|
+
discardExpiredSessions();
|
|
230
|
+
if (sessions.size + pendingKeyIssuances >= maxSessions) {
|
|
231
|
+
throw requestError(
|
|
232
|
+
"Too many open tester sessions. Forget one or wait for it to expire.",
|
|
233
|
+
429,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
pendingKeyIssuances += 1;
|
|
237
|
+
try {
|
|
238
|
+
const { publicKey, privateKey } = await generateKeyPair("rsa", {
|
|
239
|
+
modulusLength: 2_048,
|
|
240
|
+
publicExponent: 0x10001,
|
|
241
|
+
});
|
|
242
|
+
const keyId = randomUUID();
|
|
243
|
+
const expiresAt = now() + keyTtlMs;
|
|
244
|
+
const session = {
|
|
245
|
+
abortController: null,
|
|
246
|
+
expiresAt,
|
|
247
|
+
expiryTimer: null,
|
|
248
|
+
inFlight: false,
|
|
249
|
+
privateKey,
|
|
250
|
+
};
|
|
251
|
+
sessions.set(keyId, session);
|
|
252
|
+
session.expiryTimer = setTimeout(
|
|
253
|
+
() => expireSession(keyId, session),
|
|
254
|
+
keyTtlMs,
|
|
255
|
+
);
|
|
256
|
+
session.expiryTimer.unref?.();
|
|
257
|
+
return {
|
|
258
|
+
keyId,
|
|
259
|
+
publicKeyJwk: publicKey.export({ format: "jwk" }),
|
|
260
|
+
algorithm: "RSA-OAEP-256",
|
|
261
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
262
|
+
};
|
|
263
|
+
} finally {
|
|
264
|
+
pendingKeyIssuances -= 1;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async message(untrustedRequest) {
|
|
269
|
+
const request = validateMessageRequest(untrustedRequest);
|
|
270
|
+
const session = getLiveSession(request.keyId);
|
|
271
|
+
if (session.inFlight) {
|
|
272
|
+
throw requestError("Wait for the current test message to finish.", 409);
|
|
273
|
+
}
|
|
274
|
+
session.inFlight = true;
|
|
275
|
+
let encryptedCredential;
|
|
276
|
+
let decryptedCredential;
|
|
277
|
+
let authorization;
|
|
278
|
+
let timeout;
|
|
279
|
+
try {
|
|
280
|
+
encryptedCredential = decodeCiphertext(request.encryptedCredential);
|
|
281
|
+
try {
|
|
282
|
+
decryptedCredential = privateDecrypt(
|
|
283
|
+
{
|
|
284
|
+
key: session.privateKey,
|
|
285
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
286
|
+
oaepHash: "sha256",
|
|
287
|
+
},
|
|
288
|
+
encryptedCredential,
|
|
289
|
+
);
|
|
290
|
+
} catch {
|
|
291
|
+
throw requestError("Secure the client credential again before testing.");
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
decryptedCredential.length === 0 ||
|
|
295
|
+
decryptedCredential.length > 512 ||
|
|
296
|
+
/[\r\n\u0000]/u.test(decryptedCredential.toString("utf8"))
|
|
297
|
+
) {
|
|
298
|
+
throw requestError("Secure the client credential again before testing.");
|
|
299
|
+
}
|
|
300
|
+
authorization = `Bearer ${decryptedCredential.toString("utf8")}`;
|
|
301
|
+
|
|
302
|
+
let response;
|
|
303
|
+
try {
|
|
304
|
+
session.abortController = new AbortController();
|
|
305
|
+
timeout = setTimeout(
|
|
306
|
+
() => session.abortController?.abort(),
|
|
307
|
+
requestTimeoutMs,
|
|
308
|
+
);
|
|
309
|
+
response = await fetchImpl(`${request.endpointBaseUrl}/chat`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
Accept: "application/json",
|
|
313
|
+
Authorization: authorization,
|
|
314
|
+
"Content-Type": "application/json",
|
|
315
|
+
},
|
|
316
|
+
body: JSON.stringify({
|
|
317
|
+
input: request.input,
|
|
318
|
+
...(request.conversationId !== undefined
|
|
319
|
+
? { conversationId: request.conversationId }
|
|
320
|
+
: {}),
|
|
321
|
+
}),
|
|
322
|
+
redirect: "error",
|
|
323
|
+
signal: session.abortController.signal,
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw adapterError(isTimeout(error) ? 504 : 502);
|
|
327
|
+
}
|
|
328
|
+
if (!response?.ok || response.status < 200 || response.status >= 300) {
|
|
329
|
+
throw adapterError();
|
|
330
|
+
}
|
|
331
|
+
return parseAdapterResponse(await readBoundedResponse(response));
|
|
332
|
+
} finally {
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
authorization = undefined;
|
|
335
|
+
encryptedCredential?.fill(0);
|
|
336
|
+
decryptedCredential?.fill(0);
|
|
337
|
+
session.abortController = null;
|
|
338
|
+
session.inFlight = false;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async reset(request) {
|
|
343
|
+
if (!isPlainObject(request) || !isBoundedText(request.keyId, 128)) {
|
|
344
|
+
throw requestError("Choose a valid tester session to forget.");
|
|
345
|
+
}
|
|
346
|
+
const session = sessions.get(request.keyId);
|
|
347
|
+
if (session) {
|
|
348
|
+
expireSession(request.keyId, session);
|
|
349
|
+
}
|
|
350
|
+
return { forgotten: true };
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
resetAll() {
|
|
354
|
+
resetAllSessions();
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
dispose() {
|
|
358
|
+
resetAllSessions();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|