@tensor.chat/companion 0.1.0 → 0.2.0
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 +9 -5
- package/bin/tensor-companion.js +75 -32
- package/package.json +1 -1
- package/src/config.js +63 -1
- package/src/daemon.js +77 -20
- package/src/pairing.js +65 -0
- package/src/tools/files.js +161 -3
- package/src/tools/trash.js +189 -0
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**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
package/bin/tensor-companion.js
CHANGED
|
@@ -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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
package/src/config.js
CHANGED
|
@@ -33,4 +33,66 @@ function save(cfg) {
|
|
|
33
33
|
fs.renameSync(tmp, configPath());
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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 ?
|
|
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 :
|
|
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(
|
|
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(
|
|
123
|
-
case 'write': return files.write(
|
|
124
|
-
case 'edit': return files.edit(
|
|
125
|
-
case 'glob': return files.glob(
|
|
126
|
-
case 'grep': return files.grep(
|
|
127
|
-
case 'ls': return files.ls(
|
|
128
|
-
case 'fetchfile': return files.fetchfile(
|
|
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
|
-
|
|
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');
|
|
@@ -246,4 +303,4 @@ function createDaemon({ serverUrl, token, shell, log } = {}) {
|
|
|
246
303
|
};
|
|
247
304
|
}
|
|
248
305
|
|
|
249
|
-
module.exports = { createDaemon, wsUrlFor, systemInfo, VERSION };
|
|
306
|
+
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 };
|
package/src/tools/files.js
CHANGED
|
@@ -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 >=
|
|
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
|
-
|
|
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 };
|