@anusornneal/chat-relay 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -288
- package/agent/local-agent.mjs +10 -1
- package/agent/terminal-manager.mjs +462 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Chat Relay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Remote MCP relay that lets ChatGPT work with a local Windows or macOS machine through a Cloudflare-hosted relay.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- files
|
|
21
|
-
- processes
|
|
5
|
+
**Dashboard:** https://chat-relay.anusorn-hank.workers.dev/
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
There are two parts:
|
|
10
|
+
|
|
11
|
+
1. Connect ChatGPT to the relay.
|
|
12
|
+
2. Run the local agent on the computer you want ChatGPT to access.
|
|
13
|
+
|
|
14
|
+
### 1. Connect to ChatGPT
|
|
15
|
+
|
|
16
|
+
Add the production MCP server to ChatGPT:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
https://chat-relay.anusorn-hank.workers.dev/mcp
|
|
22
20
|
```
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
Complete the OAuth sign-in when ChatGPT opens the authorization flow.
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
ChatGPT MCP should connect to the plain `/mcp` endpoint. The legacy `/mcp?key=<USER_TOKEN>` flow remains available only during migration and should not be used for new connections.
|
|
25
|
+
|
|
26
|
+
ChatGPT connects to the cloud relay. To actually access files, terminal, processes, or desktop controls on a computer, that computer must also be running the local agent below.
|
|
27
|
+
|
|
28
|
+
### 2. Run the local agent with `npx`
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
Requirements: Node.js 20 or newer.
|
|
29
31
|
|
|
30
32
|
From any directory:
|
|
31
33
|
|
|
@@ -33,321 +35,152 @@ From any directory:
|
|
|
33
35
|
npx @anusornneal/chat-relay@latest remote
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
On the first run, Chat Relay opens
|
|
38
|
+
On the first run, Chat Relay opens browser-based device authorization. Choose **Continue with Google and authorize computer**, sign in with the same Google account used for the ChatGPT connector, then return to the terminal. Device sign-in requires configured Google OAuth and does not accept a local username/password.
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
The CLI saves the device credentials in your user profile, so later you can reconnect with the same command:
|
|
39
41
|
|
|
40
42
|
```bash
|
|
41
43
|
npx @anusornneal/chat-relay@latest remote
|
|
42
44
|
```
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
```text
|
|
47
|
-
Chat Relay Remote
|
|
48
|
-
-----------------
|
|
49
|
-
Agent: Primary PC (default)
|
|
50
|
-
Terminal: enabled
|
|
51
|
-
Desktop: disabled
|
|
52
|
-
Agent connected
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
## Zero-checkout CLI
|
|
56
|
-
|
|
57
|
-
Other commands:
|
|
46
|
+
Enable desktop screenshot, mouse, and keyboard access:
|
|
58
47
|
|
|
59
48
|
```bash
|
|
60
|
-
npx @anusornneal/chat-relay@latest
|
|
61
|
-
npx @anusornneal/chat-relay@latest status
|
|
62
|
-
npx @anusornneal/chat-relay@latest logout
|
|
49
|
+
npx @anusornneal/chat-relay@latest remote --desktop
|
|
63
50
|
```
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Useful options:
|
|
52
|
+
Limit filesystem access to a specific root:
|
|
68
53
|
|
|
69
54
|
```bash
|
|
70
|
-
chat-relay remote --root "C:\Users\you\Projects"
|
|
71
|
-
chat-relay login --name "Work PC"
|
|
72
|
-
chat-relay login --no-open
|
|
73
|
-
chat-relay remote --desktop
|
|
74
|
-
chat-relay remote --no-desktop
|
|
55
|
+
npx @anusornneal/chat-relay@latest remote --root "C:\Users\you\Projects"
|
|
75
56
|
```
|
|
76
57
|
|
|
77
|
-
|
|
78
|
-
## Device lifecycle and recovery
|
|
79
|
-
|
|
80
|
-
- `chat-relay status` shows the signed-in account, readable device name/id, relay URL, access state, online/offline state, last-seen time, and granted scopes without printing secrets.
|
|
81
|
-
- One account can own multiple PCs. Each PC keeps its own agent id and credential, so devices remain independently visible and revocable.
|
|
82
|
-
- Admins can rename a device from the dashboard without changing its agent id or grants.
|
|
83
|
-
- Retiring a device invalidates its current machine credential and disconnects the live agent. That credential cannot reconnect until the device is authorized again.
|
|
84
|
-
- Recovery after retirement does not require copying tokens: run `chat-relay login --force`, complete browser authorization, then run `chat-relay remote`.
|
|
85
|
-
- A different account cannot reclaim another owner's active or retired agent id; a colliding login receives a separate device identity instead.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
## Agent protocol and capabilities
|
|
89
|
-
|
|
90
|
-
Local agents send a lightweight hello handshake when their WebSocket connects. The handshake reports the protocol version, package version, platform/architecture, and the capabilities that are actually enabled on that machine. Feature routing should use the advertised capability list rather than inferring support from the package version alone.
|
|
91
|
-
|
|
92
|
-
Protocol v1 keeps legacy agents backward compatible: a connected agent that does not send hello metadata can still use the pre-handshake behavior. An agent that explicitly advertises an unsupported protocol is disconnected with a clear incompatibility reason instead of failing later on an unrelated tool call or entering a restart loop.
|
|
93
|
-
|
|
94
|
-
## Dashboard administrator sessions
|
|
95
|
-
|
|
96
|
-
Browser dashboard access uses the same Chat Relay username/password accounts but requires an explicit global administrator entitlement. Successful dashboard login creates a short-lived opaque server-side session; the browser receives an HttpOnly, Secure, SameSite=Strict cookie plus a CSRF token for state-changing requests. `ADMIN_TOKEN` remains an operator/CLI recovery credential and must never be embedded in dashboard JavaScript, browser storage, or URLs.
|
|
97
|
-
## Authentication and multi-user model
|
|
98
|
-
|
|
99
|
-
- Browser/device login uses a short-lived device code. Raw user or admin tokens are not typed into the CLI.
|
|
100
|
-
- New accounts use a unique login plus password. Passwords are stored only as salted PBKDF2-SHA256 hashes.
|
|
101
|
-
- Device start/approval requests and failed password attempts are rate-limited.
|
|
102
|
-
- CLI user sessions are opaque random tokens stored server-side only as hashes and expire after 90 days.
|
|
103
|
-
- Re-authentication revokes the previous CLI session when possible.
|
|
104
|
-
- Each local machine has an independent `agentId` and agent token; agent tokens are stored server-side only as hashes.
|
|
105
|
-
- Agent ownership is enforced before an existing machine identity can be reused, preventing shared users from rotating another owner's agent credential.
|
|
106
|
-
- Grants map users to agents with scopes: `read`, `write`, `terminal`, `process`, `desktop_read`, `desktop_control`, or `*`. Explicit legacy grants do not gain desktop access automatically.
|
|
107
|
-
- Logging out revokes the local user session and the owning machine credential.
|
|
108
|
-
- `ADMIN_TOKEN` remains separate and protects administration routes.
|
|
109
|
-
- The existing legacy owner token remains supported only as transitional compatibility while OAuth becomes the normal ChatGPT MCP path.
|
|
110
|
-
|
|
111
|
-
ChatGPT MCP should connect to the plain `/mcp` endpoint. Compatible clients discover OAuth 2.1 automatically, then use authorization-code login with PKCE S256. OAuth access tokens are audience-bound to `/mcp`; `offline_access` issues rotating refresh tokens. Dynamic client registration and protected-resource/authorization-server discovery are exposed for compatible MCP clients. The legacy `/mcp?key=<USER_TOKEN>` flow remains available only during migration. Browser/device login is for the zero-checkout local CLI and creates the same registry user/agent model used by OAuth.
|
|
112
|
-
|
|
113
|
-
## MCP tools
|
|
114
|
-
|
|
115
|
-
Identity and agent routing:
|
|
116
|
-
- `whoami`
|
|
117
|
-
- `list_agents`
|
|
118
|
-
- `ping_agent`
|
|
119
|
-
- `get_config`
|
|
120
|
-
- `get_recent_tool_calls`
|
|
121
|
-
|
|
122
|
-
Filesystem:
|
|
123
|
-
- `stat_path`
|
|
124
|
-
- `list_directory`
|
|
125
|
-
- `read_file` - bounded by bytes; returns `truncated` and `nextOffset` when more lines remain
|
|
126
|
-
- `read_multiple_files` - bounded aggregate response; supports string paths or per-file `{ path, offset, length, maxBytes }` entries plus `maxTotalBytes`
|
|
127
|
-
- `start_search`
|
|
128
|
-
- `get_more_search_results`
|
|
129
|
-
- `write_file`
|
|
130
|
-
- `edit_block`
|
|
131
|
-
- `create_directory`
|
|
132
|
-
- `move_path`
|
|
133
|
-
- `delete_path`
|
|
134
|
-
|
|
135
|
-
Processes:
|
|
136
|
-
- `list_processes`
|
|
137
|
-
- `kill_process`
|
|
138
|
-
|
|
139
|
-
Terminal:
|
|
140
|
-
- `terminal_exec`
|
|
141
|
-
- `terminal_batch_start`
|
|
142
|
-
- `terminal_batch_status`
|
|
143
|
-
- `terminal_batch_read`
|
|
144
|
-
- `terminal_batch_cancel`
|
|
145
|
-
- `terminal_start`
|
|
146
|
-
- `terminal_start_shell`
|
|
147
|
-
- `terminal_read`
|
|
148
|
-
- `terminal_write`
|
|
149
|
-
- `terminal_list`
|
|
150
|
-
- `terminal_kill`
|
|
151
|
-
|
|
152
|
-
Desktop (Windows/macOS, opt-in):
|
|
153
|
-
- `screenshot` - returns a bounded MCP image content block plus coordinate metadata
|
|
154
|
-
- `mouse_click` - left/right/middle single or double click in desktop coordinates
|
|
155
|
-
- `keyboard_input` - Unicode text or named key/modifier chord
|
|
156
|
-
|
|
157
|
-
Desktop-Commander-compatible aliases:
|
|
158
|
-
- `start_process`
|
|
159
|
-
- `read_process_output`
|
|
160
|
-
- `interact_with_process`
|
|
161
|
-
- `list_sessions`
|
|
162
|
-
- `force_terminate`
|
|
58
|
+
## `npx` commands
|
|
163
59
|
|
|
164
|
-
|
|
60
|
+
| Command | What it does |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `npx @anusornneal/chat-relay@latest remote` | Connects this computer to Chat Relay and keeps the local agent running. Enables the capabilities allowed for this device, such as files, terminal, and processes. |
|
|
63
|
+
| `npx @anusornneal/chat-relay@latest remote --desktop` | Starts the agent with desktop screenshot/input support enabled. Desktop permissions must also be granted by the relay. |
|
|
64
|
+
| `npx @anusornneal/chat-relay@latest login` | Signs in and registers this computer without starting the long-running agent. |
|
|
65
|
+
| `npx @anusornneal/chat-relay@latest login --force` | Forces a fresh sign-in and device authorization. Useful when credentials were revoked or need to be replaced. |
|
|
66
|
+
| `npx @anusornneal/chat-relay@latest status` | Shows the signed-in account, device, relay URL, online state, scopes, agent version, protocol compatibility, lifecycle state, allowed roots, and desktop state. |
|
|
67
|
+
| `npx @anusornneal/chat-relay@latest drain` | Stops accepting new long-running work so active work can finish before a restart. |
|
|
68
|
+
| `npx @anusornneal/chat-relay@latest resume` | Cancels drain mode and resumes normal work admission. |
|
|
69
|
+
| `npx @anusornneal/chat-relay@latest restart` | Restarts the local agent after drain has completed and the agent is ready to restart. |
|
|
70
|
+
| `npx @anusornneal/chat-relay@latest logout` | Revokes this computer login and removes its local credentials. |
|
|
71
|
+
| `npx @anusornneal/chat-relay@latest help` | Shows CLI usage and available options. |
|
|
165
72
|
|
|
166
|
-
|
|
73
|
+
Useful options:
|
|
167
74
|
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
npm run admin -- enable-user <userId> false
|
|
178
|
-
$env:CHAT_RELAY_PASSWORD="choose-a-password"; npm run admin -- set-login <userId> <login>
|
|
179
|
-
npm run admin -- enable-agent <agentId> false
|
|
180
|
-
npm run admin -- rotate-user <userId>
|
|
181
|
-
npm run admin -- rotate-agent <agentId>
|
|
75
|
+
```text
|
|
76
|
+
--root <path> Allowed filesystem root. Use semicolons for multiple roots.
|
|
77
|
+
--name <name> Computer display name.
|
|
78
|
+
--agent-id <id> Stable agent identifier.
|
|
79
|
+
--desktop Enable desktop access.
|
|
80
|
+
--no-desktop Disable desktop access.
|
|
81
|
+
--no-open Do not open the login browser automatically.
|
|
82
|
+
--force Force a new login.
|
|
83
|
+
--relay <url> Use another Chat Relay deployment.
|
|
182
84
|
```
|
|
183
85
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
The initial `bootstrap` migrates the legacy `CALLER_TOKEN` and `AGENT_TOKEN` secrets into an owner user and the default agent. To move that existing owner to OAuth without creating a duplicate account, set `CHAT_RELAY_PASSWORD` in the administrator shell and run `npm run admin -- set-login owner <login>`. This attaches login credentials to the same owner user, preserving its grants and agent ownership.
|
|
187
|
-
|
|
188
|
-
## Worker routes
|
|
189
|
-
|
|
190
|
-
| Route | Authentication | Purpose |
|
|
191
|
-
| --- | --- | --- |
|
|
192
|
-
| `GET /health` | none | Worker health |
|
|
193
|
-
| `GET /.well-known/oauth-protected-resource[/mcp]` | none | MCP protected-resource metadata |
|
|
194
|
-
| `GET /.well-known/oauth-authorization-server` | none | OAuth authorization-server metadata |
|
|
195
|
-
| `POST /register` | none | Dynamic registration for public PKCE clients |
|
|
196
|
-
| `GET/POST /authorize` | login/password | OAuth authorization-code sign-in |
|
|
197
|
-
| `POST /token` | public client + PKCE/refresh token | Access/refresh token exchange |
|
|
198
|
-
| `POST /auth/device/start` | none | Start CLI device authorization |
|
|
199
|
-
| `GET /device?user_code=<code>` | none | Browser sign-in/approval page |
|
|
200
|
-
| `POST /auth/device/approve` | login/password + device code | Approve or create a user account |
|
|
201
|
-
| `POST /auth/device/token` | device code | Exchange approved device code for session/agent credentials |
|
|
202
|
-
| `GET /auth/me` | user Bearer token | Current user and permitted agents |
|
|
203
|
-
| `POST /auth/session/revoke` | user Bearer token | Revoke only the current user session |
|
|
204
|
-
| `POST /auth/logout` | user Bearer token | Revoke local session and owning agent credential |
|
|
205
|
-
| `POST /mcp` | OAuth Bearer token | Streamable HTTP MCP (normal path) |
|
|
206
|
-
| `POST /mcp?key=<user token>` | legacy user token | Transitional Streamable HTTP MCP compatibility |
|
|
207
|
-
| `GET /agent?agentId=<id>` | agent Bearer token | Local agent WebSocket |
|
|
208
|
-
| `GET /status?agentId=<id>` | user token | Agent online status |
|
|
209
|
-
| `POST /relay?agentId=<id>` | user token | Direct JSON relay with scope checks |
|
|
210
|
-
| `/admin/*` | admin Bearer token | User/agent/grant administration |
|
|
211
|
-
|
|
212
|
-
## Desktop access (Windows/macOS, opt-in)
|
|
213
|
-
|
|
214
|
-
Desktop interaction is disabled by default and requires two independent gates:
|
|
215
|
-
|
|
216
|
-
1. Enable the local agent with `chat-relay remote --desktop` or `DESKTOP_ENABLED=1`. The CLI persists this setting locally. Use `--no-desktop` to disable it again.
|
|
217
|
-
2. Grant `desktop_read` for screenshots and/or `desktop_control` for mouse/keyboard input. Generic read/write/terminal/process scopes do not imply desktop access.
|
|
218
|
-
|
|
219
|
-
- `screenshot` captures the selected interactive display on Windows or macOS, scales/compresses it to a bounded JPEG, and returns it as an MCP image block. The accompanying metadata contains image size, desktop origin/size, and scale factors for converting screenshot pixels to desktop coordinates.
|
|
220
|
-
- `mouse_click` accepts integer desktop x/y coordinates, button `left|right|middle`, and click count 1 or 2. Invalid/out-of-bounds input is rejected rather than coerced.
|
|
221
|
-
- `keyboard_input` accepts either Unicode `text` or one named `key` with optional Ctrl/Alt/Shift/Win modifiers. Text and key cannot be supplied together.
|
|
222
|
-
- `desktop_step` batches 1-20 actions in one local round trip, compacts adjacent text/waits, and uses adaptive settle delays. It does not capture by default; set `captureAfter: true` when a post-action screenshot is required. Timing metadata separates local queue, input, wait/settle, capture, and encode costs.
|
|
223
|
-
- Windows uses the persistent PowerShell desktop worker. macOS uses built-in `screencapture`, `sips`, `osascript`/JXA, CoreGraphics, and System Events; no extra npm/native dependency is required.
|
|
224
|
-
- macOS desktop capture requires Screen Recording permission for the terminal/Node process, while mouse, keyboard, and window control require Accessibility permission. Missing permissions return controlled errors instead of crashing the reconnect loop.
|
|
225
|
-
- Linux desktop control is not implemented yet and returns `unsupported_platform`.
|
|
226
|
-
- Screenshot bytes, typed text, key chords, and click coordinates are not stored in recentCalls. Only action/timing/success metadata is retained there.
|
|
227
|
-
- This card does not add streaming video, OCR, remote-desktop viewer UI, clipboard sync, drag-and-drop, app-specific automation, or Session 0/service automation.
|
|
228
|
-
|
|
229
|
-
## Public plugin review
|
|
230
|
-
|
|
231
|
-
Public ChatGPT onboarding uses the plain production `/mcp` URL and OAuth; the legacy `?key=` route is migration compatibility only. Submission/reviewer requirements, permission boundaries, privacy/retention behavior, domain-verification setup, positive/negative test cases, and clean-room demo steps are maintained in `docs/plugin-review.md`.
|
|
232
|
-
|
|
233
|
-
The OpenAI domain verification token is served at `/.well-known/openai-apps-challenge` when `OPENAI_APPS_CHALLENGE` is configured. Keep reviewer credentials and challenge values separate from production administrator secrets.
|
|
234
|
-
## Runtime limits and controls
|
|
235
|
-
|
|
236
|
-
- Per-user durable rate/quota controls are available before local-agent dispatch, but both are disabled by default (`rateLimit=0`, `dailyCallQuota=0`). Set `USER_RATE_LIMIT_PER_WINDOW` and/or `USER_DAILY_CALL_QUOTA` to a positive value to enable them; `USER_RATE_WINDOW_SECONDS` controls the rate window (1-3600 seconds).
|
|
237
|
-
- Admins can inspect or override the effective policy with `GET/POST /admin/api/limits`; POST `{ "resetToDefaults": true }` returns to environment defaults. Rejections return HTTP 429 with `rate_limited` or `quota_exceeded`, `Retry-After`, and reset metadata, and are recorded as bounded usage events without dispatching agent work.
|
|
238
|
-
- Relay request/response message: 64 KiB. The local agent caps serialized responses below that transport ceiling and returns `response_too_large` instead of allowing a silent timeout.
|
|
239
|
-
- `read_file` defaults to a 32 KiB content budget (max 48 KiB) and exposes deterministic `nextOffset` continuation.
|
|
240
|
-
- `read_multiple_files` defaults to a 48 KiB aggregate budget (max 48 KiB). If not all requested files fit, use `nextIndex`; if an individual file is truncated, continue it with that entry's `nextOffset`.
|
|
241
|
-
- One-shot terminal command: max 20 seconds.
|
|
242
|
-
- `terminal_batch_start` accepts 2-20 jobs in one MCP call to avoid N-call E2E dispatch overhead. Each agent owns an independent FIFO queue and bounded execution pool.
|
|
243
|
-
- Batch concurrency defaults to 4 and is capped at 8 per agent. Override with `TERMINAL_BATCH_CONCURRENCY`; queued jobs default to 64 and are capped at 256 via `TERMINAL_BATCH_MAX_QUEUED`.
|
|
244
|
-
- Queue overflow fails fast with `queue_full` instead of spawning unbounded processes. Use `terminal_batch_status`, `terminal_batch_read`, and `terminal_batch_cancel` for lifecycle control.
|
|
245
|
-
- Batch output retained in memory is capped at 8 KiB per job; completed batches are capped at 64 per agent and also expire after 30 minutes.
|
|
246
|
-
- Scaling is horizontal by agent: each connected machine has its own queue/concurrency budget, so additional agents add execution capacity without sharing one local hot queue.
|
|
247
|
-
- Persistent terminal sessions: up to 8 running sessions per local agent.
|
|
248
|
-
- Terminal output buffer: bounded in memory; completed sessions retained for 30 minutes.
|
|
249
|
-
- Filesystem reads/writes are bounded and restricted to configured `ALLOWED_ROOTS`.
|
|
250
|
-
- Search skips common heavy directories such as `.git`, `node_modules`, `.gradle`, `.idea`, and `.wrangler`.
|
|
251
|
-
- The agent blocks a small set of high-risk system-management commands. This is defense in depth, not a security sandbox.
|
|
252
|
-
|
|
253
|
-
## Audit, retention, and recovery
|
|
254
|
-
|
|
255
|
-
- Security/admin mutations are recorded in a dedicated `Audit` Durable Object with actor, action, target, result, and sanitized scalar metadata only. Passwords, tokens, cookies, CSRF values, commands, file content, and raw tool payloads are excluded.
|
|
256
|
-
- Raw usage events default to 30-day retention while daily usage aggregates are preserved independently. Set `USAGE_RAW_RETENTION_DAYS` to change raw-event retention.
|
|
257
|
-
- Audit events default to 180-day retention. Set `AUDIT_RETENTION_DAYS` to change that window.
|
|
258
|
-
- `GET /admin/api/audit` returns bounded audit history. `GET /admin/api/operations` exposes component health and last cleanup state. `POST /admin/api/operations/cleanup` performs bounded idempotent cleanup across registry auth transients, raw usage events, and audit history.
|
|
259
|
-
- Cleanup does not intentionally remove active users, grants, agents, live sessions, pending non-expired device authorization, or long-lived usage aggregates.
|
|
260
|
-
- Durable Object state is the production source of truth. Git/npm artifacts do not back it up. Before destructive migration or account transfer, export any operator-required identity/config state separately and treat Cloudflare account/Durable Object recovery controls as the infrastructure recovery boundary.
|
|
261
|
-
- Cleanup failures are surfaced through the operations endpoint and audit result instead of being silently treated as success.
|
|
262
|
-
## Tests
|
|
263
|
-
|
|
264
|
-
The legacy relay integration test remains available:
|
|
86
|
+
## What ChatGPT can access
|
|
265
87
|
|
|
266
|
-
|
|
267
|
-
npm run dev:test
|
|
268
|
-
python test/integration.py
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
The full MCP/multi-user smoke test expects a local Worker on port 8795 plus an attached local agent:
|
|
88
|
+
Depending on the device grants and local configuration, Chat Relay can expose:
|
|
272
89
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
90
|
+
- Filesystem operations inside configured allowed roots.
|
|
91
|
+
- Terminal commands and persistent terminal sessions. Short stateless commands automatically reuse a cross-platform shell pool; stateful commands fall back to isolated execution.
|
|
92
|
+
- Process inspection and termination.
|
|
93
|
+
- Windows/macOS screenshots, mouse input, and keyboard input when desktop access is explicitly enabled.
|
|
94
|
+
- Multiple computers under one account, with per-device routing and permissions.
|
|
95
|
+
- Batched filesystem, terminal, and desktop operations to reduce remote round trips.
|
|
276
96
|
|
|
277
|
-
|
|
97
|
+
Desktop access is opt-in. Linux desktop control is not currently implemented.
|
|
278
98
|
|
|
279
|
-
|
|
99
|
+
## How it works
|
|
280
100
|
|
|
281
|
-
```
|
|
282
|
-
|
|
101
|
+
```text
|
|
102
|
+
ChatGPT
|
|
103
|
+
|
|
|
104
|
+
| Streamable HTTP MCP + OAuth
|
|
105
|
+
v
|
|
106
|
+
Cloudflare Worker
|
|
107
|
+
|
|
|
108
|
+
+-- Registry Durable Object
|
|
109
|
+
|
|
|
110
|
+
+-- Relay Durable Object
|
|
111
|
+
|
|
|
112
|
+
| WebSocket
|
|
113
|
+
v
|
|
114
|
+
Local Agent
|
|
115
|
+
- files
|
|
116
|
+
- terminal
|
|
117
|
+
- processes
|
|
118
|
+
- desktop
|
|
283
119
|
```
|
|
284
120
|
|
|
285
|
-
|
|
121
|
+
ChatGPT talks only to the cloud MCP endpoint. The local computer opens the outbound WebSocket connection to the relay.
|
|
286
122
|
|
|
287
|
-
|
|
288
|
-
TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:oauth
|
|
289
|
-
```
|
|
123
|
+
## Security model
|
|
290
124
|
|
|
291
|
-
|
|
125
|
+
- ChatGPT MCP authentication uses OAuth 2.1 authorization code flow with PKCE.
|
|
126
|
+
- Local computers use browser/device authorization; raw agent tokens do not need to be copied manually.
|
|
127
|
+
- Filesystem access is constrained to configured allowed roots.
|
|
128
|
+
- Access is scope-based: read, write, terminal, process, desktop read, and desktop control can be granted separately.
|
|
129
|
+
- Desktop access is disabled by default.
|
|
130
|
+
- Stored credentials are hashed server-side where applicable.
|
|
131
|
+
- Persisted usage/audit telemetry is metadata-only and excludes commands, file contents, screenshots, clipboard contents, and credentials.
|
|
292
132
|
|
|
293
|
-
|
|
294
|
-
TEST_RELAY_URL=http://127.0.0.1:8804 npm run test:quota
|
|
295
|
-
```
|
|
133
|
+
Chat Relay is currently intended for small trusted teams rather than an enterprise zero-trust control plane.
|
|
296
134
|
|
|
297
|
-
|
|
135
|
+
## Local configuration
|
|
298
136
|
|
|
299
|
-
|
|
300
|
-
TEST_RELAY_URL=http://127.0.0.1:8807 npm run test:desktop
|
|
301
|
-
```
|
|
137
|
+
Default CLI config locations:
|
|
302
138
|
|
|
303
|
-
|
|
139
|
+
- Windows: `%LOCALAPPDATA%\chat-relay\config.json`
|
|
140
|
+
- macOS/Linux: `$XDG_CONFIG_HOME/chat-relay/config.json` or `~/.config/chat-relay/config.json`
|
|
304
141
|
|
|
305
|
-
|
|
306
|
-
CHAT_RELAY_TARBALL=<path-to-tgz> TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:zero-checkout
|
|
307
|
-
```
|
|
142
|
+
Set `CHAT_RELAY_HOME` to override the config directory.
|
|
308
143
|
|
|
309
|
-
|
|
144
|
+
A single account can own multiple computers. Each computer keeps its own agent identity and credential.
|
|
310
145
|
|
|
311
|
-
##
|
|
146
|
+
## Development
|
|
312
147
|
|
|
313
|
-
|
|
148
|
+
Clone the repository only when developing Chat Relay itself. Normal users should use the zero-checkout `npx` flow above.
|
|
314
149
|
|
|
315
|
-
|
|
150
|
+
Common development commands:
|
|
316
151
|
|
|
317
152
|
```bash
|
|
153
|
+
npm install
|
|
154
|
+
npm start
|
|
155
|
+
npm run dev
|
|
318
156
|
npm run verify:publish
|
|
319
157
|
```
|
|
320
158
|
|
|
321
|
-
|
|
159
|
+
`npm run verify:publish` checks the publish file set, CLI entrypoint, Worker dry-run build, review artifacts, and production dependency audit.
|
|
322
160
|
|
|
323
|
-
|
|
161
|
+
## Administration
|
|
162
|
+
|
|
163
|
+
Administration is separate from the public MCP interface. The admin tooling manages users, agents, grants, lifecycle, quotas, audit data, and operational cleanup.
|
|
324
164
|
|
|
325
|
-
|
|
165
|
+
Operator commands are available through:
|
|
326
166
|
|
|
327
167
|
```bash
|
|
328
|
-
|
|
329
|
-
npx @anusornneal/chat-relay@latest remote
|
|
168
|
+
npm run admin -- <command>
|
|
330
169
|
```
|
|
331
170
|
|
|
171
|
+
Keep `ADMIN_TOKEN` and other deployment secrets out of client configuration and source control.
|
|
332
172
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
Agent updates remain explicit and user-controlled. Check the current agent version, protocol compatibility, and lifecycle with `chat-relay status`.
|
|
336
|
-
|
|
337
|
-
Before restarting an active remote, run `chat-relay drain`. Drain mode rejects new long-running work while existing terminal/session control remains available so bounded work can finish or be stopped safely. When `status` reports the lifecycle is ready to restart, run `chat-relay restart`. Use `chat-relay resume` to cancel a drain before restart.
|
|
338
|
-
|
|
339
|
-
To install a newer package, stop/restart the wrapper with the desired npm version (for example `npx @anusornneal/chat-relay@latest remote --desktop`). The agent does not self-modify or automatically cross an incompatible protocol version.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
### Trusted-team security baseline
|
|
343
|
-
|
|
344
|
-
Chat Relay is currently designed for a small trusted internal team, not as an enterprise zero-trust control plane. The intentionally small baseline is:
|
|
173
|
+
## Platform notes
|
|
345
174
|
|
|
346
|
-
-
|
|
347
|
-
-
|
|
348
|
-
-
|
|
349
|
-
-
|
|
175
|
+
- Local agent: Windows and macOS.
|
|
176
|
+
- Desktop control: Windows and macOS.
|
|
177
|
+
- Linux desktop control: not implemented.
|
|
178
|
+
- MCP transport: Streamable HTTP over HTTPS.
|
|
179
|
+
- Local agent transport: outbound WebSocket.
|
|
180
|
+
- Node.js: 20+.
|
|
350
181
|
|
|
351
|
-
|
|
182
|
+
## More information
|
|
352
183
|
|
|
353
|
-
|
|
184
|
+
- Public plugin/reviewer flow: `docs/plugin-review.md`
|
|
185
|
+
- Google sign-in setup: `docs/google-login.md`
|
|
186
|
+
- Source: https://github.com/anusornNeal/chat-relay
|
package/agent/local-agent.mjs
CHANGED
|
@@ -25,6 +25,9 @@ const MAX_RESPONSE_BYTES = 60 * 1024;
|
|
|
25
25
|
const terminals = new TerminalManager({
|
|
26
26
|
batchConcurrency: process.env.TERMINAL_BATCH_CONCURRENCY,
|
|
27
27
|
maxQueuedJobs: process.env.TERMINAL_BATCH_MAX_QUEUED,
|
|
28
|
+
fastExec: process.env.TERMINAL_FAST_EXEC !== "0",
|
|
29
|
+
fastExecPoolSize: process.env.TERMINAL_FAST_EXEC_POOL_SIZE,
|
|
30
|
+
fastExecIdleMs: process.env.TERMINAL_FAST_EXEC_IDLE_MS,
|
|
28
31
|
});
|
|
29
32
|
const scheduler = new CapabilityScheduler({
|
|
30
33
|
maxQueued: process.env.AGENT_MAX_QUEUED,
|
|
@@ -116,6 +119,7 @@ async function handlePayload(payload) {
|
|
|
116
119
|
agentName,
|
|
117
120
|
terminalEnabled,
|
|
118
121
|
terminalBatch: terminals.getBatchConfig(),
|
|
122
|
+
terminalExec: terminals.getExecConfig(),
|
|
119
123
|
concurrency: scheduler.snapshot(),
|
|
120
124
|
desktop: desktop.getConfig(),
|
|
121
125
|
allowedRoots: files.getRoots(),
|
|
@@ -313,6 +317,7 @@ function requireProtocolUpdate(details = {}) {
|
|
|
313
317
|
const socket = activeSocket;
|
|
314
318
|
activeSocket = null;
|
|
315
319
|
try { socket?.terminate(); } catch {}
|
|
320
|
+
terminals.close();
|
|
316
321
|
desktop.close();
|
|
317
322
|
const expected = details.expectedProtocolVersion ?? "current";
|
|
318
323
|
const received = details.receivedProtocolVersion ?? agentHello.protocolVersion;
|
|
@@ -329,6 +334,7 @@ function requireReauthorization(reason = "credential_revoked") {
|
|
|
329
334
|
const socket = activeSocket;
|
|
330
335
|
activeSocket = null;
|
|
331
336
|
try { socket?.terminate(); } catch {}
|
|
337
|
+
terminals.close();
|
|
332
338
|
desktop.close();
|
|
333
339
|
console.error("Agent credential was revoked or rejected.");
|
|
334
340
|
console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
|
|
@@ -402,6 +408,7 @@ function connect() {
|
|
|
402
408
|
handlerDurationMs,
|
|
403
409
|
lane: scheduleMeta.lane,
|
|
404
410
|
...(timing ? { timing } : {}),
|
|
411
|
+
...(typeof result?.execMode === "string" ? { execMode: result.execMode } : {}),
|
|
405
412
|
ok: !(result && typeof result === "object" && result.ok === false),
|
|
406
413
|
});
|
|
407
414
|
if (recentCalls.length > 100) recentCalls.shift();
|
|
@@ -470,6 +477,7 @@ function restartAgentProcess() {
|
|
|
470
477
|
const socket = activeSocket;
|
|
471
478
|
activeSocket = null;
|
|
472
479
|
try { socket?.close(1012, "restart_requested"); } catch {}
|
|
480
|
+
terminals.close();
|
|
473
481
|
desktop.close();
|
|
474
482
|
setTimeout(() => process.exit(AGENT_RESTART_EXIT_CODE), 50);
|
|
475
483
|
}
|
|
@@ -483,12 +491,13 @@ function shutdown(reason) {
|
|
|
483
491
|
const socket = activeSocket;
|
|
484
492
|
activeSocket = null;
|
|
485
493
|
try { socket?.close(1000, "client_shutdown"); } catch {}
|
|
494
|
+
terminals.close();
|
|
486
495
|
desktop.close();
|
|
487
496
|
setTimeout(() => process.exit(0), 50);
|
|
488
497
|
}
|
|
489
498
|
|
|
490
499
|
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
491
500
|
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
492
|
-
process.once("exit", () => desktop.close());
|
|
501
|
+
process.once("exit", () => { terminals.close(); desktop.close(); });
|
|
493
502
|
|
|
494
503
|
connect();
|
|
@@ -13,6 +13,11 @@ const MAX_BATCH_CONCURRENCY = 8;
|
|
|
13
13
|
const DEFAULT_BATCH_CONCURRENCY = 4;
|
|
14
14
|
const DEFAULT_MAX_QUEUED_JOBS = 64;
|
|
15
15
|
const MAX_QUEUED_JOBS = 256;
|
|
16
|
+
const DEFAULT_FAST_EXEC_POOL_SIZE = 2;
|
|
17
|
+
const MAX_FAST_EXEC_POOL_SIZE = 4;
|
|
18
|
+
const DEFAULT_FAST_EXEC_IDLE_MS = 60 * 1000;
|
|
19
|
+
const MIN_FAST_EXEC_IDLE_MS = 5 * 1000;
|
|
20
|
+
const MAX_FAST_EXEC_IDLE_MS = 10 * 60 * 1000;
|
|
16
21
|
const MAX_BATCH_READ_CHARS = 24 * 1024;
|
|
17
22
|
const MAX_JOB_READ_CHARS = 8 * 1024;
|
|
18
23
|
const MAX_RETAINED_BATCHES = 64;
|
|
@@ -59,6 +64,7 @@ export function shellForPlatform(platform, preferredShell) {
|
|
|
59
64
|
displayName: "powershell",
|
|
60
65
|
commandArgs: (command) => ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
|
|
61
66
|
interactiveArgs: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
|
|
67
|
+
persistentArgs: ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"],
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
const file = preferredShell || (platform === "darwin" ? "/bin/zsh" : "/bin/sh");
|
|
@@ -67,6 +73,7 @@ export function shellForPlatform(platform, preferredShell) {
|
|
|
67
73
|
displayName: path.basename(file),
|
|
68
74
|
commandArgs: (command) => ["-lc", command],
|
|
69
75
|
interactiveArgs: ["-l"],
|
|
76
|
+
persistentArgs: platform === "darwin" && path.basename(file) === "zsh" ? ["-f"] : [],
|
|
70
77
|
};
|
|
71
78
|
}
|
|
72
79
|
|
|
@@ -88,6 +95,410 @@ function terminateProcessTree(platform, pid) {
|
|
|
88
95
|
}, 150);
|
|
89
96
|
});
|
|
90
97
|
}
|
|
98
|
+
|
|
99
|
+
function quotePowerShell(value) {
|
|
100
|
+
return "'" + String(value).replace(/'/g, "''") + "'";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function quotePosix(value) {
|
|
104
|
+
return "'" + String(value).replace(/'/g, "'\"'\"'") + "'";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function canUsePersistentExec(platform, command) {
|
|
108
|
+
if (typeof command !== "string" || command.includes("\0")) return false;
|
|
109
|
+
if (platform === "win32") {
|
|
110
|
+
return !/\b(exit|stop-process|taskkill(?:\.exe)?|start-process\s+-wait|set-location|push-location|pop-location|set-alias|new-alias|remove-alias|set-variable|new-variable|remove-variable|import-module|remove-module|set-psdebug|set-strictmode)\b|\$(?:env|global|script):/i.test(command);
|
|
111
|
+
}
|
|
112
|
+
return !/(^|[^&])&([^&]|$)/.test(command);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function persistentExecScript(platform, command, cwd, marker) {
|
|
116
|
+
const begin = marker + "_BEGIN__";
|
|
117
|
+
const endPrefix = marker + "_END_";
|
|
118
|
+
const endSuffix = "__";
|
|
119
|
+
|
|
120
|
+
if (platform === "win32") {
|
|
121
|
+
const psCommand = quotePowerShell(command);
|
|
122
|
+
const psCwd = quotePowerShell(cwd);
|
|
123
|
+
const psBegin = quotePowerShell(begin);
|
|
124
|
+
const psEndPrefix = quotePowerShell(endPrefix);
|
|
125
|
+
const psEndSuffix = quotePowerShell(endSuffix);
|
|
126
|
+
return [
|
|
127
|
+
"& {",
|
|
128
|
+
" [Console]::Out.Write(" + psBegin + ")",
|
|
129
|
+
" [Console]::Error.Write(" + psBegin + ")",
|
|
130
|
+
" $__ChatRelayOldLocation = (Get-Location).Path",
|
|
131
|
+
" $global:LASTEXITCODE = $null",
|
|
132
|
+
" $__ChatRelayExitCode = 0",
|
|
133
|
+
" try {",
|
|
134
|
+
" Set-Location -LiteralPath " + psCwd,
|
|
135
|
+
" & ([ScriptBlock]::Create(" + psCommand + "))",
|
|
136
|
+
" $__ChatRelaySucceeded = $?",
|
|
137
|
+
" if ($null -ne $global:LASTEXITCODE) {",
|
|
138
|
+
" $__ChatRelayExitCode = [Math]::Max(0, [Math]::Min(2147483647, [int]$global:LASTEXITCODE))",
|
|
139
|
+
" } elseif (-not $__ChatRelaySucceeded) {",
|
|
140
|
+
" $__ChatRelayExitCode = 1",
|
|
141
|
+
" }",
|
|
142
|
+
" } catch {",
|
|
143
|
+
" [Console]::Error.Write(($_ | Out-String))",
|
|
144
|
+
" $__ChatRelayExitCode = 1",
|
|
145
|
+
" } finally {",
|
|
146
|
+
" Set-Location -LiteralPath $__ChatRelayOldLocation -ErrorAction SilentlyContinue",
|
|
147
|
+
" }",
|
|
148
|
+
" $__ChatRelayEnd = " + psEndPrefix + " + [string]$__ChatRelayExitCode + " + psEndSuffix,
|
|
149
|
+
" [Console]::Out.Write($__ChatRelayEnd)",
|
|
150
|
+
" [Console]::Error.Write($__ChatRelayEnd)",
|
|
151
|
+
"}",
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const shCommand = quotePosix(command);
|
|
156
|
+
const shCwd = quotePosix(cwd);
|
|
157
|
+
const shBegin = quotePosix(begin);
|
|
158
|
+
const shEndPrefix = quotePosix(endPrefix);
|
|
159
|
+
const shEndSuffix = quotePosix(endSuffix);
|
|
160
|
+
return [
|
|
161
|
+
"printf %s " + shBegin,
|
|
162
|
+
"printf %s " + shBegin + " >&2",
|
|
163
|
+
"(",
|
|
164
|
+
" cd -- " + shCwd + " || exit 200",
|
|
165
|
+
" eval " + shCommand,
|
|
166
|
+
")",
|
|
167
|
+
"__chat_relay_exit_code=$?",
|
|
168
|
+
"printf '%s%s%s' " + shEndPrefix + " \"$__chat_relay_exit_code\" " + shEndSuffix,
|
|
169
|
+
"printf '%s%s%s' " + shEndPrefix + " \"$__chat_relay_exit_code\" " + shEndSuffix + " >&2",
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class PersistentExecWorker {
|
|
174
|
+
constructor({ platform, shell }) {
|
|
175
|
+
this.platform = platform;
|
|
176
|
+
this.shell = shell;
|
|
177
|
+
this.child = null;
|
|
178
|
+
this.startPromise = null;
|
|
179
|
+
this.current = null;
|
|
180
|
+
this.reserved = false;
|
|
181
|
+
this.idleTimer = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
get busy() {
|
|
185
|
+
return this.reserved || this.current !== null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async run(command, cwd, timeoutMs) {
|
|
189
|
+
if (this.reserved || this.current) {
|
|
190
|
+
return { ok: false, exitCode: 1, stdout: "", stderr: "", error: "fast_exec_busy", fastExecUnavailable: true };
|
|
191
|
+
}
|
|
192
|
+
this.reserved = true;
|
|
193
|
+
try {
|
|
194
|
+
await this.#ensureStarted();
|
|
195
|
+
if (!this.child?.stdin?.writable) {
|
|
196
|
+
return { ok: false, exitCode: 1, stdout: "", stderr: "", error: "fast_exec_unavailable", fastExecUnavailable: true };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
clearTimeout(this.idleTimer);
|
|
200
|
+
this.idleTimer = null;
|
|
201
|
+
const marker = "__CHAT_RELAY_" + randomUUID().replace(/-/g, "") + "__";
|
|
202
|
+
const begin = marker + "_BEGIN__";
|
|
203
|
+
const endPrefix = marker + "_END_";
|
|
204
|
+
const endSuffix = "__";
|
|
205
|
+
|
|
206
|
+
return await new Promise((resolve) => {
|
|
207
|
+
const current = {
|
|
208
|
+
resolve,
|
|
209
|
+
begin,
|
|
210
|
+
endPrefix,
|
|
211
|
+
endSuffix,
|
|
212
|
+
stdout: "",
|
|
213
|
+
stderr: "",
|
|
214
|
+
stdoutBuffer: "",
|
|
215
|
+
stderrBuffer: "",
|
|
216
|
+
stdoutStarted: false,
|
|
217
|
+
stderrStarted: false,
|
|
218
|
+
stdoutEnded: false,
|
|
219
|
+
stderrEnded: false,
|
|
220
|
+
exitCode: 0,
|
|
221
|
+
startedAt: Date.now(),
|
|
222
|
+
timer: null,
|
|
223
|
+
};
|
|
224
|
+
current.timer = setTimeout(() => {
|
|
225
|
+
if (this.current !== current) return;
|
|
226
|
+
this.current = null;
|
|
227
|
+
void this.#terminate();
|
|
228
|
+
resolve({
|
|
229
|
+
ok: false,
|
|
230
|
+
exitCode: 1,
|
|
231
|
+
stdout: current.stdout,
|
|
232
|
+
stderr: current.stderr,
|
|
233
|
+
error: "Command timed out after " + timeoutMs + "ms",
|
|
234
|
+
execMode: "persistent-shell",
|
|
235
|
+
});
|
|
236
|
+
}, timeoutMs);
|
|
237
|
+
this.current = current;
|
|
238
|
+
|
|
239
|
+
const script = persistentExecScript(this.platform, command, cwd, marker);
|
|
240
|
+
try {
|
|
241
|
+
this.child.stdin.write(script + os.EOL + os.EOL);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
clearTimeout(current.timer);
|
|
244
|
+
this.current = null;
|
|
245
|
+
resolve({
|
|
246
|
+
ok: false,
|
|
247
|
+
exitCode: 1,
|
|
248
|
+
stdout: "",
|
|
249
|
+
stderr: "",
|
|
250
|
+
error: error instanceof Error ? error.message : "fast_exec_write_failed",
|
|
251
|
+
fastExecUnavailable: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
} finally {
|
|
256
|
+
this.reserved = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
scheduleIdleClose(idleMs) {
|
|
261
|
+
if (this.busy || !this.child) return;
|
|
262
|
+
clearTimeout(this.idleTimer);
|
|
263
|
+
this.idleTimer = setTimeout(() => {
|
|
264
|
+
if (!this.busy) void this.#terminate();
|
|
265
|
+
}, idleMs);
|
|
266
|
+
this.idleTimer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
close() {
|
|
270
|
+
clearTimeout(this.idleTimer);
|
|
271
|
+
this.idleTimer = null;
|
|
272
|
+
void this.#terminate();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async #ensureStarted() {
|
|
276
|
+
if (this.child && !this.child.killed) return;
|
|
277
|
+
if (this.startPromise) return this.startPromise;
|
|
278
|
+
|
|
279
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
280
|
+
const child = spawn(this.shell.file, this.shell.persistentArgs || [], {
|
|
281
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
282
|
+
windowsHide: true,
|
|
283
|
+
detached: this.platform !== "win32",
|
|
284
|
+
});
|
|
285
|
+
const onError = (error) => {
|
|
286
|
+
cleanup();
|
|
287
|
+
if (this.child === child) this.child = null;
|
|
288
|
+
reject(error);
|
|
289
|
+
};
|
|
290
|
+
const onSpawn = () => {
|
|
291
|
+
cleanup();
|
|
292
|
+
this.child = child;
|
|
293
|
+
child.stdout.setEncoding("utf8");
|
|
294
|
+
child.stderr.setEncoding("utf8");
|
|
295
|
+
child.stdout.on("data", (chunk) => this.#onData("stdout", chunk));
|
|
296
|
+
child.stderr.on("data", (chunk) => this.#onData("stderr", chunk));
|
|
297
|
+
child.stdin.on("error", (error) => this.#onPipeError(child, error));
|
|
298
|
+
child.on("exit", (code, signal) => this.#onExit(child, code, signal));
|
|
299
|
+
child.on("error", () => {});
|
|
300
|
+
resolve();
|
|
301
|
+
};
|
|
302
|
+
const cleanup = () => {
|
|
303
|
+
child.off("error", onError);
|
|
304
|
+
child.off("spawn", onSpawn);
|
|
305
|
+
};
|
|
306
|
+
child.once("error", onError);
|
|
307
|
+
child.once("spawn", onSpawn);
|
|
308
|
+
}).finally(() => {
|
|
309
|
+
this.startPromise = null;
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return this.startPromise;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#onData(stream, chunk) {
|
|
316
|
+
const current = this.current;
|
|
317
|
+
if (!current) return;
|
|
318
|
+
const bufferKey = stream + "Buffer";
|
|
319
|
+
const startedKey = stream + "Started";
|
|
320
|
+
const endedKey = stream + "Ended";
|
|
321
|
+
const outputKey = stream;
|
|
322
|
+
|
|
323
|
+
current[bufferKey] += String(chunk);
|
|
324
|
+
if (!current[startedKey]) {
|
|
325
|
+
const beginIndex = current[bufferKey].indexOf(current.begin);
|
|
326
|
+
if (beginIndex < 0) {
|
|
327
|
+
if (current[bufferKey].length > current.begin.length) {
|
|
328
|
+
current[bufferKey] = current[bufferKey].slice(-current.begin.length);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
current[bufferKey] = current[bufferKey].slice(beginIndex + current.begin.length);
|
|
333
|
+
current[startedKey] = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const endIndex = current[bufferKey].indexOf(current.endPrefix);
|
|
337
|
+
if (endIndex < 0) {
|
|
338
|
+
if (Buffer.byteLength(current[bufferKey], "utf8") > MAX_EXEC_BUFFER + current.endPrefix.length + 32) {
|
|
339
|
+
this.#overflow(stream, current);
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tail = current[bufferKey].slice(endIndex + current.endPrefix.length);
|
|
345
|
+
const suffixIndex = tail.indexOf(current.endSuffix);
|
|
346
|
+
if (suffixIndex < 0) return;
|
|
347
|
+
|
|
348
|
+
current[outputKey] += current[bufferKey].slice(0, endIndex);
|
|
349
|
+
const parsedCode = Number.parseInt(tail.slice(0, suffixIndex), 10);
|
|
350
|
+
if (Number.isInteger(parsedCode)) current.exitCode = parsedCode;
|
|
351
|
+
current[bufferKey] = tail.slice(suffixIndex + current.endSuffix.length);
|
|
352
|
+
current[endedKey] = true;
|
|
353
|
+
|
|
354
|
+
if (Buffer.byteLength(current[outputKey], "utf8") > MAX_EXEC_BUFFER) {
|
|
355
|
+
this.#overflow(stream, current);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.#completeIfDone(current);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#overflow(stream, current) {
|
|
362
|
+
if (this.current !== current) return;
|
|
363
|
+
clearTimeout(current.timer);
|
|
364
|
+
const clipped = clipText(current[stream] + current[stream + "Buffer"], MAX_EXEC_BUFFER);
|
|
365
|
+
current[stream] = clipped.text;
|
|
366
|
+
this.current = null;
|
|
367
|
+
void this.#terminate();
|
|
368
|
+
current.resolve({
|
|
369
|
+
ok: false,
|
|
370
|
+
exitCode: 1,
|
|
371
|
+
stdout: current.stdout,
|
|
372
|
+
stderr: current.stderr,
|
|
373
|
+
error: stream + " maxBuffer exceeded",
|
|
374
|
+
execMode: "persistent-shell",
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#completeIfDone(current) {
|
|
379
|
+
if (this.current !== current || !current.stdoutEnded || !current.stderrEnded) return;
|
|
380
|
+
clearTimeout(current.timer);
|
|
381
|
+
this.current = null;
|
|
382
|
+
current.resolve({
|
|
383
|
+
ok: current.exitCode === 0,
|
|
384
|
+
exitCode: current.exitCode,
|
|
385
|
+
stdout: current.stdout,
|
|
386
|
+
stderr: current.stderr,
|
|
387
|
+
error: current.exitCode === 0 ? null : "Command failed with exit code " + current.exitCode,
|
|
388
|
+
execMode: "persistent-shell",
|
|
389
|
+
shellMs: Math.max(0, Date.now() - current.startedAt),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#onPipeError(child, error) {
|
|
394
|
+
if (this.child !== child) return;
|
|
395
|
+
const current = this.current;
|
|
396
|
+
if (!current) return;
|
|
397
|
+
clearTimeout(current.timer);
|
|
398
|
+
this.current = null;
|
|
399
|
+
void this.#terminate();
|
|
400
|
+
current.resolve({
|
|
401
|
+
ok: false,
|
|
402
|
+
exitCode: 1,
|
|
403
|
+
stdout: current.stdout,
|
|
404
|
+
stderr: current.stderr,
|
|
405
|
+
error: "Persistent shell pipe failed: " + (error instanceof Error ? error.message : String(error)),
|
|
406
|
+
execMode: "persistent-shell",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#onExit(child, code, signal) {
|
|
411
|
+
if (this.child !== child) return;
|
|
412
|
+
this.child = null;
|
|
413
|
+
const current = this.current;
|
|
414
|
+
if (!current) return;
|
|
415
|
+
clearTimeout(current.timer);
|
|
416
|
+
this.current = null;
|
|
417
|
+
current.resolve({
|
|
418
|
+
ok: false,
|
|
419
|
+
exitCode: Number.isInteger(code) ? code : 1,
|
|
420
|
+
stdout: current.stdout,
|
|
421
|
+
stderr: current.stderr,
|
|
422
|
+
error: signal ? "Persistent shell exited with signal " + signal : "Persistent shell exited unexpectedly",
|
|
423
|
+
execMode: "persistent-shell",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async #terminate() {
|
|
428
|
+
const child = this.child;
|
|
429
|
+
this.child = null;
|
|
430
|
+
if (!child) return;
|
|
431
|
+
try { child.stdin?.end(); } catch {}
|
|
432
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
433
|
+
await terminateProcessTree(this.platform, child.pid);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
class PersistentExecPool {
|
|
438
|
+
constructor({ platform, shell, size, idleMs }) {
|
|
439
|
+
this.platform = platform;
|
|
440
|
+
this.shell = shell;
|
|
441
|
+
this.size = size;
|
|
442
|
+
this.idleMs = idleMs;
|
|
443
|
+
this.workers = [];
|
|
444
|
+
this.queue = [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
exec(command, cwd, timeoutMs) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
this.queue.push({ command, cwd, timeoutMs, resolve });
|
|
450
|
+
this.#pump();
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
snapshot() {
|
|
455
|
+
return {
|
|
456
|
+
enabled: true,
|
|
457
|
+
size: this.size,
|
|
458
|
+
workers: this.workers.length,
|
|
459
|
+
busy: this.workers.filter((worker) => worker.busy).length,
|
|
460
|
+
queued: this.queue.length,
|
|
461
|
+
idleMs: this.idleMs,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
close() {
|
|
466
|
+
for (const worker of this.workers) worker.close();
|
|
467
|
+
this.workers.length = 0;
|
|
468
|
+
while (this.queue.length > 0) {
|
|
469
|
+
const job = this.queue.shift();
|
|
470
|
+
job.resolve({ ok: false, exitCode: 1, stdout: "", stderr: "", error: "terminal_closed" });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#pump() {
|
|
475
|
+
while (this.queue.length > 0) {
|
|
476
|
+
let worker = this.workers.find((item) => !item.busy);
|
|
477
|
+
if (!worker && this.workers.length < this.size) {
|
|
478
|
+
worker = new PersistentExecWorker({ platform: this.platform, shell: this.shell });
|
|
479
|
+
this.workers.push(worker);
|
|
480
|
+
}
|
|
481
|
+
if (!worker) return;
|
|
482
|
+
|
|
483
|
+
const job = this.queue.shift();
|
|
484
|
+
worker.run(job.command, job.cwd, job.timeoutMs)
|
|
485
|
+
.then(job.resolve)
|
|
486
|
+
.catch((error) => job.resolve({
|
|
487
|
+
ok: false,
|
|
488
|
+
exitCode: 1,
|
|
489
|
+
stdout: "",
|
|
490
|
+
stderr: "",
|
|
491
|
+
error: error instanceof Error ? error.message : "fast_exec_unavailable",
|
|
492
|
+
fastExecUnavailable: true,
|
|
493
|
+
}))
|
|
494
|
+
.finally(() => {
|
|
495
|
+
worker.scheduleIdleClose(this.idleMs);
|
|
496
|
+
this.#pump();
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
91
502
|
function clipText(value, limit) {
|
|
92
503
|
const text = typeof value === "string" ? value : "";
|
|
93
504
|
if (text.length <= limit) return { text, truncated: false };
|
|
@@ -128,6 +539,28 @@ export class TerminalManager {
|
|
|
128
539
|
1,
|
|
129
540
|
MAX_QUEUED_JOBS,
|
|
130
541
|
);
|
|
542
|
+
this.fastExecEnabled = options.fastExec !== false;
|
|
543
|
+
this.fastExecPoolSize = normalizeInteger(
|
|
544
|
+
options.fastExecPoolSize,
|
|
545
|
+
DEFAULT_FAST_EXEC_POOL_SIZE,
|
|
546
|
+
1,
|
|
547
|
+
MAX_FAST_EXEC_POOL_SIZE,
|
|
548
|
+
);
|
|
549
|
+
this.fastExecIdleMs = normalizeInteger(
|
|
550
|
+
options.fastExecIdleMs,
|
|
551
|
+
DEFAULT_FAST_EXEC_IDLE_MS,
|
|
552
|
+
MIN_FAST_EXEC_IDLE_MS,
|
|
553
|
+
MAX_FAST_EXEC_IDLE_MS,
|
|
554
|
+
);
|
|
555
|
+
this.fastExecPool = this.fastExecEnabled
|
|
556
|
+
? new PersistentExecPool({
|
|
557
|
+
platform: this.platform,
|
|
558
|
+
shell: this.shell,
|
|
559
|
+
size: this.fastExecPoolSize,
|
|
560
|
+
idleMs: this.fastExecIdleMs,
|
|
561
|
+
})
|
|
562
|
+
: null;
|
|
563
|
+
this.fastExecStats = { persistent: 0, isolated: 0, unavailable: 0 };
|
|
131
564
|
}
|
|
132
565
|
|
|
133
566
|
async exec(command, cwd, timeoutMs) {
|
|
@@ -135,6 +568,20 @@ export class TerminalManager {
|
|
|
135
568
|
const resolvedCwd = safeCwd(cwd);
|
|
136
569
|
const timeout = normalizeTimeout(timeoutMs);
|
|
137
570
|
|
|
571
|
+
if (this.fastExecPool && canUsePersistentExec(this.platform, command)) {
|
|
572
|
+
const result = await this.fastExecPool.exec(command, resolvedCwd, timeout);
|
|
573
|
+
if (!result?.fastExecUnavailable) {
|
|
574
|
+
this.fastExecStats.persistent += 1;
|
|
575
|
+
return result;
|
|
576
|
+
}
|
|
577
|
+
this.fastExecStats.unavailable += 1;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this.fastExecStats.isolated += 1;
|
|
581
|
+
return this.#execIsolated(command, resolvedCwd, timeout);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#execIsolated(command, resolvedCwd, timeout) {
|
|
138
585
|
return new Promise((resolve) => {
|
|
139
586
|
execFile(
|
|
140
587
|
this.shell.file,
|
|
@@ -152,12 +599,27 @@ export class TerminalManager {
|
|
|
152
599
|
stdout: stdout ?? "",
|
|
153
600
|
stderr: stderr ?? "",
|
|
154
601
|
error: error ? error.message : null,
|
|
602
|
+
execMode: "isolated",
|
|
155
603
|
});
|
|
156
604
|
},
|
|
157
605
|
);
|
|
158
606
|
});
|
|
159
607
|
}
|
|
160
608
|
|
|
609
|
+
getExecConfig() {
|
|
610
|
+
return {
|
|
611
|
+
enabled: this.fastExecEnabled,
|
|
612
|
+
poolSize: this.fastExecPoolSize,
|
|
613
|
+
idleMs: this.fastExecIdleMs,
|
|
614
|
+
stats: { ...this.fastExecStats },
|
|
615
|
+
pool: this.fastExecPool?.snapshot() || null,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
close() {
|
|
620
|
+
this.fastExecPool?.close();
|
|
621
|
+
}
|
|
622
|
+
|
|
161
623
|
start(command, cwd, observability) {
|
|
162
624
|
validateCommand(command);
|
|
163
625
|
return this.#spawnSession({
|
package/package.json
CHANGED