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/confirm.mjs
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// On-PC confirmation (OFF by default): when turned on, risky calls — deletions and system-changing
|
|
2
|
+
// commands; optionally every file write, move or command — wait for a window on the PC:
|
|
3
|
+
// Allow / Allow for 10 minutes / Allow always / Deny (Esc, closing it or no answer = Deny).
|
|
4
|
+
// Settings: confirm.ini in the Bridgerator folder (always excluded from the file tools), re-read on
|
|
5
|
+
// every call so edits apply at once. Only the person turns it off; the assistant can only ask
|
|
6
|
+
// (request_confirm_off → a window on the PC). Turning it back on needs no window.
|
|
7
|
+
// ponytail: a regex list per category, not a shell parser — an obfuscated command slips through; the
|
|
8
|
+
// window is a safety net (like blockedCommands), not a boundary. Said so on the site.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import { askOnPc } from './ask.mjs';
|
|
12
|
+
|
|
13
|
+
export const ALLOW_FOR_MS = 10 * 60 * 1000;
|
|
14
|
+
export const CATEGORIES = ['delete', 'dangerous_commands', 'write_files', 'move', 'any_command', 'install', 'scheduled_scripts'];
|
|
15
|
+
const DEFAULT_ASK = { delete: true, dangerous_commands: true, write_files: false, move: false, any_command: false, install: false, scheduled_scripts: false };
|
|
16
|
+
|
|
17
|
+
export const INI_TEMPLATE = `; Bridgerator: confirmation window on this PC before risky actions by ChatGPT.
|
|
18
|
+
; Changes apply right away (no restart). The assistant cannot read or edit this file.
|
|
19
|
+
; It is a safety net: commands are matched by patterns, a disguised command can slip through.
|
|
20
|
+
|
|
21
|
+
[confirm]
|
|
22
|
+
; true = ask on this PC; false = never ask (default). Also: bridgerator.cmd confirm on|off
|
|
23
|
+
enabled = false
|
|
24
|
+
; no answer within this time = Deny
|
|
25
|
+
timeout_seconds = 120
|
|
26
|
+
|
|
27
|
+
[ask]
|
|
28
|
+
; deleting files or folders (del, rd, Remove-Item, rm, ...)
|
|
29
|
+
delete = true
|
|
30
|
+
; commands that change the system (format, diskpart, bcdedit, reg add/delete, shutdown, icacls, net user, ...)
|
|
31
|
+
dangerous_commands = true
|
|
32
|
+
; every file write or edit (write_file, edit_block)
|
|
33
|
+
write_files = false
|
|
34
|
+
; every move or rename (move_file)
|
|
35
|
+
move = false
|
|
36
|
+
; every terminal command
|
|
37
|
+
any_command = false
|
|
38
|
+
; installing or updating programs (software tool, winget / msiexec / choco in the terminal)
|
|
39
|
+
install = false
|
|
40
|
+
; ChatGPT puts a script on a schedule in Windows Task Scheduler or changes one (automations)
|
|
41
|
+
scheduled_scripts = false
|
|
42
|
+
|
|
43
|
+
[patterns]
|
|
44
|
+
; extra regular expressions (case-insensitive) that always ask, matched against commands and paths:
|
|
45
|
+
; pattern1 = \\bgit\\s+push\\b
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// Case-insensitive patterns per category, matched against the command text.
|
|
49
|
+
const RX = {
|
|
50
|
+
delete: [
|
|
51
|
+
/(^|[\s;&|('"`])(del|erase|rd|rmdir|ri|rm|rimraf|shred)(\.exe)?\s/i,
|
|
52
|
+
/\bRemove-Item\b/i, /\bClear-RecycleBin\b/i, /\bClear-Content\b/i,
|
|
53
|
+
/\[(System\.)?IO\.(File|Directory)\]::Delete/i, /\.Delete\(\s*\)/i, /\bunlink(Sync)?\s*\(/i, /\brmSync\s*\(/i,
|
|
54
|
+
/\bMove-Item\b.*\$null/i, /\bmove\b.*\bnul\b/i,
|
|
55
|
+
],
|
|
56
|
+
dangerous_commands: [
|
|
57
|
+
/\bformat(\.com)?\s+[a-z]:/i, /\bdiskpart\b/i, /\bbcdedit\b/i, /\bbcdboot\b/i, /\bmanage-bde\b/i,
|
|
58
|
+
/\breg(\.exe)?\s+(add|delete|import|restore|load|unload|copy)\b/i,
|
|
59
|
+
/\b(Set|New|Remove)-ItemProperty\b.*\bHK(LM|CR|U)\b/i, /\bHKLM:/i,
|
|
60
|
+
/\bshutdown(\.exe)?\b/i, /\b(Stop|Restart)-Computer\b/i, /\blogoff\b/i,
|
|
61
|
+
/\bcipher(\.exe)?\s+\/w/i, /\btakeown\b/i, /\bicacls\b/i, /\bSet-Acl\b/i,
|
|
62
|
+
/\bnet(\.exe)?\s+(user|localgroup|share|stop)\b/i, /\b(New|Remove|Set)-Local(User|Group)/i,
|
|
63
|
+
/\bsc(\.exe)?\s+(delete|config|stop|create)\b/i, /\b(Stop|Set|Remove|New)-Service\b/i,
|
|
64
|
+
/\bschtasks(\.exe)?\s+\/(delete|create|change)\b/i, /\b(Unregister|Register|Disable)-ScheduledTask\b/i,
|
|
65
|
+
/\bSet-ExecutionPolicy\b/i, /\bvssadmin\b.*\b(delete|resize)\b/i, /\bwmic\b.*\bshadowcopy\b/i, /\bwbadmin\b.*\bdelete\b/i,
|
|
66
|
+
/\b(Format-Volume|Clear-Disk|Initialize-Disk|Remove-Partition|Set-Partition|Resize-Partition)\b/i,
|
|
67
|
+
/\bwevtutil(\.exe)?\s+cl\b/i, /\bClear-EventLog\b/i, /\bDisable-ComputerRestore\b/i,
|
|
68
|
+
/\bnetsh\b.*\b(firewall|advfirewall)\b.*\b(set|add|delete)\b/i, /\bSet-MpPreference\b/i, /\bAdd-MpPreference\b/i,
|
|
69
|
+
/\b(taskkill|Stop-Process|kill)\b.*\b(lsass|csrss|winlogon|wininit|services|smss|svchost|explorer|dwm)\b/i,
|
|
70
|
+
],
|
|
71
|
+
install: [
|
|
72
|
+
/\bwinget(\.exe)?\s+(install|upgrade|update|uninstall|remove|import)\b/i, /\bmsiexec(\.exe)?\b/i,
|
|
73
|
+
/\b(choco|scoop)(\.exe)?\s+(install|upgrade|update|uninstall)\b/i, /\b(Install|Update|Uninstall)-(Package|Module|Script|WinGetPackage)\b/i, /\bAdd-AppxPackage\b/i,
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
const commandOf = (name, args) => (name === 'start_process' ? args?.command : name === 'interact_with_process' ? args?.input : null);
|
|
77
|
+
|
|
78
|
+
/** → { cat, fragment } — the category that makes this call ask (or cat null) and the matched text. */
|
|
79
|
+
export function findMatch(name, args = {}, ask = DEFAULT_ASK, patterns = []) {
|
|
80
|
+
const cmd = commandOf(name, args);
|
|
81
|
+
const text = typeof cmd === 'string' ? cmd : null;
|
|
82
|
+
const hay = [text, args?.path, args?.file_path, args?.source, args?.destination].filter((x) => typeof x === 'string');
|
|
83
|
+
const hit = (list, strs) => { for (const re of list) for (const h of strs) { const m = re.exec(h); if (m) return m[0].trim() || h; } return null; };
|
|
84
|
+
let f;
|
|
85
|
+
if ((f = hit(patterns, hay))) return { cat: 'pattern', fragment: f };
|
|
86
|
+
if (text && ask.delete && (f = hit(RX.delete, [text]))) return { cat: 'delete', fragment: f };
|
|
87
|
+
if (text && ask.dangerous_commands && (f = hit(RX.dangerous_commands, [text]))) return { cat: 'dangerous_commands', fragment: f };
|
|
88
|
+
if (ask.write_files && (name === 'write_file' || name === 'edit_block')) return { cat: 'write_files', fragment: '' };
|
|
89
|
+
if (ask.move && name === 'move_file') return { cat: 'move', fragment: '' };
|
|
90
|
+
if (ask.install && name === 'software' && ['install', 'upgrade'].includes(args?.action)) return { cat: 'install', fragment: '' };
|
|
91
|
+
if (text && ask.install && (f = hit(RX.install, [text]))) return { cat: 'install', fragment: f };
|
|
92
|
+
if (ask.any_command && text !== null) return { cat: 'any_command', fragment: '' };
|
|
93
|
+
return { cat: null, fragment: '' };
|
|
94
|
+
}
|
|
95
|
+
export const categorize = (...a) => findMatch(...a).cat;
|
|
96
|
+
|
|
97
|
+
// --- confirm.ini ---------------------------------------------------------------------------
|
|
98
|
+
export function parseIni(text) {
|
|
99
|
+
const out = {};
|
|
100
|
+
let sec = '';
|
|
101
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
102
|
+
const line = raw.trim();
|
|
103
|
+
if (!line || line.startsWith(';') || line.startsWith('#')) continue;
|
|
104
|
+
const m = line.match(/^\[(.+)\]$/);
|
|
105
|
+
if (m) { sec = m[1].trim().toLowerCase(); out[sec] ||= {}; continue; }
|
|
106
|
+
const i = line.indexOf('=');
|
|
107
|
+
if (i > 0) (out[sec] ||= {})[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
const bool = (v, d) => (v === undefined ? d : /^(1|true|yes|on)$/i.test(v));
|
|
112
|
+
|
|
113
|
+
export function readSettings(file) {
|
|
114
|
+
let ini = {};
|
|
115
|
+
try { ini = parseIni(fs.readFileSync(file, 'utf8')); } catch { /* missing = defaults */ }
|
|
116
|
+
const c = ini.confirm || {}, a = ini.ask || {};
|
|
117
|
+
const patterns = [];
|
|
118
|
+
for (const v of Object.values(ini.patterns || {})) { try { patterns.push(new RegExp(v, 'i')); } catch { /* bad regex: ignored */ } }
|
|
119
|
+
const secs = Number.parseInt(c.timeout_seconds, 10);
|
|
120
|
+
return {
|
|
121
|
+
enabled: bool(c.enabled, false),
|
|
122
|
+
timeoutSec: Number.isFinite(secs) && secs >= 10 && secs <= 3600 ? secs : 120,
|
|
123
|
+
ask: Object.fromEntries(CATEGORIES.map((k) => [k, bool(a[k], DEFAULT_ASK[k])])),
|
|
124
|
+
patterns,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Like readSettings, but null when the file is missing, unreadable or has no valid [confirm] enabled. */
|
|
129
|
+
export function readSettingsStrict(file) {
|
|
130
|
+
let text;
|
|
131
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
132
|
+
const ini = parseIni(text);
|
|
133
|
+
if (!ini.confirm || !/^(1|0|true|false|yes|no|on|off)$/i.test(ini.confirm.enabled || '')) return null;
|
|
134
|
+
return readSettings(file);
|
|
135
|
+
}
|
|
136
|
+
// Atomic: temp file + rename (a crash or a parallel reader never sees half a file).
|
|
137
|
+
// Windows: an antivirus / indexer / backup tool may hold the target for a moment → rename fails with
|
|
138
|
+
// EPERM / EBUSY / EACCES. Retried for up to ~1 s (like graceful-fs); still locked → the error, temp removed.
|
|
139
|
+
// wait: false → one try, no blocking wait (a background retry after an earlier failure).
|
|
140
|
+
export function writeAtomic(file, text, { wait = true } = {}) {
|
|
141
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
142
|
+
fs.writeFileSync(tmp, text);
|
|
143
|
+
for (let i = 0; ; i++) {
|
|
144
|
+
try { fs.renameSync(tmp, file); return; } catch (e) {
|
|
145
|
+
if (i >= (wait ? 40 : 0) || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) { try { fs.rmSync(tmp, { force: true }); } catch { /* gone */ } throw e; }
|
|
146
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const TEMPLATE_CRLF = INI_TEMPLATE.replace(/\n/g, '\r\n');
|
|
151
|
+
/** confirm.ini missing → created (from the last applied state when there is one, never "off" over it). */
|
|
152
|
+
export function ensureIni(file) { restoreIni(file); }
|
|
153
|
+
|
|
154
|
+
// The last applied state, kept next to confirm.ini (confirm.ini → confirm.approved, JSON): a deleted
|
|
155
|
+
// confirm.ini is recreated from it (at start, at setup and while running), so deleting the file never
|
|
156
|
+
// turns confirmations off. No copy yet (never applied) → the product defaults (confirmations off).
|
|
157
|
+
export const approvedFile = (ini) => (ini ? `${ini.replace(/\.ini$/i, '')}.approved` : '');
|
|
158
|
+
const approvedJson = (s) => JSON.stringify({ enabled: s.enabled, timeoutSec: s.timeoutSec, ask: s.ask, patterns: s.patterns.map((r) => r.source) });
|
|
159
|
+
export function readApproved(ini) {
|
|
160
|
+
let j;
|
|
161
|
+
for (let i = 0; ; i++) {
|
|
162
|
+
try { j = JSON.parse(fs.readFileSync(approvedFile(ini), 'utf8')); break; } catch (e) {
|
|
163
|
+
if (e?.code === 'ENOENT' || e instanceof SyntaxError) return null; // nothing applied yet / broken: not trusted
|
|
164
|
+
// Held by another program (antivirus): retry briefly, then fail CLOSED - a locked file never lowers protection.
|
|
165
|
+
if (i >= 3) return { enabled: true, timeoutSec: 120, ask: Object.fromEntries(CATEGORIES.map((k) => [k, true])) };
|
|
166
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!j || typeof j.enabled !== 'boolean') return null;
|
|
170
|
+
const t = j.timeoutSec;
|
|
171
|
+
return {
|
|
172
|
+
enabled: j.enabled,
|
|
173
|
+
timeoutSec: Number.isInteger(t) && t >= 10 && t <= 3600 ? t : 120,
|
|
174
|
+
ask: Object.fromEntries(CATEGORIES.map((k) => [k, typeof j.ask?.[k] === 'boolean' ? j.ask[k] : DEFAULT_ASK[k]])),
|
|
175
|
+
patterns: (Array.isArray(j.patterns) ? j.patterns : []).flatMap((p) => { try { return [new RegExp(String(p), 'i')]; } catch { return []; } }),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** The full confirm.ini text for settings `s`, built on `base` (the template by default; other lines kept). */
|
|
179
|
+
export function iniTextFrom(s, base = TEMPLATE_CRLF) {
|
|
180
|
+
let t = setIniText(base, 'confirm', 'enabled', String(s.enabled));
|
|
181
|
+
t = setIniText(t, 'confirm', 'timeout_seconds', String(s.timeoutSec));
|
|
182
|
+
for (const k of CATEGORIES) t = setIniText(t, 'ask', k, String(s.ask[k]));
|
|
183
|
+
const have = parseIni(t).patterns || {};
|
|
184
|
+
const kept = Object.values(have).map((v) => { try { return new RegExp(v, 'i').source; } catch { return null; } });
|
|
185
|
+
let i = 1;
|
|
186
|
+
for (const r of s.patterns) if (!kept.includes(r.source)) { while (`pattern${i}` in have) i++; t = setIniText(t, 'patterns', `pattern${i++}`, r.source); }
|
|
187
|
+
return t;
|
|
188
|
+
}
|
|
189
|
+
// The current text, or (missing) what a recreated file holds: the last applied state, else the template.
|
|
190
|
+
const textOrRecreated = (file) => {
|
|
191
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
192
|
+
const s = readApproved(file);
|
|
193
|
+
return s ? iniTextFrom(s) : TEMPLATE_CRLF;
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Writes settings `s` into confirm.ini in ONE atomic write (built in memory on the current text, or on
|
|
197
|
+
* the template when missing; other lines kept) — a failure leaves the file as it was (or absent).
|
|
198
|
+
*/
|
|
199
|
+
export function writeIniFrom(file, s, opts) {
|
|
200
|
+
let base;
|
|
201
|
+
try { base = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; base = TEMPLATE_CRLF; }
|
|
202
|
+
writeAtomic(file, iniTextFrom(s, base), opts);
|
|
203
|
+
}
|
|
204
|
+
/** confirm.ini missing → recreated in one write: from the last applied state when there is one, else the template. */
|
|
205
|
+
export function restoreIni(file) {
|
|
206
|
+
if (fs.existsSync(file)) return false;
|
|
207
|
+
writeAtomic(file, textOrRecreated(file));
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Sets section.key = value in place (comments and other lines kept); adds the key if missing. A missing
|
|
212
|
+
* file is recreated from the last applied state (never the "off" template over it), in the same write. */
|
|
213
|
+
export function setIni(file, section, key, value) {
|
|
214
|
+
writeAtomic(file, setIniText(textOrRecreated(file), section, key, value));
|
|
215
|
+
}
|
|
216
|
+
/** setIni on a text: → the new text. */
|
|
217
|
+
export function setIniText(text, section, key, value) {
|
|
218
|
+
const lines = String(text).split(/\r?\n/);
|
|
219
|
+
let sec = '', at = -1, end = -1;
|
|
220
|
+
for (let i = 0; i < lines.length; i++) {
|
|
221
|
+
const t = lines[i].trim(), m = t.match(/^\[(.+)\]$/);
|
|
222
|
+
if (m) { if (sec === section && end < 0) end = i; sec = m[1].trim().toLowerCase(); continue; }
|
|
223
|
+
if (sec === section && !t.startsWith(';') && t.split('=')[0].trim().toLowerCase() === key) { at = i; break; }
|
|
224
|
+
}
|
|
225
|
+
if (at >= 0) lines[at] = `${key} = ${value}`;
|
|
226
|
+
else if (lines.some((l) => l.trim().toLowerCase() === `[${section}]`)) lines.splice(end >= 0 ? end : lines.length, 0, `${key} = ${value}`);
|
|
227
|
+
else lines.push('', `[${section}]`, `${key} = ${value}`);
|
|
228
|
+
return lines.join('\r\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- the window --------------------------------------------------------------------------------
|
|
232
|
+
const HEAD = {
|
|
233
|
+
delete: 'ChatGPT wants to delete files', dangerous_commands: 'ChatGPT wants to run a system command',
|
|
234
|
+
write_files: 'ChatGPT wants to change a file', move: 'ChatGPT wants to move or rename', any_command: 'ChatGPT wants to run a command',
|
|
235
|
+
install: 'ChatGPT wants to install or update a program', scheduled_scripts: 'ChatGPT wants to schedule a script',
|
|
236
|
+
pattern: 'ChatGPT wants to do something your rules ask about',
|
|
237
|
+
};
|
|
238
|
+
export const LABEL = { delete: 'deletions', dangerous_commands: 'system commands', write_files: 'file changes', move: 'moves and renames', any_command: 'commands', install: 'installing programs', scheduled_scripts: 'scheduled scripts' };
|
|
239
|
+
const short = (s, n = 180) => { s = String(s ?? ''); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
|
|
240
|
+
|
|
241
|
+
/** What the window shows under the header: the command or path(s), ≤ 300 chars. */
|
|
242
|
+
/**
|
|
243
|
+
* What the notification shows: the command or path(s) on one line (whitespace collapsed). Too long →
|
|
244
|
+
* head … (+N chars) … tail, and the fragment that triggered the question is always kept.
|
|
245
|
+
*/
|
|
246
|
+
export function detail(name, args = {}, fragment = '', max = 140) {
|
|
247
|
+
const a = args || {};
|
|
248
|
+
const t = name === 'software' ? `${a.action === 'upgrade' ? 'Update' : 'Install'}: ${a.id || 'all programs'}`
|
|
249
|
+
: a.source && a.destination ? `${a.source} → ${a.destination}`
|
|
250
|
+
: commandOf(name, a) ?? a.path ?? a.file_path ?? JSON.stringify(a);
|
|
251
|
+
return clip(String(t ?? ''), fragment, max);
|
|
252
|
+
}
|
|
253
|
+
export function clip(t, fragment = '', max = 140) {
|
|
254
|
+
const flat = t.replace(/\s+/g, ' ').trim();
|
|
255
|
+
if (flat.length <= max) return flat;
|
|
256
|
+
const H = 60, T = 40;
|
|
257
|
+
const head = flat.slice(0, H), tail = flat.slice(-T);
|
|
258
|
+
let mid = '';
|
|
259
|
+
const frag = String(fragment || '').replace(/\s+/g, ' ').trim();
|
|
260
|
+
const i = frag ? flat.toLowerCase().indexOf(frag.toLowerCase()) : -1;
|
|
261
|
+
if (i >= 0 && !(i + frag.length <= H) && !(i >= flat.length - T)) mid = flat.slice(Math.max(H, i - 10), Math.min(flat.length - T, i + frag.length + 10));
|
|
262
|
+
const hidden = flat.length - head.length - tail.length - mid.length;
|
|
263
|
+
return mid ? `${head} … ${mid} … (+${hidden} chars) … ${tail}` : `${head} … (+${hidden} chars) … ${tail}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export { askOnPc } from './ask.mjs';
|
|
267
|
+
|
|
268
|
+
// Tools the bridge adds itself (not the engine's).
|
|
269
|
+
const NO_ARGS = { type: 'object', properties: {}, additionalProperties: false };
|
|
270
|
+
export const CONFIRM_TOOL_DEFS = [
|
|
271
|
+
{ name: 'request_confirm_off', description: 'Ask the person at this PC to turn off the confirmation window for risky actions. Only the person can approve it, in a window on the PC; returns approved or declined.', inputSchema: NO_ARGS },
|
|
272
|
+
{ name: 'confirm_on', description: 'Turn the confirmation window for risky actions on this PC back on. No approval needed.', inputSchema: NO_ARGS },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const notConfirmed = (text) => ({ content: [{ type: 'text', text }], isError: true, notConfirmed: true });
|
|
276
|
+
export const COOLDOWN_MS = 10 * 60 * 1000;
|
|
277
|
+
|
|
278
|
+
/** What `next` turns off compared with `cur` (protection lowered): list of human labels. */
|
|
279
|
+
export function weakerBy(cur, next) {
|
|
280
|
+
const off = [];
|
|
281
|
+
if (cur.enabled && !next.enabled) off.push('confirmations');
|
|
282
|
+
if (next.enabled || cur.enabled) for (const k of CATEGORIES) if (cur.ask[k] && !next.ask[k]) off.push(`confirmations for ${LABEL[k]}`);
|
|
283
|
+
const src = (l) => l.map((r) => r.source);
|
|
284
|
+
if (src(cur.patterns).some((p) => !src(next.patterns).includes(p))) off.push('your own patterns');
|
|
285
|
+
return off;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* ini: path of confirm.ini. The file can't lower protection by itself: the last applied settings are
|
|
290
|
+
* kept in memory; a change that turns something off (or a missing / broken file) is applied only
|
|
291
|
+
* after Apply on the PC ("Keep" writes the applied state back); raising protection applies at once.
|
|
292
|
+
* accept() = the bridge itself changed the file for the person (Bridgerator window, Allow always…).
|
|
293
|
+
* gate(name, args) → null (go ahead) or the result to return instead. Only the questions are
|
|
294
|
+
* serialized (one at a time); identical pending questions share one answer.
|
|
295
|
+
*/
|
|
296
|
+
export function createGate({ ini, ask = askOnPc, now = Date.now, onChange = () => {}, onAlways = () => {}, onTurnedOff = () => {}, host = os.hostname() } = {}) {
|
|
297
|
+
// At start: the last applied state (confirm.approved) when there is one, else the file, else the defaults.
|
|
298
|
+
// The file is then checked like any edit (first sync): stronger → applied at once, weaker → Apply / Keep
|
|
299
|
+
// on the PC (an edit made while the bridge was stopped too); missing / broken → nothing changes.
|
|
300
|
+
let applied = (ini && (readApproved(ini) || readSettingsStrict(ini))) || readSettings(ini);
|
|
301
|
+
const approved = approvedFile(ini);
|
|
302
|
+
// A failed write is retried at most once a minute (or when there is something new to write), and
|
|
303
|
+
// only the first try may wait for a lock (writeAtomic's ~1 s) — never a blocking retry on every call.
|
|
304
|
+
const RETRY_MS = 60 * 1000;
|
|
305
|
+
let saved = '', keepFail = null, keepFailText = '', restoreFail = null;
|
|
306
|
+
const keep = () => {
|
|
307
|
+
if (!approved) return;
|
|
308
|
+
const t = approvedJson(applied);
|
|
309
|
+
if (t === saved || (keepFail !== null && t === keepFailText && now() - keepFail < RETRY_MS)) return;
|
|
310
|
+
try { writeAtomic(approved, t, { wait: keepFail === null }); saved = t; keepFail = null; } catch { keepFail = now(); keepFailText = t; /* read-only: kept in memory */ }
|
|
311
|
+
};
|
|
312
|
+
// A deleted confirm.ini comes back with the applied state, in one write (never the "off" template).
|
|
313
|
+
const restore = () => {
|
|
314
|
+
if (restoreFail !== null && now() - restoreFail < RETRY_MS) return;
|
|
315
|
+
try { if (!fs.existsSync(ini)) writeIniFrom(ini, applied, { wait: restoreFail === null }); restoreFail = null; } catch { restoreFail = now(); /* read-only */ }
|
|
316
|
+
};
|
|
317
|
+
let chain = Promise.resolve(), pending = 0, last = applied.enabled ? 'on' : 'off', reviewing = null;
|
|
318
|
+
const allowUntil = {}; // category → time ("Allow 10 min" is per category)
|
|
319
|
+
const cooldown = {}; // 'off' / 'setup' → no new question until this time after a Deny
|
|
320
|
+
const merged = new Map(); // identical question → its promise
|
|
321
|
+
const serial = (fn) => { pending++; const p = chain.then(fn).finally(() => { pending--; }); chain = p.catch(() => {}); return p; };
|
|
322
|
+
const text = (t) => ({ content: [{ type: 'text', text: t }] });
|
|
323
|
+
const report = () => { keep(); const m = applied.enabled ? 'on' : 'off'; if (m !== last) { last = m; onChange(m); } return m; };
|
|
324
|
+
// Removed own patterns go back too (else the same question comes on every call).
|
|
325
|
+
const writeApplied = () => { try { writeIniFrom(ini, applied); } catch { /* read-only */ } };
|
|
326
|
+
// Re-read the file; lower protection only through the person.
|
|
327
|
+
const sync = () => {
|
|
328
|
+
if (!ini) return applied;
|
|
329
|
+
const next = readSettingsStrict(ini);
|
|
330
|
+
if (!next) { if (!reviewing) restore(); return applied; } // missing: recreated; broken: kept as is
|
|
331
|
+
const off = weakerBy(applied, next);
|
|
332
|
+
if (!off.length) { applied = next; report(); return applied; }
|
|
333
|
+
if (!reviewing) {
|
|
334
|
+
reviewing = serial(async () => {
|
|
335
|
+
// A failed question counts as Keep (never an unhandled rejection).
|
|
336
|
+
const r = await Promise.resolve().then(() => ask({ head: 'Bridgerator settings file changed', text: `Turn off ${off.join(', ')}?`, buttons: [['allow', 'Apply', ''], ['deny', 'Keep', 'deny']], secs: applied.timeoutSec, host, auto: 'auto-keep' })).catch(() => 'deny');
|
|
337
|
+
if (r === 'allow') { applied = readSettingsStrict(ini) || next; onTurnedOff(off); } else writeApplied();
|
|
338
|
+
report();
|
|
339
|
+
}).finally(() => { reviewing = null; });
|
|
340
|
+
}
|
|
341
|
+
return applied;
|
|
342
|
+
};
|
|
343
|
+
// skip() → an answer without asking (checked when the question's turn comes), or null.
|
|
344
|
+
const askOnce = (key, q, skip = () => null) => {
|
|
345
|
+
if (merged.has(key)) return merged.get(key);
|
|
346
|
+
const p = serial(() => skip() ?? ask(q)).finally(() => merged.delete(key));
|
|
347
|
+
merged.set(key, p);
|
|
348
|
+
return p;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
if (ini) { restore(); keep(); }
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
mode: () => { sync(); return report(); },
|
|
355
|
+
settings: () => sync(),
|
|
356
|
+
/**
|
|
357
|
+
* The bridge itself wrote the file for the person. changed = only what it wrote ({ enabled?, <category>? }):
|
|
358
|
+
* that is taken as applied, anything else in the file goes through the usual check (sync).
|
|
359
|
+
* No argument (the person's own CLI command): the whole file is taken.
|
|
360
|
+
*/
|
|
361
|
+
accept(changed) {
|
|
362
|
+
const before = applied;
|
|
363
|
+
if (changed && typeof changed === 'object') {
|
|
364
|
+
const ask = { ...applied.ask };
|
|
365
|
+
for (const k of CATEGORIES) if (typeof changed[k] === 'boolean') ask[k] = changed[k];
|
|
366
|
+
applied = { ...applied, ask, ...(typeof changed.enabled === 'boolean' ? { enabled: changed.enabled } : {}) };
|
|
367
|
+
} else applied = (ini && readSettingsStrict(ini)) || applied;
|
|
368
|
+
const off = weakerBy(before, applied);
|
|
369
|
+
if (off.length) onTurnedOff(off);
|
|
370
|
+
sync();
|
|
371
|
+
report();
|
|
372
|
+
},
|
|
373
|
+
/** Resolves when no question is on the screen (new commands wait for the person's answer). */
|
|
374
|
+
idle: () => chain,
|
|
375
|
+
get pending() { return pending; },
|
|
376
|
+
gate: async (name, args) => {
|
|
377
|
+
const s = sync();
|
|
378
|
+
if (!s.enabled) return null;
|
|
379
|
+
// A category with an open "Allow 10 min" window is skipped, so a later category of the same
|
|
380
|
+
// call (a delete that is also a system command) is still asked about.
|
|
381
|
+
const open = (c) => now() < (allowUntil[c] || 0);
|
|
382
|
+
const ask1 = Object.fromEntries(Object.entries(s.ask).map(([k, v]) => [k, v && !open(k)]));
|
|
383
|
+
const { cat, fragment } = findMatch(name, args, ask1, open('pattern') ? [] : s.patterns);
|
|
384
|
+
if (!cat) return null;
|
|
385
|
+
const buttons = [['allow', 'Allow', ''], ['allow10', 'Allow 10 min', ''], ...(cat !== 'pattern' ? [['always', 'Allow always', '']] : []), ['deny', 'Deny', 'deny']];
|
|
386
|
+
const text0 = detail(name, args, fragment);
|
|
387
|
+
// Re-checked when the question's turn comes: "Allow 10 min" given meanwhile covers it.
|
|
388
|
+
const r = await askOnce(`${cat}|${name}|${text0}`, { head: HEAD[cat], text: text0, buttons, secs: s.timeoutSec, tool: name, host }, () => (open(cat) ? 'allow' : null));
|
|
389
|
+
if (r === 'allow10') allowUntil[cat] = now() + ALLOW_FOR_MS;
|
|
390
|
+
if (r === 'always' && cat !== 'pattern') { try { setIni(ini, 'ask', cat, 'false'); } catch { /* read-only */ } applied = { ...applied, ask: { ...applied.ask, [cat]: false } }; keep(); onAlways(cat); }
|
|
391
|
+
return ['allow', 'allow10', 'always'].includes(r) ? null : notConfirmed(`The person at this PC did not allow this action (denied or no answer in ${s.timeoutSec} s). Ask them, or try something else.`);
|
|
392
|
+
},
|
|
393
|
+
/** A question of the bridge's own (bridgerator_setup): same queue, cool-down after a Deny. */
|
|
394
|
+
askPerson: async (kind, q) => {
|
|
395
|
+
if (now() < (cooldown[kind] || 0)) return 'cooldown';
|
|
396
|
+
// Checked again when the question's turn comes: a Deny given while it waited counts.
|
|
397
|
+
const r = await serial(() => (now() < (cooldown[kind] || 0) ? 'cooldown' : ask(q)));
|
|
398
|
+
if (r !== 'allow' && r !== 'cooldown') cooldown[kind] = now() + COOLDOWN_MS;
|
|
399
|
+
return r;
|
|
400
|
+
},
|
|
401
|
+
// The virtual tools (CONFIRM_TOOL_DEFS).
|
|
402
|
+
tool: async (name) => {
|
|
403
|
+
if (!ini) return text('Confirmations are not available on this PC.');
|
|
404
|
+
if (name === 'confirm_on') { setIni(ini, 'confirm', 'enabled', 'true'); applied = { ...applied, enabled: true }; sync(); report(); return text('Confirmations are on for this PC (deletions and system commands, as set in confirm.ini).'); }
|
|
405
|
+
if (!sync().enabled) return text('Confirmations are already off on this PC.');
|
|
406
|
+
if (now() < (cooldown.off || 0)) return { ...text('declined: the person at this PC said no a few minutes ago — ask them directly.'), notConfirmed: true };
|
|
407
|
+
// A call that waited in the queue and finds the cool-down already running gets 'cooldown' (the window is not moved).
|
|
408
|
+
const r = await serial(() => (now() < (cooldown.off || 0) ? 'cooldown' : ask({ head: 'ChatGPT asks to stop confirming actions on this PC. Allow?', text: 'After that, deletions and system commands run without asking you. Turn it back on any time: bridgerator.cmd confirm on', buttons: [['allow', 'Allow', ''], ['deny', 'Deny', 'deny']], secs: applied.timeoutSec, host })));
|
|
409
|
+
if (r === 'cooldown') return { ...text('declined: the person at this PC said no a few minutes ago — ask them directly.'), notConfirmed: true };
|
|
410
|
+
if (r !== 'allow') { cooldown.off = now() + COOLDOWN_MS; return { ...text('declined: the person at this PC kept confirmations on.'), notConfirmed: true }; }
|
|
411
|
+
setIni(ini, 'confirm', 'enabled', 'false');
|
|
412
|
+
applied = { ...applied, enabled: false };
|
|
413
|
+
onTurnedOff(['confirmations']);
|
|
414
|
+
report();
|
|
415
|
+
return text('approved: confirmations are off on this PC.');
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
package/src/dc-config-hooks.mjs
CHANGED
|
@@ -4,16 +4,23 @@
|
|
|
4
4
|
// sharing the user's own config, opening a browser or downloading a browser.
|
|
5
5
|
// config.js — config folder → BRIDGERATOR_DC_CONFIG_DIR (an engine the user runs for other
|
|
6
6
|
// apps keeps its limits; home-folder expansion and the shell env stay real).
|
|
7
|
-
// toolHistory.js — the local tool-call history
|
|
7
|
+
// toolHistory.js — the local tool-call history (every call with its arguments and output) stays in
|
|
8
|
+
// memory only: never written to disk (queue push removed; folder = ours).
|
|
9
|
+
// fuzzySearchLogger — the edit tool's fuzzy-match log (holds snippets of edited text) goes to the same
|
|
10
|
+
// folder, not ~\.claude-server-commander-logs.
|
|
11
|
+
// trackTools.js — no local log of every tool call with its arguments (commands, paths, text).
|
|
8
12
|
// open-browser.js — no-ops: the bridge runs hidden and never opens a browser (the engine opens
|
|
9
13
|
// its vendor's welcome page on first run).
|
|
10
14
|
// index.js — no Chrome pre-download at start (~400 MB); PDF export still fetches it on demand.
|
|
11
15
|
// terminal-manager — PowerShell (the default shell) writes UTF-8: the engine decodes output as UTF-8,
|
|
12
16
|
// so on Russian & co. Windows (console code page 866/1251) text came out as garbage.
|
|
13
|
-
const DIR_EXPR = "(
|
|
17
|
+
const DIR_EXPR = "(globalThis.__BRG_DC_DIR || path.join(USER_HOME, '.claude-server-commander'))";
|
|
14
18
|
const PATCHES = [
|
|
15
19
|
{ file: /[\\/]dist[\\/]config\.js$/, from: "const CONFIG_DIR = path.join(USER_HOME, '.claude-server-commander');", to: `const CONFIG_DIR = ${DIR_EXPR};` },
|
|
16
|
-
{ file: /[\\/]dist[\\/]utils[\\/]toolHistory\.js$/, from: "const historyDir = path.join(os.homedir(), '.claude-server-commander');", to: "const historyDir =
|
|
20
|
+
{ file: /[\\/]dist[\\/]utils[\\/]toolHistory\.js$/, from: "const historyDir = path.join(os.homedir(), '.claude-server-commander');", to: "const historyDir = globalThis.__BRG_DC_DIR || path.join(os.homedir(), '.claude-server-commander');" },
|
|
21
|
+
{ file: /[\\/]dist[\\/]utils[\\/]toolHistory\.js$/, from: ' // Queue for async write\n this.writeQueue.push(record);\n', to: ' // bridgerator: no disk writes of tool calls\n' },
|
|
22
|
+
{ file: /[\\/]dist[\\/]utils[\\/]fuzzySearchLogger\.js$/, from: "const logDir = path.join(os.homedir(), '.claude-server-commander-logs');", to: "const logDir = globalThis.__BRG_DC_DIR || path.join(os.homedir(), '.claude-server-commander-logs');" },
|
|
23
|
+
{ file: /[\\/]dist[\\/]utils[\\/]trackTools\.js$/, from: 'export async function trackToolCall(toolName, args) {\n', to: 'export async function trackToolCall(toolName, args) {\n return; // bridgerator: no local log of tool arguments\n' },
|
|
17
24
|
{ file: /[\\/]dist[\\/]utils[\\/]open-browser\.js$/, from: 'export async function openWelcomePage', replaceAll: 'export async function openBrowser() {}\nexport async function openWelcomePage() {}\n' },
|
|
18
25
|
{ file: /[\\/]dist[\\/]terminal-manager\.js$/, from: "args: ['-Command', command],", to: "args: ['-Command', '[Console]::OutputEncoding=[Text.Encoding]::UTF8;' + command]," },
|
|
19
26
|
{ file: /[\\/]dist[\\/]index\.js$/, from: ' ensureChromeAvailable();\n', to: ' // bridgerator: no Chrome pre-download\n' },
|
|
@@ -24,9 +31,12 @@ export async function load(url, context, nextLoad) {
|
|
|
24
31
|
const r = await nextLoad(url, context);
|
|
25
32
|
const p = url.startsWith('file:') ? decodeURIComponent(new URL(url).pathname) : '';
|
|
26
33
|
if (!ENGINE.test(p)) return r;
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
const patches = PATCHES.filter((x) => x.file.test(p));
|
|
35
|
+
if (!patches.length) return r;
|
|
36
|
+
let src = String(r.source).replace(/\r\n/g, '\n');
|
|
37
|
+
for (const patch of patches) {
|
|
38
|
+
if (!src.includes(patch.from)) throw new Error(`bridgerator: unexpected engine file ${p.split(/[\\/]/).slice(-2).join('/')} — refusing to start`);
|
|
39
|
+
src = patch.replaceAll ?? src.replace(patch.from, patch.to);
|
|
40
|
+
}
|
|
41
|
+
return { ...r, source: src };
|
|
32
42
|
}
|
package/src/dc-isolate.mjs
CHANGED
|
@@ -2,4 +2,9 @@
|
|
|
2
2
|
import { register } from 'node:module';
|
|
3
3
|
|
|
4
4
|
if (!process.env.BRIDGERATOR_DC_CONFIG_DIR) throw new Error('bridgerator: BRIDGERATOR_DC_CONFIG_DIR not set');
|
|
5
|
+
// The folder goes to the patched engine modules through a global, and the variable is removed from
|
|
6
|
+
// the environment: the engine's terminal (start_process) inherits process.env and must not learn
|
|
7
|
+
// where Bridgerator keeps its settings.
|
|
8
|
+
globalThis.__BRG_DC_DIR = process.env.BRIDGERATOR_DC_CONFIG_DIR;
|
|
9
|
+
delete process.env.BRIDGERATOR_DC_CONFIG_DIR;
|
|
5
10
|
register('./dc-config-hooks.mjs', import.meta.url);
|