@tensor.chat/companion 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,11 +25,15 @@ It then appears as the folder `Devices/<name>/` in that chat's file tree.
25
25
 
26
26
  ## What the AI can do, and where
27
27
 
28
- - **Shared folders** are set in the device settings on tensor.chat. Read, Write, Edit,
29
- Glob and Grep only work inside them. Paths outside, including symlinks that point
30
- outside, are refused on this machine.
31
- - **Shell commands** stay in tensor.chat's server sandbox by default. Only when you switch
32
- **Shell on the device** on for a chat does the AI get a shell on this machine: it runs
28
+ - **Shared folders** must be approved locally on the computer first with
29
+ `tensor-companion approve-folder <full-path>`. The website can request and display
30
+ folders, but it cannot grant itself access. Read, Write, Edit, Glob and Grep only
31
+ work inside locally approved folders. Paths outside, including symlinks that point
32
+ outside, are refused on this machine even if the server asks for them.
33
+ - **Shell commands** stay in tensor.chat's server sandbox by default. Approve shell locally
34
+ for seven days with `tensor-companion approve-shell` (or supply another number of days);
35
+ the website cannot grant or extend shell access.
36
+ A chat must additionally have **Shell on the device** switched on. Commands then run
33
37
  in the first shared folder with your own rights and full network, and it is not
34
38
  sandboxed — that is the point of a companion device. Git Bash is used on Windows when
35
39
  it is installed, PowerShell otherwise; `tensor-companion shell <path>` overrides it.
@@ -5,7 +5,9 @@
5
5
 
6
6
  const os = require('os');
7
7
  const config = require('../src/config');
8
+ const pairing = require('../src/pairing');
8
9
  const { createDaemon, VERSION } = require('../src/daemon');
10
+ const readline = require('readline/promises');
9
11
 
10
12
  const args = process.argv.slice(2);
11
13
  const cmd = args[0] || 'help';
