bridgerator 1.0.8 → 1.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/README.md +176 -110
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +456 -56
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +720 -29
- package/src/cli.mjs +529 -74
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +124 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +84 -23
- package/src/pair.mjs +27 -4
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +120 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -124
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
package/src/bridge.mjs
CHANGED
|
@@ -2,116 +2,807 @@
|
|
|
2
2
|
// - starts the bundled open-source Desktop Commander MCP server as a child over stdio;
|
|
3
3
|
// - pushes this PC's limits (allowedDirectories, blockedCommands) into it at startup;
|
|
4
4
|
// - opens ONE outbound WebSocket to the relay (no inbound port is opened on this PC);
|
|
5
|
-
// - forwards each relay `call` through the policy (excluded folders, disabled tools)
|
|
5
|
+
// - forwards each relay `call` through the policy (excluded / read-only folders, disabled tools)
|
|
6
|
+
// and, when turned on, the on-PC confirmation window for risky calls (confirm.mjs) to Desktop
|
|
6
7
|
// Commander and sends the filtered result back.
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
8
9
|
import WebSocket from 'ws';
|
|
9
10
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
10
11
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
11
|
-
import { createPolicy, debrand, debrandResult } from './policy.mjs';
|
|
12
|
+
import { createPolicy, debrand, debrandResult, TERMINAL_TOOLS, ALWAYS_HIDDEN } from './policy.mjs';
|
|
12
13
|
import { showNotice } from './notice.mjs';
|
|
14
|
+
import { usage } from './telemetry.mjs';
|
|
15
|
+
import { createGate, askOnPc, CONFIRM_TOOL_DEFS } from './confirm.mjs';
|
|
16
|
+
import { SETUP_TOOL_DEF, validateSetup, diffSetup, applySetup, terminalTools } from './setup-tool.mjs';
|
|
17
|
+
import { SETTINGS_TOOL_DEF, validateSettings, widens, describe, DEFAULTS, presetOf, presetChanges, expandPresets } from './settings-tool.mjs';
|
|
18
|
+
import { shorten } from './tool-text.mjs';
|
|
19
|
+
import { parseDelete, recycle as recycleOnPc, purgeFromBin } from './recycle.mjs';
|
|
20
|
+
import { createSnapshots, changedPaths, undoGroups, SNAP_DEFAULTS, UNDO_TOOL_DEF } from './undo.mjs';
|
|
21
|
+
import { createFuse, wantAwake, FUSE_DEFAULTS } from './guard.mjs';
|
|
22
|
+
import { createQueue, queueTool, QUEUE_TOOL_DEFS, QUEUE_TOOL_NAMES } from './queue.mjs';
|
|
23
|
+
import { createRecent, outcomeOf } from './recent.mjs';
|
|
24
|
+
import { readSettingsStrict, setIni, writeAtomic, CATEGORIES, findMatch, clip } from './confirm.mjs';
|
|
25
|
+
import { readPlan, planBanner, bannerVisible, dismissFor, safeUrl, forecast, setSite, accountUrl } from './plan.mjs';
|
|
26
|
+
import { createAutomations, attachAutomations, windowRpc as automationRpc } from './automations.mjs';
|
|
27
|
+
import { createBatch, attachBatch } from './batch.mjs';
|
|
28
|
+
import { createDelegate, attachDelegate, windowRpc as delegateRpc } from './delegate.mjs';
|
|
29
|
+
import { eventMessage, createEventLimiter } from './events.mjs';
|
|
30
|
+
import { createExtra } from './extra-tools.mjs';
|
|
31
|
+
import { engineErrorText } from './tool-lib.mjs';
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
|
+
import { POWERSHELL, psArgs } from './win.mjs';
|
|
34
|
+
import { stripOurs } from './ask.mjs';
|
|
35
|
+
import { siteOf } from './pair.mjs';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import os from 'node:os';
|
|
13
39
|
|
|
14
40
|
const RECONNECT_MS = 5000;
|
|
15
41
|
// The relay pings every 30 s: silence for this long means a dead link (sleep/Wi-Fi switch) that
|
|
16
42
|
// TCP alone may not notice for hours — drop it and reconnect.
|
|
17
43
|
const SILENT_MS = 75000;
|
|
18
44
|
const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
45
|
+
const STDERR_LINES = 20;
|
|
46
|
+
export const MAX_CALLS = 8;
|
|
47
|
+
/** At most `max` at once; others wait up to `waitMs` → slot() resolves true (go) or false (busy). */
|
|
48
|
+
export function createLimiter(max, waitMs) {
|
|
49
|
+
let running = 0;
|
|
50
|
+
const waiting = [];
|
|
51
|
+
return {
|
|
52
|
+
slot: () => new Promise((resolve) => {
|
|
53
|
+
if (running < max) { running++; return resolve(true); }
|
|
54
|
+
const w = { resolve, timer: setTimeout(() => { waiting.splice(waiting.indexOf(w), 1); resolve(false); }, waitMs) };
|
|
55
|
+
waiting.push(w);
|
|
56
|
+
}),
|
|
57
|
+
release: () => { const w = waiting.shift(); if (w) { clearTimeout(w.timer); w.resolve(true); } else running--; },
|
|
58
|
+
get running() { return running; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const BUSY_WAIT_MS = 30000;
|
|
19
62
|
const DC_ENTRY = createRequire(import.meta.url).resolve('@wonderwhy-er/desktop-commander/dist/index.js');
|
|
20
63
|
const ISOLATE = new URL('./dc-isolate.mjs', import.meta.url).href;
|
|
21
64
|
|
|
22
|
-
|
|
65
|
+
/** Drive roots of this PC (C:\\ … Z:\\): what the Read only preset makes read-only when there is no folder list. */
|
|
66
|
+
export function localDrives() {
|
|
67
|
+
if (process.platform !== 'win32') return [];
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const c of 'CDEFGHIJKLMNOPQRSTUVWXYZ') { const d = `${c}:\\`; try { if (fs.statSync(d).isDirectory()) out.push(d); } catch { /* no such drive */ } }
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** The same off the event loop (a dead mapped drive can take seconds to answer). */
|
|
73
|
+
export async function localDrivesAsync() {
|
|
74
|
+
if (process.platform !== 'win32') return [];
|
|
75
|
+
const all = [...'CDEFGHIJKLMNOPQRSTUVWXYZ'].map((c) => `${c}:\\`);
|
|
76
|
+
const ok = await Promise.all(all.map((d) => fs.promises.stat(d).then((s) => s.isDirectory(), () => false)));
|
|
77
|
+
return all.filter((d, i) => ok[i]);
|
|
78
|
+
}
|
|
79
|
+
const DRIVES_MS = 60000;
|
|
80
|
+
|
|
81
|
+
export async function startDesktopCommander(cfg, log = console.log, { ask: askArg, recycle: recycleArg, drives: drivesArg, delegate: delegateOpts } = {}) {
|
|
82
|
+
const ask0 = askArg || askOnPc;
|
|
83
|
+
const recycle = recycleArg || recycleOnPc;
|
|
84
|
+
// Drive list: refreshed in the background once a minute (new / removed drives are seen); only the
|
|
85
|
+
// very first use before the background check finished looks synchronously.
|
|
86
|
+
let drivesCache = null, drivesAt = 0;
|
|
87
|
+
const refreshDrives = () => {
|
|
88
|
+
drivesAt = Date.now();
|
|
89
|
+
if (drivesArg) { drivesCache = drivesArg(); return; }
|
|
90
|
+
localDrivesAsync().then((d) => { drivesCache = d; }, () => {});
|
|
91
|
+
};
|
|
92
|
+
const drives = () => {
|
|
93
|
+
if (!drivesCache) { drivesCache = (drivesArg || localDrives)(); drivesAt = Date.now(); } else if (Date.now() - drivesAt > DRIVES_MS) refreshDrives();
|
|
94
|
+
return drivesCache;
|
|
95
|
+
};
|
|
96
|
+
if (!drivesArg) refreshDrives();
|
|
97
|
+
// A long settings proposal is reviewed in the Bridgerator window (Apply there); others: notification.
|
|
98
|
+
let pendingSetup = null;
|
|
99
|
+
const ask = (q) => {
|
|
100
|
+
if (!q.review) return ask0(q);
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const id = `s${Date.now().toString(36)}`;
|
|
103
|
+
const timer = setTimeout(() => { if (pendingSetup?.id === id) { pendingSetup = null; resolve('deny'); } }, 10 * 60000);
|
|
104
|
+
pendingSetup = { id, lines: q.lines, resolve: (k) => { clearTimeout(timer); pendingSetup = null; resolve(k); } };
|
|
105
|
+
out.openWindow?.('home');
|
|
106
|
+
ask0({ head: 'ChatGPT suggests Bridgerator settings', text: 'Too many changes for a notification — review them in the Bridgerator window.', buttons: [['deny', 'Cancel', 'deny']], secs: 120, auto: 'closes', ...out.popup() })
|
|
107
|
+
.then((k) => { if (k === 'deny' && pendingSetup?.id === id && Date.now() - Number.parseInt(id.slice(1), 36) < 115000) pendingSetup.resolve('deny'); });
|
|
108
|
+
});
|
|
109
|
+
};
|
|
23
110
|
if (!cfg.dcConfigDir) throw new Error('dcConfigDir missing');
|
|
111
|
+
// Links in notices / the plan banner point at the site this PC is linked to (prod or staging).
|
|
112
|
+
try { if (cfg.relay) setSite(siteOf(cfg.relay)); } catch { /* bad relay address: the default site */ }
|
|
113
|
+
// Relative paths in calls resolve where the engine's terminal starts.
|
|
114
|
+
const engineDir = engineCwd(cfg);
|
|
115
|
+
const absPath = (p) => path.resolve(engineDir, String(p).replace(/^~(?=[\\/]|$)/, os.homedir()));
|
|
24
116
|
const transport = new StdioClientTransport({
|
|
25
117
|
command: process.execPath,
|
|
26
118
|
// Own Desktop Commander config (dc-isolate.mjs): a DC the user runs elsewhere keeps its limits.
|
|
27
119
|
args: ['--import', ISOLATE, DC_ENTRY],
|
|
28
120
|
// No calls home: telemetry kill-switch, and remote feature flags (onboarding page, promo text
|
|
29
121
|
// injected into tool results, A/B tests) replaced by an empty local set.
|
|
30
|
-
|
|
31
|
-
|
|
122
|
+
// The engine's terminal starts in the first allowed folder (never in the Bridgerator folder) and
|
|
123
|
+
// its environment carries no Bridgerator variables (the config dir is removed by dc-isolate).
|
|
124
|
+
cwd: engineDir,
|
|
125
|
+
env: { ...engineEnv(), DC_TELEMETRY_ENABLED: 'false', DESKTOP_COMMANDER_DISABLE_TELEMETRY: '1', DC_FLAG_URL: 'data:application/json,{"flags":{}}', BRIDGERATOR_DC_CONFIG_DIR: cfg.dcConfigDir },
|
|
126
|
+
stderr: 'pipe', // last lines kept for the log when the engine dies
|
|
32
127
|
});
|
|
128
|
+
const tail = [];
|
|
129
|
+
transport.stderr?.on('data', (b) => { tail.push(...String(b).split(/\r?\n/).filter(Boolean)); tail.splice(0, Math.max(0, tail.length - STDERR_LINES)); });
|
|
130
|
+
const engineErr = () => (tail.length ? `\n engine stderr (last lines):\n ${tail.join('\n ')}` : '');
|
|
33
131
|
const client = new Client({ name: 'bridgerator', version: VERSION }, { capabilities: {} });
|
|
34
|
-
await client.connect(transport);
|
|
132
|
+
try { await client.connect(transport); } catch (e) { throw new Error(`engine did not start: ${e?.message || e}${engineErr()}`); }
|
|
35
133
|
// Engine gone (crash, killed): nothing works without it — exit, the autostart starts us again.
|
|
36
134
|
let closing = false;
|
|
37
|
-
client.onclose = () => { if (closing) return; log(
|
|
135
|
+
client.onclose = () => { if (closing) return; log(`[bridgerator] engine stopped — exiting (autostart restarts the bridge)${engineErr()}`); process.exit(1); };
|
|
38
136
|
// The PC owner's limits go into Desktop Commander's own config; the model can't change them
|
|
39
137
|
// (set_config_value is never exposed to the relay, see policy.ALWAYS_HIDDEN).
|
|
40
138
|
const set = (key, value) => client.callTool({ name: 'set_config_value', arguments: { key, value } });
|
|
41
139
|
await set('allowedDirectories', cfg.allowedDirectories || []);
|
|
42
140
|
if (Array.isArray(cfg.blockedCommands)) await set('blockedCommands', cfg.blockedCommands);
|
|
43
141
|
await set('telemetryEnabled', false);
|
|
44
|
-
|
|
142
|
+
// The Bridgerator folder (key, confirm.ini) is always excluded, also when it was moved (BRIDGERATOR_HOME).
|
|
143
|
+
// Relative paths are checked where the engine opens them: its working directory (engineDir).
|
|
144
|
+
const makePolicy = () => createPolicy({ ...cfg, cwd: engineDir, deniedDirectories: [...(cfg.deniedDirectories || []), ...(cfg.configDir ? [cfg.configDir] : [])] });
|
|
145
|
+
let policy = makePolicy();
|
|
45
146
|
const { tools } = await client.listTools();
|
|
46
|
-
|
|
147
|
+
// The bridge's own extra tools (doctor, documents, search, photos, software, PC check, screen): extra-tools.mjs.
|
|
148
|
+
const extra = createExtra();
|
|
149
|
+
// Short plain descriptions (tool-text.mjs). The terminal tools are always listed: the access preset
|
|
150
|
+
// turns them off by refusing calls, so switching back needs no restart.
|
|
151
|
+
const listed = (t) => policy.visible(t) || (TERMINAL_TOOLS.includes(t.name) && !ALWAYS_HIDDEN.includes(t.name));
|
|
152
|
+
// Snapshots + Undo (on by default) in %LOCALAPPDATA%\Bridgerator\snapshots (given by the CLI).
|
|
153
|
+
const snapCfg = () => ({ ...SNAP_DEFAULTS, ...(cfg.snapshots || {}) });
|
|
154
|
+
const snapshots = cfg.snapshotsDir ? createSnapshots(cfg.snapshotsDir, { limits: snapCfg, log }) : null;
|
|
155
|
+
const visible = [...tools.filter(listed).map(debrand), ...CONFIRM_TOOL_DEFS, SETUP_TOOL_DEF, ...(cfg.configFile ? [SETTINGS_TOOL_DEF] : []), ...(cfg.configDir ? QUEUE_TOOL_DEFS : []), ...(snapshots ? [UNDO_TOOL_DEF] : []), ...extra.defs.filter(policy.visible)].map(shorten);
|
|
156
|
+
const virtual = new Set(CONFIRM_TOOL_DEFS.map((t) => t.name));
|
|
157
|
+
// namePending: a computer name given on this PC not yet accepted by the relay — kept in config.json too,
|
|
158
|
+
// so a restart before the next link does not lose it; cleared by nameSent() (welcome / live mode message).
|
|
159
|
+
const out = { onMode: () => {}, paused: false, linkState: 'offline', linkName: '', nameGen: 0, namePending: typeof cfg.namePending === 'string' ? cfg.namePending : '' };
|
|
160
|
+
// Local only (never sent): the person's task queue and the recent calls with their targets.
|
|
161
|
+
// "Queue finished" (todo + doing went to 0 with work done) → an event for the relay's notifications.
|
|
162
|
+
// A task marked failed → "task_failed" (counts only, events.mjs).
|
|
163
|
+
let queueBusy = 0, queueFailed = 0;
|
|
164
|
+
const queueChanged = () => {
|
|
165
|
+
out.onMode(mode());
|
|
166
|
+
const c = queueCounts(), busy = c.todo + c.doing;
|
|
167
|
+
if (c.failed > queueFailed) out.onEvent?.({ kind: 'task_failed', n: c.failed - queueFailed, failed: c.failed });
|
|
168
|
+
if (queueBusy && !busy && c.done + c.failed) out.onEvent?.({ kind: 'queue_done', done: c.done, failed: c.failed });
|
|
169
|
+
queueBusy = busy; queueFailed = c.failed;
|
|
170
|
+
};
|
|
171
|
+
const queue = cfg.configDir ? createQueue(path.join(cfg.configDir, 'queue.json'), { onChange: () => queueChanged() }) : null;
|
|
172
|
+
const recent = createRecent(cfg.configDir ? path.join(cfg.configDir, 'recent.jsonl') : '', { keepFile: !!cfg.configDir && cfg.localHistory === true });
|
|
173
|
+
const queueCounts = () => { const c = queue ? queue.counts() : {}; return { todo: c.todo || 0, doing: c.doing || 0, done: c.done || 0, failed: c.failed || 0 }; };
|
|
174
|
+
queueBusy = queueCounts().todo + queueCounts().doing; queueFailed = queueCounts().failed;
|
|
175
|
+
// Automations (scheduled scripts + recipes) and plan cards; their tools are in extra-tools.mjs.
|
|
176
|
+
// LOGIC: critical-logic/bridge.md ("Automations and recipes", "Plan card").
|
|
177
|
+
const autos = cfg.scriptsDir ? attachAutomations(cfg, createAutomations({ dir: cfg.scriptsDir, log, ask: (q) => ask({ ...q, ...out.popup() }), openWindow: (p) => out.openWindow?.(p), emit: (e) => out.onEvent?.(e), onChange: () => out.onMode(mode()), notify: () => cfg.automations?.notifySchedule !== false })) : null;
|
|
178
|
+
const stopAutos = autos ? autos.start() : null;
|
|
179
|
+
const batch = attachBatch(cfg, createBatch({ openWindow: (p) => out.openWindow?.(p), log }));
|
|
180
|
+
// AI ↔ AI delegation (bridgerator_delegate, window page Agents). LOGIC: critical-logic/bridge.md ("AI ↔ AI delegation").
|
|
181
|
+
const delegate = attachDelegate(cfg, createDelegate({ file: cfg.configDir ? path.join(cfg.configDir, 'delegate.json') : '', snapshots, log, emit: (e) => out.onEvent?.(e), ...(delegateOpts || {}) }));
|
|
182
|
+
const mode = () => ({ confirm: gate.mode(), readOnly: cfg.readOnlyDirectories || [], setupDone: cfg.setupDone === true, queue: queueCounts(), paused: out.paused, ...(autos ? { automations: autos.counts() } : {}) });
|
|
183
|
+
// confirm.ini is re-read on every call; a change of on/off is reported to the relay.
|
|
184
|
+
const gate = createGate({
|
|
185
|
+
ini: cfg.confirmIni,
|
|
186
|
+
ask: (q) => ask({ ...q, ...out.popup() }),
|
|
187
|
+
onChange: (m) => { log(`[bridgerator] confirmations ${m}`); out.onMode(mode()); },
|
|
188
|
+
onAlways: (cat) => showNotice({ kind: 'confirm_off', category: cat }, log),
|
|
189
|
+
onTurnedOff: (what) => { log(`[bridgerator] protection turned off: ${what.join(', ')}`); showNotice({ kind: 'protection_off', what }, log); },
|
|
190
|
+
});
|
|
191
|
+
// The bridge's own writes pass what they changed (the tray's on/off toggle passes the value it wrote).
|
|
192
|
+
// Without it only [confirm] enabled is taken, read strictly: a missing / unreadable / broken file keeps
|
|
193
|
+
// the applied state (never "off" from a lenient default) — never other edits sitting in confirm.ini.
|
|
194
|
+
out.acceptConfirm = (changed) => {
|
|
195
|
+
if (changed && typeof changed === 'object') return gate.accept(changed);
|
|
196
|
+
const s = cfg.confirmIni ? readSettingsStrict(cfg.confirmIni) : null;
|
|
197
|
+
return gate.accept(s ? { enabled: s.enabled } : {});
|
|
198
|
+
};
|
|
47
199
|
// MCP Apps widgets (file preview, config view) — served to ChatGPT through the relay as-is.
|
|
48
200
|
const resources = ((await client.listResources().catch(() => ({ resources: [] }))).resources || []).map(debrand);
|
|
49
201
|
const resourceUris = new Set(resources.map((r) => r.uri));
|
|
50
|
-
log(`[bridgerator] engine up: ${visible.length} tools; allowed=${JSON.stringify(cfg.allowedDirectories || [])} excluded=${JSON.stringify(cfg.deniedDirectories || [])}`);
|
|
51
|
-
if (!(cfg.allowedDirectories || []).length) log('[bridgerator]
|
|
202
|
+
log(`[bridgerator] engine up: ${visible.length} tools; allowed=${JSON.stringify(cfg.allowedDirectories || [])} excluded=${JSON.stringify(cfg.deniedDirectories || [])} read-only=${JSON.stringify(cfg.readOnlyDirectories || [])} confirm=${gate.mode()}`);
|
|
203
|
+
if (!(cfg.allowedDirectories || []).length) log('[bridgerator] folders: all (no list), except the always-blocked ones');
|
|
204
|
+
|
|
205
|
+
// Folders / read-only folders / confirm checks: written, then live in the engine and the policy.
|
|
206
|
+
async function applyValues(v, why) {
|
|
207
|
+
out.checkConfig?.(); // an outside edit not seen yet is reconciled, not swallowed by this write
|
|
208
|
+
const next = applySetup(cfg.configFile, cfg.confirmIni, v);
|
|
209
|
+
const foldersChanged = JSON.stringify(next.allowedDirectories) !== JSON.stringify(cfg.allowedDirectories);
|
|
210
|
+
// Only what was approved: the file may hold other changes still waiting for Apply (reconcile).
|
|
211
|
+
Object.assign(cfg, { ...(v.allowedDirectories ? { allowedDirectories: next.allowedDirectories } : {}), ...(v.readOnlyDirectories ? { readOnlyDirectories: next.readOnlyDirectories } : {}), ...(typeof v.terminal === 'boolean' ? { disabledTools: next.disabledTools } : {}), setupDone: true });
|
|
212
|
+
ownText = JSON.stringify(next, null, 2); // exactly what applySetup wrote (a later hand edit stays visible)
|
|
213
|
+
if (foldersChanged) await set('allowedDirectories', cfg.allowedDirectories || []);
|
|
214
|
+
policy = makePolicy();
|
|
215
|
+
log(`[bridgerator] settings applied (${why}): allowed=${JSON.stringify(cfg.allowedDirectories)} read-only=${JSON.stringify(cfg.readOnlyDirectories || [])}`);
|
|
216
|
+
gate.accept(v.confirm || {});
|
|
217
|
+
out.onMode(mode());
|
|
218
|
+
}
|
|
219
|
+
let ownText = null; // config.json as the bridge itself last wrote / read it
|
|
220
|
+
const readText = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
|
|
221
|
+
const patchConfig = (o) => {
|
|
222
|
+
out.checkConfig?.(); // an outside edit not seen yet is reconciled, not swallowed by this write
|
|
223
|
+
const c = JSON.parse(fs.readFileSync(cfg.configFile, 'utf8'));
|
|
224
|
+
ownText = JSON.stringify({ ...c, ...o }, null, 2);
|
|
225
|
+
writeAtomic(cfg.configFile, ownText);
|
|
226
|
+
Object.assign(cfg, o);
|
|
227
|
+
};
|
|
228
|
+
// The relay took the name (welcome after a hello that carried it, or a live mode message): not sent again.
|
|
229
|
+
out.nameSent = (name) => {
|
|
230
|
+
if (out.namePending !== name) return; // renamed again meanwhile: the newer name still waits
|
|
231
|
+
out.namePending = '';
|
|
232
|
+
if (cfg.namePending !== undefined && cfg.configFile) { try { patchConfig({ namePending: undefined }); } catch { /* read-only: sent once more */ } }
|
|
233
|
+
};
|
|
234
|
+
// V-10: the relay's name for this computer (welcome) is the name, unless a name given on this PC still
|
|
235
|
+
// waits to be sent: after a re-pair the window shows what the account shows, not the old local name.
|
|
236
|
+
// gen = nameGen when the hello was sent: a name changed on this PC after it (already sent in a live mode
|
|
237
|
+
// message, so no longer pending) is newer than this welcome's name and stays (VR-10).
|
|
238
|
+
out.relayName = (name, gen) => {
|
|
239
|
+
if ((gen !== undefined && gen !== out.nameGen) || out.namePending || typeof name !== 'string' || !name.trim() || name.length > 40 || /[\u0000-\u001f\u007f]/.test(name)) return;
|
|
240
|
+
if (cfg.deviceName === undefined || cfg.deviceName === name || !cfg.configFile) return;
|
|
241
|
+
try { patchConfig({ deviceName: name }); log(`[bridgerator] computer name from the account: "${name}"`); } catch { /* read-only: shown from the link */ }
|
|
242
|
+
};
|
|
243
|
+
// Started after an update from the window: say so once (the marker survives setup, cli.setupConfig).
|
|
244
|
+
if (cfg.updatingFrom && cfg.configFile) {
|
|
245
|
+
if (cfg.updatingFrom !== VERSION) showNotice({ kind: 'updated', version: VERSION }, log);
|
|
246
|
+
try { patchConfig({ updatingFrom: null }); } catch { /* read-only settings: shown again next start */ }
|
|
247
|
+
}
|
|
248
|
+
// State the presets / settings tool work on (same shape as getState().settings).
|
|
249
|
+
const terminalOn = () => !(cfg.disabledTools || []).includes('start_process');
|
|
250
|
+
const accessState = (o = {}) => ({ terminal: terminalOn(), allowedDirectories: cfg.allowedDirectories || [], readOnlyDirectories: cfg.readOnlyDirectories || [], ...o });
|
|
251
|
+
|
|
252
|
+
// bridgerator_setup: applied at once; after Apply on the PC only with askSettings on (setup-tool.mjs).
|
|
253
|
+
const say = (t, isError) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError: true } : {}) });
|
|
254
|
+
async function setup(args) {
|
|
255
|
+
const v = validateSetup(args);
|
|
256
|
+
if (v.error) return say(`Not applied: ${v.error}`, true);
|
|
257
|
+
if (!cfg.configFile) return say('Not applied: settings can only be changed on a configured PC.', true);
|
|
258
|
+
if (v.value.preset) {
|
|
259
|
+
const st = accessState({ ...(v.value.allowedDirectories ? { allowedDirectories: v.value.allowedDirectories } : {}), ...(v.value.readOnlyDirectories ? { readOnlyDirectories: v.value.readOnlyDirectories } : {}) });
|
|
260
|
+
for (const c of presetChanges(v.value.preset, st, drives())) v.value[c.key] = c.value;
|
|
261
|
+
delete v.value.preset;
|
|
262
|
+
}
|
|
263
|
+
// Owner 21.10: applied at once; Apply on the PC only when the person turned on "Ask before AI changes settings".
|
|
264
|
+
if (cfg.askSettings !== true) { await applyValues(v.value, 'bridgerator_setup'); return say('Applied on the PC.'); }
|
|
265
|
+
// A diff that fits the notification gets Apply there; a longer one is reviewed in the window.
|
|
266
|
+
const diff = diffSetup(cfg, cfg.confirmIni, v.value);
|
|
267
|
+
let r;
|
|
268
|
+
if (diff.short.length <= 180) {
|
|
269
|
+
r = await gate.askPerson('setup', { head: 'ChatGPT suggests Bridgerator settings', text: diff.short, buttons: [['allow', 'Apply', ''], ['deny', 'Cancel', 'deny']], secs: 120, auto: 'auto-cancel', ...popup() });
|
|
270
|
+
} else {
|
|
271
|
+
r = await gate.askPerson('setup', { review: true, lines: diff.lines });
|
|
272
|
+
}
|
|
273
|
+
if (r === 'cooldown') return { ...say('Not applied — the person at the PC declined settings a few minutes ago. Ask them before suggesting again.'), notConfirmed: true };
|
|
274
|
+
if (r !== 'allow') return { ...say('Not applied — the person at the PC cancelled or didn\'t answer.'), notConfirmed: true };
|
|
275
|
+
await applyValues(v.value, 'bridgerator_setup, approved on the PC');
|
|
276
|
+
return say('Applied on the PC.');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// At most MAX_CALLS calls run at once (several chats on one PC); the rest wait up to 30 s, then get
|
|
280
|
+
// a "busy" answer (flag busy: the relay doesn't count it).
|
|
281
|
+
const { slot, release } = createLimiter(MAX_CALLS, BUSY_WAIT_MS);
|
|
282
|
+
// bridgerator_settings: get everything; set → applied at once (askSettings on: lowering protection after Apply on the PC).
|
|
283
|
+
async function settingsTool(args = {}) {
|
|
284
|
+
const snap = async () => (await rpc('getState')).settings;
|
|
285
|
+
if (args.action === 'get') return say(JSON.stringify({ current: toolView(await snap()), defaults: DEFAULTS, alwaysBlocked: 'Bridgerator\'s own settings and program folders (not a setting)' }, null, 2));
|
|
286
|
+
if (args.action !== 'set') return say('action must be "get" or "set"', true);
|
|
287
|
+
const v = validateSettings(args.settings);
|
|
288
|
+
if (v.error) return say(`Not changed: ${v.error}`, true);
|
|
289
|
+
const cur = await snap();
|
|
290
|
+
v.changes = expandPresets(v.changes, cur, drives());
|
|
291
|
+
// Owner 21.10: everything applies at once unless "Ask before AI changes settings" is on (then only lowering protection asks).
|
|
292
|
+
const ask1 = (c) => cfg.askSettings === true && widens(cur, c);
|
|
293
|
+
const risky = v.changes.filter(ask1), safe = v.changes.filter((c) => !ask1(c));
|
|
294
|
+
for (const c of safe) await rpc('setSetting', { ...c, via: 'bridgerator_settings' });
|
|
295
|
+
const done = safe.map((c) => describe(cur, c));
|
|
296
|
+
if (!risky.length) return say(`Applied: ${done.join('; ')}`);
|
|
297
|
+
const lines = risky.map((c) => describe(cur, c)), short = lines.join(' · ');
|
|
298
|
+
const r = short.length <= 180
|
|
299
|
+
? await gate.askPerson('settings', { head: 'ChatGPT wants to change Bridgerator settings', text: short, buttons: [['allow', 'Apply', ''], ['deny', 'Cancel', 'deny']], secs: 120, auto: 'auto-cancel', ...popup() })
|
|
300
|
+
: await gate.askPerson('settings', { review: true, lines });
|
|
301
|
+
if (r === 'allow') { for (const c of risky) await rpc('setSetting', { ...c, via: 'bridgerator_settings' }); return say(`Applied${done.length ? `: ${done.join('; ')};` : ':'} after the person's OK: ${lines.join('; ')}`); }
|
|
302
|
+
return { ...say(`${done.length ? `Applied: ${done.join('; ')}. ` : ''}Not applied (the person at the PC ${r === 'cooldown' ? 'declined a few minutes ago' : 'cancelled or didn\'t answer'}): ${lines.join('; ')}`), notConfirmed: true };
|
|
303
|
+
}
|
|
52
304
|
|
|
53
305
|
async function call(name, args) {
|
|
306
|
+
if (!(await slot())) return { content: [{ type: 'text', text: 'This PC is busy with other chats — try again in a moment.' }], isError: true, busy: true };
|
|
307
|
+
inFlight++;
|
|
308
|
+
try {
|
|
309
|
+
const r = await call0(name, args);
|
|
310
|
+
const undo = r?.undoId;
|
|
311
|
+
if (undo) delete r.undoId;
|
|
312
|
+
recent.record(name, args, outcomeOf(r), undo ? { undo } : undefined);
|
|
313
|
+
if (name === 'queue_update' && args?.status === 'done' && !r.isError && cfg.notify?.queueDone === true) {
|
|
314
|
+
try { showNotice({ kind: 'queue_done', title: JSON.parse(r.content[0].text).item.title }, log); } catch { /* no title */ }
|
|
315
|
+
}
|
|
316
|
+
return r;
|
|
317
|
+
} finally { inFlight--; lastCall = Date.now(); release(); }
|
|
318
|
+
}
|
|
319
|
+
async function call0(name, args) {
|
|
320
|
+
if (out.paused) { wasPaused = true; return { content: [{ type: 'text', text: 'Bridgerator is paused on this PC. Ask the person to resume it (tray icon → Resume).' }], isError: true, paused: true }; }
|
|
321
|
+
if (wasPaused) { wasPaused = false; fuse.reset(); }
|
|
322
|
+
if (name === SETUP_TOOL_DEF.name) return setup(args);
|
|
323
|
+
if (extra.has(name)) return extra.call(name, args, { cfg, policy, gate, log, popup, engine: call0, rpc, extra, version: VERSION });
|
|
324
|
+
if (name === SETTINGS_TOOL_DEF.name && cfg.configFile) return settingsTool(args);
|
|
325
|
+
if (snapshots && name === UNDO_TOOL_DEF.name) return undoTool(args);
|
|
326
|
+
if (virtual.has(name)) return gate.tool(name);
|
|
327
|
+
if (queue && name === 'queue_clear' && args?.status === 'all') {
|
|
328
|
+
const n = queue.list().length;
|
|
329
|
+
if (!n) return say('The queue is already empty.');
|
|
330
|
+
const r = await gate.askPerson('clear', { head: `ChatGPT wants to clear ${n} task${n === 1 ? '' : 's'}`, text: 'Every task in the Bridgerator queue will be removed.', buttons: [['allow', 'Clear', ''], ['deny', 'Keep', 'deny']], secs: 120, auto: 'auto-keep', ...popup() });
|
|
331
|
+
if (r !== 'allow') return { ...say(r === 'cooldown' ? 'Not cleared — the person said no a few minutes ago.' : 'Not cleared — the person at the PC kept the queue.'), notConfirmed: true };
|
|
332
|
+
const gone = queue.clear({ status: 'all' });
|
|
333
|
+
return say(JSON.stringify({ removed: gone.length, counts: queue.counts() }));
|
|
334
|
+
}
|
|
335
|
+
if (queue && QUEUE_TOOL_NAMES.has(name)) return queueTool(queue, name, args);
|
|
54
336
|
const refusal = policy.check(name, args);
|
|
55
|
-
if (refusal) return { content: [{ type: 'text', text: refusal }], isError: true };
|
|
337
|
+
if (refusal) return { content: [{ type: 'text', text: refusal }], isError: true, refused: true };
|
|
338
|
+
// While a question is on the screen, new commands wait for the person's answer.
|
|
339
|
+
if ((name === 'start_process' || name === 'interact_with_process') && gate.pending) await gate.idle();
|
|
340
|
+
// Listed in a plan the person approved on the PC (bridgerator_plan): no question of its own, not counted by the fuse.
|
|
341
|
+
const planned = batch.covers(name, args, gate);
|
|
342
|
+
const stop = planned ? null : await gate.gate(name, args);
|
|
343
|
+
if (stop) return stop;
|
|
344
|
+
// A plain delete command → the Recycle Bin instead (recycle.mjs); other delete commands only count.
|
|
345
|
+
// A command on the PC's blockedCommands list is left to the engine, which refuses it.
|
|
346
|
+
const del = name === 'start_process' && cfg.recycleBin !== false && typeof args?.command === 'string' && !namesBlocked(args.command) ? parseDelete(args.command, engineDir) : null;
|
|
347
|
+
// Under the per-path lock too: snapshot → recycle → commit can't interleave with a call on the same path.
|
|
348
|
+
if (del) return locked(del.targets.map((p) => p.toLowerCase()), () => deleteToBin(del, args, planned));
|
|
349
|
+
const mass = name === 'move_file' ? 1 : (name === 'start_process' || name === 'interact_with_process') && findMatch(name, args, { delete: true }).cat === 'delete' ? 1 : 0;
|
|
350
|
+
if (mass && !planned) { const f = fuseCheck(mass); if (f) return f; }
|
|
351
|
+
// Snapshot → engine call → commit as one step per path: a parallel call on the same file waits,
|
|
352
|
+
// so neither records the other's result as its own "after".
|
|
353
|
+
const changed = snapshots ? changedPaths(name, args).map((x) => ({ ...x, path: absPath(x.path) })) : [];
|
|
354
|
+
return changed.length ? locked(changed.map((x) => x.path.toLowerCase()), () => engineCall(name, args, changed)) : engineCall(name, args, changed);
|
|
355
|
+
}
|
|
356
|
+
async function engineCall(name, args, changed) {
|
|
357
|
+
const pend = changed.length ? await snapshots.capture(name, changed, { task: currentTask(), label: recentTarget(args) }).catch((e) => { log(`[bridgerator] snapshot skipped: ${e.message}`); return null; }) : null;
|
|
358
|
+
let res;
|
|
56
359
|
try {
|
|
57
360
|
// Results are NOT debranded (the user's own file contents), except the engine's own
|
|
58
361
|
// get_prompts / get_config texts (debrandResult).
|
|
59
|
-
|
|
362
|
+
res = policy.filter(debrandResult(name, await client.callTool({ name, arguments: args || {} }), VERSION), name, args);
|
|
60
363
|
} catch (e) {
|
|
61
|
-
|
|
364
|
+
res = { content: [{ type: 'text', text: engineErrorText(e) }], isError: true };
|
|
365
|
+
}
|
|
366
|
+
const kept = snapshots ? snapshots.commit(pend, !res.isError) : null;
|
|
367
|
+
if (kept) {
|
|
368
|
+
const big = kept.entries.some((e) => e.noCopy === 'big' && e.before.kind === 'file');
|
|
369
|
+
res = { ...res, undoId: kept.id, content: [...(res.content || []), { type: 'text', text: big ? `[Bridgerator: no undo copy — the file is bigger than ${snapCfg().maxFileMb} MB]` : `[Bridgerator: can be undone — bridgerator_undo id ${kept.id}]` }] };
|
|
370
|
+
}
|
|
371
|
+
return res;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- Recycle Bin, mass-action fuse, Undo (LOGIC: critical-logic/bridge.md) ---------------------
|
|
375
|
+
const fuse = createFuse();
|
|
376
|
+
let fuseAsking = false, inFlight = 0, lastCall = 0, wasPaused = false;
|
|
377
|
+
const fuseCfg = () => ({ ...FUSE_DEFAULTS, ...(cfg.fuse || {}) });
|
|
378
|
+
const setPaused = (v) => { if (out.setPaused) out.setPaused(v); else { out.paused = v; out.onMode(mode()); } };
|
|
379
|
+
const currentTask = () => { const d = queue ? queue.list('doing') : []; return d.length === 1 ? { id: d[0].id, title: d[0].title } : undefined; };
|
|
380
|
+
const recentTarget = (a = {}) => String(a.path ?? a.file_path ?? (a.source ? `${a.source} → ${a.destination}` : a.command ?? '')).slice(0, 300);
|
|
381
|
+
/** n more deletions / moves: over the limit → pause + question on the PC; → the result to return, or null. */
|
|
382
|
+
function fuseCheck(n) {
|
|
383
|
+
const f = fuseCfg();
|
|
384
|
+
if (!f.enabled) return null;
|
|
385
|
+
const total = fuse.take(n, f.limit);
|
|
386
|
+
if (!total) return null;
|
|
387
|
+
log(`[bridgerator] mass-action fuse: ${total} deletions / moves within a minute (limit ${f.limit}) — paused`);
|
|
388
|
+
setPaused(true);
|
|
389
|
+
if (!fuseAsking) {
|
|
390
|
+
fuseAsking = true;
|
|
391
|
+
Promise.resolve(ask({ head: `ChatGPT deleted or moved ${total} files in a minute`, text: 'Bridgerator paused itself. Continue? No answer = it stays paused.', buttons: [['allow', 'Continue', ''], ['deny', 'Keep paused', 'deny']], secs: 300, auto: 'closes', ...popup() }))
|
|
392
|
+
.then((k) => { if (k === 'allow') { fuse.reset(); setPaused(false); } }, (e) => log(`[bridgerator] fuse question failed: ${e?.message || e}`))
|
|
393
|
+
.finally(() => { fuseAsking = false; });
|
|
62
394
|
}
|
|
395
|
+
return { content: [{ type: 'text', text: `Paused: more than ${f.limit} files were deleted or moved within a minute. Bridgerator asked the person on the PC whether to continue; wait for them.` }], isError: true, paused: true };
|
|
63
396
|
}
|
|
397
|
+
// Per-path lock (keys taken all at once, so two calls can never wait on each other in a circle).
|
|
398
|
+
const locks = new Map();
|
|
399
|
+
async function locked(keys, fn) {
|
|
400
|
+
const ks = [...new Set(keys)];
|
|
401
|
+
const before = ks.map((k) => locks.get(k)).filter(Boolean);
|
|
402
|
+
let done;
|
|
403
|
+
const mine = new Promise((r) => { done = r; });
|
|
404
|
+
for (const k of ks) locks.set(k, mine);
|
|
405
|
+
await Promise.all(before);
|
|
406
|
+
try { return await fn(); } finally { done(); for (const k of ks) if (locks.get(k) === mine) locks.delete(k); }
|
|
407
|
+
}
|
|
408
|
+
// The command WORD (first word, and the one after cmd /c), not a path segment or an argument:
|
|
409
|
+
// parseDelete takes one statement only, so these are the only words the engine checks against the list.
|
|
410
|
+
const commandWords = (c) => {
|
|
411
|
+
const out = [];
|
|
412
|
+
let rest = String(c).trim();
|
|
413
|
+
for (let k = 0; k < 2; k++) {
|
|
414
|
+
const m = /^(?:"([^"]+)"|'([^']+)'|(\S+))\s*([\s\S]*)$/.exec(rest);
|
|
415
|
+
if (!m) break;
|
|
416
|
+
out.push(path.win32.basename(m[1] ?? m[2] ?? m[3]).toLowerCase().replace(/\.(exe|com)$/, ''));
|
|
417
|
+
if (out.at(-1) !== 'cmd') break;
|
|
418
|
+
rest = m[4].replace(/^\/[ck]\s+/i, '').replace(/^"/, '');
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
};
|
|
422
|
+
const namesBlocked = (command) => { const w = commandWords(command); return (cfg.blockedCommands || []).some((b) => typeof b === 'string' && w.includes(b.trim().toLowerCase().replace(/\.(exe|com)$/, ''))); };
|
|
423
|
+
async function deleteToBin(del, args, planned = false) {
|
|
424
|
+
for (const p of del.targets) { const why = policy.writable(p); if (why) return { content: [{ type: 'text', text: why }], isError: true, refused: true }; }
|
|
425
|
+
// Listed in a plan the person approved: not counted by the fuse (like the engine path).
|
|
426
|
+
const f = planned ? null : fuseCheck(del.targets.length);
|
|
427
|
+
if (f) return f;
|
|
428
|
+
const pend = snapshots ? await snapshots.capture('delete', del.targets.map((p) => ({ path: p, role: 'delete' })), { task: currentTask(), label: String(args.command).slice(0, 300) }).catch((e) => { log(`[bridgerator] snapshot skipped: ${e.message}`); return null; }) : null;
|
|
429
|
+
const res = await recycle(del.targets, del.skip ? { skip: del.skip } : undefined);
|
|
430
|
+
const binned = res.filter((x) => x.r === 'recycled').map((x) => x.p);
|
|
431
|
+
const nobin = res.filter((x) => x.r === 'nobin').map((x) => x.p);
|
|
432
|
+
const skipped = res.filter((x) => x.r === 'skipped').map((x) => x.p);
|
|
433
|
+
const failed = res.filter((x) => x.r.startsWith('error')).map((x) => `${x.p} (${x.r.slice(6) || 'error'})`);
|
|
434
|
+
const gone = [], kept = [];
|
|
435
|
+
if (nobin.length) {
|
|
436
|
+
const r = await gate.askPerson('permdelete', { head: `ChatGPT wants to delete ${nobin.length} item${nobin.length === 1 ? '' : 's'} for good`, text: clip(`No Recycle Bin on that drive: ${nobin.join(', ')}`, '', 140), buttons: [['allow', 'Delete', ''], ['deny', 'Keep', 'deny']], secs: 120, auto: 'auto-keep', ...popup() });
|
|
437
|
+
for (const p of nobin) {
|
|
438
|
+
if (r !== 'allow') { kept.push(p); continue; }
|
|
439
|
+
try { fs.rmSync(p, { recursive: true, force: true }); gone.push(p); } catch (e) { failed.push(`${p} (${e.code || e.message})`); }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const removed = [...binned, ...gone];
|
|
443
|
+
if (pend) {
|
|
444
|
+
pend.entries = pend.entries.filter((e) => removed.includes(e.path));
|
|
445
|
+
// The exact Recycle Bin copy of each item (recycle.ps1): Undo removes that one, never another.
|
|
446
|
+
for (const e of pend.entries) { const bin = res.find((x) => x.p === e.path)?.bin; if (typeof bin === 'string') e.bin = bin; }
|
|
447
|
+
}
|
|
448
|
+
const done = snapshots ? snapshots.commit(pend, removed.length > 0) : null;
|
|
449
|
+
const lines = [];
|
|
450
|
+
if (binned.length) lines.push(`Bridgerator moved ${binned.length} item${binned.length === 1 ? '' : 's'} to the Recycle Bin instead of deleting (the command itself was not run):`, ...binned.map((p) => ` ${p}`));
|
|
451
|
+
if (gone.length) lines.push('Deleted for good after the person\'s OK (no Recycle Bin on that drive):', ...gone.map((p) => ` ${p}`));
|
|
452
|
+
if (kept.length) lines.push('Not deleted: the person at the PC kept them (no Recycle Bin on that drive):', ...kept.map((p) => ` ${p}`));
|
|
453
|
+
if (failed.length) lines.push('Could not delete:', ...failed.map((p) => ` ${p}`));
|
|
454
|
+
if (skipped.length) lines.push('Left alone (hidden, system or read-only — the command itself would skip them without -Force or /a /f):', ...skipped.map((p) => ` ${p}`));
|
|
455
|
+
if (done) lines.push(`Undo: bridgerator_undo id ${done.id} (or restore from the Recycle Bin).`);
|
|
456
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], ...(removed.length ? {} : { isError: true }), ...(kept.length && !removed.length ? { notConfirmed: true } : {}), ...(done ? { undoId: done.id } : {}) };
|
|
457
|
+
}
|
|
458
|
+
// Undo of a Recycle Bin delete restores from the snapshot: the same item then leaves the bin (no duplicate).
|
|
459
|
+
function binCleanup(results) {
|
|
460
|
+
for (const r of results) {
|
|
461
|
+
const a = snapshots.find(r.id);
|
|
462
|
+
if (a?.tool === 'delete' && r.restored.length) { try { purgeFromBin(a.entries.filter((e) => e.bin && r.restored.includes(e.path)).map((e) => ({ path: e.path, bin: e.bin }))); } catch (e) { log(`[bridgerator] Recycle Bin cleanup: ${e.message}`); } }
|
|
463
|
+
}
|
|
464
|
+
return results;
|
|
465
|
+
}
|
|
466
|
+
// bridgerator_undo. The window's Undo (rpc undo.run) skips the folder checks: the person's own choice.
|
|
467
|
+
async function undoTool(args = {}) {
|
|
468
|
+
const live = snapshots.list().filter((a) => !a.undone);
|
|
469
|
+
if (args.action === 'list') return say(JSON.stringify({ changes: live.slice(0, 30).map((a) => ({ id: a.id, time: new Date(a.t).toISOString(), tool: a.tool, what: a.label, ...(a.task ? { task: a.task } : {}), ...(a.noUndo ? { noUndo: true } : {}) })) }, null, 2));
|
|
470
|
+
if (args.action !== undefined && args.action !== 'undo') return say('action must be "undo" or "list"', true);
|
|
471
|
+
const ids = args.id ? [String(args.id)] : args.task ? live.filter((a) => a.task?.id === String(args.task)).map((a) => a.id) : live.slice(0, Number.isInteger(args.count) && args.count > 0 ? Math.min(args.count, 50) : 1).map((a) => a.id);
|
|
472
|
+
if (!ids.length) return say('Nothing to undo: no recorded changes by ChatGPT on this PC.');
|
|
473
|
+
const first = snapshots.find(ids[0])?.entries[0]?.path;
|
|
474
|
+
const stop = first ? await gate.gate('write_file', { path: first }) : null;
|
|
475
|
+
if (stop) return stop;
|
|
476
|
+
const results = binCleanup(snapshots.undoMany(ids, { check: (p) => policy.writable(p) }));
|
|
477
|
+
const restored = results.flatMap((r) => r.restored), skipped = results.flatMap((r) => r.skipped.map((s) => ({ id: r.id, ...s })));
|
|
478
|
+
return say(JSON.stringify({ undone: results.filter((r) => r.restored.length).map((r) => r.id), restored, skipped }, null, 2), !restored.length);
|
|
479
|
+
}
|
|
480
|
+
out.wantAwake = () => wantAwake({ enabled: cfg.keepAwake !== false, inFlight: inFlight + delegate.running, lastCall, queue: queueCounts() });
|
|
481
|
+
// What bridgerator_settings get shows: the names "set" takes.
|
|
482
|
+
const toolView = (s) => {
|
|
483
|
+
const { confirm, telemetry, ...rest } = s;
|
|
484
|
+
return { ...rest, confirm: { enabled: confirm.enabled, ...confirm.ask, timeoutSeconds: confirm.timeoutSec }, telemetryEnabled: telemetry };
|
|
485
|
+
};
|
|
64
486
|
async function readResource(uri) {
|
|
65
487
|
if (!resourceUris.has(uri)) return { error: 'unknown resource' };
|
|
66
488
|
try { return { result: debrand(await client.readResource({ uri })) }; } catch (e) { return { error: String(e?.message || e) }; }
|
|
67
489
|
}
|
|
68
|
-
|
|
490
|
+
// Requests of the Bridgerator window (tray helper). Made by the person at the PC: no extra prompt.
|
|
491
|
+
// Theme for our pop-ups = the window's theme (Settings → Appearance); sound / style from Notifications.
|
|
492
|
+
// lang: the questions on the PC follow the window language (pc-text.mjs, V-3).
|
|
493
|
+
const popup = () => ({ theme: cfg.theme || cfg.popupTheme || 'system', silent: cfg.notify?.sound === false, style: cfg.popupStyle === 'popup' ? 'popup' : 'toast', lang: cfg.language === 'ru' ? 'ru' : 'en' });
|
|
494
|
+
out.popup = popup;
|
|
495
|
+
const bannerNow = () => { const b = planBanner(out.plan); return { ...b, visible: bannerVisible(b, cfg.planBannerDismissed) }; };
|
|
496
|
+
out.banner = bannerNow;
|
|
497
|
+
out.onPlanMsg = (m) => { out.plan = readPlan(m); out.onPlan?.(); };
|
|
498
|
+
const BOOL_NOTIFY = ['sound', 'offline', 'queueDone', 'limit'];
|
|
499
|
+
|
|
500
|
+
// Requests of the Bridgerator window (tray helper). Made by the person at the PC: no extra prompt.
|
|
501
|
+
async function rpc(method, p = {}, paths = []) {
|
|
502
|
+
switch (method) {
|
|
503
|
+
case 'getState': {
|
|
504
|
+
const conf = gate.settings();
|
|
505
|
+
return {
|
|
506
|
+
state: out.paused ? 'paused' : out.linkState, name: cfg.deviceName || out.linkName, version: VERSION, paused: out.paused,
|
|
507
|
+
email: out.email || '', plan: out.plan || null, banner: bannerNow(), setupDone: cfg.setupDone === true,
|
|
508
|
+
settings: {
|
|
509
|
+
confirm: conf, allowedDirectories: cfg.allowedDirectories || [], readOnlyDirectories: cfg.readOnlyDirectories || [],
|
|
510
|
+
theme: popup().theme, language: cfg.language || 'en', localHistory: cfg.localHistory === true, telemetry: cfg.telemetryEnabled !== false,
|
|
511
|
+
popupStyle: popup().style, notify: Object.fromEntries(BOOL_NOTIFY.map((k) => [k, cfg.notify?.[k] !== false])),
|
|
512
|
+
autostart: cfg.autostart !== false, deviceName: cfg.deviceName || out.linkName || '',
|
|
513
|
+
preset: presetOf(accessState(), drives()), terminal: terminalOn(), protectSensitive: cfg.protectSensitive !== false,
|
|
514
|
+
recycleBin: cfg.recycleBin !== false, snapshots: snapCfg(), fuse: fuseCfg(), keepAwake: cfg.keepAwake !== false, askSettings: cfg.askSettings === true,
|
|
515
|
+
...extra.values(cfg),
|
|
516
|
+
},
|
|
517
|
+
// Labels of the extra tools' settings for the window (Settings → Tools): extra-tools.mjs.
|
|
518
|
+
extraSettings: extra.settings.map((s) => ({ key: s.key, label: s.label, hint: s.hint, default: s.default, value: extra.value(cfg, s.key), ...(s.page ? { page: s.page } : {}) })),
|
|
519
|
+
awake: { want: out.wantAwake(), on: !!out.power?.on, ac: out.power ? out.power.ac !== false : true },
|
|
520
|
+
undo: snapshots ? { count: snapshots.count, mb: Math.round((snapshots.bytes / 1048576) * 10) / 10 } : null,
|
|
521
|
+
queue: queue ? queue.counts() : {},
|
|
522
|
+
pendingSetup: pendingSetup ? { id: pendingSetup.id, lines: pendingSetup.lines } : null,
|
|
523
|
+
automations: autos ? autos.list() : null, pendingBatch: batch.pending(), usage: forecast(out.plan), agents: delegate.view(cfg),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
case 'batch.decide': return batch.decide(String(p.id), p.allow);
|
|
527
|
+
case 'delegate.detect': case 'delegate.get': case 'delegate.cancel': return delegateRpc(delegate, method, p);
|
|
528
|
+
case 'automation.list': case 'automation.get': case 'automation.run': case 'automation.enable': case 'automation.remove': case 'automation.restore': return automationRpc(autos, method, p);
|
|
529
|
+
case 'setup.decide': if (pendingSetup && p.id === pendingSetup.id) pendingSetup.resolve(p.apply === true ? 'allow' : 'deny'); return true;
|
|
530
|
+
case 'setSetting': {
|
|
531
|
+
const { key, value } = p;
|
|
532
|
+
// Who changed it, for the log: the AI (bridgerator_settings), the settings file, or the window.
|
|
533
|
+
const via = ['bridgerator_settings', 'settings file'].includes(p.via) ? p.via : 'Bridgerator window';
|
|
534
|
+
const m = /^confirm\.(enabled|[a-z_]+)$/.exec(String(key));
|
|
535
|
+
// confirm.ini held by another program (V-9): nothing changes, the person is told (notice + the window's message).
|
|
536
|
+
const iniWrite = (sec, k, v) => { try { setIni(cfg.confirmIni, sec, k, v); } catch (e) { log(`[bridgerator] confirm.ini not written (${e.code || e.message}) — setting unchanged`); showNotice({ kind: 'confirm_locked' }, log); throw new Error('CONFIRM_LOCKED'); } };
|
|
537
|
+
if (m && (m[1] === 'enabled' || CATEGORIES.includes(m[1])) && typeof value === 'boolean') { iniWrite(m[1] === 'enabled' ? 'confirm' : 'ask', m[1], String(value)); gate.accept({ [m[1]]: value }); return true; }
|
|
538
|
+
if (key === 'confirm.timeout' && [60, 120, 300].includes(value)) { iniWrite('confirm', 'timeout_seconds', String(value)); return true; }
|
|
539
|
+
if (key === 'allowedDirectories' || key === 'readOnlyDirectories') {
|
|
540
|
+
const v = validateSetup({ [key]: value });
|
|
541
|
+
if (v.error && !(key === 'readOnlyDirectories' && Array.isArray(value) && !value.length)) throw new Error(v.error);
|
|
542
|
+
await applyValues(v.value || { readOnlyDirectories: [] }, via); return true;
|
|
543
|
+
}
|
|
544
|
+
if (key === 'theme' && ['light', 'dark', 'system'].includes(value)) { patchConfig({ theme: value }); return true; }
|
|
545
|
+
if (key === 'language' && ['en', 'ru'].includes(value)) { patchConfig({ language: value }); return true; }
|
|
546
|
+
if (key === 'localHistory' && typeof value === 'boolean') { patchConfig({ localHistory: value }); recent.setKeep(value); return true; }
|
|
547
|
+
if (key === 'telemetryEnabled' && typeof value === 'boolean') { patchConfig({ telemetryEnabled: value }); return true; }
|
|
548
|
+
if (key === 'popupStyle' && ['toast', 'popup'].includes(value)) { patchConfig({ popupStyle: value }); return true; }
|
|
549
|
+
const n = /^notify\.(\w+)$/.exec(String(key));
|
|
550
|
+
if (n && BOOL_NOTIFY.includes(n[1]) && typeof value === 'boolean') { patchConfig({ notify: { ...(cfg.notify || {}), [n[1]]: value } }); return true; }
|
|
551
|
+
// The new name goes to the relay too (mode message; the relay renames the computer in the account).
|
|
552
|
+
if (key === 'deviceName' && typeof value === 'string' && value.trim().length <= 40) { patchConfig({ deviceName: value.trim() || undefined, namePending: value.trim() || undefined }); out.namePending = value.trim(); out.nameGen++; out.onMode({ ...mode(), ...(value.trim() ? { deviceName: value.trim() } : {}) }); return true; }
|
|
553
|
+
if (key === 'autostart' && typeof value === 'boolean' && out.hooks?.autostart) { await out.hooks.autostart(value); patchConfig({ autostart: value }); return true; }
|
|
554
|
+
if (key === 'preset' && ['read_only', 'files', 'full'].includes(value)) { for (const c of presetChanges(value, accessState(), drives())) await rpc('setSetting', { ...c, via }); return true; }
|
|
555
|
+
if (key === 'terminal' && typeof value === 'boolean') { patchConfig({ disabledTools: terminalTools(cfg.disabledTools, value) }); policy = makePolicy(); out.onMode(mode()); return true; }
|
|
556
|
+
if (key === 'protectSensitive' && typeof value === 'boolean') { patchConfig({ protectSensitive: value }); policy = makePolicy(); return true; }
|
|
557
|
+
if ((key === 'recycleBin' || key === 'keepAwake' || key === 'askSettings') && typeof value === 'boolean') { patchConfig({ [key]: value }); return true; }
|
|
558
|
+
const g = /^(snapshots|fuse)\.(\w+)$/.exec(String(key));
|
|
559
|
+
if (g) {
|
|
560
|
+
const v = validateSettings({ [g[1]]: { [g[2]]: value } });
|
|
561
|
+
if (v.error) throw new Error(v.error);
|
|
562
|
+
patchConfig({ [g[1]]: { ...(g[1] === 'fuse' ? fuseCfg() : snapCfg()), [g[2]]: value } });
|
|
563
|
+
if (g[1] === 'snapshots') snapshots?.prune(); else fuse.reset();
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
if (extra.set(cfg, key, value, patchConfig)) return true;
|
|
567
|
+
throw new Error(`unknown setting ${key}`);
|
|
568
|
+
}
|
|
569
|
+
case 'queue.list': return queue.list(p.status);
|
|
570
|
+
case 'queue.add': return queue.add({ ...p, ...(paths.length ? { paths: [...(p.paths || []), ...paths] } : {}) });
|
|
571
|
+
case 'queue.edit': return queue.edit(p);
|
|
572
|
+
case 'queue.update': return queue.update(p);
|
|
573
|
+
case 'queue.reorder': return queue.reorder(p);
|
|
574
|
+
case 'queue.remove': return queue.remove(p);
|
|
575
|
+
case 'queue.restore': return queue.restore(p);
|
|
576
|
+
case 'queue.clearFinished': return queue.clearFinished();
|
|
577
|
+
case 'queue.clear': return queue.clear({ status: p.status });
|
|
578
|
+
case 'queue.restoreMany': return queue.restoreMany(p);
|
|
579
|
+
case 'recent.list': return recent.list();
|
|
580
|
+
case 'recent.clear': recent.clear(); return true;
|
|
581
|
+
case 'undo.list': { const list = snapshots ? snapshots.list() : []; return { actions: list.slice(0, 300), groups: undoGroups(list).slice(0, 30), count: list.length, mb: snapshots ? Math.round((snapshots.bytes / 1048576) * 10) / 10 : 0 }; }
|
|
582
|
+
case 'undo.run': {
|
|
583
|
+
if (!snapshots) throw new Error('Undo is off');
|
|
584
|
+
const ids = (Array.isArray(p.ids) ? p.ids : []).filter((x) => typeof x === 'string').slice(0, 500);
|
|
585
|
+
const r = binCleanup(snapshots.undoMany(ids));
|
|
586
|
+
return { restored: r.flatMap((x) => x.restored), skipped: r.flatMap((x) => x.skipped) };
|
|
587
|
+
}
|
|
588
|
+
case 'undo.clear': snapshots?.clear(); return true;
|
|
589
|
+
case 'dismissBanner': { const b = planBanner(out.plan); if (b.dismissible) patchConfig({ planBannerDismissed: dismissFor(b) }); out.onPlan?.(); return true; }
|
|
590
|
+
case 'testNotification': return ask({ head: 'Test notification', text: 'This is how Bridgerator asks before risky actions. Nothing will run.', buttons: [['allow', 'Allow', ''], ['allow10', 'Allow 10 min', ''], ['always', 'Allow always', ''], ['deny', 'Deny', 'deny']], secs: 60, auto: 'closes', ...popup() });
|
|
591
|
+
case 'checkUpdate': {
|
|
592
|
+
// The download list on our own site (what the installer would install), not npm.
|
|
593
|
+
const r = await fetch(`${siteOf(cfg.relay)}/bridgerator-win.json`, { signal: AbortSignal.timeout(10000) });
|
|
594
|
+
const latest = String((await r.json()).version || '');
|
|
595
|
+
const newer = (a, b) => { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0); return false; };
|
|
596
|
+
return { latest, current: VERSION, upToDate: !newer(latest, VERSION) };
|
|
597
|
+
}
|
|
598
|
+
case 'update': {
|
|
599
|
+
// Owner 21.10: no console for the person. Our own signed installer from our own site, hidden; it stops
|
|
600
|
+
// this bridge and starts the new one. The new version says "updated" (updatingFrom, kept by setup).
|
|
601
|
+
const site = siteOf(cfg.relay);
|
|
602
|
+
if (!/^https:\/\//.test(site)) throw new Error('no https site to update from');
|
|
603
|
+
patchConfig({ updatingFrom: VERSION });
|
|
604
|
+
showNotice({ kind: 'updating', version: String(p.version || '') }, log);
|
|
605
|
+
const ps = spawn(POWERSHELL, psArgs(`& ([scriptblock]::Create((Invoke-RestMethod '${site.replace(/'/g, "''")}/install.ps1')))`), { windowsHide: true, stdio: 'ignore', env: stripOurs(process.env) });
|
|
606
|
+
ps.on('error', (e) => log(`[bridgerator] update did not start: ${e.message}`));
|
|
607
|
+
ps.unref();
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
case 'pause': out.setPaused?.(true); return true;
|
|
611
|
+
case 'resume': out.setPaused?.(false); return true;
|
|
612
|
+
case 'unlink': if (!out.hooks?.unlink) throw new Error('not available'); setTimeout(() => out.hooks.unlink(), 300); return true;
|
|
613
|
+
case 'uninstall': if (!out.hooks?.uninstall) throw new Error('not available'); setTimeout(() => out.hooks.uninstall(), 300); return true;
|
|
614
|
+
default: throw new Error(`unknown request ${method}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// --- config.json changed outside the bridge (hand edit, CLI, a script) ------------------------------
|
|
618
|
+
// LOGIC: critical-logic/bridge.md ("Settings file"). Shown in the window within a second or two;
|
|
619
|
+
// lowering protection / widening access applies only after Apply on the PC ("Keep" writes the
|
|
620
|
+
// applied values back) — the same rule as confirm.ini (confirm.mjs createGate).
|
|
621
|
+
let reconciling = Promise.resolve();
|
|
622
|
+
async function reconcile(j) {
|
|
623
|
+
const cur = (await rpc('getState')).settings;
|
|
624
|
+
const changes = [];
|
|
625
|
+
const want = (key, v, now) => { if (JSON.stringify(v) !== JSON.stringify(now)) changes.push({ key, value: v }); };
|
|
626
|
+
const list = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []);
|
|
627
|
+
want('allowedDirectories', list(j.allowedDirectories), cur.allowedDirectories);
|
|
628
|
+
want('readOnlyDirectories', list(j.readOnlyDirectories), cur.readOnlyDirectories);
|
|
629
|
+
want('terminal', !list(j.disabledTools).includes('start_process'), cur.terminal);
|
|
630
|
+
for (const k of ['protectSensitive', 'recycleBin', 'keepAwake']) want(k, j[k] !== false, cur[k]);
|
|
631
|
+
want('askSettings', j.askSettings === true, cur.askSettings === true);
|
|
632
|
+
want('localHistory', j.localHistory === true, cur.localHistory);
|
|
633
|
+
want('telemetryEnabled', j.telemetryEnabled !== false, cur.telemetry);
|
|
634
|
+
want('theme', j.theme || j.popupTheme || 'system', cur.theme);
|
|
635
|
+
want('language', j.language || 'en', cur.language);
|
|
636
|
+
want('popupStyle', j.popupStyle === 'popup' ? 'popup' : 'toast', cur.popupStyle);
|
|
637
|
+
for (const k of BOOL_NOTIFY) want(`notify.${k}`, j.notify?.[k] !== false, cur.notify[k]);
|
|
638
|
+
want('deviceName', String(j.deviceName || ''), String(cfg.deviceName || ''));
|
|
639
|
+
for (const s of extra.settings) want(s.key, extra.value(j, s.key), extra.value(cfg, s.key));
|
|
640
|
+
for (const [g, d, now] of [['snapshots', SNAP_DEFAULTS, snapCfg()], ['fuse', FUSE_DEFAULTS, fuseCfg()]]) {
|
|
641
|
+
const f = { ...d, ...(j[g] && typeof j[g] === 'object' ? j[g] : {}) };
|
|
642
|
+
for (const k of Object.keys(d)) want(`${g}.${k}`, f[k], now[k]);
|
|
643
|
+
}
|
|
644
|
+
const den = list(j.deniedDirectories), denNow = cfg.deniedDirectories || [];
|
|
645
|
+
const denRemoved = denNow.filter((x) => !den.some((y) => y.toLowerCase() === x.toLowerCase()));
|
|
646
|
+
if (!changes.length && JSON.stringify(den) === JSON.stringify(denNow)) return;
|
|
647
|
+
const risky = changes.filter((c) => widens(cur, c)), safe = changes.filter((c) => !widens(cur, c));
|
|
648
|
+
const back = {};
|
|
649
|
+
const top = (key) => (key === 'terminal' ? 'disabledTools' : key.split('.')[0]);
|
|
650
|
+
for (const c of safe) { try { await rpc('setSetting', { ...c, via: 'settings file' }); } catch (e) { log(`[bridgerator] settings file: ${c.key} not applied (${e.message})`); back[top(c.key)] = cfg[top(c.key)]; } }
|
|
651
|
+
if (!denRemoved.length) { cfg.deniedDirectories = den; policy = makePolicy(); }
|
|
652
|
+
const lines = [...risky.map((c) => describe(cur, c)), ...(denRemoved.length ? [`Excluded folders: ${denRemoved.map((x) => `−${x}`).join(', ')}`] : [])];
|
|
653
|
+
if (lines.length) {
|
|
654
|
+
const r = await gate.askPerson('file', { head: 'Bridgerator settings file changed', text: clip(`Apply: ${lines.join(' · ')}?`, '', 180), buttons: [['allow', 'Apply', ''], ['deny', 'Keep', 'deny']], secs: 120, auto: 'auto-keep', ...popup() });
|
|
655
|
+
if (r === 'allow') {
|
|
656
|
+
for (const c of risky) { try { await rpc('setSetting', { ...c, via: 'settings file' }); } catch (e) { log(`[bridgerator] settings file: ${c.key} not applied (${e.message})`); back[top(c.key)] = cfg[top(c.key)]; } }
|
|
657
|
+
if (denRemoved.length) { cfg.deniedDirectories = den; policy = makePolicy(); }
|
|
658
|
+
log(`[bridgerator] settings file change applied on the PC: ${lines.join('; ')}`);
|
|
659
|
+
} else {
|
|
660
|
+
for (const c of risky) back[top(c.key)] = cfg[top(c.key)];
|
|
661
|
+
if (denRemoved.length) back.deniedDirectories = denNow;
|
|
662
|
+
log(`[bridgerator] settings file change kept out (${r}): ${lines.join('; ')}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (Object.keys(back).length) patchConfig(back);
|
|
666
|
+
out.onMode(mode());
|
|
667
|
+
}
|
|
668
|
+
const onConfigFile = () => {
|
|
669
|
+
const text = readText(cfg.configFile);
|
|
670
|
+
if (text === null || text === ownText) return;
|
|
671
|
+
let j;
|
|
672
|
+
try { j = JSON.parse(text); } catch { log('[bridgerator] config.json is not valid JSON — keeping the applied settings'); return; }
|
|
673
|
+
ownText = text;
|
|
674
|
+
reconciling = reconciling.then(() => reconcile(j)).catch((e) => log(`[bridgerator] settings file change not applied: ${e?.message || e}`));
|
|
675
|
+
};
|
|
676
|
+
if (cfg.configFile) { ownText = readText(cfg.configFile); fs.watchFile(cfg.configFile, { interval: 1000, persistent: false }, onConfigFile); out.checkConfig = onConfigFile; }
|
|
677
|
+
out.settled = () => reconciling;
|
|
678
|
+
|
|
679
|
+
return Object.assign(out, { client, tools: visible, resources, call, rpc, readResource, engineErr, mode, queue, recent, snapshots, close: () => { closing = true; stopAutos?.(); delegate.stop(); if (cfg.configFile) fs.unwatchFile(cfg.configFile, onConfigFile); return client.close(); } });
|
|
69
680
|
}
|
|
70
681
|
|
|
682
|
+
// The bridge never runs with TLS checks turned off, and neither does its engine child.
|
|
683
|
+
export function cleanEnv(env = process.env) {
|
|
684
|
+
const e = { ...env };
|
|
685
|
+
// Windows variable names are case-insensitive: node_tls_reject_unauthorized works there too.
|
|
686
|
+
for (const k of Object.keys(e)) if (k.toUpperCase() === 'NODE_TLS_REJECT_UNAUTHORIZED') delete e[k];
|
|
687
|
+
return e;
|
|
688
|
+
}
|
|
689
|
+
/** cleanEnv without any BRIDGERATOR_* / BR_* variables (test hooks, our paths). */
|
|
690
|
+
export function engineEnv(env = process.env) {
|
|
691
|
+
const e = cleanEnv(env);
|
|
692
|
+
for (const k of Object.keys(e)) if (/^(BRIDGERATOR_|BR_)/i.test(k)) delete e[k];
|
|
693
|
+
return e;
|
|
694
|
+
}
|
|
695
|
+
export function engineCwd(cfg, home = os.homedir()) {
|
|
696
|
+
for (const d of cfg.allowedDirectories || []) { try { if (fs.statSync(d).isDirectory()) return d; } catch { /* missing */ } }
|
|
697
|
+
return home;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export const checkRelay = (url) => {
|
|
701
|
+
if (!/^wss:\/\/[^\s/]+/i.test(String(url || ''))) throw new Error(`relay must be a wss:// address, got "${url}"`);
|
|
702
|
+
return url;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// 4004 = another bridge with this key took the link: wait longer each time (1 min … 30 min).
|
|
706
|
+
let replaced = 0, toldReplaced = false, stats = null, linkedAt = 0;
|
|
707
|
+
const REPLACED_RESET_MS = 60 * 60000;
|
|
708
|
+
const events = createEventLimiter(); // one per bridge process, across reconnects
|
|
709
|
+
export const replacedDelay = (n) => Math.min(60000 * 2 ** Math.max(0, n - 1), 30 * 60000);
|
|
710
|
+
|
|
71
711
|
export function connect(cfg, dc, log = console.log) {
|
|
72
|
-
|
|
712
|
+
checkRelay(cfg.relay);
|
|
713
|
+
try { setSite(siteOf(cfg.relay)); } catch { /* checked above */ }
|
|
714
|
+
// Heartbeat counters (telemetry.mjs) — only when telemetry is on.
|
|
715
|
+
if (!stats && cfg.usageFile && cfg.installId && cfg.telemetryEnabled !== false) {
|
|
716
|
+
stats = usage(cfg, new Set(dc.tools.map((t) => t.name)), cfg.usageFile);
|
|
717
|
+
setInterval(() => stats.tick(), 3600 * 1000).unref();
|
|
718
|
+
const inner = dc.call;
|
|
719
|
+
dc.call = async (name, args) => { const r = await inner(name, args); stats.count(name, !!r?.isError); return r; };
|
|
720
|
+
}
|
|
721
|
+
const ws = new WebSocket(cfg.relay, { handshakeTimeout: 15000, maxPayload: 4 * 1024 * 1024 });
|
|
722
|
+
let helloName = '', helloGen = 0;
|
|
723
|
+
const nameSent = (n) => { if (dc.nameSent) dc.nameSent(n); else if (dc.namePending === n) dc.namePending = ''; };
|
|
724
|
+
// Live mode updates ({confirm, readOnly}) go over the current link.
|
|
725
|
+
// A name given on this PC reaches the relay in this mode message; offline it waits for the next hello.
|
|
726
|
+
dc.onMode = (m) => { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'mode', ...m })); if (typeof m.deviceName === 'string') nameSent(m.deviceName); } };
|
|
727
|
+
// Events for the relay's notifications (Telegram…): counts / status only, never names or contents.
|
|
728
|
+
dc.onEvent = (e) => {
|
|
729
|
+
const m = eventMessage(e);
|
|
730
|
+
if (!m || ws.readyState !== ws.OPEN) return;
|
|
731
|
+
if (!events.take()) { if (!dc.eventsDropped) log('[bridgerator] more than 30 notifications a minute for the relay — the rest are dropped'); dc.eventsDropped = true; return; }
|
|
732
|
+
dc.eventsDropped = false;
|
|
733
|
+
ws.send(JSON.stringify(m));
|
|
734
|
+
};
|
|
73
735
|
let alive = false, seen = Date.now();
|
|
74
736
|
const touch = () => { seen = Date.now(); };
|
|
75
|
-
const watch = setInterval(() => { if (Date.now() - seen > SILENT_MS) { log('[bridgerator] relay silent for 75s'); ws.terminate(); } }, 15000);
|
|
737
|
+
const watch = setInterval(() => { dc.mode?.(); if (Date.now() - seen > SILENT_MS) { log('[bridgerator] relay silent for 75s'); ws.terminate(); } }, 15000);
|
|
76
738
|
ws.on('ping', touch);
|
|
77
739
|
ws.on('pong', touch);
|
|
78
740
|
ws.on('open', () => {
|
|
79
741
|
alive = true;
|
|
80
742
|
touch();
|
|
81
|
-
|
|
743
|
+
// deviceName: only a name given on this PC while it was offline (not yet sent) — the relay renames the
|
|
744
|
+
// computer. Never on every hello: that would undo a rename made in the cabinet at each reconnect.
|
|
745
|
+
// Cleared only when the relay accepts this hello (welcome): a dropped / refused link sends it again.
|
|
746
|
+
helloName = dc.namePending ? String(dc.namePending) : '';
|
|
747
|
+
helloGen = dc.nameGen;
|
|
748
|
+
ws.send(JSON.stringify({ type: 'hello', token: cfg.token, tools: dc.tools, resources: dc.resources, ...(dc.mode ? dc.mode() : {}), ...(helloName ? { deviceName: helloName } : {}) }));
|
|
82
749
|
log('[bridgerator] linked to relay, waiting for calls');
|
|
83
750
|
});
|
|
84
|
-
ws.on('message',
|
|
751
|
+
ws.on('message', (raw) => { onMessage(raw).catch((e) => log(`[bridgerator] message failed: ${e?.message || e}`)); });
|
|
752
|
+
async function onMessage(raw) {
|
|
85
753
|
touch();
|
|
86
754
|
let msg; try { msg = JSON.parse(raw); } catch { return; }
|
|
87
|
-
if (msg.type === 'welcome') {
|
|
88
|
-
|
|
755
|
+
if (msg.type === 'welcome') {
|
|
756
|
+
if (helloName) { nameSent(helloName); helloName = ''; }
|
|
757
|
+
dc.relayName?.(String(msg.device ?? ''), helloGen);
|
|
758
|
+
// The 4004 back-off is NOT reset here: two bridges with one key would otherwise swap the link
|
|
759
|
+
// every minute forever. It resets only after the link stayed ours for REPLACED_RESET_MS.
|
|
760
|
+
linkedAt = Date.now(); log(`[bridgerator] registered as "${msg.device}"`);
|
|
761
|
+
if (typeof msg.email === 'string' && msg.email.length < 200) dc.email = msg.email;
|
|
762
|
+
if (msg.plan && typeof msg.plan === 'object') dc.onPlanMsg?.(msg.plan);
|
|
763
|
+
dc.onLink?.('online', String(msg.device || ''));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (msg.type === 'notice') {
|
|
767
|
+
if (['limit', 'expiring', 'expired'].includes(msg.kind) && cfg.notify?.limit === false) return;
|
|
768
|
+
showNotice({ ...msg, renewUrl: msg.renewUrl ?? dc.plan?.renewUrl }, log);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (msg.type === 'plan') { dc.onPlanMsg?.(msg); return; }
|
|
89
772
|
if (msg.type === 'read_resource' && msg.callId) {
|
|
90
773
|
const r = await dc.readResource(msg.uri);
|
|
91
774
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'result', callId: msg.callId, ...r }));
|
|
92
775
|
return;
|
|
93
776
|
}
|
|
94
|
-
if (msg.type !== 'call' || !msg.callId) return;
|
|
95
|
-
|
|
777
|
+
if (msg.type !== 'call' || !msg.callId || typeof msg.name !== 'string') return;
|
|
778
|
+
let result;
|
|
779
|
+
try { result = await dc.call(msg.name, msg.args && typeof msg.args === 'object' && !Array.isArray(msg.args) ? msg.args : {}); } catch (e) { result = { content: [{ type: 'text', text: `bridge error: ${e?.message || e}` }], isError: true }; }
|
|
96
780
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'result', callId: msg.callId, result }));
|
|
97
|
-
}
|
|
98
|
-
const die = (why) => {
|
|
781
|
+
}
|
|
782
|
+
const die = (why, ms = RECONNECT_MS) => {
|
|
99
783
|
if (ws._down) return;
|
|
100
784
|
ws._down = true;
|
|
101
785
|
clearInterval(watch);
|
|
102
|
-
log(`[bridgerator] disconnected (${why}); reconnecting in ${
|
|
103
|
-
setTimeout(() => connect(cfg, dc, log),
|
|
786
|
+
log(`[bridgerator] disconnected (${why}); reconnecting in ${Math.round(ms / 1000)}s`);
|
|
787
|
+
setTimeout(() => connect(cfg, dc, log), ms);
|
|
104
788
|
};
|
|
105
789
|
ws.on('close', (code) => {
|
|
106
790
|
clearInterval(watch);
|
|
791
|
+
dc.onLink?.('offline');
|
|
107
792
|
// Exit 0 = done on purpose: the autostart loop (run-hidden.vbs) does not restart it.
|
|
108
793
|
if (code === 4003) {
|
|
109
|
-
log(
|
|
794
|
+
log(`[bridgerator] This computer is no longer linked to your Bridgerator account. Run the install command from ${accountUrl()} again.`);
|
|
110
795
|
ws._down = true;
|
|
111
796
|
showNotice({ kind: 'unlinked' }, log, () => process.exit(0));
|
|
112
797
|
return;
|
|
113
798
|
}
|
|
114
|
-
if (code === 4004) {
|
|
799
|
+
if (code === 4004) {
|
|
800
|
+
if (!toldReplaced) { toldReplaced = true; showNotice({ kind: 'replaced' }, log); }
|
|
801
|
+
if (linkedAt && Date.now() - linkedAt > REPLACED_RESET_MS) replaced = 0;
|
|
802
|
+
linkedAt = 0;
|
|
803
|
+
die('another Bridgerator with this computer\'s key took over the link', replacedDelay(++replaced));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
115
806
|
die(`close ${code}`);
|
|
116
807
|
});
|
|
117
808
|
ws.on('error', (e) => { if (!alive) die(`connect: ${e.message}`); });
|