@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 +230 -0
- package/agent/file-manager.mjs +265 -0
- package/agent/local-agent.mjs +157 -0
- package/agent/process-manager.mjs +69 -0
- package/agent/terminal-manager.mjs +229 -0
- package/bin/chat-relay.js +5 -0
- package/cli/config.mjs +113 -0
- package/cli/device-login.mjs +168 -0
- package/cli/index.mjs +145 -0
- package/cli/remote.mjs +88 -0
- package/package.json +65 -0
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();
|