@hera-al/standardnode 1.0.1
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/LICENSE +21 -0
- package/README.md +241 -0
- package/config.stdnode.example.yaml +44 -0
- package/dist/commands/browser-proxy.d.ts +22 -0
- package/dist/commands/browser-proxy.js +164 -0
- package/dist/commands/shell.d.ts +25 -0
- package/dist/commands/shell.js +62 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +141 -0
- package/dist/gateway-link.d.ts +30 -0
- package/dist/gateway-link.js +315 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +170 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +64 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TGP / Hera Artificial Life
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# @hera-al/standardnode
|
|
2
|
+
|
|
3
|
+
> **Part of [Hera Artificial Life](https://github.com/hera-artificial-life/hera-al)** — an opinionated AI assistant platform that runs locally on your machine. This package is the remote execution agent that connects your machines to the Hera gateway.
|
|
4
|
+
|
|
5
|
+
Lightweight remote execution node that connects to a Hera gateway via WebSocket. Once paired, it can execute shell commands and proxy browser automation requests dispatched by the central server.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **WebSocket connection** to Hera gateway (Nostromo) with automatic reconnect
|
|
10
|
+
- **Signature-based pairing** — each node has a unique cryptographic identity, approved by an admin
|
|
11
|
+
- **Shell execution** — run commands remotely with optional allowlist and timeout
|
|
12
|
+
- **Browser proxy** — forward requests to a local [`@hera-al/browser-server`](https://www.npmjs.com/package/@hera-al/browser-server) instance (optional)
|
|
13
|
+
- **Auto-detection** — discovers the gateway URL automatically when running inside the Hera project
|
|
14
|
+
- **YAML configuration** — auto-generated on first run, CLI flags override and persist
|
|
15
|
+
- **File logging** — append-only with automatic rotation at 10 MB
|
|
16
|
+
- **Multi-platform** — macOS, Linux, Windows (Node.js ≥ 18)
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @hera-al/standardnode
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### With browser automation (optional)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @hera-al/standardnode @hera-al/browser-server
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
### CLI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx hera-stdnode --ws ws://localhost:3001/nostromo/ws/nodes
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
All CLI flags:
|
|
39
|
+
|
|
40
|
+
| Flag | Default | Description |
|
|
41
|
+
| ---------- | ---------------------- | ------------------------------------ |
|
|
42
|
+
| `--ws` | *(auto-detected)* | WebSocket URL of the Hera gateway |
|
|
43
|
+
| `--name` | *(OS hostname)* | Display name for this node |
|
|
44
|
+
| `--config` | `config.stdnode.yaml` | Path to configuration file |
|
|
45
|
+
| `--init` | — | Copy example config to current directory and exit |
|
|
46
|
+
| `--help` | — | Show help message |
|
|
47
|
+
|
|
48
|
+
Values passed via CLI are persisted to the config file.
|
|
49
|
+
|
|
50
|
+
### First run
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Option A: just launch — config is auto-generated with sensible defaults
|
|
54
|
+
hera-stdnode --ws wss://yourserver:3001/nostromo/ws/nodes
|
|
55
|
+
|
|
56
|
+
# Option B: generate a commented config first, edit it, then launch
|
|
57
|
+
hera-stdnode --init
|
|
58
|
+
# → creates config.stdnode.yaml in the current directory
|
|
59
|
+
vi config.stdnode.yaml
|
|
60
|
+
hera-stdnode
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### From source
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone <repo>
|
|
67
|
+
cd standardnode
|
|
68
|
+
npm install
|
|
69
|
+
npm start -- --ws ws://localhost:3001/nostromo/ws/nodes
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## WebSocket URL format
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
ws[s]://<hostname>:<port><basePath>/ws/nodes
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| Segment | Description | Default |
|
|
79
|
+
| ------------ | ----------------------------------------------- | ------------ |
|
|
80
|
+
| `<hostname>` | Server address or Tailscale DNS name | `localhost` |
|
|
81
|
+
| `<port>` | Nostromo UI port configured on the Hera server | `3001` |
|
|
82
|
+
| `<basePath>` | Nostromo base path configured on the Hera server| `/nostromo` |
|
|
83
|
+
|
|
84
|
+
Use `wss://` when connecting through Tailscale Serve or any TLS-terminated proxy.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
On first run a `config.stdnode.yaml` is created with a unique node ID and cryptographic signature. You can also copy the bundled `config.stdnode.example.yaml` as a starting point.
|
|
89
|
+
|
|
90
|
+
### node
|
|
91
|
+
|
|
92
|
+
| Key | Type | Default | Description |
|
|
93
|
+
| ------------- | ------ | -------------- | ------------------------------------ |
|
|
94
|
+
| `id` | string | *(generated)* | Unique node identifier (UUID) |
|
|
95
|
+
| `displayName` | string | *(hostname)* | Human-readable name shown in Nostromo|
|
|
96
|
+
|
|
97
|
+
### gateway
|
|
98
|
+
|
|
99
|
+
| Key | Type | Default | Description |
|
|
100
|
+
| ------------- | ------- | -------------------------------------- | -------------------------------- |
|
|
101
|
+
| `enabled` | boolean | `true` | Enable gateway connection |
|
|
102
|
+
| `url` | string | `ws://localhost:3001/nostromo/ws/nodes` | WebSocket endpoint |
|
|
103
|
+
| `token` | string | `""` | Authentication token (reserved) |
|
|
104
|
+
| `signature` | string | *(generated)* | Node signature for pairing |
|
|
105
|
+
| `reconnectMs` | number | `5000` | Reconnect delay in milliseconds |
|
|
106
|
+
|
|
107
|
+
### commands.shell
|
|
108
|
+
|
|
109
|
+
| Key | Type | Default | Description |
|
|
110
|
+
| ----------- | -------- | ------- | --------------------------------------------- |
|
|
111
|
+
| `enabled` | boolean | `true` | Enable shell command execution |
|
|
112
|
+
| `allowlist` | string[] | `[]` | If non-empty, only these commands are allowed |
|
|
113
|
+
| `timeout` | number | `30000` | Command timeout in milliseconds |
|
|
114
|
+
|
|
115
|
+
### browser
|
|
116
|
+
|
|
117
|
+
| Key | Type | Default | Description |
|
|
118
|
+
| ---------------- | -------- | ------- | ---------------------------------------------- |
|
|
119
|
+
| `enabled` | boolean | `false` | Enable browser automation proxy |
|
|
120
|
+
| `controlPort` | number | `3002` | Port for the browser HTTP control server |
|
|
121
|
+
| `headless` | boolean | `false` | Run Chrome in headless mode |
|
|
122
|
+
| `noSandbox` | boolean | `false` | Disable Chrome sandbox (CI/Docker) |
|
|
123
|
+
| `attachOnly` | boolean | `false` | Only attach to an already-running Chrome |
|
|
124
|
+
| `executablePath` | string | — | Custom Chrome/Brave/Edge executable path |
|
|
125
|
+
| `allowProfiles` | string[] | `[]` | If non-empty, only these profiles are allowed |
|
|
126
|
+
|
|
127
|
+
### logs
|
|
128
|
+
|
|
129
|
+
| Key | Type | Default | Description |
|
|
130
|
+
| ----- | ------ | ---------------- | ------------------------------ |
|
|
131
|
+
| `dir` | string | `./logs-stdnode` | Directory for log files |
|
|
132
|
+
|
|
133
|
+
Log files are written as `stdnode.log` with rotation at 10 MB (up to 9 rotated files).
|
|
134
|
+
|
|
135
|
+
## Pairing flow
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
┌──────────┐ ┌──────────┐ ┌───────────┐
|
|
139
|
+
│ Node │──hello──▶│ Nostromo │ │ Admin │
|
|
140
|
+
│ │ │ (gateway)│◀─approve─│ (web UI) │
|
|
141
|
+
│ │◀─status─│ │ │ │
|
|
142
|
+
└──────────┘ └──────────┘ └───────────┘
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
1. Node connects and sends a `hello` message with ID, signature, and capabilities
|
|
146
|
+
2. Nostromo shows the node as **pending** in the admin UI
|
|
147
|
+
3. An admin approves (or revokes) the node
|
|
148
|
+
4. Once approved, the node starts receiving and executing commands
|
|
149
|
+
5. If the connection drops, the node reconnects automatically after `reconnectMs`
|
|
150
|
+
|
|
151
|
+
## Supported commands
|
|
152
|
+
|
|
153
|
+
| Command | Description |
|
|
154
|
+
| --------------- | --------------------------------------------------------- |
|
|
155
|
+
| `shell.run` | Execute a shell command (`cmd` + `args` array) |
|
|
156
|
+
| `shell.which` | Resolve a binary path |
|
|
157
|
+
| `browser.proxy` | Forward an HTTP request to the local browser-server |
|
|
158
|
+
|
|
159
|
+
### shell.run
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"cmd": "/usr/bin/ls",
|
|
164
|
+
"args": ["-la", "/tmp"],
|
|
165
|
+
"cwd": "/home/user",
|
|
166
|
+
"timeout": 10000,
|
|
167
|
+
"env": { "NODE_ENV": "production" }
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Returns `{ stdout, stderr, exitCode }`.
|
|
172
|
+
|
|
173
|
+
> **Security:** Commands are spawned with `shell: false` — no shell injection possible. Use the `allowlist` to restrict which binaries can be executed.
|
|
174
|
+
|
|
175
|
+
### browser.proxy
|
|
176
|
+
|
|
177
|
+
Requires `@hera-al/browser-server` installed and `browser.enabled: true` in config.
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"method": "GET",
|
|
182
|
+
"path": "/snapshot",
|
|
183
|
+
"query": { "mode": "text" },
|
|
184
|
+
"profile": "default",
|
|
185
|
+
"timeoutMs": 30000
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Returns the browser-server response. File results (screenshots, PDFs) are automatically base64-encoded.
|
|
190
|
+
|
|
191
|
+
## Architecture
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
┌─────────────────────────────────────────────────┐
|
|
195
|
+
│ Hera Gateway (Nostromo) │
|
|
196
|
+
└────────────────────┬────────────────────────────┘
|
|
197
|
+
│ WebSocket
|
|
198
|
+
┌──────────┴──────────┐
|
|
199
|
+
│ StandardNode │
|
|
200
|
+
│ ┌────────────────┐ │
|
|
201
|
+
│ │ Gateway Link │ │ ← connection, pairing, heartbeat
|
|
202
|
+
│ └───────┬────────┘ │
|
|
203
|
+
│ ┌─────┴─────┐ │
|
|
204
|
+
│ │ │ │
|
|
205
|
+
│ Shell Browser │
|
|
206
|
+
│ Runner Proxy │
|
|
207
|
+
│ │ │ │
|
|
208
|
+
│ ▼ ▼ │
|
|
209
|
+
│ spawn() fetch() │ ← local processes / browser-server
|
|
210
|
+
└─────────────────────┘
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- **Gateway Link** — WebSocket client with reconnect, heartbeat (30s), and command dispatch
|
|
214
|
+
- **Shell Runner** — secure process spawning with allowlist, timeout, env isolation
|
|
215
|
+
- **Browser Proxy** — HTTP proxy to local `@hera-al/browser-server` with file detection and profile filtering
|
|
216
|
+
|
|
217
|
+
## Auto-detection
|
|
218
|
+
|
|
219
|
+
When running inside the Hera project directory (i.e. `standardnode/` is a subfolder), the node reads `../config.yaml` on first run and auto-configures the gateway URL from the Nostromo settings. The `--ws` flag always takes priority.
|
|
220
|
+
|
|
221
|
+
## NPM scripts
|
|
222
|
+
|
|
223
|
+
| Script | Description |
|
|
224
|
+
| --------------- | -------------------------------------------- |
|
|
225
|
+
| `npm start` | Run via tsx (development, no build needed) |
|
|
226
|
+
| `npm run dev` | Run with file watcher (hot reload) |
|
|
227
|
+
| `npm run build` | Compile TypeScript to `dist/` |
|
|
228
|
+
| `npm run node` | Run the compiled build (`node dist/index.js`)|
|
|
229
|
+
| `npm run help` | Show CLI help |
|
|
230
|
+
|
|
231
|
+
All scripts forward extra flags: `npm start -- --ws <url> --name <name>`.
|
|
232
|
+
|
|
233
|
+
## Requirements
|
|
234
|
+
|
|
235
|
+
- Node.js ≥ 18
|
|
236
|
+
- macOS, Linux, or Windows
|
|
237
|
+
- A running Hera gateway with Nostromo enabled
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
[MIT](./LICENSE) — © 2026 TGP / Hera Artificial Life
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# StandardNode configuration
|
|
2
|
+
# Copy this file to config.stdnode.yaml and adjust as needed.
|
|
3
|
+
|
|
4
|
+
node:
|
|
5
|
+
# Auto-generated on first run if left empty
|
|
6
|
+
id: ""
|
|
7
|
+
# Defaults to OS hostname if left empty
|
|
8
|
+
displayName: ""
|
|
9
|
+
|
|
10
|
+
gateway:
|
|
11
|
+
enabled: true
|
|
12
|
+
# WebSocket URL of the Hera gateway (Nostromo)
|
|
13
|
+
url: ws://localhost:3001/nostromo/ws/nodes
|
|
14
|
+
token: ""
|
|
15
|
+
# Auto-generated on first run if left empty
|
|
16
|
+
signature: ""
|
|
17
|
+
reconnectMs: 5000
|
|
18
|
+
|
|
19
|
+
logs:
|
|
20
|
+
# Directory for log files (created automatically)
|
|
21
|
+
dir: ./logs-stdnode
|
|
22
|
+
|
|
23
|
+
commands:
|
|
24
|
+
shell:
|
|
25
|
+
enabled: true
|
|
26
|
+
# If non-empty, only these commands are allowed
|
|
27
|
+
allowlist: []
|
|
28
|
+
timeout: 30000
|
|
29
|
+
|
|
30
|
+
# Browser automation via CDP (Chrome DevTools Protocol)
|
|
31
|
+
browser:
|
|
32
|
+
enabled: false
|
|
33
|
+
# Port for the browser HTTP control server
|
|
34
|
+
controlPort: 3002
|
|
35
|
+
# Run Chrome in headless mode
|
|
36
|
+
headless: false
|
|
37
|
+
# Disable Chrome sandbox (needed in some Linux environments)
|
|
38
|
+
noSandbox: false
|
|
39
|
+
# Only attach to an already-running Chrome (don't launch one)
|
|
40
|
+
attachOnly: false
|
|
41
|
+
# Custom Chrome/Brave/Edge executable path
|
|
42
|
+
# executablePath: /usr/bin/google-chrome
|
|
43
|
+
# If non-empty, only these browser profiles are allowed
|
|
44
|
+
allowProfiles: []
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Logger } from "../logger.js";
|
|
2
|
+
export interface BrowserProxyParams {
|
|
3
|
+
method?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
query?: Record<string, string | number | boolean | null | undefined>;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
profile?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface BrowserProxyFile {
|
|
11
|
+
path: string;
|
|
12
|
+
base64: string;
|
|
13
|
+
mimeType?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface BrowserProxyResult {
|
|
16
|
+
result: unknown;
|
|
17
|
+
files?: BrowserProxyFile[];
|
|
18
|
+
}
|
|
19
|
+
export declare function createBrowserProxy(controlPort: number, allowProfiles: string[], log: Logger): {
|
|
20
|
+
handleProxy: (params: BrowserProxyParams) => Promise<BrowserProxyResult>;
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=browser-proxy.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
const TAG = "BrowserProxy";
|
|
4
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
5
|
+
// ---------- MIME detection (extension-based) ----------
|
|
6
|
+
const MIME_MAP = {
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".jpg": "image/jpeg",
|
|
9
|
+
".jpeg": "image/jpeg",
|
|
10
|
+
".gif": "image/gif",
|
|
11
|
+
".webp": "image/webp",
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".pdf": "application/pdf",
|
|
14
|
+
".html": "text/html",
|
|
15
|
+
".json": "application/json",
|
|
16
|
+
".txt": "text/plain",
|
|
17
|
+
".zip": "application/zip",
|
|
18
|
+
".mp4": "video/mp4",
|
|
19
|
+
".webm": "video/webm",
|
|
20
|
+
};
|
|
21
|
+
function detectMime(filePath) {
|
|
22
|
+
return MIME_MAP[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
23
|
+
}
|
|
24
|
+
// ---------- Helpers ----------
|
|
25
|
+
/** Extract file paths from a browser response (path, imagePath, download.path) */
|
|
26
|
+
function collectFilePaths(payload) {
|
|
27
|
+
const paths = new Set();
|
|
28
|
+
const obj = typeof payload === "object" && payload !== null
|
|
29
|
+
? payload
|
|
30
|
+
: null;
|
|
31
|
+
if (!obj)
|
|
32
|
+
return [];
|
|
33
|
+
if (typeof obj.path === "string" && obj.path.trim()) {
|
|
34
|
+
paths.add(obj.path.trim());
|
|
35
|
+
}
|
|
36
|
+
if (typeof obj.imagePath === "string" && obj.imagePath.trim()) {
|
|
37
|
+
paths.add(obj.imagePath.trim());
|
|
38
|
+
}
|
|
39
|
+
const download = obj.download;
|
|
40
|
+
if (download && typeof download === "object") {
|
|
41
|
+
const dlPath = download.path;
|
|
42
|
+
if (typeof dlPath === "string" && dlPath.trim()) {
|
|
43
|
+
paths.add(dlPath.trim());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...paths];
|
|
47
|
+
}
|
|
48
|
+
/** Read a file from disk, check size, encode as base64 */
|
|
49
|
+
async function readProxyFile(filePath) {
|
|
50
|
+
const st = await stat(filePath).catch(() => null);
|
|
51
|
+
if (!st || !st.isFile())
|
|
52
|
+
return null;
|
|
53
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
54
|
+
throw new Error(`File exceeds ${Math.round(MAX_FILE_BYTES / (1024 * 1024))}MB: ${filePath}`);
|
|
55
|
+
}
|
|
56
|
+
const buffer = await readFile(filePath);
|
|
57
|
+
return {
|
|
58
|
+
path: filePath,
|
|
59
|
+
base64: buffer.toString("base64"),
|
|
60
|
+
mimeType: detectMime(filePath),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// ---------- Proxy handler ----------
|
|
64
|
+
export function createBrowserProxy(controlPort, allowProfiles, log) {
|
|
65
|
+
const baseUrl = `http://127.0.0.1:${controlPort}`;
|
|
66
|
+
function isProfileAllowed(profile) {
|
|
67
|
+
if (allowProfiles.length === 0)
|
|
68
|
+
return true;
|
|
69
|
+
if (!profile)
|
|
70
|
+
return false;
|
|
71
|
+
return allowProfiles.includes(profile.trim());
|
|
72
|
+
}
|
|
73
|
+
async function handleProxy(params) {
|
|
74
|
+
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
|
|
75
|
+
if (!pathValue) {
|
|
76
|
+
throw new Error("INVALID_REQUEST: path required");
|
|
77
|
+
}
|
|
78
|
+
const method = (typeof params.method === "string" ? params.method.toUpperCase() : "GET");
|
|
79
|
+
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
|
|
80
|
+
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
|
|
81
|
+
// Profile allowlist check
|
|
82
|
+
if (allowProfiles.length > 0 && path !== "/profiles") {
|
|
83
|
+
const profileToCheck = requestedProfile || "default";
|
|
84
|
+
if (!isProfileAllowed(profileToCheck)) {
|
|
85
|
+
throw new Error("INVALID_REQUEST: browser profile not allowed");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Build URL with query params
|
|
89
|
+
const url = new URL(path, baseUrl);
|
|
90
|
+
if (requestedProfile) {
|
|
91
|
+
url.searchParams.set("profile", requestedProfile);
|
|
92
|
+
}
|
|
93
|
+
const rawQuery = params.query ?? {};
|
|
94
|
+
for (const [key, value] of Object.entries(rawQuery)) {
|
|
95
|
+
if (value === undefined || value === null)
|
|
96
|
+
continue;
|
|
97
|
+
url.searchParams.set(key, String(value));
|
|
98
|
+
}
|
|
99
|
+
log.info(TAG, `proxy: ${method} ${url.pathname}${url.search}`);
|
|
100
|
+
// HTTP fetch to local browser server
|
|
101
|
+
const fetchOpts = { method };
|
|
102
|
+
if (params.body !== undefined && method !== "GET") {
|
|
103
|
+
fetchOpts.headers = { "Content-Type": "application/json" };
|
|
104
|
+
fetchOpts.body = JSON.stringify(params.body);
|
|
105
|
+
}
|
|
106
|
+
const timeoutMs = Math.max(1000, Math.min(120_000, params.timeoutMs ?? 30_000));
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
+
fetchOpts.signal = controller.signal;
|
|
110
|
+
let response;
|
|
111
|
+
try {
|
|
112
|
+
response = await fetch(url.toString(), fetchOpts);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw new Error(`Browser server unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
let result;
|
|
121
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
122
|
+
if (contentType.includes("application/json")) {
|
|
123
|
+
result = await response.json();
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
result = await response.text();
|
|
127
|
+
}
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const message = result && typeof result === "object" && "error" in result
|
|
130
|
+
? String(result.error)
|
|
131
|
+
: `HTTP ${response.status}`;
|
|
132
|
+
throw new Error(message);
|
|
133
|
+
}
|
|
134
|
+
// Filter profiles by allowlist if applicable
|
|
135
|
+
if (allowProfiles.length > 0 && path === "/profiles") {
|
|
136
|
+
const obj = typeof result === "object" && result !== null ? result : {};
|
|
137
|
+
const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];
|
|
138
|
+
obj.profiles = profiles.filter((entry) => {
|
|
139
|
+
if (!entry || typeof entry !== "object")
|
|
140
|
+
return false;
|
|
141
|
+
const name = entry.name;
|
|
142
|
+
return typeof name === "string" && allowProfiles.includes(name);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Collect and read files (screenshots, PDFs, downloads)
|
|
146
|
+
let files;
|
|
147
|
+
const paths = collectFilePaths(result);
|
|
148
|
+
if (paths.length > 0) {
|
|
149
|
+
const loaded = await Promise.all(paths.map(async (p) => {
|
|
150
|
+
const file = await readProxyFile(p);
|
|
151
|
+
if (!file) {
|
|
152
|
+
throw new Error(`Browser proxy file not found: ${p}`);
|
|
153
|
+
}
|
|
154
|
+
return file;
|
|
155
|
+
}));
|
|
156
|
+
if (loaded.length > 0) {
|
|
157
|
+
files = loaded;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return files ? { result, files } : { result };
|
|
161
|
+
}
|
|
162
|
+
return { handleProxy };
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=browser-proxy.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NodeConfig } from "../config.js";
|
|
2
|
+
import type { Logger } from "../logger.js";
|
|
3
|
+
export interface ShellRunParams {
|
|
4
|
+
cmd: string;
|
|
5
|
+
args?: string[];
|
|
6
|
+
cwd?: string;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export interface ShellRunResult {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
exitCode: number | null;
|
|
14
|
+
}
|
|
15
|
+
export interface ShellWhichParams {
|
|
16
|
+
cmd: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ShellWhichResult {
|
|
19
|
+
path: string | null;
|
|
20
|
+
}
|
|
21
|
+
export declare function createShellCommands(config: NodeConfig, log: Logger): {
|
|
22
|
+
shellRun: (params: ShellRunParams) => Promise<ShellRunResult>;
|
|
23
|
+
shellWhich: (params: ShellWhichParams) => Promise<ShellWhichResult>;
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=shell.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn, execFile } from "node:child_process";
|
|
2
|
+
const TAG = "Shell";
|
|
3
|
+
export function createShellCommands(config, log) {
|
|
4
|
+
const shellConfig = config.commands.shell;
|
|
5
|
+
function validateCommand(cmd) {
|
|
6
|
+
if (!shellConfig.enabled) {
|
|
7
|
+
throw new Error("Shell commands are disabled");
|
|
8
|
+
}
|
|
9
|
+
if (shellConfig.allowlist.length > 0 && !shellConfig.allowlist.includes(cmd)) {
|
|
10
|
+
throw new Error(`Command "${cmd}" is not in the allowlist`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function shellRun(params) {
|
|
14
|
+
validateCommand(params.cmd);
|
|
15
|
+
log.info(TAG, `run: ${params.cmd} ${(params.args ?? []).join(" ")}`);
|
|
16
|
+
const timeout = params.timeout ?? shellConfig.timeout;
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const proc = spawn(params.cmd, params.args ?? [], {
|
|
19
|
+
cwd: params.cwd,
|
|
20
|
+
env: params.env ? { ...process.env, ...params.env } : undefined,
|
|
21
|
+
timeout,
|
|
22
|
+
shell: false,
|
|
23
|
+
});
|
|
24
|
+
const stdoutChunks = [];
|
|
25
|
+
const stderrChunks = [];
|
|
26
|
+
proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
27
|
+
proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
28
|
+
proc.on("close", (code) => {
|
|
29
|
+
log.info(TAG, `run: ${params.cmd} exited with code ${code}`);
|
|
30
|
+
resolve({
|
|
31
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
32
|
+
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
33
|
+
exitCode: code,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
proc.on("error", (err) => {
|
|
37
|
+
log.error(TAG, `run: ${params.cmd} error: ${err.message}`);
|
|
38
|
+
resolve({
|
|
39
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
40
|
+
stderr: err.message,
|
|
41
|
+
exitCode: 1,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function shellWhich(params) {
|
|
47
|
+
validateCommand(params.cmd);
|
|
48
|
+
log.debug(TAG, `which: ${params.cmd}`);
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
execFile("which", [params.cmd], (err, stdout) => {
|
|
51
|
+
if (err) {
|
|
52
|
+
resolve({ path: null });
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
resolve({ path: stdout.trim() });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return { shellRun, shellWhich };
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=shell.js.map
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
3
|
+
node: z.ZodObject<{
|
|
4
|
+
id: z.ZodDefault<z.ZodString>;
|
|
5
|
+
displayName: z.ZodDefault<z.ZodString>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
gateway: z.ZodObject<{
|
|
8
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
9
|
+
url: z.ZodDefault<z.ZodString>;
|
|
10
|
+
token: z.ZodDefault<z.ZodString>;
|
|
11
|
+
signature: z.ZodDefault<z.ZodString>;
|
|
12
|
+
reconnectMs: z.ZodDefault<z.ZodNumber>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
logs: z.ZodDefault<z.ZodObject<{
|
|
15
|
+
dir: z.ZodDefault<z.ZodString>;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
commands: z.ZodObject<{
|
|
18
|
+
shell: z.ZodObject<{
|
|
19
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
20
|
+
allowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
21
|
+
timeout: z.ZodDefault<z.ZodNumber>;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
browser: z.ZodDefault<z.ZodObject<{
|
|
25
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
26
|
+
controlPort: z.ZodDefault<z.ZodNumber>;
|
|
27
|
+
headless: z.ZodDefault<z.ZodBoolean>;
|
|
28
|
+
noSandbox: z.ZodDefault<z.ZodBoolean>;
|
|
29
|
+
attachOnly: z.ZodDefault<z.ZodBoolean>;
|
|
30
|
+
executablePath: z.ZodOptional<z.ZodString>;
|
|
31
|
+
allowProfiles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
32
|
+
}, z.core.$strip>>;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
export type NodeConfig = z.infer<typeof ConfigSchema>;
|
|
35
|
+
export declare function loadConfig(opts?: {
|
|
36
|
+
configPath?: string;
|
|
37
|
+
ws?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
}): NodeConfig;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=config.d.ts.map
|