@@ -17,22 +19,10 @@ function opt(name, fallback) {
17
19
 
18
20
  function out(s) { process.stdout.write(s + '\n'); }
19
21
 
20
- async function postJson(base, route, body) {
21
- const res = await fetch(base.replace(/\/+$/, '') + route, {
22
- method: 'POST',
23
- headers: { 'content-type': 'application/json' },
24
- body: JSON.stringify(body || {}),
25
- });
26
- let data = null;
27
- try { data = await res.json(); } catch (_) {}
28
- if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
29
- return data;
30
- }
31
-
32
22
  async function pair() {
33
23
  const server = opt('--server', config.load().serverUrl || config.DEFAULT_SERVER);
34
24
  const name = opt('--name', os.hostname());
35
- const start = await postJson(server, '/api/companion/pair/start', { platform: process.platform, hostname: os.hostname() });
25
+ const start = await pairing.start(server);
36
26
  const pretty = start.code.slice(0, 4) + '-' + start.code.slice(4);
37
27
  out('');
38
28
  out(` Pairing code: ${pretty}`);
@@ -40,27 +30,17 @@ async function pair() {
40
30
  out(` Open ${server} → Settings → Companion devices → "Pair a device" and enter the code.`);
41
31
  out(` This code expires in ${Math.round(start.expiresInSec / 60)} minutes.`);
42
32
  out('');
43
- const deadline = Date.now() + start.expiresInSec * 1000;
44
- while (Date.now() < deadline) {
45
- await new Promise((r) => setTimeout(r, 3000));
46
- const poll = await postJson(server, '/api/companion/pair/poll', { pollSecret: start.pollSecret });
47
- if (poll.status === 'confirmed') {
48
- const cfg = { ...config.load(), serverUrl: server, token: poll.token, deviceId: poll.deviceId, name: poll.name || name };
49
- config.save(cfg);
50
- out(` Paired as "${cfg.name}". Config saved to ${config.configPath()}`);
51
- out(' Start the daemon with: npx @tensor.chat/companion run');
52
- return;
53
- }
54
- if (poll.status === 'expired') break;
55
- }
56
- throw new Error('the pairing code expired — run "npx @tensor.chat/companion pair" again');
33
+ const cfg = await pairing.waitAndSave(server, start, { name });
34
+ if (!cfg) throw new Error('the pairing code expired — run "npx @tensor.chat/companion pair" again');
35
+ out(` Paired as "${cfg.name}". Config saved to ${config.configPath()}`);
36
+ out(' Start the daemon with: npx @tensor.chat/companion run');
57
37
  }
58
38
 
59
39
  function run() {
60
40
  const cfg = config.load();
61
41
  if (!cfg.token || !cfg.serverUrl) throw new Error('not paired yet — run "npx @tensor.chat/companion pair" first');
62
42
  const stamp = () => new Date().toISOString().slice(11, 19);
63
- const daemon = createDaemon({ serverUrl: cfg.serverUrl, token: cfg.token, shell: cfg.shell, log: (m) => out(`[${stamp()}] ${m}`) });
43
+ const daemon = createDaemon({ serverUrl: cfg.serverUrl, token: cfg.token, shell: cfg.shell, approvedRoots: () => config.approvedRoots(), approvedShell: () => config.shellApproved(), log: (m) => out(`[${stamp()}] ${m}`) });
64
44
  daemon.events.on('welcome', (w) => out(`[${stamp()}] ready as "${w.name || cfg.name}" (device ${w.deviceId})`));
65
45
  daemon.events.on('config', (c) => {
66
46
  const bad = c.roots.filter((r) => !r.ok);
@@ -71,6 +51,7 @@ function run() {
71
51
  daemon.events.on('approval', (a) => out(`[${stamp()}] approval requested for ${a.tool}: ${a.summary || ''} — answer it in the chat`));
72
52
  daemon.events.on('unauthorized', () => { out('the server rejected this device — pair again with "npx @tensor.chat/companion pair"'); process.exit(2); });
73
53
  daemon.events.on('removed', () => { out('this device was removed in the account settings — exiting'); process.exit(3); });
54
+ daemon.events.on('local_approval_request', (a) => out(`[${stamp()}] local approval requested: run npx @tensor.chat/companion allow ${a.code}`));
74
55
  daemon.start();
75
56
  const stop = () => { daemon.stop(); setTimeout(() => process.exit(0), 200); };
76
57
  process.on('SIGINT', stop);
@@ -102,13 +83,63 @@ function shell() {
102
83
  }
103
84
 
104
85
  function unpair() {
105
- const cfg = config.load();
106
- delete cfg.token;
107
- delete cfg.deviceId;
108
- config.save(cfg);
86
+ pairing.forget();
109
87
  out('local pairing removed — also remove the device in the account settings');
110
88
  }
111
89
 
90
+ function approveFolder() {
91
+ const folder = args.slice(1).join(' ');
92
+ if (!folder) throw new Error('usage: tensor-companion approve-folder <full-path>');
93
+ const real = config.approveRoot(folder);
94
+ out(`Approved locally: ${real}`);
95
+ }
96
+
97
+ function revokeFolder() {
98
+ const folder = args.slice(1).join(' ');
99
+ if (!folder) throw new Error('usage: tensor-companion revoke-folder <full-path>');
100
+ out(config.revokeRoot(folder) ? `Local approval removed: ${folder}` : `No local approval matched: ${folder}`);
101
+ }
102
+
103
+ function folders() {
104
+ const roots = config.approvedRoots();
105
+ out(roots.length ? roots.map((r) => ` ${r}`).join('\n') : ' (no folders approved locally)');
106
+ }
107
+
108
+ function approveShell() {
109
+ const days = args[1] == null ? 7 : Number(args[1]);
110
+ const until = config.approveShell(days);
111
+ out(`Shell access approved locally until ${until}.`);
112
+ }
113
+
114
+ function revokeShell() {
115
+ config.revokeShell();
116
+ out('Local shell approval removed.');
117
+ }
118
+
119
+ async function deviceRequest(route, method = 'GET') {
120
+ const cfg = config.load();
121
+ if (!cfg.token || !cfg.serverUrl) throw new Error('not paired yet');
122
+ const res = await fetch(cfg.serverUrl.replace(/\/+$/, '') + route, { method, headers: { authorization: `Bearer ${cfg.token}`, 'content-type': 'application/json' } });
123
+ const data = await res.json().catch(() => ({}));
124
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
125
+ return data;
126
+ }
127
+
128
+ async function allowRequest() {
129
+ const code = String(args[1] || '').replace(/[^A-Fa-f0-9]/g, '').toUpperCase();
130
+ if (!code) throw new Error('usage: tensor-companion allow <code>');
131
+ const { approval: a } = await deviceRequest(`/api/companion/local-approvals/${encodeURIComponent(code)}`);
132
+ const description = a.kind === 'folder' ? `folder "${a.path}"` : `shell access for ${a.days} day(s)`;
133
+ out(`Request: allow ${description} on this computer.`);
134
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
135
+ const answer = await rl.question('Approve locally? [y/N] ');
136
+ rl.close();
137
+ if (!/^y(?:es)?$/i.test(answer.trim())) { out('Not approved.'); return; }
138
+ if (a.kind === 'folder') config.approveRoot(a.path); else config.approveShell(a.days);
139
+ await deviceRequest(`/api/companion/local-approvals/${encodeURIComponent(code)}/complete`, 'POST');
140
+ out(`Approved locally: ${description}`);
141
+ }
142
+
112
143
  function help() {
113
144
  out(`tensor-companion ${VERSION}
114
145
 
@@ -116,6 +147,12 @@ function help() {
116
147
  tensor-companion run stay connected and run tools for the AI
117
148
  tensor-companion status show the current pairing
118
149
  tensor-companion shell <path|auto> choose the shell Bash commands run in
150
+ tensor-companion approve-folder <full-path> allow this folder on this computer
151
+ tensor-companion revoke-folder <full-path> remove a local folder approval
152
+ tensor-companion folders list locally approved folders
153
+ tensor-companion approve-shell [days] allow remote shell locally (default: 7 days)
154
+ tensor-companion revoke-shell remove the local shell approval
155
+ tensor-companion allow <code> confirm a request shown by tensor.chat
119
156
  tensor-companion unpair forget the local pairing`);
120
157
  }
121
158
 
@@ -125,6 +162,12 @@ function help() {
125
162
  else if (cmd === 'run') run();
126
163
  else if (cmd === 'status') status();
127
164
  else if (cmd === 'shell') shell();
165
+ else if (cmd === 'approve-folder') approveFolder();
166
+ else if (cmd === 'revoke-folder') revokeFolder();
167
+ else if (cmd === 'folders') folders();
168
+ else if (cmd === 'approve-shell') approveShell();
169
+ else if (cmd === 'revoke-shell') revokeShell();
170
+ else if (cmd === 'allow') await allowRequest();
128
171
  else if (cmd === 'unpair') unpair();
129
172
  else help();
130
173
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor.chat/companion",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Lets your tensor.chat AI use this machine: files, shell and internet through your own connection.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://tensor.chat",
package/src/config.js CHANGED
@@ -33,4 +33,66 @@ function save(cfg) {
33
33
  fs.renameSync(tmp, configPath());
34
34
  }
35
35
 
36
- module.exports = { DEFAULT_SERVER, homeDir, configPath, load, save };
36
+ function approvedRoots(cfg = load()) {
37
+ return Array.isArray(cfg.approvedRoots) ? cfg.approvedRoots.map(String) : [];
38
+ }
39
+
40
+ function shellApproved(cfg = load()) {
41
+ const until = Date.parse(String(cfg.shellApprovedUntil || ''));
42
+ return Number.isFinite(until) && until > Date.now();
43
+ }
44
+
45
+ function approveShell(days = 7) {
46
+ const n = Number(days);
47
+ if (!Number.isInteger(n) || n < 1 || n > 365) throw new Error('shell approval days must be an integer from 1 to 365');
48
+ const cfg = load();
49
+ cfg.shellApprovedUntil = new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
50
+ save(cfg);
51
+ return cfg.shellApprovedUntil;
52
+ }
53
+
54
+ function revokeShell() {
55
+ const cfg = load();
56
+ delete cfg.shellApprovedUntil;
57
+ save(cfg);
58
+ }
59
+
60
+ function approveRoot(folder) {
61
+ const raw = expandHome(folder);
62
+ if (!raw) throw new Error('a folder path is required');
63
+ let real;
64
+ try {
65
+ real = fs.realpathSync.native(path.resolve(raw));
66
+ if (!fs.statSync(real).isDirectory()) throw new Error('not a folder');
67
+ } catch (e) {
68
+ throw new Error(`cannot approve "${raw}": ${e.message}`);
69
+ }
70
+ const cfg = load();
71
+ const roots = approvedRoots(cfg);
72
+ const key = process.platform === 'win32' ? real.toLowerCase() : real;
73
+ if (!roots.some((r) => (process.platform === 'win32' ? r.toLowerCase() : r) === key)) roots.push(real);
74
+ cfg.approvedRoots = roots;
75
+ save(cfg);
76
+ return real;
77
+ }
78
+
79
+ function revokeRoot(folder) {
80
+ const input = expandHome(folder);
81
+ let raw = path.resolve(input);
82
+ try { raw = fs.realpathSync.native(raw); } catch (_) {}
83
+ const cfg = load();
84
+ const key = process.platform === 'win32' ? raw.toLowerCase() : raw;
85
+ const before = approvedRoots(cfg);
86
+ cfg.approvedRoots = before.filter((r) => (process.platform === 'win32' ? path.resolve(r).toLowerCase() : path.resolve(r)) !== key);
87
+ save(cfg);
88
+ return before.length !== cfg.approvedRoots.length;
89
+ }
90
+
91
+ function expandHome(folder) {
92
+ const raw = String(folder || '').trim();
93
+ if (raw === '~') return os.homedir();
94
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) return path.join(os.homedir(), raw.slice(2));
95
+ return raw;
96
+ }
97
+
98
+ module.exports = { DEFAULT_SERVER, homeDir, configPath, load, save, approvedRoots, approveRoot, revokeRoot, shellApproved, approveShell, revokeShell };
package/src/daemon.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  const os = require('os');
7
7
  const path = require('path');
8
+ const fs = require('fs');
8
9
  const EventEmitter = require('events');
9
10
  const WebSocket = require('ws');
10
11
  const { Roots } = require('./paths');
@@ -34,7 +35,7 @@ function isRoot() {
34
35
  try { return typeof process.getuid === 'function' && process.getuid() === 0; } catch (_) { return false; }
35
36
  }
36
37
 
37
- function systemInfo(shell) {
38
+ function systemInfo(shell, localAuthority = false) {
38
39
  let username = null;
39
40
  try { username = os.userInfo().username; } catch (_) {}
40
41
  return {
@@ -50,14 +51,44 @@ function systemInfo(shell) {
50
51
  shell: bash.shellFor(shell).cmd,
51
52
  shellKind: bash.shellFor(shell).kind,
52
53
  cwd: process.cwd(),
54
+ localAuthority: localAuthority ? 1 : null,
53
55
  };
54
56
  }
55
57
 
56
- function createDaemon({ serverUrl, token, shell, log } = {}) {
58
+ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
59
+ const requested = Array.isArray(requestedRoots) ? requestedRoots.map(String) : [];
60
+ const names = Array.isArray(labels) ? labels.map(String) : [];
61
+ const approved = (Array.isArray(approvedRoots) ? approvedRoots : []).map((r) => path.resolve(String(r)));
62
+ const norm = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
63
+ const inside = (candidate, allowed) => {
64
+ const rel = path.relative(norm(allowed), norm(candidate));
65
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
66
+ };
67
+ const roots = [];
68
+ const keptLabels = [];
69
+ const rejected = [];
70
+ requested.forEach((raw, i) => {
71
+ const abs = path.resolve(raw);
72
+ let real;
73
+ try { real = fs.realpathSync.native(abs); } catch (_) {
74
+ rejected.push({ path: abs, ok: false, error: 'folder does not exist on this computer' });
75
+ return;
76
+ }
77
+ if (approved.some((allow) => inside(real, allow))) {
78
+ roots.push(real);
79
+ keptLabels.push(names[i] || '');
80
+ } else rejected.push({ path: abs, ok: false, error: 'not approved on this computer' });
81
+ });
82
+ return { roots, labels: keptLabels, rejected };
83
+ }
84
+
85
+ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell } = {}) {
57
86
  if (!serverUrl || !token) throw new Error('serverUrl and token are required');
58
87
  const emitter = new EventEmitter();
59
88
  const say = typeof log === 'function' ? log : () => {};
60
89
  const roots = new Roots([]);
90
+ const localApprovals = typeof approvedRoots === 'function' ? approvedRoots : null;
91
+ const localShellApproval = typeof approvedShell === 'function' ? approvedShell : null;
61
92
  const running = new Map(); // call id → AbortController
62
93
  const jobs = new Jobs();
63
94
  const unsentExits = []; // job endings the server has not heard yet (offline at the time)
@@ -69,22 +100,26 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
69
100
  }
70
101
 
71
102
  function applyConfig(msg) {
72
- state.roots = Array.isArray(msg.roots) ? msg.roots.map(String) : [];
73
- state.labels = Array.isArray(msg.labels) ? msg.labels.map(String) : [];
103
+ const requested = Array.isArray(msg.roots) ? msg.roots.map(String) : [];
104
+ const labels = Array.isArray(msg.labels) ? msg.labels.map(String) : [];
105
+ const approved = localApprovals ? localApprovals() : [];
106
+ const authorized = authorizeRequestedRoots(requested, labels, approved);
107
+ state.roots = authorized.roots;
108
+ state.labels = authorized.labels;
74
109
  state.mode = msg.mode || null;
75
110
  state.name = msg.name || state.name;
76
111
  roots.set(state.roots, state.labels);
77
- send({ t: 'roots_status', roots: roots.status() });
78
- emitter.emit('config', { roots: roots.status(), mode: state.mode, name: state.name });
112
+ send({ t: 'roots_status', roots: [...roots.status(), ...authorized.rejected] });
113
+ emitter.emit('config', { roots: [...roots.status(), ...authorized.rejected], mode: state.mode, name: state.name });
79
114
  }
80
115
 
81
- async function runTool(tool, args, signal, timeoutMs) {
116
+ async function runTool(tool, args, signal, timeoutMs, callRoots = roots) {
82
117
  const a = args && typeof args === 'object' ? args : {};
83
118
  switch (tool) {
84
119
  case 'bash': {
85
- const cwdRes = a.cwd ? roots.resolve(a.cwd) : null;
120
+ const cwdRes = a.cwd ? callRoots.resolve(a.cwd) : null;
86
121
  if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
87
- const cwd = cwdRes ? cwdRes.abs : roots.primary();
122
+ const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
88
123
  if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
89
124
  const job = jobs.start(String(a.command || ''), { cwd, shell });
90
125
  // A cancelled call (turn aborted, server timeout) takes its foreground job with
@@ -99,7 +134,7 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
99
134
  if (signal) signal.removeEventListener('abort', onAbort);
100
135
  if (ended) {
101
136
  const r = jobs.result(job);
102
- r.touched = extractTouched(roots, a.command, r.stdout, r.stderr);
137
+ r.touched = extractTouched(callRoots, a.command, r.stdout, r.stderr);
103
138
  delete r.stdout;
104
139
  delete r.stderr;
105
140
  jobs.poll(job.id); // the one-shot result already carried every line
@@ -119,13 +154,21 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
119
154
  const p = jobs.poll(job.id);
120
155
  return { ok: true, jobId: job.id, running: p.status === 'running', status: p.status, exitCode: p.exitCode, jobMs: p.ms, text: p.text, skipped: p.skipped };
121
156
  }
122
- case 'read': return files.read(roots, a.path, { lines: a.lines });
123
- case 'write': return files.write(roots, a.path, a.content, { overwrite: a.overwrite === true });
124
- case 'edit': return files.edit(roots, a.path, a.edits);
125
- case 'glob': return files.glob(roots, a.pattern);
126
- case 'grep': return files.grep(roots, a.command);
127
- case 'ls': return files.ls(roots, a.path);
128
- case 'fetchfile': return files.fetchfile(roots, a.path, { maxBytes: a.maxBytes });
157
+ case 'read': return files.read(callRoots, a.path, { lines: a.lines });
158
+ case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
159
+ case 'edit': return files.edit(callRoots, a.path, a.edits);
160
+ case 'glob': return files.glob(callRoots, a.pattern);
161
+ case 'grep': return files.grep(callRoots, a.command);
162
+ case 'ls': return files.ls(callRoots, a.path, { max: a.max });
163
+ case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
164
+ case 'fetchpart': return files.fetchpart(callRoots, a.path, { offset: a.offset, length: a.length });
165
+ // Das eingehaengte Laufwerk schreibt ueber diese vier zurueck. Sie stehen der KI
166
+ // NICHT offen - sie kommen nur aus dem Adapter, der das Laufwerk bedient.
167
+ case 'writepart': return files.writepart(callRoots, a.path, { offset: a.offset, base64: a.base64, truncate: a.truncate });
168
+ case 'mkdirpath': return files.mkdirpath(callRoots, a.path);
169
+ case 'removepath': return await files.removepath(callRoots, a.path, { endgueltig: a.endgueltig === true });
170
+ case 'renamepath': return files.renamepath(callRoots, a.from, a.to);
171
+ case 'statpath': return files.statpath(callRoots, a.path);
129
172
  case 'fetch': return fetchUrl(a.url, { signal });
130
173
  default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
131
174
  }
@@ -133,6 +176,13 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
133
176
 
134
177
  async function onCall(msg) {
135
178
  const id = String(msg.id);
179
+ const tool = String(msg.tool || '');
180
+ if ((tool === 'bash' || tool === 'job') && localShellApproval && !localShellApproval()) {
181
+ const r = { ok: false, text: '(shell access is not approved on this computer)' };
182
+ send({ t: 'result', id, ...r });
183
+ emitter.emit('result', { id, tool, ok: false });
184
+ return;
185
+ }
136
186
  if (running.size >= MAX_CONCURRENT_CALLS) {
137
187
  send({ t: 'result', id, ok: false, text: '(the companion device is busy with other calls — try again in a moment)' });
138
188
  return;
@@ -142,7 +192,13 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
142
192
  emitter.emit('call', { id, tool: msg.tool, args: msg.args });
143
193
  let r;
144
194
  try {
145
- r = await runTool(String(msg.tool || ''), msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined);
195
+ let callRoots = roots;
196
+ if (Array.isArray(msg.roots)) {
197
+ const approved = localApprovals ? localApprovals() : [];
198
+ const scoped = authorizeRequestedRoots(msg.roots, msg.labels, approved);
199
+ callRoots = new Roots(scoped.roots, scoped.labels);
200
+ }
201
+ r = await runTool(tool, msg.args, ctrl.signal, Number(msg.timeoutMs) || undefined, callRoots);
146
202
  } catch (e) {
147
203
  r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
148
204
  }
@@ -180,6 +236,7 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
180
236
  case 'cancel': { const c = running.get(String(msg.id)); if (c) c.abort(); break; }
181
237
  case 'approval': emitter.emit('approval', msg); break;
182
238
  case 'approval_resolved': emitter.emit('approval_resolved', msg); break;
239
+ case 'local_approval_request': emitter.emit('local_approval_request', msg); break;
183
240
  default: break;
184
241
  }
185
242
  }
@@ -207,7 +264,7 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
207
264
  ws.on('open', () => {
208
265
  state.connected = true;
209
266
  state.reconnectMs = RECONNECT_MIN_MS;
210
- send(systemInfo(shell));
267
+ send(systemInfo(shell, !!localApprovals && !!localShellApproval));
211
268
  flushExits();
212
269
  say(`connected to ${serverUrl}`);
213
270
  emitter.emit('connected');
@@ -217,6 +274,11 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
217
274
  const code = res && res.statusCode;
218
275
  say(`server refused the connection (HTTP ${code})`);
219
276
  if (code === 401) { state.stopped = true; emitter.emit('unauthorized'); }
277
+ // With this listener in place, ws leaves the refused handshake open: no
278
+ // 'close' ever comes, so no reconnect either. A 502 from nginx while the
279
+ // server restarts left the device offline until the app was restarted
280
+ // (26.09.2026). End it here; 'close' then schedules the next try.
281
+ try { ws.terminate(); } catch (_) {}
220
282
  });
221
283
  ws.on('error', (e) => { say(`socket error: ${e.message}`); });
222
284
  ws.on('close', (code, reason) => {
@@ -246,4 +308,4 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
246
308
  };
247
309
  }
248
310
 
249
- module.exports = { createDaemon, wsUrlFor, systemInfo, VERSION };
311
+ module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, VERSION };
package/src/pairing.js ADDED
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+ // Pairing this machine with an account: ask the server for a code, wait until a
3
+ // signed-in user confirms it, keep the token. The command line shows the code to a
4
+ // person; the desktop app hands it to its own signed-in page. Both walk these steps.
5
+
6
+ const os = require('os');
7
+ const config = require('./config');
8
+
9
+ const POLL_MS = 3000;
10
+ const REQUEST_TIMEOUT_MS = 30000;
11
+
12
+ // `signal` has to reach fetch itself: a request that hangs in the network would
13
+ // otherwise outlive the abort, and the pairing dialog would wait on it forever.
14
+ async function postJson(base, route, body, signal, timeoutMs = REQUEST_TIMEOUT_MS) {
15
+ const limit = AbortSignal.timeout(timeoutMs);
16
+ const res = await fetch(base.replace(/\/+$/, '') + route, {
17
+ method: 'POST',
18
+ headers: { 'content-type': 'application/json' },
19
+ body: JSON.stringify(body || {}),
20
+ signal: signal ? AbortSignal.any([signal, limit]) : limit,
21
+ });
22
+ let data = null;
23
+ try { data = await res.json(); } catch (_) {}
24
+ if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
25
+ return data;
26
+ }
27
+
28
+ function start(server) {
29
+ return postJson(server, '/api/companion/pair/start', { platform: process.platform, hostname: os.hostname() });
30
+ }
31
+
32
+ // Resolves with the saved config once the code is confirmed, or null when the code
33
+ // expired or `signal` was aborted first.
34
+ async function waitAndSave(server, started, { name, pollMs = POLL_MS, signal, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
35
+ const deadline = Date.now() + started.expiresInSec * 1000;
36
+ while (Date.now() < deadline && !(signal && signal.aborted)) {
37
+ await new Promise((r) => setTimeout(r, pollMs));
38
+ let poll;
39
+ try {
40
+ poll = await postJson(server, '/api/companion/pair/poll', { pollSecret: started.pollSecret }, signal, timeoutMs);
41
+ } catch (e) {
42
+ // A stop ends the wait. A request that ran out of time does not: the code is
43
+ // still good for as long as the server said, so the next turn asks again.
44
+ if (signal && signal.aborted) return null;
45
+ if (e && (e.name === 'AbortError' || e.name === 'TimeoutError')) continue;
46
+ throw e;
47
+ }
48
+ if (poll.status === 'confirmed') {
49
+ const cfg = { ...config.load(), serverUrl: server, token: poll.token, deviceId: poll.deviceId, name: poll.name || name || os.hostname() };
50
+ config.save(cfg);
51
+ return cfg;
52
+ }
53
+ if (poll.status === 'expired') return null;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function forget() {
59
+ const cfg = config.load();
60
+ delete cfg.token;
61
+ delete cfg.deviceId;
62
+ config.save(cfg);
63
+ }
64
+
65
+ module.exports = { start, waitAndSave, forget };
@@ -300,10 +300,18 @@ function grep(roots, command) {
300
300
  // server ONE small text file so it can keep a copy in the chat. Nothing walks the
301
301
  // whole tree here — the user's folders may hold terabytes.
302
302
  const LS_MAX_ENTRIES = 2000;
303
+ // Der harte Deckel, den auch das Laufwerk nicht ueberschreitet: irgendwo muss die
304
+ // Grenze liegen, sonst legt ein Ordner mit einer Million Eintraegen den Daemon
305
+ // des Nutzers lahm.
306
+ const LS_HARD_MAX = 100000;
303
307
  const FETCH_MAX_BYTES_DEFAULT = 2 * 1024 * 1024;
304
308
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
305
309
 
306
- function ls(roots, p) {
310
+ function ls(roots, p, { max } = {}) {
311
+ // Der Deckel ist fuer die KI da. Das eingehaengte Laufwerk fragt mit einem
312
+ // hoeheren, weil ein halb gelisteter Ordner dort schlimmer ist als ein langes
313
+ // Listing: ls` zeigt sonst weniger, als da ist, und niemand merkt es.
314
+ const deckel = Number(max) > 0 ? Math.min(Number(max), LS_HARD_MAX) : LS_MAX_ENTRIES;
307
315
  const okRoots = roots.okRoots();
308
316
  const raw = String(p == null ? '' : p).trim();
309
317
  if (!raw) {
@@ -318,7 +326,7 @@ function ls(roots, p) {
318
326
  const entries = [];
319
327
  let truncated = false;
320
328
  for (const d of names) {
321
- if (entries.length >= LS_MAX_ENTRIES) { truncated = true; break; }
329
+ if (entries.length >= deckel) { truncated = true; break; }
322
330
  const abs = path.join(r.abs, d.name);
323
331
  let st = null;
324
332
  try { st = fs.statSync(abs); } catch (_) { continue; }
@@ -346,4 +354,154 @@ function fetchfile(roots, p, { maxBytes } = {}) {
346
354
  return ok('', { content, bytes: st.size, mtime: Math.round(st.mtimeMs) });
347
355
  }
348
356
 
349
- module.exports = { read, write, edit, glob, grep, ls, fetchfile, isTextName, parseLineRange, globToRegex, READ_CHAR_CAP, WRITE_CHAR_CAP, LS_MAX_ENTRIES, FETCH_MAX_BYTES_DEFAULT };
357
+ // One slice of ANY file, for the server's on-demand materialisation. fetchfile above
358
+ // answers "give me this small text file"; this one answers "give me bytes 0..N of
359
+ // whatever this is", because a video the AI touched has to reach a classifier too and
360
+ // no single WebSocket frame carries it. size+mtime come with every slice: the caller
361
+ // notices a file that changed under it mid-transfer instead of writing a torn copy.
362
+ const PART_MAX_BYTES = 1024 * 1024;
363
+
364
+ function fetchpart(roots, p, { offset, length } = {}) {
365
+ const r = roots.resolve(p);
366
+ if (r.error) return err(`(cannot fetch "${p}" — ${r.error})`);
367
+ let st = null;
368
+ try { st = fs.statSync(r.abs); } catch (_) {}
369
+ if (!st || !st.isFile()) return err(`(no such file: ${roots.display(r.abs)})`);
370
+ const from = Math.max(0, Math.floor(Number(offset) || 0));
371
+ const want = Math.min(Number(length) > 0 ? Math.floor(Number(length)) : PART_MAX_BYTES, PART_MAX_BYTES);
372
+ const meta = { bytes: st.size, mtime: Math.round(st.mtimeMs), offset: from };
373
+ if (from >= st.size) return ok('', { ...meta, base64: '', read: 0, eof: true });
374
+ const buf = Buffer.alloc(Math.min(want, st.size - from));
375
+ let fd = null;
376
+ let read = 0;
377
+ try {
378
+ fd = fs.openSync(r.abs, 'r');
379
+ read = fs.readSync(fd, buf, 0, buf.length, from);
380
+ } catch (e) {
381
+ return err(`(cannot read ${roots.display(r.abs)} — ${e.message})`);
382
+ } finally {
383
+ if (fd != null) { try { fs.closeSync(fd); } catch (_) {} }
384
+ }
385
+ return ok('', { ...meta, base64: buf.subarray(0, read).toString('base64'), read, eof: from + read >= st.size });
386
+ }
387
+
388
+ // ---- Das Netzlaufwerk schreibt zurueck ------------------------------------
389
+ //
390
+ // fetchpart liest JEDE Datei in Scheiben; diese vier schreiben sie genauso. Sie
391
+ // sind fuer das eingehaengte Laufwerk da, nicht fuer die KI: <Write> und <Edit>
392
+ // bleiben auf Text beschraenkt, weil ein Modell, das eine Binaerdatei "bearbeitet",
393
+ // sie kaputt macht. Ein Laufwerk dagegen muss alles koennen, was ein Laufwerk kann -
394
+ // sonst laeuft ffmpeg ins Leere, sobald es seine Ausgabe schreiben will.
395
+
396
+ // Eine Scheibe an ihren Platz. Zustandslos mit Absicht: jede Scheibe traegt ihren
397
+ // eigenen Versatz, also kann eine verlorene Verbindung nichts halb Gemerktes
398
+ // hinterlassen. offset 0 kuerzt die Datei - so faengt jedes Schreiben an.
399
+ function writepart(roots, p, { offset, base64, truncate } = {}) {
400
+ const r = roots.resolve(p);
401
+ if (r.error) return err(`(cannot write "${p}" — ${r.error})`);
402
+ const from = Math.max(0, Math.floor(Number(offset) || 0));
403
+ const buf = Buffer.from(String(base64 || ''), 'base64');
404
+ if (buf.length > PART_MAX_BYTES) return err(`(slice too large: ${buf.length} bytes, limit ${PART_MAX_BYTES})`);
405
+ let fd = null;
406
+ try {
407
+ fs.mkdirSync(path.dirname(r.abs), { recursive: true });
408
+ // Erst oeffnen, dann entscheiden. Zwischen einem existsSync und dem Oeffnen
409
+ // passt ein fremder Schreibvorgang: legt jemand die Datei genau dort an,
410
+ // leerte sie das 'w' wieder. 'r+' scheitert sauber, wenn es sie nicht gibt.
411
+ try {
412
+ fd = fs.openSync(r.abs, 'r+');
413
+ } catch (e) {
414
+ if (e && e.code !== 'ENOENT') throw e;
415
+ fd = fs.openSync(r.abs, 'w');
416
+ }
417
+ if (truncate === true) fs.ftruncateSync(fd, from);
418
+ if (buf.length) fs.writeSync(fd, buf, 0, buf.length, from);
419
+ } catch (e) {
420
+ return err(`(cannot write ${roots.display(r.abs)} — ${e.message})`);
421
+ } finally {
422
+ if (fd != null) { try { fs.closeSync(fd); } catch (_) {} }
423
+ }
424
+ let st = null;
425
+ try { st = fs.statSync(r.abs); } catch (_) {}
426
+ return ok('', { bytes: st ? st.size : null, mtime: st ? Math.round(st.mtimeMs) : null, wrote: buf.length });
427
+ }
428
+ function mkdirpath(roots, p) {
429
+ const r = roots.resolve(p);
430
+ if (r.error) return err(`(cannot create "${p}" — ${r.error})`);
431
+ try { fs.mkdirSync(r.abs, { recursive: true }); }
432
+ catch (e) { return err(`(cannot create ${roots.display(r.abs)} — ${e.message})`); }
433
+ return ok('');
434
+ }
435
+
436
+ // Loeschen heisst loeschen - ein Laufwerk hat keinen Papierkorb. Ein Ordner geht nur
437
+ // MIT Inhalt weg, sonst waere `rm -r` auf dem Laufwerk keine Loeschung, sondern ein
438
+ // Fehler, und die Werkzeuge daran wuerden sich seltsam verhalten.
439
+ // Loeschen heisst: in den Papierkorb des Rechners. Auf dem Server ist eine
440
+ // geloeschte Datei ein Eintrag, den wir zurueckholen koennen; auf einer fremden
441
+ // Platte gibt es das nicht - dort waere weg wirklich weg. Den Papierkorb kennt
442
+ // der Nutzer, er weiss, wo er nachsieht, und er raeumt ihn selbst.
443
+ //
444
+ // `endgueltig` gibt es fuer den einen Fall, in dem ein Papierkorb keinen Sinn
445
+ // ergibt: unsere eigenen Entwuerfe (.part), die beim Abbruch weggeraeumt werden.
446
+ // Die sammelten sich dort sonst an, ohne dass sie je jemand vermisst haette.
447
+ async function removepath(roots, p, { endgueltig = false } = {}) {
448
+ const r = roots.resolve(p);
449
+ if (r.error) return err(`(cannot delete "${p}" — ${r.error})`);
450
+ if (endgueltig) {
451
+ try { fs.rmSync(r.abs, { recursive: true, force: false }); }
452
+ catch (e) { return err(`(cannot delete ${roots.display(r.abs)} — ${e.code === 'ENOENT' ? 'no such file' : e.message})`); }
453
+ return ok('');
454
+ }
455
+ try { await require('./trash').inDenPapierkorb(r.abs); }
456
+ catch (e) {
457
+ // NICHT ersatzweise vernichten. Lieber eine Fehlermeldung als eine Datei,
458
+ // die der Nutzer nie wiedersieht.
459
+ const grund = e && e.code === 'ENOENT' ? 'no such file' : (e && e.message ? e.message : String(e));
460
+ return err(`(cannot move ${roots.display(r.abs)} to the recycle bin — ${grund})`);
461
+ }
462
+ return ok('', { inDenPapierkorb: true });
463
+ }
464
+
465
+ // Beide Seiten muessen im geteilten Ordner liegen: ein Verschieben ist sonst ein
466
+ // Weg hinaus, an dem der Zaun nichts merkt.
467
+ // Ein einzelnes Nachsehen. Das Laufwerk fragt danach fuer jede Datei, die es
468
+ // anfasst; ueber ein Verzeichnislisting waere das je Frage ein Gang durch den
469
+ // ganzen Ordner - und der Ordner koennte 20 000 Eintraege haben.
470
+ function statpath(roots, p) {
471
+ const r = roots.resolve(p);
472
+ if (r.error) return err(`(cannot stat "${p}" — ${r.error})`);
473
+ let st = null;
474
+ try { st = fs.lstatSync(r.abs); } catch (_) { st = null; }
475
+ if (!st) return err(`(no such file: ${roots.display(r.abs)})`);
476
+ // Eine Verknuepfung wird als das gemeldet, worauf sie zeigt - so sieht sie jedes
477
+ // andere Werkzeug auf diesem Rechner auch. Zeigt sie ins Leere, bleibt sie, was
478
+ // sie ist: nichts Lesbares.
479
+ if (st.isSymbolicLink()) {
480
+ try { st = fs.statSync(r.abs); } catch (_) { return err(`(broken link: ${roots.display(r.abs)})`); }
481
+ }
482
+ return ok('', { dir: st.isDirectory(), bytes: st.size, mtime: Math.round(st.mtimeMs) });
483
+ }
484
+
485
+ function renamepath(roots, from, to) {
486
+ const a = roots.resolve(from);
487
+ if (a.error) return err(`(cannot move "${from}" — ${a.error})`);
488
+ const b = roots.resolve(to);
489
+ if (b.error) return err(`(cannot move to "${to}" — ${b.error})`);
490
+ try {
491
+ fs.mkdirSync(path.dirname(b.abs), { recursive: true });
492
+ fs.renameSync(a.abs, b.abs);
493
+ } catch (e) {
494
+ // Die Quelle ist weg und das Ziel ist da: dann ist genau das passiert, was
495
+ // erreicht werden sollte - nur die Antwort ging verloren, und der Aufruf kam
496
+ // ein zweites Mal. Das als Fehler zu melden, hiesse den Aufrufer anzuluegen
497
+ // ueber eine Datei, die laengst an ihrem Platz liegt.
498
+ if (e && e.code === 'ENOENT' && fs.existsSync(b.abs)) return ok('', { schonDa: true });
499
+ return err(`(cannot move ${roots.display(a.abs)} — ${e.message})`);
500
+ }
501
+ return ok('');
502
+ }
503
+
504
+ module.exports = {
505
+ statpath,
506
+ fetchpart, writepart, mkdirpath, removepath, renamepath,
507
+ PART_MAX_BYTES, read, write, edit, glob, grep, ls, fetchfile, isTextName, parseLineRange, globToRegex, READ_CHAR_CAP, WRITE_CHAR_CAP, LS_MAX_ENTRIES, FETCH_MAX_BYTES_DEFAULT };
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+ // Loeschen heisst hier: in den Papierkorb des Rechners, nicht ins Nichts.
3
+ //
4
+ // Auf dem Server ist eine geloeschte Datei ein Eintrag in unserer Datenbank, den
5
+ // wir zurueckholen koennen. Auf dem Rechner des Nutzers gibt es das nicht - dort
6
+ // ist weg wirklich weg. Der Rechner hat aber seinen eigenen Papierkorb, und den
7
+ // kennt der Nutzer: er weiss, wo er nachsehen muss, und er raeumt ihn selbst.
8
+ //
9
+ // Jedes System macht das anders, und zwar wirklich anders - nicht nur mit einem
10
+ // anderen Ordnernamen:
11
+ // Windows hat einen Papierkorb, in den nur die Shell einsortieren kann.
12
+ // macOS hat ~/.Trash, ein gewoehnlicher Ordner.
13
+ // Linux hat die freedesktop-Ablage: Datei nach files/, und daneben eine
14
+ // .trashinfo mit Herkunft und Zeit, sonst weiss der Dateimanager
15
+ // beim Zuruecklegen nicht, wohin.
16
+ //
17
+ // Geht das Einsortieren nicht, wird NICHT ersatzweise vernichtet. Lieber eine
18
+ // Fehlermeldung als eine Datei, die der Nutzer nie wiedersieht.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+ const { execFile } = require('child_process');
24
+
25
+ // Ein Name, den es im Zielordner noch nicht gibt. Zwei gleichnamige Dateien aus
26
+ // zwei Ordnern landen sonst uebereinander, und die erste waere doch weg.
27
+ const NUMMERN_BIS = 50;
28
+
29
+ function freierName(ordner, name) {
30
+ let ziel = path.join(ordner, name);
31
+ if (!fs.existsSync(ziel)) return ziel;
32
+ const ext = path.extname(name);
33
+ const stamm = path.basename(name, ext);
34
+ // Erst durchnummerieren: "bericht.1.txt" liest sich im Papierkorb, ein Zufall
35
+ // nicht. Nach ein paar Dutzend ist die Lesbarkeit ohnehin dahin, und dann
36
+ // zaehlt nur noch, dass ein Name frei ist.
37
+ for (let i = 1; i <= NUMMERN_BIS; i++) {
38
+ ziel = path.join(ordner, `${stamm}.${i}${ext}`);
39
+ if (!fs.existsSync(ziel)) return ziel;
40
+ }
41
+ for (;;) {
42
+ ziel = path.join(ordner, `${stamm}.${require('crypto').randomBytes(4).toString('hex')}${ext}`);
43
+ if (!fs.existsSync(ziel)) return ziel;
44
+ }
45
+ }
46
+
47
+ // Ein Verschieben, das auch ueber Dateisystemgrenzen hinweg geht. rename schafft
48
+ // das nicht - und der Papierkorb liegt oft auf einer anderen Platte als die Datei.
49
+ // Kopieren und dann erst wegnehmen. Die Reihenfolge ist die ganze Vorsicht: ein
50
+ // Abbruch dazwischen laesst eine Kopie zu viel zurueck, nie eine zu wenig.
51
+ function kopiereUndLoesche(von, nach) {
52
+ fs.cpSync(von, nach, { recursive: true, errorOnExist: true, force: false });
53
+ fs.rmSync(von, { recursive: true, force: false });
54
+ }
55
+
56
+ function verschiebe(von, nach) {
57
+ try {
58
+ fs.renameSync(von, nach);
59
+ return;
60
+ } catch (e) {
61
+ // Der Papierkorb liegt oft auf einer anderen Platte als die Datei, und ueber
62
+ // Dateisystemgrenzen hinweg kann rename nichts ausrichten.
63
+ if (!e || (e.code !== 'EXDEV' && e.code !== 'EPERM')) throw e;
64
+ }
65
+ // coverage:host-only Hierhin kommt nur, wer wirklich ueber eine
66
+ // Dateisystemgrenze verschiebt. Auf einer Kiste mit einem einzigen Dateisystem
67
+ // kann KEIN Test das ausloesen - rename gelingt dort immer. Was hier passiert,
68
+ // ist einzeln geprueft: kopiereUndLoesche in tests/_trash.js.
69
+ kopiereUndLoesche(von, nach);
70
+ // coverage:host-only-end
71
+ }
72
+
73
+ // --- Linux: die freedesktop-Ablage -----------------------------------------
74
+ //
75
+ // Ohne die .trashinfo daneben zeigt der Dateimanager den Eintrag zwar an, kann
76
+ // ihn aber nicht zuruecklegen - er weiss nicht, wo er herkam.
77
+ function linuxPapierkorb(abs) {
78
+ const heim = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
79
+ const korb = path.join(heim, 'Trash');
80
+ const dateien = path.join(korb, 'files');
81
+ const infos = path.join(korb, 'info');
82
+ fs.mkdirSync(dateien, { recursive: true, mode: 0o700 });
83
+ fs.mkdirSync(infos, { recursive: true, mode: 0o700 });
84
+
85
+ const ziel = freierName(dateien, path.basename(abs));
86
+ const info = path.join(infos, path.basename(ziel) + '.trashinfo');
87
+ // Die Zeit in der Ortszeit des Rechners, ohne Zone - so will es die Spezifikation.
88
+ const jetzt = new Date();
89
+ const zz = (n) => String(n).padStart(2, '0');
90
+ const stempel = `${jetzt.getFullYear()}-${zz(jetzt.getMonth() + 1)}-${zz(jetzt.getDate())}T`
91
+ + `${zz(jetzt.getHours())}:${zz(jetzt.getMinutes())}:${zz(jetzt.getSeconds())}`;
92
+ // Der Pfad wird URL-kodiert, aber die Trenner bleiben Trenner.
93
+ const pfad = abs.split('/').map((s) => encodeURIComponent(s)).join('/');
94
+
95
+ // Erst die Notiz, dann die Datei: bricht es dazwischen ab, liegt eine Notiz
96
+ // ohne Datei herum (harmlos) statt einer Datei ohne Herkunft (nicht mehr
97
+ // zurueckzulegen).
98
+ fs.writeFileSync(info, `[Trash Info]\nPath=${pfad}\nDeletionDate=${stempel}\n`, { mode: 0o600 });
99
+ try {
100
+ verschiebe(abs, ziel);
101
+ } catch (e) {
102
+ try { fs.unlinkSync(info); } catch (_) {}
103
+ throw e;
104
+ }
105
+ return ziel;
106
+ }
107
+
108
+ // --- macOS: ein ganz gewoehnlicher Ordner ----------------------------------
109
+ // coverage:host-only Jeder Weg hier laeuft nur auf SEINEM System, und ein Lauf
110
+ // kann hoechstens einen davon nehmen. Der Windows-Weg WIRD auf einem
111
+ // Windows-Rechner wirklich gefahren (tests/_trash.js legt dort zwei Dateien in
112
+ // den echten Papierkorb, eine davon mit Leerzeichen und & im Namen) - genau dort
113
+ // sass auch der Fehler, den ein Pruefer am 21.09.2026 vermutet und eine Messung
114
+ // bestaetigt hat. Die beiden anderen sind ueber ihre Bestandteile geprueft:
115
+ // freierName und verschiebe, und der eingestellte Ort, den ein Nutzer ohne
116
+ // Arbeitsoberflaeche ohnehin braucht.
117
+ function macPapierkorb(abs) {
118
+ const korb = path.join(os.homedir(), '.Trash');
119
+ fs.mkdirSync(korb, { recursive: true });
120
+ const ziel = freierName(korb, path.basename(abs));
121
+ verschiebe(abs, ziel);
122
+ return ziel;
123
+ }
124
+
125
+ // --- Windows: nur die Shell darf in den Papierkorb -------------------------
126
+ //
127
+ // Es gibt keinen Ordner, in den man schieben koennte: der Papierkorb ist eine
128
+ // Sicht der Shell, und nur sie legt die Wiederherstellungsdaten an. Deshalb geht
129
+ // es hier ueber PowerShell und die VisualBasic-Dateioperationen - das ist auf
130
+ // jedem Windows vorhanden und braucht kein zusaetzliches Paket.
131
+ function windowsPapierkorb(abs) {
132
+ return new Promise((fertig, scheitern) => {
133
+ if (!abs) return scheitern(new Error('the recycle bin needs a path'));
134
+ const skript = [
135
+ 'Add-Type -AssemblyName Microsoft.VisualBasic;',
136
+ // Der Pfad kommt ueber die Umgebung, nicht ueber die Befehlszeile. Hinter
137
+ // -Command ist $args LEER (gemessen 21.09.2026): PowerShell liest alles
138
+ // nach dem Skripttext als WEITERE Befehle, ein Pfad mit einem Leerzeichen
139
+ // zerfaellt dabei in zwei davon. $p waere leer geblieben und jedes
140
+ // Loeschen auf einem Windows-Rechner gescheitert. Ueber die Umgebung gibt
141
+ // es die Frage nach Anfuehrungszeichen gar nicht erst.
142
+ '$p = $env:COMPANION_TRASH_TARGET;',
143
+ 'if (Test-Path -LiteralPath $p -PathType Container) {',
144
+ ' [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory($p,',
145
+ " 'OnlyErrorDialogs', 'SendToRecycleBin');",
146
+ '} else {',
147
+ ' [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p,',
148
+ " 'OnlyErrorDialogs', 'SendToRecycleBin');",
149
+ '}',
150
+ ].join(' ');
151
+ execFile('powershell.exe',
152
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', skript],
153
+ { timeout: 30000, windowsHide: true, env: { ...process.env, COMPANION_TRASH_TARGET: abs } },
154
+ (e, _out, err) => {
155
+ if (e) return scheitern(new Error(String(err || e.message).trim().split('\n')[0] || 'the recycle bin refused'));
156
+ fertig(abs);
157
+ });
158
+ });
159
+ }
160
+
161
+ // coverage:host-only-end
162
+
163
+ /**
164
+ * Legt `abs` in den Papierkorb des Rechners. Wirft, wenn das nicht geht -
165
+ * ersatzweise zu vernichten waere das Gegenteil dessen, wofuer das hier da ist.
166
+ */
167
+ async function inDenPapierkorb(abs) {
168
+ // Ein eigener Ort, wenn der Nutzer einen nennt. Das braucht, wer den
169
+ // Papierkorb des Systems nicht benutzen will oder kann - eine Kiste ohne
170
+ // Arbeitsoberflaeche hat keinen -, und die Tests legen ihre Sachen damit in
171
+ // ihr eigenes Verzeichnis statt in den echten Papierkorb des Entwicklers.
172
+ const eigener = process.env.COMPANION_TRASH_DIR;
173
+ if (eigener) {
174
+ fs.mkdirSync(eigener, { recursive: true, mode: 0o700 });
175
+ const ziel = freierName(eigener, path.basename(abs));
176
+ verschiebe(abs, ziel);
177
+ return ziel;
178
+ }
179
+ // coverage:host-only Jede dieser drei Zeilen laeuft nur auf IHREM System - ein
180
+ // Lauf kann hoechstens eine davon nehmen. Geprueft sind sie einzeln: der
181
+ // Linux-Weg direkt (tests/_trash.js), die beiden anderen ueber freierName und
182
+ // kopiereUndLoesche, aus denen sie bestehen.
183
+ if (process.platform === 'win32') return await windowsPapierkorb(abs);
184
+ if (process.platform === 'darwin') return macPapierkorb(abs);
185
+ return linuxPapierkorb(abs);
186
+ // coverage:host-only-end
187
+ }
188
+
189
+ module.exports = { inDenPapierkorb, freierName, verschiebe, kopiereUndLoesche, linuxPapierkorb, macPapierkorb, windowsPapierkorb, NUMMERN_BIS };