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.
@@ -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
- fill="#137c74"
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
@@ -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 in the [README](../README.md#quick-install), or run:
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
- curl http://127.0.0.1:12435/v1/models \
133
- -H "Authorization: Bearer $RELMIO_LOCAL_KEY"
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
- export CODEX_REMOTE_TOKEN="<capability shown once by the wizard>"
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. Read the bearer rather than placing it in a
240
+ shell command:
225
241
 
226
242
  ```bash
227
- export RELMIO_CODEX_CHAT_KEY="<capability shown once by the wizard>"
228
- curl http://127.0.0.1:14501/chat \
229
- -H "Authorization: Bearer $RELMIO_CODEX_CHAT_KEY" \
230
- -H "Content-Type: application/json" \
231
- --data '{"input":"Reply with a short hello."}'
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
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "relmio",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Install private local OpenAI API and Codex endpoints with explicit provider credential boundaries, plus the existing isolated n8n sidecar.",
5
5
  "keywords": [
6
6
  "relmio",
@@ -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
- sendJson(response, 200, result);
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
  });