bridgerator 1.1.2 → 1.1.4
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/bridge.mjs +2 -1
- package/src/policy.mjs +302 -293
- package/src/tray.ps1 +1 -1
- package/ui/app.js +5 -1
- package/ui/i18n/en.json +3 -1
- package/ui/i18n/ru.json +3 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bridgerator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "bridgerator",
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.4",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bridgerator",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Connect ChatGPT to your Windows PC: files, search and terminal through an outbound-only bridge with folder limits. No inbound ports, no admin rights.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/bridge.mjs
CHANGED
|
@@ -512,7 +512,7 @@ export async function startDesktopCommander(cfg, log = console.log, { ask: askAr
|
|
|
512
512
|
const conf = gate.settings();
|
|
513
513
|
return {
|
|
514
514
|
state: out.paused ? 'paused' : out.linkState, name: cfg.deviceName || out.linkName, version: VERSION, paused: out.paused,
|
|
515
|
-
email: out.email || '', plan: out.plan || null, banner: bannerNow(), setupDone: cfg.setupDone === true,
|
|
515
|
+
email: out.email || '', plan: out.plan || null, banner: bannerNow(), setupDone: cfg.setupDone === true, allowOpened: cfg.chatgptAllowOpened === true,
|
|
516
516
|
settings: {
|
|
517
517
|
confirm: conf, allowedDirectories: cfg.allowedDirectories || [], readOnlyDirectories: cfg.readOnlyDirectories || [],
|
|
518
518
|
theme: popup().theme, language: cfg.language || 'en', localHistory: cfg.localHistory === true, telemetry: cfg.telemetryEnabled !== false,
|
|
@@ -594,6 +594,7 @@ export async function startDesktopCommander(cfg, log = console.log, { ask: askAr
|
|
|
594
594
|
return { restored: r.flatMap((x) => x.restored), skipped: r.flatMap((x) => x.skipped) };
|
|
595
595
|
}
|
|
596
596
|
case 'undo.clear': snapshots?.clear(); return true;
|
|
597
|
+
case 'chatgptAllowOpened': patchConfig({ chatgptAllowOpened: true }); return true;
|
|
597
598
|
case 'dismissBanner': { const b = planBanner(out.plan); if (b.dismissible) patchConfig({ planBannerDismissed: dismissFor(b) }); out.onPlan?.(); return true; }
|
|
598
599
|
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() });
|
|
599
600
|
case 'checkUpdate': {
|
package/src/policy.mjs
CHANGED
|
@@ -1,293 +1,302 @@
|
|
|
1
|
-
// Bridge-side access policy, applied BEFORE a call reaches Desktop Commander and to its results.
|
|
2
|
-
// Desktop Commander itself only knows allowedDirectories; it has no "except these folders" rule,
|
|
3
|
-
// so deniedDirectories is enforced here: input paths are resolved (symlinks/junctions included)
|
|
4
|
-
// and refused when they fall inside a denied folder; result lines that mention a denied path are
|
|
5
|
-
// dropped (search hits, recursive listings).
|
|
6
|
-
// The terminal is NOT covered by folder rules (a shell can read anything the user can); commands
|
|
7
|
-
// that name a denied folder are refused as a courtesy, the real control is disabledTools.
|
|
8
|
-
import fs from 'node:fs';
|
|
9
|
-
import os from 'node:os';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
|
|
13
|
-
// Tools the model must never get: they would let it rewrite its own limits, leak telemetry, read the
|
|
14
|
-
// local log of earlier calls, or fetch from the network (PDF export loads remote content).
|
|
15
|
-
export const ALWAYS_HIDDEN = ['set_config_value', 'give_feedback_to_desktop_commander', 'get_usage_stats', 'write_pdf', 'get_recent_tool_calls'];
|
|
16
|
-
// Tools that change files; refused inside readOnlyDirectories (move_file: on either side).
|
|
17
|
-
export const WRITE_TOOLS = ['write_file', 'edit_block', 'move_file', 'create_directory'];
|
|
18
|
-
// Argument names that carry filesystem paths (at any depth): path, paths, file_path, outputPath, directory, source, destination…
|
|
19
|
-
const MAX_DEPTH = 8, MAX_NODES = 5000;
|
|
20
|
-
const PATH_KEYS = /(path|paths|file|files|dir|directory|folder|source|dest|destination)$/i;
|
|
21
|
-
|
|
22
|
-
// The terminal: off in the access presets Read only / Files (config disabledTools). The bridge always
|
|
23
|
-
// lists these, refused while off, so turning the terminal on needs no restart.
|
|
24
|
-
export const TERMINAL_TOOLS = ['start_process', 'interact_with_process', 'kill_process'];
|
|
25
|
-
const PKG = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]+$/, '');
|
|
26
|
-
|
|
27
|
-
// LOGIC: see critical-logic/bridge.md. Always excluded, whatever the config says: Bridgerator's own
|
|
28
|
-
// settings, key, snapshots and program files — without this the AI could switch any protection off.
|
|
29
|
-
export function ownDenied(env = process.env, home = os.homedir()) {
|
|
30
|
-
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
31
|
-
const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
32
|
-
const npm = path.join(appData, 'npm');
|
|
33
|
-
return [path.join(appData, 'Bridgerator'), path.join(local, 'Bridgerator'), path.join(npm, 'node_modules', 'bridgerator'),
|
|
34
|
-
...['bridgerator', 'bridgerator.cmd', 'bridgerator.ps1'].map((x) => path.join(npm, x)), PKG];
|
|
35
|
-
}
|
|
36
|
-
// "Protect sensitive folders" (config protectSensitive, off by default since 21.10): autostart folder,
|
|
37
|
-
// shell profiles that run code at start, credentials, the whole AppData.
|
|
38
|
-
export function sensitiveDenied(env = process.env, home = os.homedir()) {
|
|
39
|
-
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
40
|
-
const docs = [path.join(home, 'Documents'), ...(env.OneDrive ? [path.join(env.OneDrive, 'Documents')] : [])];
|
|
41
|
-
return [
|
|
42
|
-
path.join(appData, 'npm'), path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'),
|
|
43
|
-
...docs.flatMap((d) => [path.join(d, 'WindowsPowerShell'), path.join(d, 'PowerShell')]),
|
|
44
|
-
...['.ssh', '.aws', '.azure', '.kube', '.docker', '.gnupg', '.config', '.npmrc', '.git-credentials', '.gitconfig', '.claude', 'AppData'].map((x) => path.join(home, x)),
|
|
45
|
-
];
|
|
46
|
-
}
|
|
47
|
-
/** Built-in exclusions: own folders always; sensitive ones unless protectSensitive is false. */
|
|
48
|
-
export function builtinDenied(env = process.env, home = os.homedir(), { protectSensitive = true } = {}) {
|
|
49
|
-
return [...ownDenied(env, home), ...(protectSensitive ? sensitiveDenied(env, home) : [])];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const expandHome = (p) => (p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? path.join(os.homedir(), p.slice(1)) : p);
|
|
53
|
-
// base: the folder a relative path is resolved against (default: this process's cwd).
|
|
54
|
-
export const norm = (p, base) => path.resolve(base || '.', expandHome(String(p))).replace(/[\\/]+$/, '').toLowerCase();
|
|
55
|
-
|
|
56
|
-
// Resolve symlinks/junctions on the deepest existing ancestor, like Desktop Commander does.
|
|
57
|
-
export function realish(p, base) {
|
|
58
|
-
const start = path.resolve(base || '.', expandHome(String(p)));
|
|
59
|
-
let cur = start;
|
|
60
|
-
const rest = [];
|
|
61
|
-
for (;;) {
|
|
62
|
-
try { return path.join(fs.realpathSync.native(cur), ...rest); } catch { /* walk up */ }
|
|
63
|
-
const up = path.dirname(cur);
|
|
64
|
-
if (up === cur) return start;
|
|
65
|
-
rest.unshift(path.basename(cur));
|
|
66
|
-
cur = up;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
-
// "c:\secret" mentioned as a path (not as the prefix of "c:\secretive"), any slash style / JSON escaping.
|
|
72
|
-
const flat = (s) => String(s).toLowerCase().replace(/[\\/]+/g, '\\');
|
|
73
|
-
const mentionRe = (d) => new RegExp(`${esc(d)}(?![a-z0-9_.\\-])`);
|
|
74
|
-
|
|
75
|
-
// Quotes and the escape characters of cmd (^) and PowerShell (`) can split a path in a command.
|
|
76
|
-
export const unquote = (s) => String(s).replace(/["'^`]/g, '');
|
|
77
|
-
// 8.3 short names (C:\Users\3C8A~1\…): the deepest existing part is resolved to its long name.
|
|
78
|
-
// Linear: tokens first (one regex over the whole command backtracks quadratically), at most 50 lookups.
|
|
79
|
-
export const longNames = (s) => {
|
|
80
|
-
let n = 0;
|
|
81
|
-
return String(s).replace(/[^\s"'|&;<>]+/g, (tok) => (n < 50 && /~\d/.test(tok) ? tok.replace(/[a-z]:[\\/].*$/i, (m) => (/~\d/.test(m) && ++n <= 50 ? realish(m) : m)) : tok));
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
// Path-like parts of a command: absolute (C:\…, also after = or >) and relative with a backslash
|
|
85
|
-
// (sub\x, .\x, ..\x), quoted or not. Capped (count and length): this runs on every command.
|
|
86
|
-
const MAX_CMD_PATHS = 64;
|
|
87
|
-
export function commandPaths(s) {
|
|
88
|
-
const out = [];
|
|
89
|
-
for (const m of String(s).slice(0, 20000).matchAll(/"([^"]{1,1000})"|'([^']{1,1000})'|([^\s"'|&;<>(),]{1,1000})/g)) {
|
|
90
|
-
let t = (m[1] ?? m[2] ?? m[3]).trim();
|
|
91
|
-
const d = /[a-z]:[\\/]/i.exec(t);
|
|
92
|
-
if (d) t = t.slice(d.index);
|
|
93
|
-
else if (/^[-/]/.test(t) || !t.includes('\\') || t.includes(':')) continue; // a flag, a switch, no folder part, a URL
|
|
94
|
-
out.push(t);
|
|
95
|
-
if (out.length >= MAX_CMD_PATHS) break;
|
|
96
|
-
}
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
// What a command WRITES (a heuristic like the other command checks — the terminal is not a boundary):
|
|
100
|
-
// redirect targets (> / >> / 2> file; not 2>&1, >nul, >$null), the destination of a copy (-Destination
|
|
101
|
-
// or the last path), every path after a move / delete / write verb of its own segment (a move empties
|
|
102
|
-
// its source too). Read sources and paths of other segments (a | b ; c && d) are not targets.
|
|
103
|
-
const COPY_VERB = /^(copy|xcopy|robocopy|cp|cpi|copy-item)$/i;
|
|
104
|
-
const WRITE_VERB = /^(set-content|add-content|out-file|new-item|ni|remove-item|ri|rm|del|erase|rd|rmdir|mkdir|md|move|mv|mi|move-item|copy|xcopy|robocopy|cp|cpi|copy-item|ren|rename|rename-item|rni|clear-content|tee-object|tee|expand-archive|compress-archive|icacls|attrib|takeown)(\.exe)?$/i;
|
|
105
|
-
const NUL_TARGET = /^(\$?null|nul|con)$/i;
|
|
106
|
-
const segments = (s) => {
|
|
107
|
-
const out = [];
|
|
108
|
-
let cur = '', q = '';
|
|
109
|
-
for (const ch of String(s).slice(0, 20000)) {
|
|
110
|
-
if (q) { if (ch === q) q = ''; cur += ch; continue; }
|
|
111
|
-
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
|
112
|
-
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n' || ch === '\r') { out.push(cur); cur = ''; continue; }
|
|
113
|
-
cur += ch;
|
|
114
|
-
}
|
|
115
|
-
out.push(cur);
|
|
116
|
-
return out;
|
|
117
|
-
};
|
|
118
|
-
export function writeTargets(s) {
|
|
119
|
-
const out = [];
|
|
120
|
-
const text = String(s).slice(0, 20000);
|
|
121
|
-
for (const m of text.matchAll(/[*\d]?>>?(?!&)\s*("([^"]{1,1000})"|'([^']{1,1000})'|([^\s"'|&;<>()]{1,1000}))/g)) {
|
|
122
|
-
const t = (m[2] ?? m[3] ?? m[4]).trim();
|
|
123
|
-
if (t && !NUL_TARGET.test(t)) out.push(t);
|
|
124
|
-
}
|
|
125
|
-
for (const seg of segments(text)) {
|
|
126
|
-
const words = [...seg.matchAll(/[^\s"'(){}=]+/g)];
|
|
127
|
-
const v = words.find((w) => WRITE_VERB.test(w[0].replace(/^[.&@]+/, '')));
|
|
128
|
-
if (!v) continue;
|
|
129
|
-
const rest = seg.slice(v.index + v[0].length).replace(/[*\d]?>>?(?!&)\s*("[^"]*"|'[^']*'|[^\s"'|&;<>()]+)/g, ' ');
|
|
130
|
-
const named = commandPaths(rest);
|
|
131
|
-
if (COPY_VERB.test(v[0].replace(/^[.&@]+|\.exe$/gi, ''))) {
|
|
132
|
-
const d = /(^|\s)-destination\s+("[^"]{1,1000}"|'[^']{1,1000}'|[^\s"']{1,1000})/i.exec(rest);
|
|
133
|
-
const dest = d ? commandPaths(d[2])[0] : named.at(-1);
|
|
134
|
-
if (dest) out.push(dest);
|
|
135
|
-
} else out.push(...named);
|
|
136
|
-
if (out.length >= MAX_CMD_PATHS) break;
|
|
137
|
-
}
|
|
138
|
-
return out.slice(0, MAX_CMD_PATHS);
|
|
139
|
-
}
|
|
140
|
-
/** %VAR%, $env:VAR, ${env:VAR} and a leading ~ expanded with the real environment (case-insensitive names). */
|
|
141
|
-
export function expandVars(s, env = process.env, home = os.homedir()) {
|
|
142
|
-
const get = (n) => { const k = Object.keys(env).find((x) => x.toLowerCase() === String(n).toLowerCase()); return k ? env[k] : undefined; };
|
|
143
|
-
return String(s)
|
|
144
|
-
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => get(n) ?? m)
|
|
145
|
-
.replace(/\$\{env:([^}]+)\}/gi, (m, n) => get(n) ?? m)
|
|
146
|
-
.replace(/\$env:([A-Za-z_]\w*)/gi, (m, n) => get(n) ?? m)
|
|
147
|
-
.replace(/(^|[\s"'(=;,])~(?=[\\/]|$|[\s"'])/g, (m, pre) => pre + home);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// cfg.cwd: the folder the ENGINE resolves relative paths against (its working directory): relative
|
|
151
|
-
// arguments are checked where the engine will open them, never against the bridge's own folder.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return null;
|
|
240
|
-
},
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1
|
+
// Bridge-side access policy, applied BEFORE a call reaches Desktop Commander and to its results.
|
|
2
|
+
// Desktop Commander itself only knows allowedDirectories; it has no "except these folders" rule,
|
|
3
|
+
// so deniedDirectories is enforced here: input paths are resolved (symlinks/junctions included)
|
|
4
|
+
// and refused when they fall inside a denied folder; result lines that mention a denied path are
|
|
5
|
+
// dropped (search hits, recursive listings).
|
|
6
|
+
// The terminal is NOT covered by folder rules (a shell can read anything the user can); commands
|
|
7
|
+
// that name a denied folder are refused as a courtesy, the real control is disabledTools.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
// Tools the model must never get: they would let it rewrite its own limits, leak telemetry, read the
|
|
14
|
+
// local log of earlier calls, or fetch from the network (PDF export loads remote content).
|
|
15
|
+
export const ALWAYS_HIDDEN = ['set_config_value', 'give_feedback_to_desktop_commander', 'get_usage_stats', 'write_pdf', 'get_recent_tool_calls'];
|
|
16
|
+
// Tools that change files; refused inside readOnlyDirectories (move_file: on either side).
|
|
17
|
+
export const WRITE_TOOLS = ['write_file', 'edit_block', 'move_file', 'create_directory'];
|
|
18
|
+
// Argument names that carry filesystem paths (at any depth): path, paths, file_path, outputPath, directory, source, destination…
|
|
19
|
+
const MAX_DEPTH = 8, MAX_NODES = 5000;
|
|
20
|
+
const PATH_KEYS = /(path|paths|file|files|dir|directory|folder|source|dest|destination)$/i;
|
|
21
|
+
|
|
22
|
+
// The terminal: off in the access presets Read only / Files (config disabledTools). The bridge always
|
|
23
|
+
// lists these, refused while off, so turning the terminal on needs no restart.
|
|
24
|
+
export const TERMINAL_TOOLS = ['start_process', 'interact_with_process', 'kill_process'];
|
|
25
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url)).replace(/[\\/]+$/, '');
|
|
26
|
+
|
|
27
|
+
// LOGIC: see critical-logic/bridge.md. Always excluded, whatever the config says: Bridgerator's own
|
|
28
|
+
// settings, key, snapshots and program files — without this the AI could switch any protection off.
|
|
29
|
+
export function ownDenied(env = process.env, home = os.homedir()) {
|
|
30
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
31
|
+
const local = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
32
|
+
const npm = path.join(appData, 'npm');
|
|
33
|
+
return [path.join(appData, 'Bridgerator'), path.join(local, 'Bridgerator'), path.join(npm, 'node_modules', 'bridgerator'),
|
|
34
|
+
...['bridgerator', 'bridgerator.cmd', 'bridgerator.ps1'].map((x) => path.join(npm, x)), PKG];
|
|
35
|
+
}
|
|
36
|
+
// "Protect sensitive folders" (config protectSensitive, off by default since 21.10): autostart folder,
|
|
37
|
+
// shell profiles that run code at start, credentials, the whole AppData.
|
|
38
|
+
export function sensitiveDenied(env = process.env, home = os.homedir()) {
|
|
39
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
40
|
+
const docs = [path.join(home, 'Documents'), ...(env.OneDrive ? [path.join(env.OneDrive, 'Documents')] : [])];
|
|
41
|
+
return [
|
|
42
|
+
path.join(appData, 'npm'), path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup'),
|
|
43
|
+
...docs.flatMap((d) => [path.join(d, 'WindowsPowerShell'), path.join(d, 'PowerShell')]),
|
|
44
|
+
...['.ssh', '.aws', '.azure', '.kube', '.docker', '.gnupg', '.config', '.npmrc', '.git-credentials', '.gitconfig', '.claude', 'AppData'].map((x) => path.join(home, x)),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
/** Built-in exclusions: own folders always; sensitive ones unless protectSensitive is false. */
|
|
48
|
+
export function builtinDenied(env = process.env, home = os.homedir(), { protectSensitive = true } = {}) {
|
|
49
|
+
return [...ownDenied(env, home), ...(protectSensitive ? sensitiveDenied(env, home) : [])];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const expandHome = (p) => (p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? path.join(os.homedir(), p.slice(1)) : p);
|
|
53
|
+
// base: the folder a relative path is resolved against (default: this process's cwd).
|
|
54
|
+
export const norm = (p, base) => path.resolve(base || '.', expandHome(String(p))).replace(/[\\/]+$/, '').toLowerCase();
|
|
55
|
+
|
|
56
|
+
// Resolve symlinks/junctions on the deepest existing ancestor, like Desktop Commander does.
|
|
57
|
+
export function realish(p, base) {
|
|
58
|
+
const start = path.resolve(base || '.', expandHome(String(p)));
|
|
59
|
+
let cur = start;
|
|
60
|
+
const rest = [];
|
|
61
|
+
for (;;) {
|
|
62
|
+
try { return path.join(fs.realpathSync.native(cur), ...rest); } catch { /* walk up */ }
|
|
63
|
+
const up = path.dirname(cur);
|
|
64
|
+
if (up === cur) return start;
|
|
65
|
+
rest.unshift(path.basename(cur));
|
|
66
|
+
cur = up;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
// "c:\secret" mentioned as a path (not as the prefix of "c:\secretive"), any slash style / JSON escaping.
|
|
72
|
+
const flat = (s) => String(s).toLowerCase().replace(/[\\/]+/g, '\\');
|
|
73
|
+
const mentionRe = (d) => new RegExp(`${esc(d)}(?![a-z0-9_.\\-])`);
|
|
74
|
+
|
|
75
|
+
// Quotes and the escape characters of cmd (^) and PowerShell (`) can split a path in a command.
|
|
76
|
+
export const unquote = (s) => String(s).replace(/["'^`]/g, '');
|
|
77
|
+
// 8.3 short names (C:\Users\3C8A~1\…): the deepest existing part is resolved to its long name.
|
|
78
|
+
// Linear: tokens first (one regex over the whole command backtracks quadratically), at most 50 lookups.
|
|
79
|
+
export const longNames = (s) => {
|
|
80
|
+
let n = 0;
|
|
81
|
+
return String(s).replace(/[^\s"'|&;<>]+/g, (tok) => (n < 50 && /~\d/.test(tok) ? tok.replace(/[a-z]:[\\/].*$/i, (m) => (/~\d/.test(m) && ++n <= 50 ? realish(m) : m)) : tok));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Path-like parts of a command: absolute (C:\…, also after = or >) and relative with a backslash
|
|
85
|
+
// (sub\x, .\x, ..\x), quoted or not. Capped (count and length): this runs on every command.
|
|
86
|
+
const MAX_CMD_PATHS = 64;
|
|
87
|
+
export function commandPaths(s) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const m of String(s).slice(0, 20000).matchAll(/"([^"]{1,1000})"|'([^']{1,1000})'|([^\s"'|&;<>(),]{1,1000})/g)) {
|
|
90
|
+
let t = (m[1] ?? m[2] ?? m[3]).trim();
|
|
91
|
+
const d = /[a-z]:[\\/]/i.exec(t);
|
|
92
|
+
if (d) t = t.slice(d.index);
|
|
93
|
+
else if (/^[-/]/.test(t) || !t.includes('\\') || t.includes(':')) continue; // a flag, a switch, no folder part, a URL
|
|
94
|
+
out.push(t);
|
|
95
|
+
if (out.length >= MAX_CMD_PATHS) break;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
// What a command WRITES (a heuristic like the other command checks — the terminal is not a boundary):
|
|
100
|
+
// redirect targets (> / >> / 2> file; not 2>&1, >nul, >$null), the destination of a copy (-Destination
|
|
101
|
+
// or the last path), every path after a move / delete / write verb of its own segment (a move empties
|
|
102
|
+
// its source too). Read sources and paths of other segments (a | b ; c && d) are not targets.
|
|
103
|
+
const COPY_VERB = /^(copy|xcopy|robocopy|cp|cpi|copy-item)$/i;
|
|
104
|
+
const WRITE_VERB = /^(set-content|add-content|out-file|new-item|ni|remove-item|ri|rm|del|erase|rd|rmdir|mkdir|md|move|mv|mi|move-item|copy|xcopy|robocopy|cp|cpi|copy-item|ren|rename|rename-item|rni|clear-content|tee-object|tee|expand-archive|compress-archive|icacls|attrib|takeown)(\.exe)?$/i;
|
|
105
|
+
const NUL_TARGET = /^(\$?null|nul|con)$/i;
|
|
106
|
+
const segments = (s) => {
|
|
107
|
+
const out = [];
|
|
108
|
+
let cur = '', q = '';
|
|
109
|
+
for (const ch of String(s).slice(0, 20000)) {
|
|
110
|
+
if (q) { if (ch === q) q = ''; cur += ch; continue; }
|
|
111
|
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
|
112
|
+
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n' || ch === '\r') { out.push(cur); cur = ''; continue; }
|
|
113
|
+
cur += ch;
|
|
114
|
+
}
|
|
115
|
+
out.push(cur);
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
export function writeTargets(s) {
|
|
119
|
+
const out = [];
|
|
120
|
+
const text = String(s).slice(0, 20000);
|
|
121
|
+
for (const m of text.matchAll(/[*\d]?>>?(?!&)\s*("([^"]{1,1000})"|'([^']{1,1000})'|([^\s"'|&;<>()]{1,1000}))/g)) {
|
|
122
|
+
const t = (m[2] ?? m[3] ?? m[4]).trim();
|
|
123
|
+
if (t && !NUL_TARGET.test(t)) out.push(t);
|
|
124
|
+
}
|
|
125
|
+
for (const seg of segments(text)) {
|
|
126
|
+
const words = [...seg.matchAll(/[^\s"'(){}=]+/g)];
|
|
127
|
+
const v = words.find((w) => WRITE_VERB.test(w[0].replace(/^[.&@]+/, '')));
|
|
128
|
+
if (!v) continue;
|
|
129
|
+
const rest = seg.slice(v.index + v[0].length).replace(/[*\d]?>>?(?!&)\s*("[^"]*"|'[^']*'|[^\s"'|&;<>()]+)/g, ' ');
|
|
130
|
+
const named = commandPaths(rest);
|
|
131
|
+
if (COPY_VERB.test(v[0].replace(/^[.&@]+|\.exe$/gi, ''))) {
|
|
132
|
+
const d = /(^|\s)-destination\s+("[^"]{1,1000}"|'[^']{1,1000}'|[^\s"']{1,1000})/i.exec(rest);
|
|
133
|
+
const dest = d ? commandPaths(d[2])[0] : named.at(-1);
|
|
134
|
+
if (dest) out.push(dest);
|
|
135
|
+
} else out.push(...named);
|
|
136
|
+
if (out.length >= MAX_CMD_PATHS) break;
|
|
137
|
+
}
|
|
138
|
+
return out.slice(0, MAX_CMD_PATHS);
|
|
139
|
+
}
|
|
140
|
+
/** %VAR%, $env:VAR, ${env:VAR} and a leading ~ expanded with the real environment (case-insensitive names). */
|
|
141
|
+
export function expandVars(s, env = process.env, home = os.homedir()) {
|
|
142
|
+
const get = (n) => { const k = Object.keys(env).find((x) => x.toLowerCase() === String(n).toLowerCase()); return k ? env[k] : undefined; };
|
|
143
|
+
return String(s)
|
|
144
|
+
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => get(n) ?? m)
|
|
145
|
+
.replace(/\$\{env:([^}]+)\}/gi, (m, n) => get(n) ?? m)
|
|
146
|
+
.replace(/\$env:([A-Za-z_]\w*)/gi, (m, n) => get(n) ?? m)
|
|
147
|
+
.replace(/(^|[\s"'(=;,])~(?=[\\/]|$|[\s"'])/g, (m, pre) => pre + home);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// cfg.cwd: the folder the ENGINE resolves relative paths against (its working directory): relative
|
|
151
|
+
// arguments are checked where the engine will open them, never against the bridge's own folder.
|
|
152
|
+
// Terminal commands that change or stop Bridgerator itself (its CLI's setting/lifecycle commands, removing the
|
|
153
|
+
// package, ending its processes or autostart task). Read-only uses (doctor, status, --version, a folder name) pass.
|
|
154
|
+
const SELF_CHANGE = [
|
|
155
|
+
/\bbridgerator(?:\.cmd|\.ps1|\.exe)?["']?\s+(?:confirm|read-only|telemetry|setup|uninstall|stop|run|unlink)\b/i,
|
|
156
|
+
/\b(?:npm|pnpm|yarn)(?:\.cmd)?\s[^|;&\n]*\b(?:rm|remove|uninstall|un|r|unlink)\s[^|;&\n]*\bbridgerator\b/i,
|
|
157
|
+
/\b(?:taskkill|tskill|pskill|kill|stop-process|wmic|schtasks|(?:start|stop|disable|enable|unregister|set)-scheduledtask)\b[^|;&\n]*\bbridgerator/i,
|
|
158
|
+
/\bbridgerator[^;&\n]*\|\s*(?:stop-process|kill|(?:stop|disable|unregister|set)-scheduledtask)\b/i,
|
|
159
|
+
];
|
|
160
|
+
export function createPolicy(cfg = {}) {
|
|
161
|
+
const base = cfg.cwd || process.cwd();
|
|
162
|
+
// protectSensitive absent = on (callers that don't say); the bridge passes the person's setting.
|
|
163
|
+
const denied = [...new Set([...builtinDenied(process.env, os.homedir(), { protectSensitive: cfg.protectSensitive !== false }), ...(cfg.deniedDirectories || [])].map((d) => norm(realish(d))))];
|
|
164
|
+
const res = denied.map(mentionRe);
|
|
165
|
+
const mentionsDenied = (s) => { const f = flat(s); return res.some((r) => r.test(f)); };
|
|
166
|
+
const hidden = new Set([...ALWAYS_HIDDEN, ...(cfg.disabledTools || [])]);
|
|
167
|
+
const inDenied = (p) => {
|
|
168
|
+
const n = norm(p, base);
|
|
169
|
+
return denied.find((d) => n === d || n.startsWith(d + path.sep)) || null;
|
|
170
|
+
};
|
|
171
|
+
const deniedFor = (p) => inDenied(p) || inDenied(realish(p, base));
|
|
172
|
+
const readOnly = (cfg.readOnlyDirectories || []).map((d) => norm(realish(d)));
|
|
173
|
+
const inRo = (n) => readOnly.some((d) => n === d || n.startsWith(d + path.sep));
|
|
174
|
+
const readOnlyFor = (p) => inRo(norm(p, base)) || inRo(norm(realish(p, base)));
|
|
175
|
+
// A folder that CONTAINS an excluded / read-only folder (moving or deleting it takes that folder along).
|
|
176
|
+
const holds = (list, p) => { const a = norm(p, base), b = norm(realish(p, base)); return list.some((d) => d.startsWith(a + path.sep) || d.startsWith(b + path.sep)); };
|
|
177
|
+
|
|
178
|
+
// Every path-like string at any depth — iterative, with caps (deep / huge arguments are refused).
|
|
179
|
+
function pathsIn(args) {
|
|
180
|
+
const out = [], stack = [[args, '', 0]];
|
|
181
|
+
let seen = 0;
|
|
182
|
+
while (stack.length) {
|
|
183
|
+
const [v, key, depth] = stack.pop();
|
|
184
|
+
if (++seen > MAX_NODES || depth > MAX_DEPTH) throw new Error('arguments too deeply nested or too large');
|
|
185
|
+
if (typeof v === 'string') { if (v && PATH_KEYS.test(key)) out.push(v); }
|
|
186
|
+
else if (Array.isArray(v)) for (const x of v) stack.push([x, key, depth + 1]);
|
|
187
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) stack.push([x, k, depth + 1]);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
denied,
|
|
194
|
+
visible: (tool) => !hidden.has(tool.name),
|
|
195
|
+
// → null when allowed, otherwise the refusal text returned to the model.
|
|
196
|
+
check(name, args) {
|
|
197
|
+
if (hidden.has(name)) return TERMINAL_TOOLS.includes(name) ? 'The terminal is off on this PC (access preset Read only or Files). The person can switch to Full in Bridgerator → Safety, or you can ask with bridgerator_settings.' : `Tool "${name}" is disabled on this computer.`;
|
|
198
|
+
if (name === 'read_file' && args?.isUrl) return 'Reading URLs is disabled on this computer.';
|
|
199
|
+
let paths;
|
|
200
|
+
try { paths = pathsIn(args); } catch (e) { return `Refused by Bridgerator: ${e.message}.`; }
|
|
201
|
+
for (const p of paths) {
|
|
202
|
+
// UNC / device paths (\\server\share, \\?\, \\.\): network shares and raw devices are out of scope.
|
|
203
|
+
if (/^[\\/]{2}/.test(p.trim())) return `Access denied by Bridgerator: network and device paths ("${p}") are not allowed.`;
|
|
204
|
+
if (deniedFor(p)) return `Access denied by Bridgerator: "${p}" is inside an excluded folder.`;
|
|
205
|
+
if (WRITE_TOOLS.includes(name) && readOnlyFor(p)) return `Access denied by Bridgerator: "${p}" is in a read-only folder — files there can be read, not changed.`;
|
|
206
|
+
if (WRITE_TOOLS.includes(name) && holds(denied, p)) return `Access denied by Bridgerator: "${p}" contains an excluded folder.`;
|
|
207
|
+
if (WRITE_TOOLS.includes(name) && holds(readOnly, p)) return `Access denied by Bridgerator: "${p}" contains a read-only folder.`;
|
|
208
|
+
// A hard link is the same file under another name (maybe inside an excluded folder): no writes.
|
|
209
|
+
if (WRITE_TOOLS.includes(name)) { try { if (fs.lstatSync(path.resolve(base, expandHome(p))).nlink > 1) return `Access denied by Bridgerator: "${p}" is a hard-linked file — it can't be changed through Bridgerator.`; } catch { /* doesn't exist yet */ } }
|
|
210
|
+
}
|
|
211
|
+
for (const k of ['command', 'input']) {
|
|
212
|
+
if (typeof args?.[k] !== 'string') continue;
|
|
213
|
+
// Checked as written AND with variables expanded (%APPDATA%, $env:APPDATA, ${env:APPDATA}, ~),
|
|
214
|
+
// with quotes / escape characters removed ("%USERPROFILE%"\.ssh, .s^sh, .`ssh) and 8.3 short
|
|
215
|
+
// names of existing folders made long (C:\PROGRA~1).
|
|
216
|
+
const u = unquote(args[k]);
|
|
217
|
+
const texts = [args[k], expandVars(args[k]), u, expandVars(u)];
|
|
218
|
+
texts.push(...texts.map(longNames));
|
|
219
|
+
if (texts.some(mentionsDenied)) return `Access denied by Bridgerator: the ${k} refers to an excluded folder.`;
|
|
220
|
+
// Paths in the command resolved like the file tools do (relative → the terminal's folder,
|
|
221
|
+
// junctions / symbolic links → their target): a link to an excluded folder is refused too.
|
|
222
|
+
const named = [...new Set([...commandPaths(texts[1]), ...commandPaths(texts[3])])].slice(0, MAX_CMD_PATHS);
|
|
223
|
+
// UNC / device paths are refused like path arguments — never resolved here (a lookup on a
|
|
224
|
+
// network path blocks the bridge's main thread).
|
|
225
|
+
const unc = named.find((p) => /^[\\/]{2}[^\\/]+[\\/]/.test(p));
|
|
226
|
+
if (unc) return `Access denied by Bridgerator: network and device paths ("${unc}") are not allowed.`;
|
|
227
|
+
if (named.some((p) => deniedFor(p))) return `Access denied by Bridgerator: the ${k} refers to an excluded folder.`;
|
|
228
|
+
// Read-only folders, for commands that change files (a courtesy: a script can still write there).
|
|
229
|
+
// Only what the command writes counts (as written with its quotes and as unquoted).
|
|
230
|
+
if (readOnly.length) {
|
|
231
|
+
const targets = [...new Set([...writeTargets(texts[1]), ...writeTargets(texts[3])])].filter((p) => !/^[\\/]{2}/.test(p));
|
|
232
|
+
if (targets.some((p) => readOnlyFor(p))) return `Access denied by Bridgerator: the ${k} would change a read-only folder — files there can be read, not changed.`;
|
|
233
|
+
}
|
|
234
|
+
// Courtesy, like the folder check (the terminal itself is not a boundary): commands that change or stop
|
|
235
|
+
// Bridgerator itself are for the person; its files are covered by the folder rule above. Owner 26.10: the AI
|
|
236
|
+
// sets up everything else — the word alone (a project folder, npm i -g bridgerator, doctor) is fine.
|
|
237
|
+
if (texts.some((x) => SELF_CHANGE.some((re) => re.test(x)))) return 'This command would change or stop Bridgerator itself. Change its settings with bridgerator_settings (they apply at once); to turn confirmations off use request_confirm_off. Stopping, re-linking or removing Bridgerator is for the person at this PC.';
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
},
|
|
241
|
+
/** Refusal text when Bridgerator itself may not remove / restore this path (excluded, read-only, network), else null. */
|
|
242
|
+
writable(p) {
|
|
243
|
+
if (/^[\\/]{2}/.test(String(p).trim())) return `Access denied by Bridgerator: network and device paths ("${p}") are not allowed.`;
|
|
244
|
+
if (deniedFor(p)) return `Access denied by Bridgerator: "${p}" is inside an excluded folder.`;
|
|
245
|
+
if (readOnlyFor(p)) return `Access denied by Bridgerator: "${p}" is in a read-only folder.`;
|
|
246
|
+
if (holds(denied, p)) return `Access denied by Bridgerator: "${p}" contains an excluded folder.`;
|
|
247
|
+
if (holds(readOnly, p)) return `Access denied by Bridgerator: "${p}" contains a read-only folder.`;
|
|
248
|
+
return null;
|
|
249
|
+
},
|
|
250
|
+
// Drop result lines that reveal anything inside a denied folder. `args` lets a listing of a
|
|
251
|
+
// parent folder hide the excluded child, whose entries are printed relative to that parent.
|
|
252
|
+
filter(result, name, args) {
|
|
253
|
+
if (!denied.length || !result || !Array.isArray(result.content)) return result;
|
|
254
|
+
const rel = [];
|
|
255
|
+
if (name === 'list_directory' && typeof args?.path === 'string') {
|
|
256
|
+
const at = norm(realish(args.path, base));
|
|
257
|
+
for (const d of denied) if (d.startsWith(at + path.sep)) rel.push(new RegExp(`^${esc(d.slice(at.length + 1))}(?![a-z0-9_.\\-])`));
|
|
258
|
+
}
|
|
259
|
+
const leaks = (line) => mentionsDenied(line) || rel.some((r) => r.test(flat(line).trim().replace(/^\[(dir|file)\]\s*/, '')));
|
|
260
|
+
let dropped = 0;
|
|
261
|
+
const content = result.content.map((c) => {
|
|
262
|
+
if (c?.type !== 'text' || typeof c.text !== 'string') return c;
|
|
263
|
+
const lines = c.text.split(/\r?\n/);
|
|
264
|
+
const kept = lines.filter((ln) => !leaks(ln));
|
|
265
|
+
dropped += lines.length - kept.length;
|
|
266
|
+
return { ...c, text: kept.join('\n') };
|
|
267
|
+
});
|
|
268
|
+
if (dropped) content.push({ type: 'text', text: `[Bridgerator: ${dropped} line(s) hidden — excluded folder]` });
|
|
269
|
+
return { ...result, content };
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The bundled engine's own name and links are not shown to the user: tool titles/descriptions and
|
|
275
|
+
// widget HTML say Bridgerator. Tool RESULTS are never rewritten (they are the user's own data).
|
|
276
|
+
const BRAND = [[/"DC: \.\.\." or /g, ''], [/Desktop ?Commander/g, 'Bridgerator'], [/https?:\/\/(www\.)?desktopcommander\.app[^\s"'\)<]*/g, 'https://bridgerator.recondeck.com']];
|
|
277
|
+
export function debrand(v) {
|
|
278
|
+
if (typeof v === 'string') return BRAND.reduce((s, [re, to]) => s.replace(re, to), v);
|
|
279
|
+
if (Array.isArray(v)) return v.map(debrand);
|
|
280
|
+
if (v && typeof v === 'object') {
|
|
281
|
+
const o = {};
|
|
282
|
+
// _meta keys (ui:// template URIs) are identifiers, not user-facing text — keep them as is.
|
|
283
|
+
for (const [k, x] of Object.entries(v)) o[k] = k === '_meta' || k === 'uri' ? x : debrand(x);
|
|
284
|
+
return o;
|
|
285
|
+
}
|
|
286
|
+
return v;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Two results are the engine's own text, not user data: the prompt catalog (get_prompts says
|
|
290
|
+
// "Desktop Commander Prompt Categories") and get_config, whose `version` is the engine's and is
|
|
291
|
+
// shown as "Bridgerator version" — replaced by the bridge's own version.
|
|
292
|
+
export function debrandResult(name, result, version) {
|
|
293
|
+
if (!result || typeof result !== 'object') return result;
|
|
294
|
+
if (name === 'get_prompts') return debrand(result);
|
|
295
|
+
const sc = result.structuredContent, engine = sc?.config?.version;
|
|
296
|
+
if (name !== 'get_config' || typeof engine !== 'string') return result;
|
|
297
|
+
const label = `"version": ${JSON.stringify(engine)}`;
|
|
298
|
+
const content = Array.isArray(result.content)
|
|
299
|
+
? result.content.map((c) => (c?.type === 'text' && typeof c.text === 'string' ? { ...c, text: c.text.replace(label, `"version": ${JSON.stringify(version)}`) } : c))
|
|
300
|
+
: result.content;
|
|
301
|
+
return { ...result, content, structuredContent: { ...sc, config: { ...sc.config, version } } };
|
|
302
|
+
}
|
package/src/tray.ps1
CHANGED
|
@@ -73,7 +73,7 @@ $account = if ($env:BR_ACCOUNT -match '^https://[a-z0-9.-]+(:\d+)?/app$') { $env
|
|
|
73
73
|
$siteHost = ([Uri]$account).Host
|
|
74
74
|
$send = { param($o) [BrIO]::Send(($o | ConvertTo-Json -Compress -Depth 6)) }
|
|
75
75
|
# Links from the window / menu: only our own site (the main one, or the linked one), opened in the default browser.
|
|
76
|
-
$openUrl = { param($u) try { $x = [Uri]$u; if ($x.Scheme -eq 'https' -and ($x.Host -eq 'bridgerator.recondeck.com' -or $x.Host -eq $siteHost) -and -not $x.UserInfo) { Start-Process $x.AbsoluteUri } } catch {} }
|
|
76
|
+
$openUrl = { param($u) try { $x = [Uri]$u; if ($x.Scheme -eq 'https' -and ($x.Host -eq 'bridgerator.recondeck.com' -or $x.Host -eq $siteHost -or ($x.Host -eq 'chatgpt.com' -and $x.AbsolutePath -like '/settings/*')) -and -not $x.UserInfo) { Start-Process $x.AbsoluteUri } } catch {} }
|
|
77
77
|
$openAccount = { & $openUrl $account }
|
|
78
78
|
$g0 = [Drawing.Graphics]::FromHwnd([IntPtr]::Zero); $script:dpi = $g0.DpiX / 96.0; $g0.Dispose()
|
|
79
79
|
if ($env:BR_DPI) { $script:dpi = [double]$env:BR_DPI } # test harness: simulate scaling
|
package/ui/app.js
CHANGED
|
@@ -171,6 +171,9 @@ function awakeLine() {
|
|
|
171
171
|
const k = s.keepAwake === false ? 'status.awakeOff' : a.on ? 'status.awakeOn' : a.want && !a.ac ? 'status.awakeBattery' : 'status.awakeIdle';
|
|
172
172
|
return `<div class="awake-line ${a.on ? 'on' : ''}"><span class="dot ${a.on ? 'online' : 'offline'}"></span>${esc(t(k))}</div>`;
|
|
173
173
|
}
|
|
174
|
+
// The Bridgerator app's "Permission" page in ChatGPT (App ID of the store app). Owner 26.10: on first use ChatGPT
|
|
175
|
+
// asks before most actions ("Allow low-risk"); one switch to "Allow all" stops that, like Desktop Commander.
|
|
176
|
+
const CHATGPT_PERMISSIONS = 'https://chatgpt.com/settings/plugins-settings/plugin_asdk_app_6ad78d4a3c408191a9215fd304394c4a/permissions';
|
|
174
177
|
P.home = () => {
|
|
175
178
|
const st = state.state || 'offline';
|
|
176
179
|
const c = state.queue || {};
|
|
@@ -193,7 +196,7 @@ P.home = () => {
|
|
|
193
196
|
<a class="card tile" href="#recent"><span class="k">${esc(t('status.cardLast'))}</span><span class="v">${esc(last ? ago(last.t) : t('status.never'))}</span><span class="s" title="${esc(last ? `${last.tool} · ${last.target}` : '')}">${esc(last ? [last.tool, lastTarget(last.target)].filter(Boolean).join(' · ') : t('status.noActions'))}</span></a>
|
|
194
197
|
</div>
|
|
195
198
|
<section class="card steps" aria-label="${esc(t('aria.getStarted'))}">
|
|
196
|
-
<div class="step ok"><span class="check ok">${CHECK(12, 3.5)}</span><span class="
|
|
199
|
+
<div class="step ${state.allowOpened ? 'ok' : ''}"><span class="check ${state.allowOpened ? 'ok' : 'next'}">${state.allowOpened ? CHECK(12, 3.5) : ''}</span><div class="stack"><span class="t">${esc(t('status.stepAllow'))}</span>${state.allowOpened ? '' : `<span class="h">${esc(t('status.stepAllowHint'))}</span>`}</div>${state.allowOpened ? '' : `<button class="btn btn-secondary w112" data-act="chatgptAllow">${esc(t('status.stepAllowBtn'))}</button>`}</div>
|
|
197
200
|
<div class="step ${state.setupDone ? 'ok' : ''}"><span class="check ${state.setupDone ? 'ok' : 'next'}">${state.setupDone ? CHECK(12, 3.5) : ''}</span><div class="stack"><span class="t">${esc(t('status.step2'))}</span>${state.setupDone ? '' : `<span class="h">${esc(t('status.step2Hint'))} <span class="mono">${esc(t('status.step2Phrase'))}</span></span>`}</div>${state.setupDone ? '' : `<button class="btn btn-secondary w112" data-copy="${esc(t('status.step2Phrase'))}">${esc(t('status.copyPhrase'))}</button>`}</div>
|
|
198
201
|
<div class="step ${anyTodo ? 'ok' : ''}"><span class="check ${anyTodo ? 'ok' : state.setupDone ? 'next' : ''}">${anyTodo ? CHECK(12, 3.5) : ''}</span><div class="stack"><span class="t">${esc(t('status.step3'))}</span>${anyTodo ? '' : `<span class="h">${esc(t('status.step3Hint'))} <span class="mono">${esc(t('talk.work'))}</span></span>`}</div>${anyTodo ? '' : `<a class="btn btn-secondary w112" href="#queue">${esc(t('status.openQueue'))}</a>`}</div>
|
|
199
202
|
</section></main>`;
|
|
@@ -788,6 +791,7 @@ document.addEventListener('click', async (e) => {
|
|
|
788
791
|
case 'support': return rpc('openUrl', { url: 'https://bridgerator.recondeck.com/support' }).catch(fail);
|
|
789
792
|
case 'plan': return rpc('openUrl', { url: state.banner?.url || state.plan?.renewUrl || 'https://bridgerator.recondeck.com/app' }).catch(fail);
|
|
790
793
|
case 'dismissBanner': return rpc('dismissBanner').then(() => refresh(), fail);
|
|
794
|
+
case 'chatgptAllow': return rpc('openUrl', { url: CHATGPT_PERMISSIONS }).then(() => rpc('chatgptAllowOpened')).then(() => refresh(), fail);
|
|
791
795
|
case 'pause': case 'resume': return rpc(d.act).then(() => refresh(), fail);
|
|
792
796
|
case 'addTask': return taskDialog(null);
|
|
793
797
|
case 'allowAll': return setSetting('allowedDirectories', []);
|
package/ui/i18n/en.json
CHANGED
|
@@ -57,7 +57,9 @@
|
|
|
57
57
|
"agoMin": "{n} min ago",
|
|
58
58
|
"agoHour": "{n} h ago",
|
|
59
59
|
"agoDay": "{n} d ago",
|
|
60
|
-
"
|
|
60
|
+
"stepAllow": "Let ChatGPT work without asking each time",
|
|
61
|
+
"stepAllowHint": "In ChatGPT: Bridgerator → Permission → Allow all",
|
|
62
|
+
"stepAllowBtn": "Open",
|
|
61
63
|
"step2": "Set up with ChatGPT",
|
|
62
64
|
"step2Hint": "In ChatGPT, say:",
|
|
63
65
|
"step2Phrase": "Set up Bridgerator",
|
package/ui/i18n/ru.json
CHANGED
|
@@ -57,7 +57,9 @@
|
|
|
57
57
|
"agoMin": "{n} мин назад",
|
|
58
58
|
"agoHour": "{n} ч назад",
|
|
59
59
|
"agoDay": "{n} дн. назад",
|
|
60
|
-
"
|
|
60
|
+
"stepAllow": "ChatGPT работает без лишних вопросов",
|
|
61
|
+
"stepAllowHint": "В ChatGPT: Bridgerator → Permission → Allow all",
|
|
62
|
+
"stepAllowBtn": "Открыть",
|
|
61
63
|
"step2": "Настроить вместе с ChatGPT",
|
|
62
64
|
"step2Hint": "Скажите в ChatGPT:",
|
|
63
65
|
"step2Phrase": "Set up Bridgerator",
|