examedix-reach 0.0.0-stage → 0.1.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/DISCLOSURE +26 -0
- package/README.md +37 -2
- package/THIRD-PARTY-NOTICES.txt +17 -0
- package/bin/reach.js +259 -0
- package/dist/agent.js +357 -0
- package/package.json +58 -6
package/DISCLOSURE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
ExamediX Reach is a legitimate remote-workstation integration for computers that the user owns or is explicitly authorized to administer.
|
|
2
|
+
|
|
3
|
+
The package can connect an authorized Windows computer to the ExamediX Reach service and expose user-approved workstation capabilities such as:
|
|
4
|
+
- listing, reading, creating, editing, moving, copying and deleting authorized files;
|
|
5
|
+
- searching authorized folders;
|
|
6
|
+
- reading and writing supported document formats;
|
|
7
|
+
- inspecting and managing processes;
|
|
8
|
+
- starting and interacting with user-level processes;
|
|
9
|
+
- running user-level PowerShell when the local user has explicitly enabled Reach Power Mode.
|
|
10
|
+
|
|
11
|
+
These capabilities are dual-use because file, process and shell operations can resemble capabilities found in remote-administration or security tools. Reach is intended only for legitimate administration of the user's own or otherwise authorized computers.
|
|
12
|
+
|
|
13
|
+
Reach includes security controls including:
|
|
14
|
+
- explicit device pairing and account ownership checks;
|
|
15
|
+
- OAuth-scoped access;
|
|
16
|
+
- outbound-only TLS connections from the Windows agent;
|
|
17
|
+
- no inbound workstation listener;
|
|
18
|
+
- local filesystem boundaries;
|
|
19
|
+
- blocking of credential stores, private keys and common secret files;
|
|
20
|
+
- Power Mode disabled by default unless explicitly authorized in a local Windows consent dialog;
|
|
21
|
+
- audit metadata with sensitive values redacted;
|
|
22
|
+
- Windows DPAPI protection for the local device credential.
|
|
23
|
+
|
|
24
|
+
Unauthorized access, credential theft, covert surveillance, malware deployment, destructive misuse, or bypassing security controls is prohibited by the Reach Terms of Service.
|
|
25
|
+
|
|
26
|
+
Explicit local consent is collected before pairing/agent startup, stored on the computer, and may be changed with the power-mode command. A missing or inconsistent consent record fails closed. Power Mode runs with the permissions of the logged-in Windows user and is not a security sandbox.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ExamediX Reach
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ExamediX Reach connects ChatGPT to Windows computers you own or are authorized to administer.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
Requires Windows 10/11 and Node.js 20 or newer.
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
npx --yes examedix-reach@latest remote
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
On the first run, Reach asks for **explicit local permission** to enable Power Mode, **before** starting the agent and pairing the computer. **No** is selected by default. Choose **No** for restricted operation, or **Yes** if you knowingly want ChatGPT to run PowerShell and Windows programs with your Windows user permissions. These commands may change or delete files, install software and use network services. Power Mode is not a sandbox.
|
|
14
|
+
|
|
15
|
+
Reach then displays a pairing code and opens the account page. Sign in or create an account, pair your computer, and keep the agent process running while you need access through ChatGPT.
|
|
16
|
+
|
|
17
|
+
## Change Power Mode permission
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
npx --yes examedix-reach@latest power-mode
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This shows the local consent dialog again. After changing permission, **stop and restart the running Reach agent**.
|
|
24
|
+
|
|
25
|
+
The choice is stored locally in `%LOCALAPPDATA%\ExamediX\ReachData` and is reused on future starts when its consent record and agent configuration agree. A missing or inconsistent record never silently enables Power Mode.
|
|
26
|
+
|
|
27
|
+
Additional commands:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
npx examedix-reach@latest setup
|
|
31
|
+
npx examedix-reach@latest doctor
|
|
32
|
+
npx examedix-reach@latest --version
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Reach uses an outbound TLS connection; no inbound Windows port is required.
|
|
36
|
+
|
|
37
|
+
Security and privacy: https://examedix.ch/reach/security
|
|
38
|
+
Support: https://examedix.ch/reach/support
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
ExamediX Reach third-party notices
|
|
2
|
+
|
|
3
|
+
This distribution includes the Node.js runtime and open-source JavaScript dependencies.
|
|
4
|
+
Node.js is distributed under its project license; the complete Node license file is included as runtime/NODE-LICENSE.txt.
|
|
5
|
+
|
|
6
|
+
Direct runtime dependencies:
|
|
7
|
+
- ws 8.x — MIT
|
|
8
|
+
- exceljs 4.4.0 — MIT
|
|
9
|
+
- jszip 3.x — MIT OR GPL-3.0-or-later
|
|
10
|
+
- pdf-parse 2.4.5 — MIT
|
|
11
|
+
- pdf-lib 1.17.1 — MIT
|
|
12
|
+
|
|
13
|
+
Security-pinned transitive dependency:
|
|
14
|
+
- uuid 11.1.1 — MIT (forced through npm overrides to avoid older-version security advisories)
|
|
15
|
+
|
|
16
|
+
Transitive dependencies remain subject to their respective license terms included in the distributed node_modules packages.
|
|
17
|
+
ExamediX Reach does not claim ownership of third-party open-source components.
|
package/bin/reach.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
|
|
9
|
+
// Keep this version in sync with package.json before publishing.
|
|
10
|
+
const VERSION = "0.1.1";
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const agent = path.resolve(here, "..", "dist", "agent.js");
|
|
13
|
+
const command = (process.argv[2] || "remote").toLowerCase();
|
|
14
|
+
const root = process.env.REACH_ROOT || (
|
|
15
|
+
process.platform === "win32" && process.env.LOCALAPPDATA
|
|
16
|
+
? path.join(process.env.LOCALAPPDATA, "ExamediX", "ReachData")
|
|
17
|
+
: path.join(os.homedir(), ".examedix-reach")
|
|
18
|
+
);
|
|
19
|
+
const configFile = path.join(root, "config.json");
|
|
20
|
+
const consentFile = path.join(root, "power-mode-consent.json");
|
|
21
|
+
|
|
22
|
+
function openBrowser(url) {
|
|
23
|
+
try {
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
const p = spawn("cmd.exe", ["/d", "/c", "start", "", url], {
|
|
26
|
+
detached: true, stdio: "ignore", windowsHide: true
|
|
27
|
+
});
|
|
28
|
+
p.unref();
|
|
29
|
+
} else if (process.platform === "darwin") {
|
|
30
|
+
const p = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
31
|
+
p.unref();
|
|
32
|
+
} else {
|
|
33
|
+
const p = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
34
|
+
p.unref();
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function printHelp() {
|
|
40
|
+
console.log(`ExamediX Reach ${VERSION}
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
examedix-reach remote Connect this computer to Reach (default)
|
|
44
|
+
examedix-reach setup Create/show a pairing code, then exit
|
|
45
|
+
examedix-reach power-mode Review or change local Power Mode permission
|
|
46
|
+
examedix-reach doctor Check local prerequisites and Reach service
|
|
47
|
+
examedix-reach --version Show version
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
npx examedix-reach@latest remote
|
|
51
|
+
npx examedix-reach@latest power-mode
|
|
52
|
+
|
|
53
|
+
Local data:
|
|
54
|
+
${root}
|
|
55
|
+
|
|
56
|
+
Reach:
|
|
57
|
+
https://examedix.ch/reach
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function doctor() {
|
|
62
|
+
const result = {};
|
|
63
|
+
result.node = process.version;
|
|
64
|
+
result.platform = `${process.platform} ${os.release()}`;
|
|
65
|
+
result.root = root;
|
|
66
|
+
result.deviceFile = fs.existsSync(path.join(root, "device.json"));
|
|
67
|
+
result.agentFile = fs.existsSync(agent);
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch("https://examedix.ch/reach/health", {
|
|
70
|
+
signal: AbortSignal.timeout(10000)
|
|
71
|
+
});
|
|
72
|
+
result.service = r.ok ? "online" : `http-${r.status}`;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
result.service = "unreachable";
|
|
75
|
+
result.error = e.message;
|
|
76
|
+
}
|
|
77
|
+
console.log(JSON.stringify(result, null, 2));
|
|
78
|
+
process.exit(result.agentFile && result.service === "online" ? 0 : 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function windowsOnly() {
|
|
82
|
+
if (process.platform !== "win32") {
|
|
83
|
+
throw new Error("ExamediX Reach currently supports Windows only.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readObject(file) {
|
|
88
|
+
if (!fs.existsSync(file)) return {};
|
|
89
|
+
const obj = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
90
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
91
|
+
throw new Error(`Invalid JSON object in ${file}; refusing to overwrite it.`);
|
|
92
|
+
}
|
|
93
|
+
return obj;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Atomic replace to avoid a truncated configuration on interrupted writes.
|
|
97
|
+
function atomicWriteJson(file, data) {
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
100
|
+
try {
|
|
101
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, {
|
|
102
|
+
encoding: "utf8", flag: "wx", mode: 0o600
|
|
103
|
+
});
|
|
104
|
+
fs.renameSync(tmp, file);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readPriorConsent() {
|
|
112
|
+
const record = readObject(consentFile);
|
|
113
|
+
return typeof record.enabled === "boolean" ? record.enabled : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function setPowerMode(enabled, recordConsent = true) {
|
|
117
|
+
// Preserve all other agent settings rather than replacing config.json.
|
|
118
|
+
const cfg = readObject(configFile);
|
|
119
|
+
cfg.allowArbitraryExecution = enabled;
|
|
120
|
+
atomicWriteJson(configFile, cfg);
|
|
121
|
+
if (recordConsent) {
|
|
122
|
+
atomicWriteJson(consentFile, {
|
|
123
|
+
enabled,
|
|
124
|
+
recordedAt: new Date().toISOString(),
|
|
125
|
+
source: "examedix-reach-cli-explicit-local-consent"
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Exit code 10 means the person clicked Yes; 20 means No.
|
|
131
|
+
// Other exit statuses mean the Windows dialog could not be shown.
|
|
132
|
+
function askWindowsDialog() {
|
|
133
|
+
const script = String.raw`
|
|
134
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
135
|
+
$body = 'Power Mode lets ChatGPT, through ExamediX Reach, run Windows programs and PowerShell commands using your Windows account permissions.' + [Environment]::NewLine + [Environment]::NewLine + 'Commands may change or delete files, install software, and access network services.' + [Environment]::NewLine + [Environment]::NewLine + 'Enable Power Mode on this computer?' + [Environment]::NewLine + [Environment]::NewLine + 'Choose No for the safer restricted mode. You can change your choice later.'
|
|
136
|
+
$result = [System.Windows.Forms.MessageBox]::Show($body, 'ExamediX Reach - Power Mode', [System.Windows.Forms.MessageBoxButtons]::YesNo, [System.Windows.Forms.MessageBoxIcon]::Warning, [System.Windows.Forms.MessageBoxDefaultButton]::Button2)
|
|
137
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::Yes) { exit 10 }
|
|
138
|
+
if ($result -eq [System.Windows.Forms.DialogResult]::No) { exit 20 }
|
|
139
|
+
exit 1
|
|
140
|
+
`;
|
|
141
|
+
const run = spawnSync("powershell.exe", [
|
|
142
|
+
"-NoProfile", "-STA", "-NonInteractive", "-Command", script
|
|
143
|
+
], { encoding: "utf8", windowsHide: true, timeout: 300000 });
|
|
144
|
+
if (run.status === 10) return true;
|
|
145
|
+
if (run.status === 20) return false;
|
|
146
|
+
if (run.error) console.error(`Windows consent dialog unavailable: ${run.error.message}`);
|
|
147
|
+
if (run.stderr && run.stderr.trim()) console.error(run.stderr.trim());
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function askTerminalFallback() {
|
|
152
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
153
|
+
console.log("\nExamediX Reach - Power Mode");
|
|
154
|
+
console.log("Enabling Power Mode allows ChatGPT to run programs and PowerShell commands with your Windows permissions.");
|
|
155
|
+
console.log("Commands may change/delete files or use the network. Default: No.");
|
|
156
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
157
|
+
try {
|
|
158
|
+
const answer = await rl.question("Enable Power Mode? [y/N]: ");
|
|
159
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
160
|
+
} finally {
|
|
161
|
+
rl.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function askConsent() {
|
|
166
|
+
const choice = askWindowsDialog();
|
|
167
|
+
if (choice !== null) return choice;
|
|
168
|
+
return askTerminalFallback();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function ensurePowerModeChoice(force = false) {
|
|
172
|
+
windowsOnly();
|
|
173
|
+
fs.mkdirSync(root, { recursive: true });
|
|
174
|
+
const prior = readPriorConsent();
|
|
175
|
+
const cfg = readObject(configFile);
|
|
176
|
+
|
|
177
|
+
if (!force && prior !== null && cfg.allowArbitraryExecution === prior) {
|
|
178
|
+
console.log(`ExamediX Reach Power Mode: ${prior ? "ON" : "OFF"} (saved choice)`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fail closed whenever we lack a valid, matching record of explicit consent.
|
|
183
|
+
if (prior === null || cfg.allowArbitraryExecution !== prior) {
|
|
184
|
+
setPowerMode(false, false);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const selected = await askConsent();
|
|
188
|
+
if (selected === null) {
|
|
189
|
+
setPowerMode(false, false);
|
|
190
|
+
console.log("Power Mode OFF. Consent dialog unavailable; run in an interactive Windows session to select Power Mode.");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setPowerMode(selected, true);
|
|
195
|
+
console.log(`ExamediX Reach Power Mode: ${selected ? "ON (authorized locally)" : "OFF (restricted mode)"}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runAgent(args, openOnPair = false) {
|
|
199
|
+
windowsOnly();
|
|
200
|
+
fs.mkdirSync(root, { recursive: true });
|
|
201
|
+
const child = spawn(process.execPath, [agent, "--root", root, ...args], {
|
|
202
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
203
|
+
windowsHide: false
|
|
204
|
+
});
|
|
205
|
+
let opened = false;
|
|
206
|
+
child.stdout.on("data", buf => {
|
|
207
|
+
const text = buf.toString();
|
|
208
|
+
process.stdout.write(text);
|
|
209
|
+
if (openOnPair && !opened && text.includes("PAIRING CODE:")) {
|
|
210
|
+
opened = true;
|
|
211
|
+
console.log("\nOpening ExamediX Reach account/pairing page...");
|
|
212
|
+
openBrowser("https://examedix.ch/reach/signup");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
child.stderr.on("data", buf => process.stderr.write(buf));
|
|
216
|
+
child.on("error", err => { console.error(`Could not start Reach agent: ${err.message}`); process.exitCode = 1; });
|
|
217
|
+
const forward = sig => { try { child.kill(sig); } catch {} };
|
|
218
|
+
process.on("SIGINT", () => forward("SIGINT"));
|
|
219
|
+
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
220
|
+
child.on("exit", code => process.exit(code ?? 0));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function main() {
|
|
224
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
225
|
+
console.log(VERSION);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
229
|
+
printHelp();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (command === "doctor") {
|
|
233
|
+
await doctor();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (command === "power-mode") {
|
|
237
|
+
await ensurePowerModeChoice(true);
|
|
238
|
+
console.log("Restart a running Reach agent to apply the new permission.");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (command === "setup" || command === "remote") {
|
|
242
|
+
// The approval must happen before the agent starts and before pairing.
|
|
243
|
+
await ensurePowerModeChoice();
|
|
244
|
+
const first = !fs.existsSync(path.join(root, "device.json"));
|
|
245
|
+
if (command === "remote" && first) {
|
|
246
|
+
console.log("First Reach connection: a pairing code will be created.");
|
|
247
|
+
}
|
|
248
|
+
runAgent(command === "setup" ? ["--pair-only"] : [], first);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
console.error(`Unknown command: ${command}\n`);
|
|
252
|
+
printHelp();
|
|
253
|
+
process.exitCode = 2;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
main().catch(err => {
|
|
257
|
+
console.error(`ExamediX Reach: ${err.message}`);
|
|
258
|
+
process.exitCode = 1;
|
|
259
|
+
});
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import { spawn, execFile, spawnSync } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
import ExcelJS from "exceljs";
|
|
11
|
+
import { PDFParse } from "pdf-parse";
|
|
12
|
+
import JSZip from "jszip";
|
|
13
|
+
import { PDFDocument, StandardFonts } from "pdf-lib";
|
|
14
|
+
|
|
15
|
+
const VERSION="0.1.0";
|
|
16
|
+
const PAIR_ONLY=process.argv.includes("--pair-only");
|
|
17
|
+
if(process.argv.includes("--version")){console.log("ExamediX Reach Agent "+VERSION);process.exit(0);}
|
|
18
|
+
if(process.argv.includes("--help")){console.log("ExamediX Reach Agent "+VERSION+"\nUsage: ReachAgent [--version] [--help] [--pair-only] [--root <path>]");process.exit(0);}
|
|
19
|
+
const execFileAsync=promisify(execFile);
|
|
20
|
+
|
|
21
|
+
function excelValue(v){
|
|
22
|
+
if(v==null)return "";
|
|
23
|
+
if(v instanceof Date)return v.toISOString();
|
|
24
|
+
if(typeof v!=="object")return v;
|
|
25
|
+
if(Object.prototype.hasOwnProperty.call(v,"result"))return excelValue(v.result);
|
|
26
|
+
if(Array.isArray(v.richText))return v.richText.map(x=>x.text||"").join("");
|
|
27
|
+
if(Object.prototype.hasOwnProperty.call(v,"text"))return String(v.text??"");
|
|
28
|
+
if(Object.prototype.hasOwnProperty.call(v,"hyperlink"))return String(v.text??v.hyperlink??"");
|
|
29
|
+
if(Object.prototype.hasOwnProperty.call(v,"error"))return String(v.error??"");
|
|
30
|
+
return JSON.stringify(v);
|
|
31
|
+
}
|
|
32
|
+
function colIndex(s){
|
|
33
|
+
let n=0;
|
|
34
|
+
for(const ch of String(s).toUpperCase())n=n*26+(ch.charCodeAt(0)-64);
|
|
35
|
+
return n-1;
|
|
36
|
+
}
|
|
37
|
+
function sliceA1(rows,range){
|
|
38
|
+
const m=String(range).trim().match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
|
|
39
|
+
if(!m)throw new Error("Excel range must use A1:D100 format.");
|
|
40
|
+
const c1=colIndex(m[1]),r1=Number(m[2])-1,c2=colIndex(m[3]),r2=Number(m[4])-1;
|
|
41
|
+
return rows.slice(r1,r2+1).map(row=>Array.from({length:c2-c1+1},(_,i)=>row[c1+i]??""));
|
|
42
|
+
}
|
|
43
|
+
function decodeXmlText(s){
|
|
44
|
+
return String(s)
|
|
45
|
+
.replace(/</g,"<").replace(/>/g,">").replace(/"/g,'"')
|
|
46
|
+
.replace(/'/g,"'").replace(/&/g,"&")
|
|
47
|
+
.replace(/&#(\d+);/g,(_,n)=>String.fromCodePoint(Number(n)))
|
|
48
|
+
.replace(/&#x([0-9a-f]+);/gi,(_,n)=>String.fromCodePoint(parseInt(n,16)));
|
|
49
|
+
}
|
|
50
|
+
async function assertReadableFile(filePath,maxBytes,label="file"){
|
|
51
|
+
const st=await fsp.stat(filePath);
|
|
52
|
+
if(!st.isFile())throw new Error("Path is not a file.");
|
|
53
|
+
if(st.size>maxBytes)throw new Error(`${label} exceeds the local Reach safety limit (${Math.ceil(maxBytes/1024/1024)} MB).`);
|
|
54
|
+
return st;
|
|
55
|
+
}
|
|
56
|
+
async function loadSafeOfficeZip(filePath){
|
|
57
|
+
await assertReadableFile(filePath,config.maxFileReadBytes,"Office document");
|
|
58
|
+
const zip=await JSZip.loadAsync(await fsp.readFile(filePath));
|
|
59
|
+
const entries=Object.values(zip.files);
|
|
60
|
+
if(entries.length>config.maxOfficeEntries)throw new Error("Office document contains too many ZIP entries.");
|
|
61
|
+
let total=0;
|
|
62
|
+
for(const e of entries){
|
|
63
|
+
const n=Number(e?._data?.uncompressedSize||0);
|
|
64
|
+
if(Number.isFinite(n)){total+=n;if(total>config.maxOfficeUncompressedBytes)throw new Error("Office document expands beyond the local Reach safety limit.");}
|
|
65
|
+
}
|
|
66
|
+
return zip;
|
|
67
|
+
}
|
|
68
|
+
async function extractDocxText(filePath){
|
|
69
|
+
const zip=await loadSafeOfficeZip(filePath);
|
|
70
|
+
const entry=zip.file("word/document.xml");
|
|
71
|
+
if(!entry)throw new Error("Invalid DOCX: word/document.xml is missing.");
|
|
72
|
+
const docXmlSize=Number(entry?._data?.uncompressedSize||0);
|
|
73
|
+
if(docXmlSize>config.maxFileReadBytes)throw new Error("DOCX document.xml exceeds the local Reach safety limit.");
|
|
74
|
+
let xml=await entry.async("string");
|
|
75
|
+
xml=xml.replace(/<w:tab\b[^>]*\/>/gi,"\t")
|
|
76
|
+
.replace(/<w:br\b[^>]*\/>/gi,"\n")
|
|
77
|
+
.replace(/<\/w:tc>/gi,"\t")
|
|
78
|
+
.replace(/<\/w:tr>/gi,"\n")
|
|
79
|
+
.replace(/<\/w:p>/gi,"\n")
|
|
80
|
+
.replace(/<[^>]+>/g,"");
|
|
81
|
+
return decodeXmlText(xml).replace(/\t+\n/g,"\n").replace(/\n{3,}/g,"\n\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rootIndex=process.argv.indexOf("--root");
|
|
85
|
+
const rootArg=rootIndex>=0&&process.argv[rootIndex+1]?process.argv[rootIndex+1]:"";
|
|
86
|
+
const ROOT=path.resolve(rootArg||process.env.REACH_ROOT||path.join(os.homedir(),".examedix-reach"));
|
|
87
|
+
const DEVICE_FILE=path.join(ROOT,"device.json");
|
|
88
|
+
const CONFIG_FILE=path.join(ROOT,"config.json");
|
|
89
|
+
const DEFAULT_CONFIG={
|
|
90
|
+
allowedDirectories:[os.homedir()],
|
|
91
|
+
fileReadLineLimit:1000,
|
|
92
|
+
fileWriteLineLimit:1000,
|
|
93
|
+
telemetryEnabled:false,
|
|
94
|
+
defaultShell:"powershell.exe",
|
|
95
|
+
allowArbitraryExecution:false,
|
|
96
|
+
maxFileReadBytes:67108864,
|
|
97
|
+
maxImageReadBytes:20971520,
|
|
98
|
+
maxEditFileBytes:33554432,
|
|
99
|
+
maxSearchFileBytes:268435456,
|
|
100
|
+
maxOfficeUncompressedBytes:268435456,
|
|
101
|
+
maxOfficeEntries:10000,
|
|
102
|
+
blockedCommands:["format","diskpart","bcdedit","cipher","takeown","shutdown","restart-computer","stop-computer","remove-computer","enable-bitlocker","disable-bitlocker","clear-tpm","initialize-disk","clear-disk"],
|
|
103
|
+
blockedCommandFragments:[".ssh","get-credential","cmdkey","vaultcmd","credentialmanager","get-secret","secretmanagement","get-clipboard","chrome\\user data","edge\\user data","mozilla\\firefox\\profiles",".aws\\credentials",".azure",".kube\\config",".git-credentials"],
|
|
104
|
+
restrictedDirectories:[".ssh",".gnupg",".aws",".azure",".kube","AppData\\Local\\Google\\Chrome\\User Data","AppData\\Local\\Microsoft\\Edge\\User Data","AppData\\Roaming\\Mozilla\\Firefox\\Profiles"],
|
|
105
|
+
restrictedFileNames:[".env",".npmrc",".pypirc",".netrc",".git-credentials","credentials","credentials.json","login data","id_rsa","id_ed25519","id_ecdsa","id_dsa"],
|
|
106
|
+
restrictedExtensions:[".pem",".key",".p12",".pfx",".kdbx"]
|
|
107
|
+
};
|
|
108
|
+
const sessions=new Map(), searches=new Map(), recent=[];
|
|
109
|
+
let config={...DEFAULT_CONFIG};
|
|
110
|
+
|
|
111
|
+
function loadConfig(){try{config={...DEFAULT_CONFIG,...JSON.parse(fs.readFileSync(CONFIG_FILE,"utf8"))};}catch{config={...DEFAULT_CONFIG};saveConfig();}}
|
|
112
|
+
function saveConfig(){fs.mkdirSync(ROOT,{recursive:true});fs.writeFileSync(CONFIG_FILE,JSON.stringify(config,null,2));}
|
|
113
|
+
loadConfig();
|
|
114
|
+
|
|
115
|
+
function log(tool,args,result,ok=true){recent.push({ts:new Date().toISOString(),tool,args:JSON.stringify(args??{}).slice(0,500),ok});if(recent.length>200)recent.shift();return result;}
|
|
116
|
+
function abs(p){return path.resolve(String(p));}
|
|
117
|
+
function canonicalPath(p){
|
|
118
|
+
const requested=abs(p);
|
|
119
|
+
let cur=requested;
|
|
120
|
+
const suffix=[];
|
|
121
|
+
while(!fs.existsSync(cur)){
|
|
122
|
+
const parent=path.dirname(cur);
|
|
123
|
+
if(parent===cur)break;
|
|
124
|
+
suffix.unshift(path.basename(cur));
|
|
125
|
+
cur=parent;
|
|
126
|
+
}
|
|
127
|
+
let real=cur;
|
|
128
|
+
try{real=fs.realpathSync.native(cur);}catch{}
|
|
129
|
+
return path.resolve(real,...suffix);
|
|
130
|
+
}
|
|
131
|
+
function allowed(p){
|
|
132
|
+
const a=canonicalPath(p).toLowerCase(), roots=config.allowedDirectories||[];
|
|
133
|
+
if(!roots.length)return true;
|
|
134
|
+
return roots.some(r=>{const rr=canonicalPath(r).toLowerCase();return a===rr||a.startsWith(rr+path.sep.toLowerCase());});
|
|
135
|
+
}
|
|
136
|
+
function restrictedRoots(){return (config.restrictedDirectories||[]).map(p=>canonicalPath(path.join(os.homedir(),p)));}
|
|
137
|
+
function restrictedPath(p){
|
|
138
|
+
const a=canonicalPath(p),low=a.toLowerCase(),base=path.basename(a).toLowerCase(),ext=path.extname(a).toLowerCase();
|
|
139
|
+
if(restrictedRoots().some(r=>{const x=r.toLowerCase();return low===x||low.startsWith(x+path.sep.toLowerCase());}))return true;
|
|
140
|
+
if((config.restrictedFileNames||[]).map(x=>x.toLowerCase()).includes(base))return true;
|
|
141
|
+
if((config.restrictedExtensions||[]).map(x=>x.toLowerCase()).includes(ext))return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
function isAncestorOfRestricted(p){
|
|
145
|
+
const a=canonicalPath(p).toLowerCase();
|
|
146
|
+
return restrictedRoots().some(r=>{const x=r.toLowerCase();return x===a||x.startsWith(a+path.sep.toLowerCase());});
|
|
147
|
+
}
|
|
148
|
+
function ensureAllowed(p){if(!allowed(p))throw new Error(`Path not allowed: ${p}`);if(restrictedPath(p))throw new Error("Reach does not access credential stores, private keys, password vaults, or other restricted authentication data.");}
|
|
149
|
+
function blocked(command){
|
|
150
|
+
const low=String(command).toLowerCase();
|
|
151
|
+
const exact=(config.blockedCommands||[]).find(x=>new RegExp("(^|[\\s;&|])"+String(x).replace(/[.*+?^$(){}|[\\]\\\\]/g,"\\\\$&")+"([\\s;&|]|$)","i").test(low));
|
|
152
|
+
if(exact)return exact;
|
|
153
|
+
const fragment=(config.blockedCommandFragments||[]).find(x=>low.includes(String(x).toLowerCase()));
|
|
154
|
+
if(fragment)return fragment;
|
|
155
|
+
if(/(^|[\\s"'\\/=])\.env($|[\\s"'\\/])/i.test(low))return ".env";
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
function safeUserCommand(command){
|
|
159
|
+
const s=String(command||"").trim();
|
|
160
|
+
return /^whoami(?:\s+\/\w+)?$/i.test(s) ||
|
|
161
|
+
/^hostname$/i.test(s) ||
|
|
162
|
+
/^git\s+(status|diff|log|branch|show)(?:\s+[^;&|]*)?$/i.test(s);
|
|
163
|
+
}
|
|
164
|
+
function sliceLines(text,offset=0,length=config.fileReadLineLimit){
|
|
165
|
+
const lines=text.replace(/\r\n/g,"\n").split("\n");
|
|
166
|
+
if(offset<0)return lines.slice(Math.max(0,lines.length+offset)).join("\n");
|
|
167
|
+
return lines.slice(offset,offset+length).join("\n");
|
|
168
|
+
}
|
|
169
|
+
async function powershell(command,timeoutMs=30000,internal=false){
|
|
170
|
+
if(!internal&&!config.allowArbitraryExecution&&!safeUserCommand(command))return {ok:false,message:"Arbitrary command execution is disabled in the local Reach settings. Only approved diagnostic commands are available."};
|
|
171
|
+
const denied=blocked(command);
|
|
172
|
+
if(denied)return {ok:false,message:`Command blocked by ExamediX Reach safety policy: ${denied}`};
|
|
173
|
+
try{const {stdout,stderr}=await execFileAsync("powershell.exe",["-NoLogo","-NoProfile","-NonInteractive","-Command",command],{windowsHide:true,timeout:Math.min(Math.max(Number(timeoutMs)||30000,1000),120000),maxBuffer:16*1024*1024,encoding:"utf8"});return {ok:true,stdout:stdout??"",stderr:stderr??""};}
|
|
174
|
+
catch(e){return {ok:false,stdout:e?.stdout??"",stderr:e?.stderr??"",message:e?.message??String(e),code:e?.code??null};}
|
|
175
|
+
}
|
|
176
|
+
function shellArgs(shell,command){
|
|
177
|
+
const s=(shell||config.defaultShell||"powershell.exe").toLowerCase();
|
|
178
|
+
if(s.includes("powershell")||s.endsWith("pwsh.exe"))return ["-NoLogo","-NoProfile","-Command",command];
|
|
179
|
+
if(s.endsWith("cmd.exe")||s==="cmd")return ["/d","/s","/c",command];
|
|
180
|
+
return ["-lc",command];
|
|
181
|
+
}
|
|
182
|
+
function startProc(command,shell){
|
|
183
|
+
if(!config.allowArbitraryExecution&&!safeUserCommand(command))throw new Error("Arbitrary process execution is disabled in the local Reach settings. Only approved diagnostic commands are available.");
|
|
184
|
+
const denied=blocked(command);
|
|
185
|
+
if(denied)throw new Error(`Command blocked by ExamediX Reach safety policy: ${denied}`);
|
|
186
|
+
const sh=shell||config.defaultShell||"powershell.exe";
|
|
187
|
+
const child=spawn(sh,shellArgs(sh,command),{windowsHide:true,stdio:["pipe","pipe","pipe"]});
|
|
188
|
+
const sess={pid:child.pid,command,shell:sh,started:new Date().toISOString(),lines:[],partial:"",cursor:0,done:false,exitCode:null};
|
|
189
|
+
const push=buf=>{const text=sess.partial+buf.toString();const parts=text.split(/\r?\n/);sess.partial=parts.pop()??"";sess.lines.push(...parts);if(sess.lines.length>200000){const drop=sess.lines.length-150000;sess.lines.splice(0,drop);sess.cursor=Math.max(0,sess.cursor-drop);}};
|
|
190
|
+
child.stdout.on("data",push);child.stderr.on("data",push);
|
|
191
|
+
child.on("exit",code=>{if(sess.partial){sess.lines.push(sess.partial);sess.partial="";}sess.done=true;sess.exitCode=code;});
|
|
192
|
+
sess.child=child;sessions.set(child.pid,sess);return sess;
|
|
193
|
+
}
|
|
194
|
+
function readSess(sess,offset=0,length=config.fileReadLineLimit){
|
|
195
|
+
if(sess.partial){/* keep partial until line ends */}
|
|
196
|
+
let start;
|
|
197
|
+
if(offset===0){start=sess.cursor;sess.cursor=Math.min(sess.lines.length,start+length);}
|
|
198
|
+
else if(offset<0)start=Math.max(0,sess.lines.length+offset);
|
|
199
|
+
else start=offset;
|
|
200
|
+
const lines=sess.lines.slice(start,start+length);
|
|
201
|
+
return {pid:sess.pid,output:lines.join("\n"),start,returned:lines.length,total:sess.lines.length,done:sess.done,exitCode:sess.exitCode};
|
|
202
|
+
}
|
|
203
|
+
async function walk(root,fn,includeHidden=false){
|
|
204
|
+
const stack=[root];
|
|
205
|
+
while(stack.length){
|
|
206
|
+
const dir=stack.pop(); let ents=[]; try{ents=await fsp.readdir(dir,{withFileTypes:true});}catch{continue;}
|
|
207
|
+
for(const e of ents){if(!includeHidden&&e.name.startsWith("."))continue;if(e.isSymbolicLink())continue;const p=path.join(dir,e.name);if(!allowed(p)||restrictedPath(p))continue;if(e.isDirectory())stack.push(p);else if(e.isFile())await fn(p);}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function wildcardToRegExp(pat){return new RegExp("^"+pat.replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*").replace(/\?/g,".")+"$","i");}
|
|
211
|
+
async function runSearch(s){
|
|
212
|
+
try{
|
|
213
|
+
const root=abs(s.path);ensureAllowed(root);
|
|
214
|
+
const patt=s.literal?null:new RegExp(s.pattern,s.ignoreCase?"i":"");
|
|
215
|
+
const fileRe=s.filePattern?wildcardToRegExp(s.filePattern):null;
|
|
216
|
+
await walk(root,async p=>{
|
|
217
|
+
if(s.stopped||s.results.length>=s.maxResults)return;
|
|
218
|
+
const name=path.basename(p);
|
|
219
|
+
if(s.type==="files"){
|
|
220
|
+
const ok=s.literal?(s.ignoreCase?name.toLowerCase().includes(s.pattern.toLowerCase()):name.includes(s.pattern)):patt.test(name);
|
|
221
|
+
if(ok)s.results.push({path:p});
|
|
222
|
+
}else{
|
|
223
|
+
if(fileRe&&!fileRe.test(name))return;
|
|
224
|
+
let txt;try{const st=await fsp.stat(p);if(st.size>config.maxSearchFileBytes)return;txt=await fsp.readFile(p,"utf8");}catch{return;}
|
|
225
|
+
const lines=txt.split(/\r?\n/);
|
|
226
|
+
for(let i=0;i<lines.length&&s.results.length<s.maxResults;i++){
|
|
227
|
+
const line=lines[i],ok=s.literal?(s.ignoreCase?line.toLowerCase().includes(s.pattern.toLowerCase()):line.includes(s.pattern)):patt.test(line);
|
|
228
|
+
if(ok){s.results.push({path:p,line:i+1,text:line.slice(0,1000)});if(s.contextLines){/* compact V1: matching line only */}}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},s.includeHidden);
|
|
232
|
+
s.status=s.stopped?"STOPPED":"COMPLETED";
|
|
233
|
+
}catch(e){s.status="ERROR";s.error=e.message;}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function dispatch(tool,args={}){
|
|
237
|
+
try{
|
|
238
|
+
if(tool==="system_info")return log(tool,args,await powershell("$mem=(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory; $os=[System.Environment]::OSVersion.VersionString; [pscustomobject]@{Computer=$env:COMPUTERNAME;User=$env:USERNAME;Windows=$os;RAM_GB=[math]::Round($mem/1GB,2);PowerShell=$PSVersionTable.PSVersion.ToString();CurrentDirectory=(Get-Location).Path}|ConvertTo-Json -Compress",30000,true));
|
|
239
|
+
if(tool==="run_powershell")return log(tool,args,await powershell(args.command,args.timeout_ms??30000,false));
|
|
240
|
+
if(tool==="read_file"){
|
|
241
|
+
const p=abs(args.path);ensureAllowed(p);const ext=path.extname(p).toLowerCase();const st=await fsp.stat(p);if(!st.isFile())throw new Error("Path is not a file.");
|
|
242
|
+
if([".png",".jpg",".jpeg",".gif",".webp"].includes(ext)){if(st.size>config.maxImageReadBytes)throw new Error("Image exceeds the local Reach base64 safety limit.");const b=await fsp.readFile(p);return log(tool,args,{ok:true,kind:"base64",mime:ext===".png"?"image/png":"image/"+ext.slice(1),data:b.toString("base64")});}
|
|
243
|
+
if([".xlsx",".xlsm"].includes(ext)){await loadSafeOfficeZip(p);const wb=new ExcelJS.Workbook();await wb.xlsx.readFile(p);const requested=args.sheet!=null?String(args.sheet):null;const ws=requested==null?wb.worksheets[0]:(/^\d+$/.test(requested)?wb.worksheets[Number(requested)]:wb.getWorksheet(requested));if(!ws)throw new Error("Sheet not found");const sn=ws.name;let arr=[];const cols=Math.max(ws.columnCount,1);ws.eachRow({includeEmpty:true},row=>{const vals=[];for(let c=1;c<=cols;c++)vals.push(excelValue(row.getCell(c).value));arr.push(vals);});if(args.range)arr=sliceA1(arr,args.range);if(args.offset!=null)arr=arr.slice(args.offset,args.offset+(args.length??config.fileReadLineLimit));return log(tool,args,{ok:true,sheet:sn,data:arr});}
|
|
244
|
+
if(ext===".xls")throw new Error("Legacy .xls is not supported by the secure Reach build. Convert it to .xlsx first.");
|
|
245
|
+
if(ext===".pdf"){if(st.size>config.maxFileReadBytes)throw new Error("PDF exceeds the local Reach safety limit.");const parser=new PDFParse({data:await fsp.readFile(p)});try{const data=await parser.getText();return log(tool,args,{ok:true,pages:data.total,text:sliceLines(data.text,args.offset??0,args.length??config.fileReadLineLimit)});}finally{await parser.destroy();}}
|
|
246
|
+
if(ext===".docx"){const text=await extractDocxText(p);return log(tool,args,{ok:true,text:sliceLines(text,args.offset??0,args.length??config.fileReadLineLimit)});}
|
|
247
|
+
if(st.size>config.maxFileReadBytes)throw new Error("Text file exceeds the local Reach direct-read safety limit; use Search or Power Mode streaming commands for very large files.");const txt=await fsp.readFile(p,"utf8");return log(tool,args,{ok:true,text:sliceLines(txt,args.offset??0,args.length??config.fileReadLineLimit)});
|
|
248
|
+
}
|
|
249
|
+
if(tool==="read_multiple_files"){const out=[];for(const p of args.paths||[]){try{out.push({path:p,result:await dispatch("read_file",{path:p})});}catch(e){out.push({path:p,error:e.message});}}return log(tool,args,{ok:true,files:out});}
|
|
250
|
+
if(tool==="write_file"){
|
|
251
|
+
const p=abs(args.path);ensureAllowed(p);await fsp.mkdir(path.dirname(p),{recursive:true});const ext=path.extname(p).toLowerCase();
|
|
252
|
+
if(ext===".xlsx"){const parsed=JSON.parse(args.content);const wb=new ExcelJS.Workbook();if(Array.isArray(parsed)){const ws=wb.addWorksheet("Sheet1");for(const row of parsed)ws.addRow(Array.isArray(row)?row:[row]);}else{for(const [name,rows] of Object.entries(parsed)){const ws=wb.addWorksheet(String(name).slice(0,31)||"Sheet");for(const row of rows)ws.addRow(Array.isArray(row)?row:[row]);}}await wb.xlsx.writeFile(p);return log(tool,args,{ok:true,path:p});}
|
|
253
|
+
if(ext===".xls"||ext===".xlsm")throw new Error("Reach writes .xlsx only; .xls/.xlsm rewriting is disabled to avoid format or macro loss.");
|
|
254
|
+
if(args.mode==="append")await fsp.appendFile(p,args.content,"utf8");else await fsp.writeFile(p,args.content,"utf8");return log(tool,args,{ok:true,path:p,bytes:Buffer.byteLength(args.content)});
|
|
255
|
+
}
|
|
256
|
+
if(tool==="write_pdf"){
|
|
257
|
+
const src=abs(args.path),out=abs(args.outputPath||args.path);ensureAllowed(src);ensureAllowed(out);await fsp.mkdir(path.dirname(out),{recursive:true});let doc;
|
|
258
|
+
if(Array.isArray(args.content)){
|
|
259
|
+
const st=await fsp.stat(src).catch(()=>null);if(!st)throw new Error("Source PDF not found");doc=await PDFDocument.load(await fsp.readFile(src));
|
|
260
|
+
for(const op of args.content){
|
|
261
|
+
if(op.type==="delete"){for(const idx of [...op.pageIndexes].sort((a,b)=>b-a))if(idx>=0&&idx<doc.getPageCount())doc.removePage(idx);}
|
|
262
|
+
else if(op.type==="insert"){
|
|
263
|
+
if(op.sourcePdfPath){const sp=abs(op.sourcePdfPath);ensureAllowed(sp);const sd=await PDFDocument.load(await fsp.readFile(sp));const pages=await doc.copyPages(sd,sd.getPageIndices());let at=Math.max(0,Math.min(op.pageIndex,doc.getPageCount()));for(const pg of pages)doc.insertPage(at++,pg);}
|
|
264
|
+
else{const font=await doc.embedFont(StandardFonts.Helvetica);const page=doc.insertPage(Math.max(0,Math.min(op.pageIndex,doc.getPageCount())));const txt=String(op.markdown||"").replace(/[#*_>-]/g,"");const lines=txt.match(/.{1,90}(?:\s|$)/g)||[""];let y=page.getHeight()-50;for(const line of lines){if(y<40)break;page.drawText(line.trim(),{x:50,y,size:11,font});y-=16;}}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}else{
|
|
268
|
+
doc=await PDFDocument.create();const font=await doc.embedFont(StandardFonts.Helvetica);const txt=String(args.content||"").replace(/[#*_>-]/g,"");const lines=txt.match(/.{1,90}(?:\s|$)/g)||[""];let page=doc.addPage(),y=page.getHeight()-50;for(const line of lines){if(y<40){page=doc.addPage();y=page.getHeight()-50;}page.drawText(line.trim(),{x:50,y,size:11,font});y-=16;}
|
|
269
|
+
}
|
|
270
|
+
await fsp.writeFile(out,await doc.save());return log(tool,args,{ok:true,path:out,pages:doc.getPageCount()});
|
|
271
|
+
}
|
|
272
|
+
if(tool==="create_directory"){const p=abs(args.path);ensureAllowed(p);await fsp.mkdir(p,{recursive:true});return log(tool,args,{ok:true,path:p});}
|
|
273
|
+
if(tool==="move_file"){const s=abs(args.source),d=abs(args.destination);ensureAllowed(s);ensureAllowed(d);const st=await fsp.stat(s);if(st.isDirectory()&&isAncestorOfRestricted(s))throw new Error("Reach will not move a directory that contains restricted authentication data.");await fsp.mkdir(path.dirname(d),{recursive:true});await fsp.rename(s,d);return log(tool,args,{ok:true,source:s,destination:d});}
|
|
274
|
+
if(tool==="copy_file"){const s=abs(args.source),d=abs(args.destination);ensureAllowed(s);ensureAllowed(d);const st=await fsp.stat(s);if(st.isDirectory()&&isAncestorOfRestricted(s))throw new Error("Reach will not recursively copy a directory that contains restricted authentication data.");await fsp.mkdir(path.dirname(d),{recursive:true});await fsp.cp(s,d,{recursive:true,force:!!args.overwrite});return log(tool,args,{ok:true,source:s,destination:d});}
|
|
275
|
+
if(tool==="delete_file"){const p=abs(args.path);ensureAllowed(p);const st=await fsp.stat(p).catch(()=>null);if(st?.isDirectory()&&isAncestorOfRestricted(p))throw new Error("Reach will not delete a directory that contains restricted authentication data.");await fsp.rm(p,{recursive:true,force:!!args.force});return log(tool,args,{ok:true,path:p});}
|
|
276
|
+
if(tool==="list_directory"){const root=abs(args.path);ensureAllowed(root);const depth=Math.max(1,Math.min(args.depth??2,20));const rows=[];async function rec(dir,d){let ents=await fsp.readdir(dir,{withFileTypes:true});for(const e of ents){if(e.isSymbolicLink())continue;const p=path.join(dir,e.name);if(!allowed(p)||restrictedPath(p))continue;rows.push({type:e.isDirectory()?"dir":"file",path:path.relative(root,p)||".",fullPath:p});if(e.isDirectory()&&d<depth)await rec(p,d+1);}}await rec(root,1);return log(tool,args,{ok:true,root,items:rows});}
|
|
277
|
+
if(tool==="get_file_info"){const p=abs(args.path);ensureAllowed(p);const st=await fsp.stat(p);const out={path:p,size:st.size,created:st.birthtime.toISOString(),modified:st.mtime.toISOString(),isDirectory:st.isDirectory(),isFile:st.isFile()};if(st.isFile()&&st.size<50*1024*1024){try{const t=await fsp.readFile(p,"utf8");out.lineCount=t.split(/\r?\n/).length;}catch{}}return log(tool,args,{ok:true,info:out});}
|
|
278
|
+
if(tool==="edit_block"){const p=abs(args.file_path);ensureAllowed(p);const ext=path.extname(p).toLowerCase();if([".docx",".xlsx",".xls",".xlsm",".pdf"].includes(ext))throw new Error("Binary Office/PDF files require their dedicated read/write tools; generic edit_block is disabled to prevent corruption.");const st=await fsp.stat(p);if(st.size>config.maxEditFileBytes)throw new Error("Text file exceeds the local Reach edit safety limit.");let txt=await fsp.readFile(p,"utf8");const old=String(args.old_string),neu=String(args.new_string);let count=0,pos=0;while((pos=txt.indexOf(old,pos))!==-1){count++;pos+=old.length;}const exp=args.expected_replacements??1;if(count!==exp)throw new Error(`Expected ${exp} replacements, found ${count}`);txt=txt.split(old).join(neu);await fsp.writeFile(p,txt,"utf8");return log(tool,args,{ok:true,replacements:count,path:p});}
|
|
279
|
+
if(tool==="start_process"){const s=startProc(args.command,args.shell);return log(tool,args,{ok:true,pid:s.pid,status:"RUNNING",command:s.command,shell:s.shell});}
|
|
280
|
+
if(tool==="read_process_output"){const s=sessions.get(Number(args.pid));if(!s)throw new Error("Session not found");return log(tool,args,{ok:true,...readSess(s,args.offset??0,args.length??config.fileReadLineLimit)});}
|
|
281
|
+
if(tool==="interact_with_process"){const s=sessions.get(Number(args.pid));if(!s)throw new Error("Session not found");s.child.stdin.write(String(args.input)+(String(args.input).endsWith("\n")?"":"\n"));await new Promise(r=>setTimeout(r,Math.min(args.timeout_ms??500,3000)));return log(tool,args,{ok:true,...readSess(s,0,args.length??config.fileReadLineLimit)});}
|
|
282
|
+
if(tool==="force_terminate"){const s=sessions.get(Number(args.pid));if(s&&!s.done)s.child.kill("SIGKILL");return log(tool,args,{ok:true,pid:Number(args.pid)});}
|
|
283
|
+
if(tool==="list_sessions")return log(tool,args,{ok:true,sessions:[...sessions.values()].map(s=>({pid:s.pid,command:s.command,shell:s.shell,started:s.started,done:s.done,exitCode:s.exitCode,lines:s.lines.length}))});
|
|
284
|
+
if(tool==="list_processes"){const r=await powershell("Get-Process | Select-Object Id,ProcessName,CPU,WorkingSet64,Path | ConvertTo-Json -Depth 2 -Compress",30000,true);return log(tool,args,r);}
|
|
285
|
+
if(tool==="kill_process"){const r=await powershell(`Stop-Process -Id ${Number(args.pid)} -Force -ErrorAction Stop; 'OK'`,30000,true);return log(tool,args,r);}
|
|
286
|
+
if(tool==="start_search"){const id="search_"+crypto.randomUUID();const s={id,path:args.path,pattern:args.pattern,type:args.searchType??"files",filePattern:args.filePattern,ignoreCase:args.ignoreCase!==false,literal:!!args.literalSearch,includeHidden:!!args.includeHidden,contextLines:args.contextLines??0,maxResults:Math.min(args.maxResults??1000,100000),results:[],status:"RUNNING",stopped:false,created:new Date().toISOString()};searches.set(id,s);runSearch(s);return log(tool,args,{ok:true,sessionId:id,status:s.status});}
|
|
287
|
+
if(tool==="get_more_search_results"){const s=searches.get(args.sessionId);if(!s)throw new Error("Search not found");const off=args.offset??0,len=args.length??100;const start=off<0?Math.max(0,s.results.length+off):off;return log(tool,args,{ok:true,status:s.status,error:s.error??null,total:s.results.length,results:s.results.slice(start,start+len)});}
|
|
288
|
+
if(tool==="stop_search"){const s=searches.get(args.sessionId);if(!s)throw new Error("Search not found");s.stopped=true;s.status="STOPPING";return log(tool,args,{ok:true,sessionId:s.id});}
|
|
289
|
+
if(tool==="list_searches")return log(tool,args,{ok:true,searches:[...searches.values()].map(s=>({id:s.id,status:s.status,path:s.path,pattern:s.pattern,type:s.type,total:s.results.length,created:s.created}))});
|
|
290
|
+
|
|
291
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
292
|
+
}catch(e){return log(tool,args,{ok:false,message:e?.message??String(e)},false);}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function postJson(url,obj){
|
|
296
|
+
return new Promise((resolve,reject)=>{
|
|
297
|
+
const u=new URL(url),data=Buffer.from(JSON.stringify(obj));
|
|
298
|
+
const r=https.request({hostname:u.hostname,port:443,path:u.pathname+u.search,method:"POST",headers:{"content-type":"application/json","content-length":data.length}},res=>{
|
|
299
|
+
const chunks=[];res.on("data",c=>chunks.push(c));res.on("end",()=>{try{const x=JSON.parse(Buffer.concat(chunks).toString("utf8"));if(res.statusCode>=200&&res.statusCode<300)resolve(x);else reject(new Error(x.error||("HTTP "+res.statusCode)));}catch(e){reject(e);}});
|
|
300
|
+
});r.on("error",reject);r.write(data);r.end();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function dpapiProtect(secret){
|
|
304
|
+
const script="Add-Type -AssemblyName System.Security;$s=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($s);$e=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);[Convert]::ToBase64String($e)";
|
|
305
|
+
const r=spawnSync("powershell.exe",["-NoLogo","-NoProfile","-NonInteractive","-Command",script],{input:String(secret),encoding:"utf8",windowsHide:true,maxBuffer:1024*1024});
|
|
306
|
+
if(r.status!==0)throw new Error("Could not protect local device credential with Windows DPAPI.");
|
|
307
|
+
return String(r.stdout).trim();
|
|
308
|
+
}
|
|
309
|
+
function dpapiUnprotect(ciphertext){
|
|
310
|
+
const script="Add-Type -AssemblyName System.Security;$s=[Console]::In.ReadToEnd();$e=[Convert]::FromBase64String($s.Trim());$b=[System.Security.Cryptography.ProtectedData]::Unprotect($e,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);[Text.Encoding]::UTF8.GetString($b)";
|
|
311
|
+
const r=spawnSync("powershell.exe",["-NoLogo","-NoProfile","-NonInteractive","-Command",script],{input:String(ciphertext),encoding:"utf8",windowsHide:true,maxBuffer:1024*1024});
|
|
312
|
+
if(r.status!==0)throw new Error("Could not decrypt the local Reach device credential for this Windows user.");
|
|
313
|
+
return String(r.stdout).trim();
|
|
314
|
+
}
|
|
315
|
+
function persistDevice(d){
|
|
316
|
+
const stored={...d};
|
|
317
|
+
delete stored.agent_token;
|
|
318
|
+
fs.writeFileSync(DEVICE_FILE,JSON.stringify(stored,null,2));
|
|
319
|
+
}
|
|
320
|
+
async function loadOrCreateDevice(){
|
|
321
|
+
if(fs.existsSync(DEVICE_FILE)){
|
|
322
|
+
let d;
|
|
323
|
+
try{d=JSON.parse(fs.readFileSync(DEVICE_FILE,"utf8"));}catch{throw new Error("Reach device identity file is invalid JSON.");}
|
|
324
|
+
if(d.agent_token_dpapi){
|
|
325
|
+
return {...d,agent_token:dpapiUnprotect(d.agent_token_dpapi)};
|
|
326
|
+
}
|
|
327
|
+
if(d.agent_token){
|
|
328
|
+
const token=d.agent_token;
|
|
329
|
+
const migrated={...d,agent_token_dpapi:dpapiProtect(token)};
|
|
330
|
+
delete migrated.agent_token;
|
|
331
|
+
persistDevice(migrated);
|
|
332
|
+
return {...migrated,agent_token:token};
|
|
333
|
+
}
|
|
334
|
+
throw new Error("Reach device identity is missing its local device credential.");
|
|
335
|
+
}
|
|
336
|
+
const agent_token=crypto.randomBytes(48).toString("base64url");
|
|
337
|
+
const pair=await postJson("https://examedix.ch/reach/api/agent/pair/start",{name:process.env.REACH_DEVICE_NAME||os.hostname(),platform:"Windows "+os.release(),version:VERSION,agent_token});
|
|
338
|
+
const d={device_id:pair.device_id,agent_token_dpapi:dpapiProtect(agent_token),pairing_code:pair.pairing_code,pairing_expires_at:Date.now()+pair.expires_in*1000};
|
|
339
|
+
persistDevice(d);
|
|
340
|
+
console.log("PAIRING CODE:",pair.pairing_code);
|
|
341
|
+
return {...d,agent_token};
|
|
342
|
+
}
|
|
343
|
+
const device=await loadOrCreateDevice();
|
|
344
|
+
console.log("ExamediX Reach device:",device.device_id);
|
|
345
|
+
if(device.pairing_code&&device.pairing_expires_at>Date.now())console.log("PAIRING CODE:",device.pairing_code);
|
|
346
|
+
if(PAIR_ONLY)process.exit(0);
|
|
347
|
+
|
|
348
|
+
let retry=1000;
|
|
349
|
+
function connect(){
|
|
350
|
+
const url="wss://examedix.ch/reach/agent?device_id="+encodeURIComponent(device.device_id);
|
|
351
|
+
const ws=new WebSocket(url,{headers:{Authorization:"Bearer "+device.agent_token,"X-Reach-Agent-Version":VERSION},handshakeTimeout:15000});
|
|
352
|
+
ws.on("open",()=>{retry=1000;console.log("ExamediX Reach agent connected");});
|
|
353
|
+
ws.on("message",async data=>{let msg;try{msg=JSON.parse(data.toString());}catch{return;}if(!msg?.id||!msg?.tool)return;const result=await dispatch(msg.tool,msg.args||{});if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({id:msg.id,result}));});
|
|
354
|
+
ws.on("close",()=>{setTimeout(connect,retry);retry=Math.min(retry*2,30000);});
|
|
355
|
+
ws.on("error",e=>console.error("ExamediX Reach connection:",e.message));
|
|
356
|
+
}
|
|
357
|
+
connect();
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "examedix-reach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "examedix-reach",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Securely connect ChatGPT to Windows computers you own or are authorized to administer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"author": "ExamediX Technologies",
|
|
8
|
+
"homepage": "https://examedix.ch/reach",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/examedix/examedix-reach.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://examedix.ch/reach/support"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"examedix-reach": "bin/reach.js",
|
|
21
|
+
"reach": "bin/reach.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"THIRD-PARTY-NOTICES.txt",
|
|
28
|
+
"DISCLOSURE"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"chatgpt",
|
|
35
|
+
"mcp",
|
|
36
|
+
"windows",
|
|
37
|
+
"remote-work",
|
|
38
|
+
"file-system",
|
|
39
|
+
"process-management",
|
|
40
|
+
"workstation"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"exceljs": "^4.4.0",
|
|
44
|
+
"jszip": "^3.10.1",
|
|
45
|
+
"pdf-lib": "^1.17.1",
|
|
46
|
+
"pdf-parse": "2.4.5",
|
|
47
|
+
"ws": "^8.18.3"
|
|
48
|
+
},
|
|
49
|
+
"overrides": {
|
|
50
|
+
"uuid": "11.1.1"
|
|
51
|
+
},
|
|
52
|
+
"os": [
|
|
53
|
+
"win32"
|
|
54
|
+
],
|
|
55
|
+
"contentPolicy": {
|
|
56
|
+
"class": "dual-use"
|
|
57
|
+
}
|
|
58
|
+
}
|