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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "bridgerator",
3
- "version": "1.1.2",
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.2",
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.2",
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
- export function createPolicy(cfg = {}) {
153
- const base = cfg.cwd || process.cwd();
154
- // protectSensitive absent = on (callers that don't say); the bridge passes the person's setting.
155
- const denied = [...new Set([...builtinDenied(process.env, os.homedir(), { protectSensitive: cfg.protectSensitive !== false }), ...(cfg.deniedDirectories || [])].map((d) => norm(realish(d))))];
156
- const res = denied.map(mentionRe);
157
- const mentionsDenied = (s) => { const f = flat(s); return res.some((r) => r.test(f)); };
158
- const hidden = new Set([...ALWAYS_HIDDEN, ...(cfg.disabledTools || [])]);
159
- const inDenied = (p) => {
160
- const n = norm(p, base);
161
- return denied.find((d) => n === d || n.startsWith(d + path.sep)) || null;
162
- };
163
- const deniedFor = (p) => inDenied(p) || inDenied(realish(p, base));
164
- const readOnly = (cfg.readOnlyDirectories || []).map((d) => norm(realish(d)));
165
- const inRo = (n) => readOnly.some((d) => n === d || n.startsWith(d + path.sep));
166
- const readOnlyFor = (p) => inRo(norm(p, base)) || inRo(norm(realish(p, base)));
167
- // A folder that CONTAINS an excluded / read-only folder (moving or deleting it takes that folder along).
168
- 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)); };
169
-
170
- // Every path-like string at any depth — iterative, with caps (deep / huge arguments are refused).
171
- function pathsIn(args) {
172
- const out = [], stack = [[args, '', 0]];
173
- let seen = 0;
174
- while (stack.length) {
175
- const [v, key, depth] = stack.pop();
176
- if (++seen > MAX_NODES || depth > MAX_DEPTH) throw new Error('arguments too deeply nested or too large');
177
- if (typeof v === 'string') { if (v && PATH_KEYS.test(key)) out.push(v); }
178
- else if (Array.isArray(v)) for (const x of v) stack.push([x, key, depth + 1]);
179
- else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) stack.push([x, k, depth + 1]);
180
- }
181
- return out;
182
- }
183
-
184
- return {
185
- denied,
186
- visible: (tool) => !hidden.has(tool.name),
187
- // → null when allowed, otherwise the refusal text returned to the model.
188
- check(name, args) {
189
- 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.`;
190
- if (name === 'read_file' && args?.isUrl) return 'Reading URLs is disabled on this computer.';
191
- let paths;
192
- try { paths = pathsIn(args); } catch (e) { return `Refused by Bridgerator: ${e.message}.`; }
193
- for (const p of paths) {
194
- // UNC / device paths (\\server\share, \\?\, \\.\): network shares and raw devices are out of scope.
195
- if (/^[\\/]{2}/.test(p.trim())) return `Access denied by Bridgerator: network and device paths ("${p}") are not allowed.`;
196
- if (deniedFor(p)) return `Access denied by Bridgerator: "${p}" is inside an excluded folder.`;
197
- 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.`;
198
- if (WRITE_TOOLS.includes(name) && holds(denied, p)) return `Access denied by Bridgerator: "${p}" contains an excluded folder.`;
199
- if (WRITE_TOOLS.includes(name) && holds(readOnly, p)) return `Access denied by Bridgerator: "${p}" contains a read-only folder.`;
200
- // A hard link is the same file under another name (maybe inside an excluded folder): no writes.
201
- 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 */ } }
202
- }
203
- for (const k of ['command', 'input']) {
204
- if (typeof args?.[k] !== 'string') continue;
205
- // Checked as written AND with variables expanded (%APPDATA%, $env:APPDATA, ${env:APPDATA}, ~),
206
- // with quotes / escape characters removed ("%USERPROFILE%"\.ssh, .s^sh, .`ssh) and 8.3 short
207
- // names of existing folders made long (C:\PROGRA~1).
208
- const u = unquote(args[k]);
209
- const texts = [args[k], expandVars(args[k]), u, expandVars(u)];
210
- texts.push(...texts.map(longNames));
211
- if (texts.some(mentionsDenied)) return `Access denied by Bridgerator: the ${k} refers to an excluded folder.`;
212
- // Paths in the command resolved like the file tools do (relative → the terminal's folder,
213
- // junctions / symbolic links → their target): a link to an excluded folder is refused too.
214
- const named = [...new Set([...commandPaths(texts[1]), ...commandPaths(texts[3])])].slice(0, MAX_CMD_PATHS);
215
- // UNC / device paths are refused like path arguments — never resolved here (a lookup on a
216
- // network path blocks the bridge's main thread).
217
- const unc = named.find((p) => /^[\\/]{2}[^\\/]+[\\/]/.test(p));
218
- if (unc) return `Access denied by Bridgerator: network and device paths ("${unc}") are not allowed.`;
219
- if (named.some((p) => deniedFor(p))) return `Access denied by Bridgerator: the ${k} refers to an excluded folder.`;
220
- // Read-only folders, for commands that change files (a courtesy: a script can still write there).
221
- // Only what the command writes counts (as written with its quotes and as unquoted).
222
- if (readOnly.length) {
223
- const targets = [...new Set([...writeTargets(texts[1]), ...writeTargets(texts[3])])].filter((p) => !/^[\\/]{2}/.test(p));
224
- 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.`;
225
- }
226
- // Courtesy, like the folder check (the terminal itself is not a boundary): anything about
227
- // Bridgerator itself — its commands, files, processes — is for the person at the PC.
228
- if (texts.some((x) => /\bbridgerator\b/i.test(x))) return 'Commands about Bridgerator itself (its settings, files or processes) can only be run by the person at this PC. Use request_confirm_off to ask for confirmations to be turned off.';
229
- }
230
- return null;
231
- },
232
- /** Refusal text when Bridgerator itself may not remove / restore this path (excluded, read-only, network), else null. */
233
- writable(p) {
234
- if (/^[\\/]{2}/.test(String(p).trim())) return `Access denied by Bridgerator: network and device paths ("${p}") are not allowed.`;
235
- if (deniedFor(p)) return `Access denied by Bridgerator: "${p}" is inside an excluded folder.`;
236
- if (readOnlyFor(p)) return `Access denied by Bridgerator: "${p}" is in a read-only folder.`;
237
- if (holds(denied, p)) return `Access denied by Bridgerator: "${p}" contains an excluded folder.`;
238
- if (holds(readOnly, p)) return `Access denied by Bridgerator: "${p}" contains a read-only folder.`;
239
- return null;
240
- },
241
- // Drop result lines that reveal anything inside a denied folder. `args` lets a listing of a
242
- // parent folder hide the excluded child, whose entries are printed relative to that parent.
243
- filter(result, name, args) {
244
- if (!denied.length || !result || !Array.isArray(result.content)) return result;
245
- const rel = [];
246
- if (name === 'list_directory' && typeof args?.path === 'string') {
247
- const at = norm(realish(args.path, base));
248
- for (const d of denied) if (d.startsWith(at + path.sep)) rel.push(new RegExp(`^${esc(d.slice(at.length + 1))}(?![a-z0-9_.\\-])`));
249
- }
250
- const leaks = (line) => mentionsDenied(line) || rel.some((r) => r.test(flat(line).trim().replace(/^\[(dir|file)\]\s*/, '')));
251
- let dropped = 0;
252
- const content = result.content.map((c) => {
253
- if (c?.type !== 'text' || typeof c.text !== 'string') return c;
254
- const lines = c.text.split(/\r?\n/);
255
- const kept = lines.filter((ln) => !leaks(ln));
256
- dropped += lines.length - kept.length;
257
- return { ...c, text: kept.join('\n') };
258
- });
259
- if (dropped) content.push({ type: 'text', text: `[Bridgerator: ${dropped} line(s) hidden — excluded folder]` });
260
- return { ...result, content };
261
- },
262
- };
263
- }
264
-
265
- // The bundled engine's own name and links are not shown to the user: tool titles/descriptions and
266
- // widget HTML say Bridgerator. Tool RESULTS are never rewritten (they are the user's own data).
267
- const BRAND = [[/"DC: \.\.\." or /g, ''], [/Desktop ?Commander/g, 'Bridgerator'], [/https?:\/\/(www\.)?desktopcommander\.app[^\s"'\)<]*/g, 'https://bridgerator.recondeck.com']];
268
- export function debrand(v) {
269
- if (typeof v === 'string') return BRAND.reduce((s, [re, to]) => s.replace(re, to), v);
270
- if (Array.isArray(v)) return v.map(debrand);
271
- if (v && typeof v === 'object') {
272
- const o = {};
273
- // _meta keys (ui:// template URIs) are identifiers, not user-facing text — keep them as is.
274
- for (const [k, x] of Object.entries(v)) o[k] = k === '_meta' || k === 'uri' ? x : debrand(x);
275
- return o;
276
- }
277
- return v;
278
- }
279
-
280
- // Two results are the engine's own text, not user data: the prompt catalog (get_prompts says
281
- // "Desktop Commander Prompt Categories") and get_config, whose `version` is the engine's and is
282
- // shown as "Bridgerator version" — replaced by the bridge's own version.
283
- export function debrandResult(name, result, version) {
284
- if (!result || typeof result !== 'object') return result;
285
- if (name === 'get_prompts') return debrand(result);
286
- const sc = result.structuredContent, engine = sc?.config?.version;
287
- if (name !== 'get_config' || typeof engine !== 'string') return result;
288
- const label = `"version": ${JSON.stringify(engine)}`;
289
- const content = Array.isArray(result.content)
290
- ? result.content.map((c) => (c?.type === 'text' && typeof c.text === 'string' ? { ...c, text: c.text.replace(label, `"version": ${JSON.stringify(version)}`) } : c))
291
- : result.content;
292
- return { ...result, content, structuredContent: { ...sc, config: { ...sc.config, version } } };
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="grow t">${esc(t('status.step1'))}</span></div>
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
- "step1": "Link this PC to your account",
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
- "step1": "Привязать ПК к аккаунту",
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",