relmio 0.6.0 → 0.8.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 +51 -0
- package/README.md +54 -436
- package/docs/brand.md +5 -4
- package/docs/faq.md +45 -0
- package/docs/getting-started.md +38 -0
- package/docs/images/brand/relmio-mark.svg +2 -8
- package/docs/local-endpoints-spec.md +27 -0
- package/docs/local-endpoints.md +72 -11
- package/docs/reference.md +94 -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/gateway/codex-chat.js +103 -2
- package/src/services/local-chat-test.js +501 -0
- package/src/ui/local.css +196 -1
- package/src/ui/local.html +113 -5
- package/src/ui/local.js +395 -1
- package/src/web/server.js +203 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
Relmio gives you two intentionally separate paths:
|
|
4
|
+
|
|
5
|
+
| Need | Use | Credential |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| An OpenAI-compatible local endpoint | Local OpenAI API gateway (`/v1`) | Your OpenAI Platform API key |
|
|
8
|
+
| A trusted native Codex integration | Codex App Server (JSON-RPC over WebSocket) | ChatGPT sign-in and a local capability |
|
|
9
|
+
| A small local chat backend | Codex Chat Adapter (`POST /chat`) | ChatGPT sign-in and a local bearer credential |
|
|
10
|
+
| An n8n bridge on a VPS | The separate n8n sidecar | Your locally created ChatGPT sign-in file |
|
|
11
|
+
|
|
12
|
+
ChatGPT sign-in is never converted into an OpenAI Platform API key. The Codex
|
|
13
|
+
options are experimental and are not generic `/v1` services.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
On macOS, Linux, WSL, or Git Bash, start the local wizard with:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx --yes --ignore-scripts relmio@latest
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The wizard prints a private loopback URL, verifies Docker before it changes
|
|
24
|
+
anything, and asks for final confirmation before remote VPS writes. It binds
|
|
25
|
+
local endpoints to `127.0.0.1`, never to a LAN interface.
|
|
26
|
+
|
|
27
|
+
For installation options and prerequisites, see the [package
|
|
28
|
+
README](https://www.npmjs.com/package/relmio). For a VPS/n8n walkthrough, see
|
|
29
|
+
[VPS and n8n](./vps-and-n8n.md).
|
|
30
|
+
|
|
31
|
+
## Choose the next guide
|
|
32
|
+
|
|
33
|
+
- [Local endpoints](./local-endpoints.md) for the local gateway, Codex App
|
|
34
|
+
Server, or Chat Adapter.
|
|
35
|
+
- [Troubleshooting](./troubleshooting.md) when Docker, authentication, or a
|
|
36
|
+
local image build stops the flow.
|
|
37
|
+
- [Security](./security.md) for credential and trust boundaries.
|
|
38
|
+
- [Reference](./reference.md) for test commands and protocol notes.
|
|
@@ -5,12 +5,6 @@
|
|
|
5
5
|
aria-labelledby="relmio-mark-title"
|
|
6
6
|
>
|
|
7
7
|
<title id="relmio-mark-title">Relmio mark</title>
|
|
8
|
-
<path
|
|
9
|
-
|
|
10
|
-
d="M7 8h19l31 24H41L26 20v10L7 17Z"
|
|
11
|
-
/>
|
|
12
|
-
<path
|
|
13
|
-
fill="#12211f"
|
|
14
|
-
d="M7 47l19-13v10l15-12h16L26 56H7Z"
|
|
15
|
-
/>
|
|
8
|
+
<path fill="#137c74" d="M8 13 28 5v38c0 4-2 7-6 9L8 59Z" />
|
|
9
|
+
<path fill="#12211f" d="M36 5l11 5c6 3 9 8 9 15v9c0 3 1 5 3 7l3 3c3 3 3 8 0 11s-8 3-11 0L40 44c-3-3-4-6-4-10Z" />
|
|
16
10
|
</svg>
|
|
@@ -112,6 +112,33 @@ say so, and the installer must reject native Windows before any write.
|
|
|
112
112
|
Every wizard API route continues to require the existing `X-Setup-Token` and
|
|
113
113
|
same-origin protections.
|
|
114
114
|
|
|
115
|
+
### Chat Adapter tester APIs
|
|
116
|
+
|
|
117
|
+
The tester is available only after a live `codex-chat` installation reaches the
|
|
118
|
+
Ready screen. Sanitized preview mode rejects all three routes. Each route uses
|
|
119
|
+
the same `POST` exact-Origin and `X-Setup-Token` protections as the rest of
|
|
120
|
+
the local wizard.
|
|
121
|
+
|
|
122
|
+
- `POST /api/local/chat-test/key` returns only `keyId`, an RSA public JWK,
|
|
123
|
+
`RSA-OAEP-256`, and an expiry. Its private key remains only in the local
|
|
124
|
+
server's bounded, in-memory tester-session map.
|
|
125
|
+
- `POST /api/local/chat-test/message` accepts a literal loopback adapter base
|
|
126
|
+
URL, `keyId`, RSA-OAEP SHA-256 ciphertext, a bounded input, and an optional
|
|
127
|
+
bounded conversation ID. It returns only validated `conversationId` and
|
|
128
|
+
`output`.
|
|
129
|
+
- `POST /api/local/chat-test/reset` invalidates the specified in-memory key.
|
|
130
|
+
|
|
131
|
+
The browser sends no adapter request and stores no tester data in browser
|
|
132
|
+
storage. It clears the plaintext credential input before it awaits key issuance
|
|
133
|
+
or encryption, then retains ciphertext and key ID only in page memory. The
|
|
134
|
+
server does not retain prompts or transcript beyond a single request.
|
|
135
|
+
|
|
136
|
+
The local proxy accepts only `http://127.0.0.1:<1-65535>` with an optional
|
|
137
|
+
trailing slash. It rejects DNS names, IPv6, credentials, query strings,
|
|
138
|
+
fragments, paths, redirects, malformed JSON, oversized request/response data,
|
|
139
|
+
expiry, and concurrent use. It appends `/chat`, uses a bounded timeout, sends
|
|
140
|
+
no `Origin` header, and returns generic redacted failures.
|
|
141
|
+
|
|
115
142
|
### `GET /api/local/docker/status`
|
|
116
143
|
|
|
117
144
|
Returns local Docker and Compose availability. It never returns filesystem
|
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
|
|
239
|
+
can start a conversation. 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
|
|
@@ -241,6 +261,24 @@ text:
|
|
|
241
261
|
}
|
|
242
262
|
```
|
|
243
263
|
|
|
264
|
+
To verify incremental delivery, request Relmio's versioned event stream. The
|
|
265
|
+
stream emits `start`, `progress`, zero or more `delta` events, and exactly one
|
|
266
|
+
`terminal` event. A completed terminal includes the `conversationId`; a failed
|
|
267
|
+
terminal is preceded by a redacted `error` event:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
read -r -s RELMIO_CODEX_CHAT_KEY
|
|
271
|
+
printf '\n'
|
|
272
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CODEX_CHAT_KEY" |
|
|
273
|
+
curl --no-buffer --fail-with-body --silent --show-error \
|
|
274
|
+
--request POST http://127.0.0.1:14501/chat \
|
|
275
|
+
--header @- \
|
|
276
|
+
--header "Accept: text/event-stream" \
|
|
277
|
+
--header "Content-Type: application/json" \
|
|
278
|
+
--data '{"input":"What is a robot? Answer in two short sentences."}'
|
|
279
|
+
unset RELMIO_CODEX_CHAT_KEY
|
|
280
|
+
```
|
|
281
|
+
|
|
244
282
|
Send that `conversationId` with the next `input` to continue the same
|
|
245
283
|
conversation. The adapter initializes the official App Server, starts or
|
|
246
284
|
resumes the thread, runs a read-only conversational turn, and returns the
|
|
@@ -262,6 +300,29 @@ LAN, multi-user, or production service. It enforces bounded request bodies,
|
|
|
262
300
|
output, concurrency, process lifetime, and sanitized failures, but those
|
|
263
301
|
controls do not create a general-purpose API entitlement.
|
|
264
302
|
|
|
303
|
+
### In-wizard Chat Adapter tester
|
|
304
|
+
|
|
305
|
+
The Ready screen for an installed Chat Adapter includes a narrow local tester.
|
|
306
|
+
It is intended for a literal `http://127.0.0.1:PORT` adapter address only. The
|
|
307
|
+
browser never calls the adapter: it calls the local wizard's existing
|
|
308
|
+
same-origin, `X-Setup-Token` protected APIs, and the wizard makes the
|
|
309
|
+
server-side `POST /chat` request without an `Origin` header.
|
|
310
|
+
|
|
311
|
+
When the user secures the displayed client credential, the browser clears the
|
|
312
|
+
input and encrypts it with the tester's short-lived RSA-OAEP SHA-256 public
|
|
313
|
+
key. The private key exists only in local server memory, expires after a few
|
|
314
|
+
minutes, has a bounded session count, and can be invalidated with **Forget
|
|
315
|
+
tester**. The browser retains only ciphertext and key ID for the test session;
|
|
316
|
+
it keeps prompts and transcript only in current-page memory and DOM.
|
|
317
|
+
|
|
318
|
+
This reduces accidental credential transit and storage exposure. It is not
|
|
319
|
+
encryption at rest or end-to-end encryption, and it cannot protect against a
|
|
320
|
+
compromised browser, extension, or local machine. The tester rejects redirects,
|
|
321
|
+
non-loopback URLs, malformed or oversized data, concurrent key use, and
|
|
322
|
+
adapter failures with redacted messages. Assistant text appears incrementally
|
|
323
|
+
while the adapter is working; the tester reports success only after the
|
|
324
|
+
completed terminal event arrives.
|
|
325
|
+
|
|
265
326
|
## Network and container boundary
|
|
266
327
|
|
|
267
328
|
Every local Compose project publishes exactly one host mapping:
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
Stream a conversation and inspect its explicit terminal state:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
|
|
33
|
+
curl --no-buffer --fail-with-body --silent --show-error \
|
|
34
|
+
--request POST "$RELMIO_CHAT_BASE_URL/chat" \
|
|
35
|
+
--header @- \
|
|
36
|
+
--header "Accept: text/event-stream" \
|
|
37
|
+
--header "Content-Type: application/json" \
|
|
38
|
+
--data '{"input":"What is love? Answer conversationally."}'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The event order is `start`, `progress`, zero or more `delta` events, then one
|
|
42
|
+
`terminal`. Only `terminal: completed` is success. A failed terminal is
|
|
43
|
+
preceded by a redacted `error` event and must not be treated as a partial
|
|
44
|
+
answer.
|
|
45
|
+
|
|
46
|
+
Copy the returned `conversationId`, then send a continuation:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export RELMIO_CONVERSATION_ID="CONVERSATION_ID_FROM_THE_PREVIOUS_RESPONSE"
|
|
50
|
+
printf 'Authorization: Bearer %s\n' "$RELMIO_CHAT_CLIENT_CREDENTIAL" |
|
|
51
|
+
curl --fail-with-body --silent --show-error \
|
|
52
|
+
--request POST "$RELMIO_CHAT_BASE_URL/chat" \
|
|
53
|
+
--header @- \
|
|
54
|
+
--header "Content-Type: application/json" \
|
|
55
|
+
--data "{\"input\":\"Continue with one short sentence.\",\"conversationId\":\"$RELMIO_CONVERSATION_ID\"}"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Unset the shell credential when finished:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
unset RELMIO_CHAT_CLIENT_CREDENTIAL
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Raw Codex App Server command
|
|
65
|
+
|
|
66
|
+
The raw Codex App Server transport is JSON-RPC over WebSocket, experimental,
|
|
67
|
+
and high-trust. It is for trusted native clients only; it is not an
|
|
68
|
+
OpenAI-compatible `/v1` endpoint. Read the one-time local capability into a
|
|
69
|
+
named environment variable rather than placing it in the command:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
read -r -s RELMIO_CODEX_CLIENT_CREDENTIAL
|
|
73
|
+
printf '\n'
|
|
74
|
+
codex --remote ws://127.0.0.1:14500 \
|
|
75
|
+
--remote-auth-token-env RELMIO_CODEX_CLIENT_CREDENTIAL
|
|
76
|
+
unset RELMIO_CODEX_CLIENT_CREDENTIAL
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Keep that capability private. A trusted Codex client can control the isolated
|
|
80
|
+
container and may be able to recover its ChatGPT session credential.
|
|
81
|
+
|
|
82
|
+
## Local wizard tester API
|
|
83
|
+
|
|
84
|
+
The browser never contacts the adapter directly. While the local wizard is
|
|
85
|
+
running, its same-origin, `X-Setup-Token` protected APIs are:
|
|
86
|
+
|
|
87
|
+
| Method | Route | Purpose |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `POST` | `/api/local/chat-test/key` | Issue one ephemeral RSA-OAEP public key |
|
|
90
|
+
| `POST` | `/api/local/chat-test/message` | Send encrypted credential and one bounded chat turn |
|
|
91
|
+
| `POST` | `/api/local/chat-test/reset` | Invalidate the tester key and clear the browser transcript |
|
|
92
|
+
|
|
93
|
+
The local proxy accepts only a literal `http://127.0.0.1:PORT` adapter base
|
|
94
|
+
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
@@ -12,6 +12,7 @@ const MAX_PROTOCOL_STDOUT_BYTES = 256 * 1024;
|
|
|
12
12
|
const MAX_PROTOCOL_STDERR_BYTES = 64 * 1024;
|
|
13
13
|
const TURN_TIMEOUT_MS = 120_000;
|
|
14
14
|
const TERMINATION_GRACE_MS = 2_000;
|
|
15
|
+
const KEEPALIVE_INTERVAL_MS = 15_000;
|
|
15
16
|
const CONVERSATIONAL_INSTRUCTION =
|
|
16
17
|
"Provide a conversational answer only. Do not inspect or edit files, run commands, call tools, or access external resources.";
|
|
17
18
|
|
|
@@ -65,6 +66,70 @@ function sendError(response, status, code) {
|
|
|
65
66
|
sendJson(response, status, { error: { code } });
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function acceptsEventStream(request) {
|
|
70
|
+
if (headerOccurrences(request, "accept") > 1) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const value = request.headers.accept;
|
|
74
|
+
return (
|
|
75
|
+
typeof value === "string" &&
|
|
76
|
+
value
|
|
77
|
+
.split(",")
|
|
78
|
+
.some((entry) => entry.trim().split(";", 1)[0] === "text/event-stream")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function startEventStream(response, keepaliveIntervalMs) {
|
|
83
|
+
let ended = false;
|
|
84
|
+
response.writeHead(200, {
|
|
85
|
+
"Cache-Control": "no-store",
|
|
86
|
+
Connection: "keep-alive",
|
|
87
|
+
"Content-Encoding": "none",
|
|
88
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
89
|
+
"X-Accel-Buffering": "no",
|
|
90
|
+
"X-Content-Type-Options": "nosniff",
|
|
91
|
+
"X-Relmio-Stream": "v1",
|
|
92
|
+
});
|
|
93
|
+
const send = (event, data) => {
|
|
94
|
+
if (ended || response.writableEnded || response.destroyed) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
98
|
+
};
|
|
99
|
+
const keepalive = setInterval(() => {
|
|
100
|
+
if (!ended && !response.writableEnded && !response.destroyed) {
|
|
101
|
+
response.write(": keepalive\n\n");
|
|
102
|
+
}
|
|
103
|
+
}, keepaliveIntervalMs);
|
|
104
|
+
keepalive.unref?.();
|
|
105
|
+
send("start", { requestId: randomUUID() });
|
|
106
|
+
return {
|
|
107
|
+
send,
|
|
108
|
+
complete(result) {
|
|
109
|
+
if (ended) return;
|
|
110
|
+
send("terminal", {
|
|
111
|
+
outcome: "completed",
|
|
112
|
+
conversationId: result.conversationId,
|
|
113
|
+
});
|
|
114
|
+
ended = true;
|
|
115
|
+
clearInterval(keepalive);
|
|
116
|
+
response.end();
|
|
117
|
+
},
|
|
118
|
+
fail(code = "upstream_failed", retryable = true) {
|
|
119
|
+
if (ended) return;
|
|
120
|
+
send("error", { code, retryable });
|
|
121
|
+
send("terminal", { outcome: "failed" });
|
|
122
|
+
ended = true;
|
|
123
|
+
clearInterval(keepalive);
|
|
124
|
+
response.end();
|
|
125
|
+
},
|
|
126
|
+
dispose() {
|
|
127
|
+
ended = true;
|
|
128
|
+
clearInterval(keepalive);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
68
133
|
function hasValidBearer(request, verifier) {
|
|
69
134
|
if (headerOccurrences(request, "authorization") !== 1) {
|
|
70
135
|
return false;
|
|
@@ -196,6 +261,8 @@ function createAppServerOperation({
|
|
|
196
261
|
input,
|
|
197
262
|
conversationId,
|
|
198
263
|
packageVersion,
|
|
264
|
+
onEvent,
|
|
265
|
+
onFailure,
|
|
199
266
|
signal,
|
|
200
267
|
spawnProcess,
|
|
201
268
|
terminationGraceMs,
|
|
@@ -241,6 +308,7 @@ function createAppServerOperation({
|
|
|
241
308
|
let finalOutput = null;
|
|
242
309
|
let latestDeltaItemId = null;
|
|
243
310
|
let deltaBytes = 0;
|
|
311
|
+
let emittedDeltaBytes = 0;
|
|
244
312
|
const deltaOutputs = new Map();
|
|
245
313
|
let phase = "initializing";
|
|
246
314
|
let outcome = null;
|
|
@@ -298,6 +366,9 @@ function createAppServerOperation({
|
|
|
298
366
|
return;
|
|
299
367
|
}
|
|
300
368
|
outcome = { error, result };
|
|
369
|
+
if (error) {
|
|
370
|
+
onFailure?.(error.message === "timeout" ? "timeout" : "upstream_failed");
|
|
371
|
+
}
|
|
301
372
|
signal?.removeEventListener?.("abort", abortOperation);
|
|
302
373
|
clearTimeout(timeout);
|
|
303
374
|
timeout = undefined;
|
|
@@ -316,6 +387,7 @@ function createAppServerOperation({
|
|
|
316
387
|
};
|
|
317
388
|
const startThread = () => {
|
|
318
389
|
phase = "thread";
|
|
390
|
+
onEvent?.("progress", { phase: "starting_thread" });
|
|
319
391
|
write({
|
|
320
392
|
id: 1,
|
|
321
393
|
method: conversationId ? "thread/resume" : "thread/start",
|
|
@@ -337,6 +409,7 @@ function createAppServerOperation({
|
|
|
337
409
|
};
|
|
338
410
|
const startTurn = () => {
|
|
339
411
|
phase = "turn";
|
|
412
|
+
onEvent?.("progress", { phase: "starting_turn" });
|
|
340
413
|
write({
|
|
341
414
|
id: 2,
|
|
342
415
|
method: "turn/start",
|
|
@@ -418,11 +491,13 @@ function createAppServerOperation({
|
|
|
418
491
|
return;
|
|
419
492
|
}
|
|
420
493
|
deltaBytes += partBytes;
|
|
494
|
+
emittedDeltaBytes += partBytes;
|
|
421
495
|
latestDeltaItemId = params.itemId;
|
|
422
496
|
deltaOutputs.set(
|
|
423
497
|
params.itemId,
|
|
424
498
|
`${deltaOutputs.get(params.itemId) ?? ""}${params.delta}`,
|
|
425
499
|
);
|
|
500
|
+
onEvent?.("delta", { text: params.delta });
|
|
426
501
|
return;
|
|
427
502
|
}
|
|
428
503
|
if (message.method === "item/completed") {
|
|
@@ -468,6 +543,9 @@ function createAppServerOperation({
|
|
|
468
543
|
failProtocol();
|
|
469
544
|
return;
|
|
470
545
|
}
|
|
546
|
+
if (emittedDeltaBytes === 0) {
|
|
547
|
+
onEvent?.("delta", { text: output });
|
|
548
|
+
}
|
|
471
549
|
settle(null, { conversationId: threadId, output });
|
|
472
550
|
return;
|
|
473
551
|
}
|
|
@@ -552,6 +630,7 @@ function createAppServerOperation({
|
|
|
552
630
|
},
|
|
553
631
|
},
|
|
554
632
|
});
|
|
633
|
+
onEvent?.("progress", { phase: "initializing" });
|
|
555
634
|
});
|
|
556
635
|
}
|
|
557
636
|
|
|
@@ -603,6 +682,7 @@ export async function startCodexChatGateway({
|
|
|
603
682
|
spawnProcess = spawn,
|
|
604
683
|
terminationGraceMs = TERMINATION_GRACE_MS,
|
|
605
684
|
turnTimeoutMs = TURN_TIMEOUT_MS,
|
|
685
|
+
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
|
|
606
686
|
} = {}) {
|
|
607
687
|
if (!isLoopbackListenHost(host)) {
|
|
608
688
|
throw new TypeError("Codex Chat must listen on a literal loopback-safe host.");
|
|
@@ -627,6 +707,13 @@ export async function startCodexChatGateway({
|
|
|
627
707
|
) {
|
|
628
708
|
throw new TypeError("The Codex Chat turn timeout is invalid.");
|
|
629
709
|
}
|
|
710
|
+
if (
|
|
711
|
+
!Number.isSafeInteger(keepaliveIntervalMs) ||
|
|
712
|
+
keepaliveIntervalMs < 1 ||
|
|
713
|
+
keepaliveIntervalMs > 60_000
|
|
714
|
+
) {
|
|
715
|
+
throw new TypeError("The Codex Chat keepalive interval is invalid.");
|
|
716
|
+
}
|
|
630
717
|
const verifier = validateTokenVerifier(tokenVerifier);
|
|
631
718
|
const safePackageVersion = validatePackageVersion(packageVersion);
|
|
632
719
|
let activeOperation = false;
|
|
@@ -673,6 +760,7 @@ export async function startCodexChatGateway({
|
|
|
673
760
|
activeOperation = true;
|
|
674
761
|
const controller = new AbortController();
|
|
675
762
|
let disconnected = false;
|
|
763
|
+
let eventStream;
|
|
676
764
|
const onDisconnect = () => {
|
|
677
765
|
if (request.aborted || !response.writableEnded) {
|
|
678
766
|
disconnected = true;
|
|
@@ -686,9 +774,16 @@ export async function startCodexChatGateway({
|
|
|
686
774
|
if (disconnected) {
|
|
687
775
|
throw new Error("unavailable");
|
|
688
776
|
}
|
|
777
|
+
if (acceptsEventStream(request)) {
|
|
778
|
+
eventStream = startEventStream(response, keepaliveIntervalMs);
|
|
779
|
+
}
|
|
689
780
|
return createAppServerOperation({
|
|
690
781
|
...chat,
|
|
691
782
|
packageVersion: safePackageVersion,
|
|
783
|
+
onEvent: eventStream?.send,
|
|
784
|
+
onFailure: eventStream
|
|
785
|
+
? (code) => eventStream.fail(code)
|
|
786
|
+
: undefined,
|
|
692
787
|
signal: controller.signal,
|
|
693
788
|
spawnProcess,
|
|
694
789
|
terminationGraceMs,
|
|
@@ -697,13 +792,18 @@ export async function startCodexChatGateway({
|
|
|
697
792
|
})
|
|
698
793
|
.then((result) => {
|
|
699
794
|
if (!disconnected && !response.writableEnded) {
|
|
700
|
-
|
|
795
|
+
if (eventStream) eventStream.complete(result);
|
|
796
|
+
else sendJson(response, 200, result);
|
|
701
797
|
}
|
|
702
798
|
})
|
|
703
799
|
.catch((error) => {
|
|
704
800
|
if (disconnected || response.writableEnded) {
|
|
705
801
|
return;
|
|
706
802
|
}
|
|
803
|
+
if (eventStream) {
|
|
804
|
+
eventStream.fail();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
707
807
|
const status = error?.status;
|
|
708
808
|
const code = error?.code;
|
|
709
809
|
if (status === 413 || status === 499 || code === "invalid_json" || code === "invalid_request") {
|
|
@@ -713,6 +813,7 @@ export async function startCodexChatGateway({
|
|
|
713
813
|
sendError(response, 503, "unavailable");
|
|
714
814
|
})
|
|
715
815
|
.finally(() => {
|
|
816
|
+
eventStream?.dispose();
|
|
716
817
|
activeOperation = false;
|
|
717
818
|
});
|
|
718
819
|
});
|