@anusornneal/chat-relay 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # chat-relay
2
+
3
+ Cloudflare Worker + Durable Objects relay that exposes one or more local Windows agents to ChatGPT through remote MCP.
4
+
5
+ ```
6
+ ChatGPT / MCP client
7
+ |
8
+ | Streamable HTTP MCP
9
+ v
10
+ Cloudflare Worker
11
+ |
12
+ +-- Registry Durable Object (users, agents, grants)
13
+ |
14
+ +-- Relay Durable Object per agent
15
+ |
16
+ | WebSocket
17
+ v
18
+ Local Agent
19
+ - terminal
20
+ - files
21
+ - processes
22
+ ```
23
+
24
+ The relay is generic and has no Devflow-specific logic.
25
+
26
+ ## Zero-checkout CLI
27
+
28
+ End users do not need this repository. After the npm package is published, the normal command is:
29
+
30
+ ```bash
31
+ npx @anusornneal/chat-relay@latest remote
32
+ ```
33
+
34
+ First run starts a device-login flow, opens the browser, and shows a short code. The browser page signs in with a Chat Relay login/password; if that login does not exist yet, it creates the account. After approval the CLI receives scoped user-session and agent credentials, stores them outside the project, and connects the local agent.
35
+
36
+ Subsequent runs reuse the local credentials:
37
+
38
+ ```bash
39
+ npx @anusornneal/chat-relay@latest remote
40
+ ```
41
+
42
+ Other commands:
43
+
44
+ ```bash
45
+ npx @anusornneal/chat-relay@latest login
46
+ npx @anusornneal/chat-relay@latest status
47
+ npx @anusornneal/chat-relay@latest logout
48
+ ```
49
+
50
+ On Windows the config defaults to `%LOCALAPPDATA%\chat-relay\config.json`. On macOS/Linux it uses `$XDG_CONFIG_HOME/chat-relay/config.json` or `~/.config/chat-relay/config.json`. Set `CHAT_RELAY_HOME` to override the location.
51
+
52
+ Useful options:
53
+
54
+ ```bash
55
+ chat-relay remote --root "C:\Users\you\Projects"
56
+ chat-relay login --name "Work PC"
57
+ chat-relay login --no-open
58
+ ```
59
+
60
+ The published package locates its bundled local agent relative to the package itself, so commands work from any working directory. Existing repository users can still run `npm start`; legacy `.dev.vars` credentials are migrated once into the user-level config.
61
+
62
+ ## Authentication and multi-user model
63
+
64
+ - Browser/device login uses a short-lived device code. Raw user or admin tokens are not typed into the CLI.
65
+ - New accounts use a unique login plus password. Passwords are stored only as salted PBKDF2-SHA256 hashes.
66
+ - Device start/approval requests and failed password attempts are rate-limited.
67
+ - CLI user sessions are opaque random tokens stored server-side only as hashes and expire after 90 days.
68
+ - Re-authentication revokes the previous CLI session when possible.
69
+ - Each local machine has an independent `agentId` and agent token; agent tokens are stored server-side only as hashes.
70
+ - Agent ownership is enforced before an existing machine identity can be reused, preventing shared users from rotating another owner's agent credential.
71
+ - Grants map users to agents with scopes: `read`, `write`, `terminal`, `process`, or `*`.
72
+ - Logging out revokes the local user session and the owning machine credential.
73
+ - `ADMIN_TOKEN` remains separate and protects administration routes.
74
+ - The existing legacy owner token remains supported so current ChatGPT MCP URLs continue to work during migration.
75
+
76
+ ChatGPT MCP supports OAuth 2.1 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 supported during migration. Browser/device login is for the zero-checkout local CLI and creates the same registry user/agent model used by OAuth.
77
+
78
+ ## MCP tools
79
+
80
+ Identity and agent routing:
81
+ - `whoami`
82
+ - `list_agents`
83
+ - `ping_agent`
84
+ - `get_config`
85
+ - `get_recent_tool_calls`
86
+
87
+ Filesystem:
88
+ - `stat_path`
89
+ - `list_directory`
90
+ - `read_file`
91
+ - `read_multiple_files`
92
+ - `start_search`
93
+ - `get_more_search_results`
94
+ - `write_file`
95
+ - `edit_block`
96
+ - `create_directory`
97
+ - `move_path`
98
+ - `delete_path`
99
+
100
+ Processes:
101
+ - `list_processes`
102
+ - `kill_process`
103
+
104
+ Terminal:
105
+ - `terminal_exec`
106
+ - `terminal_start`
107
+ - `terminal_start_shell`
108
+ - `terminal_read`
109
+ - `terminal_write`
110
+ - `terminal_list`
111
+ - `terminal_kill`
112
+
113
+ Desktop-Commander-compatible aliases:
114
+ - `start_process`
115
+ - `read_process_output`
116
+ - `interact_with_process`
117
+ - `list_sessions`
118
+ - `force_terminate`
119
+
120
+ ## Administration
121
+
122
+ Administration is intentionally separate from MCP. Set the Cloudflare `ADMIN_TOKEN` secret, keep the same value in the local ignored `.dev.vars` on the administrator machine, and use:
123
+
124
+ ```
125
+ npm run admin -- state
126
+ npm run admin -- bootstrap default "Primary PC"
127
+ npm run admin -- create-user "Alice"
128
+ npm run admin -- create-agent "Work Laptop" work-laptop
129
+ npm run admin -- grant <userId> <agentId> read,write,terminal,process
130
+ npm run admin -- revoke <userId> <agentId>
131
+ npm run admin -- enable-user <userId> false
132
+ npm run admin -- enable-agent <agentId> false
133
+ npm run admin -- rotate-user <userId>
134
+ npm run admin -- rotate-agent <agentId>
135
+ ```
136
+
137
+ Create and rotate commands return the new raw token once. Store it on the corresponding client/agent; the registry retains only its hash.
138
+
139
+ The initial `bootstrap` migrates the legacy `CALLER_TOKEN` and `AGENT_TOKEN` secrets into an owner user and the default agent so an existing installation can upgrade without changing its current ChatGPT URL or local agent token.
140
+
141
+ ## Worker routes
142
+
143
+ | Route | Authentication | Purpose |
144
+ | --- | --- | --- |
145
+ | `GET /health` | none | Worker health |
146
+ | `GET /.well-known/oauth-protected-resource[/mcp]` | none | MCP protected-resource metadata |
147
+ | `GET /.well-known/oauth-authorization-server` | none | OAuth authorization-server metadata |
148
+ | `POST /register` | none | Dynamic registration for public PKCE clients |
149
+ | `GET/POST /authorize` | login/password | OAuth authorization-code sign-in |
150
+ | `POST /token` | public client + PKCE/refresh token | Access/refresh token exchange |
151
+ | `POST /auth/device/start` | none | Start CLI device authorization |
152
+ | `GET /device?user_code=<code>` | none | Browser sign-in/approval page |
153
+ | `POST /auth/device/approve` | login/password + device code | Approve or create a user account |
154
+ | `POST /auth/device/token` | device code | Exchange approved device code for session/agent credentials |
155
+ | `GET /auth/me` | user Bearer token | Current user and permitted agents |
156
+ | `POST /auth/session/revoke` | user Bearer token | Revoke only the current user session |
157
+ | `POST /auth/logout` | user Bearer token | Revoke local session and owning agent credential |
158
+ | `POST /mcp?key=<user token>` | user token | Streamable HTTP MCP |
159
+ | `GET /agent?agentId=<id>` | agent Bearer token | Local agent WebSocket |
160
+ | `GET /status?agentId=<id>` | user token | Agent online status |
161
+ | `POST /relay?agentId=<id>` | user token | Direct JSON relay with scope checks |
162
+ | `/admin/*` | admin Bearer token | User/agent/grant administration |
163
+
164
+ ## Runtime limits and controls
165
+
166
+ - Relay request/response message: 64 KiB.
167
+ - One-shot terminal command: max 20 seconds.
168
+ - Persistent terminal sessions: up to 8 running sessions per local agent.
169
+ - Terminal output buffer: bounded in memory; completed sessions retained for 30 minutes.
170
+ - Filesystem reads/writes are bounded and restricted to configured `ALLOWED_ROOTS`.
171
+ - Search skips common heavy directories such as `.git`, `node_modules`, `.gradle`, `.idea`, and `.wrangler`.
172
+ - The agent blocks a small set of high-risk system-management commands. This is defense in depth, not a security sandbox.
173
+
174
+ ## Tests
175
+
176
+ The legacy relay integration test remains available:
177
+
178
+ ```
179
+ npm run dev:test
180
+ python test/integration.py
181
+ ```
182
+
183
+ The full MCP/multi-user smoke test expects a local Worker on port 8795 plus an attached local agent:
184
+
185
+ ```
186
+ node test/multiuser-smoke.mjs
187
+ ```
188
+
189
+ It verifies the MCP tool surface, agent routing, filesystem operations, search sessions, process listing, persistent terminals, scope enforcement, multi-agent selection, and token rotation.
190
+
191
+ Device/browser login is covered by:
192
+
193
+ ```bash
194
+ TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:device-auth
195
+ ```
196
+
197
+ OAuth discovery, DCR, PKCE, token exchange, refresh rotation, audience binding, MCP Bearer auth, and legacy-key compatibility are covered by:
198
+
199
+ ```bash
200
+ TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:oauth
201
+ ```
202
+
203
+ After `npm pack`, the zero-checkout package smoke test installs and runs the tarball from a temporary directory:
204
+
205
+ ```bash
206
+ CHAT_RELAY_TARBALL=<path-to-tgz> TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:zero-checkout
207
+ ```
208
+
209
+ The npm package is configured as `@anusornneal/chat-relay`. Publishing requires an authenticated npm account with access to that scope.
210
+
211
+ ## CI and npm publishing
212
+
213
+ GitHub Actions runs package verification on Node 20 and Node 24. A separate integration job starts a local Worker and verifies device auth, OAuth, and the zero-checkout tarball flow.
214
+
215
+ Before publishing locally:
216
+
217
+ ```bash
218
+ npm run verify:publish
219
+ ```
220
+
221
+ This checks the npm file set, CLI entrypoint, Worker dry-run build, and production dependency audit. The package whitelist excludes .dev.vars, Worker source, tests, and repository-only files.
222
+
223
+ The repository also includes a Publish npm workflow for version tags or manual dispatch. It supports npm trusted publishing through GitHub OIDC and can also use an NPM_TOKEN repository secret when configured.
224
+
225
+ The first registry publish still requires npm authorization for the @anusornneal scope. After publishing, verify the exact public UX from a clean directory:
226
+
227
+ ```bash
228
+ npx @anusornneal/chat-relay@latest status
229
+ npx @anusornneal/chat-relay@latest remote
230
+ ```
@@ -0,0 +1,265 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ const MAX_READ_BYTES = 256 * 1024;
6
+ const MAX_WRITE_BYTES = 256 * 1024;
7
+ const MAX_SEARCH_FILES = 5000;
8
+ const SKIP_DIRS = new Set([".git", "node_modules", ".gradle", ".idea", ".wrangler"]);
9
+
10
+ function splitRoots(spec) {
11
+ if (!spec) return [process.cwd()];
12
+ return spec.split(";").map((value) => value.trim()).filter(Boolean).map((value) => path.resolve(value));
13
+ }
14
+
15
+ function within(root, target) {
16
+ const relative = path.relative(root, target);
17
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
18
+ }
19
+
20
+ async function existingCanonical(target) {
21
+ return fs.realpath(target);
22
+ }
23
+
24
+ export class FileManager {
25
+ constructor(rootSpec) {
26
+ this.roots = splitRoots(rootSpec);
27
+ this.searches = new Map();
28
+ }
29
+
30
+ getRoots() {
31
+ return [...this.roots];
32
+ }
33
+
34
+ async #resolveExisting(input) {
35
+ const target = path.resolve(String(input));
36
+ const canonical = await existingCanonical(target);
37
+ for (const root of this.roots) {
38
+ const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
39
+ if (within(canonicalRoot, canonical)) return canonical;
40
+ }
41
+ throw new Error("path_not_allowed");
42
+ }
43
+
44
+ async #resolveTarget(input) {
45
+ const target = path.resolve(String(input));
46
+ const parent = await existingCanonical(path.dirname(target));
47
+ for (const root of this.roots) {
48
+ const canonicalRoot = await existingCanonical(root).catch(() => path.resolve(root));
49
+ if (within(canonicalRoot, parent)) return target;
50
+ }
51
+ throw new Error("path_not_allowed");
52
+ }
53
+
54
+ async stat(input) {
55
+ const target = await this.#resolveExisting(input);
56
+ const info = await fs.stat(target);
57
+ return {
58
+ path: target,
59
+ type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
60
+ size: info.size,
61
+ modifiedAt: info.mtime.toISOString(),
62
+ createdAt: info.birthtime.toISOString(),
63
+ };
64
+ }
65
+
66
+ async list(input, depth = 1) {
67
+ const target = await this.#resolveExisting(input);
68
+ const maxDepth = Math.min(Math.max(Number(depth) || 1, 0), 3);
69
+ const entries = [];
70
+ await this.#walkList(target, 0, maxDepth, entries);
71
+ return { path: target, entries };
72
+ }
73
+
74
+ async #walkList(current, level, maxDepth, output) {
75
+ const items = await fs.readdir(current, { withFileTypes: true });
76
+ items.sort((a, b) => a.name.localeCompare(b.name));
77
+ for (const item of items) {
78
+ const fullPath = path.join(current, item.name);
79
+ let size = null;
80
+ if (item.isFile()) size = (await fs.stat(fullPath)).size;
81
+ output.push({
82
+ path: fullPath,
83
+ name: item.name,
84
+ type: item.isDirectory() ? "directory" : item.isFile() ? "file" : "other",
85
+ size,
86
+ });
87
+ if (output.length >= 1000) return;
88
+ if (item.isDirectory() && level < maxDepth && !SKIP_DIRS.has(item.name)) {
89
+ await this.#walkList(fullPath, level + 1, maxDepth, output);
90
+ if (output.length >= 1000) return;
91
+ }
92
+ }
93
+ }
94
+
95
+ async read(input, offset = 0, length = 200) {
96
+ const target = await this.#resolveExisting(input);
97
+ const info = await fs.stat(target);
98
+ if (!info.isFile()) throw new Error("not_a_file");
99
+ if (info.size > MAX_READ_BYTES * 8) throw new Error("file_too_large");
100
+
101
+ const text = await fs.readFile(target, "utf8");
102
+ const lines = text.split(/\r?\n/);
103
+ const start = Math.max(Number(offset) || 0, 0);
104
+ const count = Math.min(Math.max(Number(length) || 200, 1), 1000);
105
+ return {
106
+ path: target,
107
+ offset: start,
108
+ length: Math.min(count, Math.max(lines.length - start, 0)),
109
+ totalLines: lines.length,
110
+ content: lines.slice(start, start + count).join("\n"),
111
+ };
112
+ }
113
+
114
+ async readMany(paths) {
115
+ if (!Array.isArray(paths) || paths.length === 0 || paths.length > 20) {
116
+ throw new Error("invalid_paths");
117
+ }
118
+ const files = [];
119
+ for (const input of paths) {
120
+ try {
121
+ files.push({ ok: true, ...(await this.read(input, 0, 1000)) });
122
+ } catch (error) {
123
+ files.push({ ok: false, path: String(input), error: error instanceof Error ? error.message : "read_failed" });
124
+ }
125
+ }
126
+ return { files };
127
+ }
128
+
129
+ async write(input, content, mode = "rewrite") {
130
+ if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
131
+ throw new Error("content_too_large");
132
+ }
133
+ const target = await this.#resolveTarget(input);
134
+ if (mode === "append") await fs.appendFile(target, content, "utf8");
135
+ else if (mode === "rewrite") await fs.writeFile(target, content, "utf8");
136
+ else throw new Error("invalid_write_mode");
137
+ return this.stat(target);
138
+ }
139
+
140
+ async edit(input, oldText, newText, expectedReplacements = 1) {
141
+ if (typeof oldText !== "string" || oldText.length === 0 || typeof newText !== "string") {
142
+ throw new Error("invalid_edit");
143
+ }
144
+ const target = await this.#resolveExisting(input);
145
+ const text = await fs.readFile(target, "utf8");
146
+ const parts = text.split(oldText);
147
+ const matches = parts.length - 1;
148
+ const expected = Number(expectedReplacements) || 1;
149
+ if (matches !== expected) {
150
+ throw new Error(`replacement_count_mismatch:${matches}`);
151
+ }
152
+ const updated = parts.join(newText);
153
+ if (Buffer.byteLength(updated, "utf8") > MAX_WRITE_BYTES * 8) throw new Error("file_too_large");
154
+ await fs.writeFile(target, updated, "utf8");
155
+ return { path: target, replacements: matches };
156
+ }
157
+
158
+ async mkdir(input) {
159
+ const target = await this.#resolveTarget(input);
160
+ await fs.mkdir(target, { recursive: true });
161
+ return this.stat(target);
162
+ }
163
+
164
+ async move(source, destination) {
165
+ const from = await this.#resolveExisting(source);
166
+ const to = await this.#resolveTarget(destination);
167
+ await fs.rename(from, to);
168
+ return { from, to };
169
+ }
170
+
171
+ async remove(input, recursive = false) {
172
+ const target = await this.#resolveExisting(input);
173
+ if (this.roots.some((root) => path.resolve(root) === path.resolve(target))) {
174
+ throw new Error("cannot_delete_root");
175
+ }
176
+ const info = await fs.stat(target);
177
+ if (info.isDirectory()) await fs.rm(target, { recursive: Boolean(recursive), force: false });
178
+ else await fs.unlink(target);
179
+ return { ok: true, path: target };
180
+ }
181
+
182
+ async startSearch(input, pattern, searchType = "files", maxResults = 100) {
183
+ const target = await this.#resolveExisting(input);
184
+ if (typeof pattern !== "string" || pattern.length === 0 || pattern.length > 300) {
185
+ throw new Error("invalid_pattern");
186
+ }
187
+ if (!["files", "content"].includes(searchType)) throw new Error("invalid_search_type");
188
+
189
+ const sessionId = randomUUID();
190
+ const session = {
191
+ id: sessionId,
192
+ status: "RUNNING",
193
+ createdAt: Date.now(),
194
+ results: [],
195
+ error: null,
196
+ };
197
+ this.searches.set(sessionId, session);
198
+
199
+ try {
200
+ const limit = Math.min(Math.max(Number(maxResults) || 100, 1), 500);
201
+ const needle = pattern.toLowerCase();
202
+ let scanned = 0;
203
+
204
+ const walk = async (current) => {
205
+ if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) return;
206
+ const items = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
207
+ for (const item of items) {
208
+ if (session.results.length >= limit || scanned >= MAX_SEARCH_FILES) break;
209
+ if (item.isDirectory() && SKIP_DIRS.has(item.name)) continue;
210
+ const fullPath = path.join(current, item.name);
211
+ scanned++;
212
+
213
+ if (searchType === "files" && item.name.toLowerCase().includes(needle)) {
214
+ session.results.push({ path: fullPath, name: item.name, type: item.isDirectory() ? "directory" : "file" });
215
+ } else if (searchType === "content" && item.isFile()) {
216
+ const info = await fs.stat(fullPath).catch(() => null);
217
+ if (info && info.size <= 1024 * 1024) {
218
+ const text = await fs.readFile(fullPath, "utf8").catch(() => null);
219
+ if (text != null) {
220
+ const index = text.toLowerCase().indexOf(needle);
221
+ if (index >= 0) {
222
+ const before = text.slice(0, index);
223
+ const line = before.split(/\r?\n/).length;
224
+ session.results.push({ path: fullPath, line, preview: text.slice(index, index + 240) });
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ if (item.isDirectory()) await walk(fullPath);
231
+ }
232
+ };
233
+
234
+ await walk(target);
235
+ session.status = "COMPLETED";
236
+ session.scanned = scanned;
237
+ } catch (error) {
238
+ session.status = "ERROR";
239
+ session.error = error instanceof Error ? error.message : "search_failed";
240
+ }
241
+
242
+ return {
243
+ sessionId,
244
+ status: session.status,
245
+ totalResults: session.results.length,
246
+ scanned: session.scanned ?? 0,
247
+ error: session.error,
248
+ };
249
+ }
250
+
251
+ getSearchResults(sessionId, offset = 0, length = 50) {
252
+ const session = this.searches.get(String(sessionId));
253
+ if (!session) throw new Error("search_not_found");
254
+ const start = Math.max(Number(offset) || 0, 0);
255
+ const count = Math.min(Math.max(Number(length) || 50, 1), 100);
256
+ return {
257
+ sessionId: session.id,
258
+ status: session.status,
259
+ totalResults: session.results.length,
260
+ offset: start,
261
+ results: session.results.slice(start, start + count),
262
+ error: session.error,
263
+ };
264
+ }
265
+ }
@@ -0,0 +1,157 @@
1
+ import WebSocket from "ws";
2
+ import { FileManager } from "./file-manager.mjs";
3
+ import { ProcessManager } from "./process-manager.mjs";
4
+ import { TerminalManager } from "./terminal-manager.mjs";
5
+
6
+ const relayUrl = process.env.RELAY_URL;
7
+ const agentToken = process.env.AGENT_TOKEN;
8
+ const agentId = process.env.AGENT_ID || "default";
9
+ const agentName = process.env.AGENT_NAME || agentId;
10
+ const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
11
+ const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
12
+ const terminals = new TerminalManager();
13
+ const files = new FileManager(process.env.ALLOWED_ROOTS);
14
+ const processes = new ProcessManager();
15
+ const recentCalls = [];
16
+
17
+ if (!relayUrl || !agentToken) {
18
+ console.error("RELAY_URL and AGENT_TOKEN are required");
19
+ process.exit(1);
20
+ }
21
+
22
+ const wsUrl = relayUrl.replace(/^http/, "ws").replace(/\/$/, "") +
23
+ `/agent?agentId=${encodeURIComponent(agentId)}`;
24
+
25
+ async function handlePayload(payload) {
26
+ if (!payload || typeof payload !== "object") return payload;
27
+
28
+ switch (payload.action) {
29
+ case "ping":
30
+ return { ok: true, action: "pong", at: new Date().toISOString(), agentId, agentName };
31
+
32
+ case "agent.config":
33
+ return processes.info({
34
+ agentId,
35
+ agentName,
36
+ terminalEnabled,
37
+ allowedRoots: files.getRoots(),
38
+ reconnectMs,
39
+ });
40
+ case "agent.recentCalls":
41
+ return recentCalls.slice(-(Math.min(Math.max(Number(payload.limit) || 50, 1), 100)));
42
+
43
+ case "fs.stat":
44
+ return files.stat(payload.path);
45
+ case "fs.list":
46
+ return files.list(payload.path, payload.depth);
47
+ case "fs.read":
48
+ return files.read(payload.path, payload.offset, payload.length);
49
+ case "fs.readMany":
50
+ return files.readMany(payload.paths);
51
+ case "fs.write":
52
+ return files.write(payload.path, payload.content, payload.mode);
53
+ case "fs.edit":
54
+ return files.edit(payload.path, payload.oldText, payload.newText, payload.expectedReplacements);
55
+ case "fs.mkdir":
56
+ return files.mkdir(payload.path);
57
+ case "fs.move":
58
+ return files.move(payload.source, payload.destination);
59
+ case "fs.delete":
60
+ return files.remove(payload.path, payload.recursive);
61
+ case "fs.search.start":
62
+ return files.startSearch(payload.path, payload.pattern, payload.searchType, payload.maxResults);
63
+ case "fs.search.results":
64
+ return files.getSearchResults(payload.sessionId, payload.offset, payload.length);
65
+
66
+ case "process.list":
67
+ return processes.list(payload.filter);
68
+ case "process.kill":
69
+ return processes.kill(payload.pid);
70
+
71
+ case "terminal.exec":
72
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
73
+ return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
74
+ case "terminal.start":
75
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
76
+ return terminals.start(payload.command, payload.cwd);
77
+ case "terminal.shell.start":
78
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
79
+ return terminals.startShell(payload.cwd);
80
+ case "terminal.read":
81
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
82
+ return terminals.read(payload.sessionId, payload.afterSeq, payload.maxChars);
83
+ case "terminal.write":
84
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
85
+ return terminals.write(payload.sessionId, payload.input, payload.appendNewline !== false);
86
+ case "terminal.kill":
87
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
88
+ return terminals.kill(payload.sessionId);
89
+ case "terminal.list":
90
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
91
+ return terminals.list();
92
+
93
+ default:
94
+ return { ok: false, error: "unknown_action" };
95
+ }
96
+ }
97
+
98
+ function connect() {
99
+ console.log(`Connecting to ${wsUrl}`);
100
+ console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
101
+ const socket = new WebSocket(wsUrl, {
102
+ headers: { Authorization: `Bearer ${agentToken}` },
103
+ });
104
+
105
+ socket.on("open", () => console.log("Agent connected"));
106
+
107
+ socket.on("message", async (raw) => {
108
+ let message;
109
+ try {
110
+ message = JSON.parse(raw.toString());
111
+ } catch {
112
+ return;
113
+ }
114
+
115
+ if (!message || typeof message.requestId !== "string") return;
116
+
117
+ const startedAt = Date.now();
118
+ const action = message.payload && typeof message.payload === "object"
119
+ ? String(message.payload.action ?? "unknown")
120
+ : "unknown";
121
+ try {
122
+ const result = await handlePayload(message.payload);
123
+ recentCalls.push({
124
+ at: new Date().toISOString(),
125
+ action,
126
+ durationMs: Date.now() - startedAt,
127
+ ok: !(result && typeof result === "object" && result.ok === false),
128
+ });
129
+ if (recentCalls.length > 100) recentCalls.shift();
130
+ socket.send(JSON.stringify({ requestId: message.requestId, payload: result }));
131
+ } catch (error) {
132
+ recentCalls.push({
133
+ at: new Date().toISOString(),
134
+ action,
135
+ durationMs: Date.now() - startedAt,
136
+ ok: false,
137
+ error: error instanceof Error ? error.message : "agent_error",
138
+ });
139
+ if (recentCalls.length > 100) recentCalls.shift();
140
+ socket.send(JSON.stringify({
141
+ requestId: message.requestId,
142
+ payload: { ok: false, error: error instanceof Error ? error.message : "agent_error" },
143
+ }));
144
+ }
145
+ });
146
+
147
+ socket.on("close", (code, reason) => {
148
+ console.log(`Agent disconnected (${code}) ${reason.toString()}`);
149
+ setTimeout(connect, reconnectMs);
150
+ });
151
+
152
+ socket.on("error", (error) => {
153
+ console.error("WebSocket error:", error.message);
154
+ });
155
+ }
156
+
157
+ connect();