@tensor.chat/companion 0.3.0 → 0.4.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/package.json +1 -1
- package/src/daemon.js +7 -0
- package/src/tools/files.js +16 -7
- package/src/tools/outside.js +101 -0
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -13,6 +13,7 @@ const bash = require('./tools/bash');
|
|
|
13
13
|
const files = require('./tools/files');
|
|
14
14
|
const { fetchUrl } = require('./tools/fetch');
|
|
15
15
|
const { extractTouched } = require('./tools/touched');
|
|
16
|
+
const { outsideFolders } = require('./tools/outside');
|
|
16
17
|
const { Jobs } = require('./tools/jobs');
|
|
17
18
|
|
|
18
19
|
const VERSION = require('../package.json').version;
|
|
@@ -158,6 +159,12 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
158
159
|
case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
|
|
159
160
|
case 'edit': return files.edit(callRoots, a.path, a.edits);
|
|
160
161
|
case 'glob': return files.glob(callRoots, a.pattern);
|
|
162
|
+
// The server asks before it judges a shell command: what of it lies outside
|
|
163
|
+
// the shared folders, read with this machine's own variables. Nothing runs.
|
|
164
|
+
case 'outside': {
|
|
165
|
+
const at = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
166
|
+
return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
|
|
167
|
+
}
|
|
161
168
|
case 'grep': return files.grep(callRoots, a.command);
|
|
162
169
|
case 'ls': return files.ls(callRoots, a.path, { max: a.max });
|
|
163
170
|
case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
|
package/src/tools/files.js
CHANGED
|
@@ -172,17 +172,26 @@ function walk(root, onEntry) {
|
|
|
172
172
|
// A pattern written as the tree shows it ("Devices/Devbox/game/**", or just the device
|
|
173
173
|
// folder "Devices/Devbox/**") narrows to that shared folder and continues relative to
|
|
174
174
|
// it; anything else is matched relative to every shared folder, as before.
|
|
175
|
+
// Every folder's own name is tried before the device folder: the device folder is the
|
|
176
|
+
// start of every name, so tried in the same pass it caught "Devices/PC/VoiceType/*" at
|
|
177
|
+
// the first folder, and only the first shared folder could be searched (03.10.2026).
|
|
175
178
|
function splitTreePattern(okRoots, pattern) {
|
|
176
179
|
const p = pattern.replace(/\\/g, '/');
|
|
177
180
|
const low = p.toLowerCase();
|
|
181
|
+
const rest = (prefix) => {
|
|
182
|
+
if (!prefix) return null;
|
|
183
|
+
const pre = prefix.toLowerCase();
|
|
184
|
+
if (low === pre) return '*';
|
|
185
|
+
return low.startsWith(pre + '/') ? p.slice(prefix.length + 1) || '*' : null;
|
|
186
|
+
};
|
|
178
187
|
for (const r of okRoots) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
188
|
+
const after = r.label ? rest(r.label) : null;
|
|
189
|
+
if (after !== null) return { roots: [r], pattern: after };
|
|
190
|
+
}
|
|
191
|
+
for (const r of okRoots) {
|
|
192
|
+
const cut = r.label ? r.label.lastIndexOf('/') : -1;
|
|
193
|
+
const after = rest(cut > 0 ? r.label.slice(0, cut) : '');
|
|
194
|
+
if (after !== null) return { roots: okRoots, pattern: after };
|
|
186
195
|
}
|
|
187
196
|
return { roots: okRoots, pattern: p };
|
|
188
197
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Which folders outside the shared ones does a shell command name? The safety check on
|
|
3
|
+
// the server can only guess what "$env:USERPROFILE\.voicetype" means - this computer
|
|
4
|
+
// knows (03.10.2026: four commands like that left it "not sure"). Every path-looking
|
|
5
|
+
// word of the command, with this machine's variables put in, that names an existing
|
|
6
|
+
// file or folder - or a new file in an existing folder - and lies outside every shared
|
|
7
|
+
// folder counts, as the folder that holds it. A command can reach further than its
|
|
8
|
+
// words (a script reads what it likes): this helps the question, it is no fence.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { isInside, realExisting } = require('../paths');
|
|
14
|
+
|
|
15
|
+
const MAX_FOLDERS = 10;
|
|
16
|
+
const MAX_WORDS = 2000;
|
|
17
|
+
// What ends a path written without quotes
|
|
18
|
+
const STOP = '\\s"\'<>|*?;,`()';
|
|
19
|
+
const WIN_ABS = new RegExp(`(?<![\\w\\\\/])[A-Za-z]:[\\\\/][^${STOP}]*`, 'g');
|
|
20
|
+
const UNC = new RegExp(`(?<![\\w\\\\])\\\\\\\\[^${STOP}\\\\]+\\\\[^${STOP}]+`, 'g');
|
|
21
|
+
const DOTDOT = new RegExp(`(?<![\\w.\\\\/])\\.\\.(?:[\\\\/][^${STOP}]*)?(?![\\w.])`, 'g');
|
|
22
|
+
const POSIX_ABS = new RegExp(`(?<![\\w.~\\\\/:])/[^${STOP}]+`, 'g');
|
|
23
|
+
const WHOLE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\.\.(?:[\\/]|$)|\/)/;
|
|
24
|
+
|
|
25
|
+
// The command as this machine reads it: PowerShell's $env:NAME, cmd's %NAME%, the
|
|
26
|
+
// POSIX $NAME and ${NAME}, and ~ for the home folder. A name it does not know stays.
|
|
27
|
+
function expand(command, { env, home, platform }) {
|
|
28
|
+
const keys = Object.keys(env);
|
|
29
|
+
const get = (name) => {
|
|
30
|
+
const key = platform === 'win32' ? keys.find((k) => k.toLowerCase() === name.toLowerCase()) : (name in env ? name : null);
|
|
31
|
+
if (key != null && env[key] != null) return String(env[key]);
|
|
32
|
+
return /^(home|userprofile)$/i.test(name) ? home : null;
|
|
33
|
+
};
|
|
34
|
+
return String(command == null ? '' : command)
|
|
35
|
+
.replace(/\$\{env:([A-Za-z_][\w()]*)\}|\$env:([A-Za-z_]\w*)/gi, (m, a, b) => get(a || b) ?? m)
|
|
36
|
+
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => get(n) ?? m)
|
|
37
|
+
.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) => get(a || b) ?? m)
|
|
38
|
+
.replace(/(^|[\s"'=(])~(?=[\\/]|$|[\s"')])/g, (m, pre) => pre + home);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The words that may be paths: a quoted one that starts like a path is one path,
|
|
42
|
+
// spaces and all - cut at a space, "R:\Projekte\Open Chat Pal\x" named a stray file
|
|
43
|
+
// "R:\Projekte\Open". Other quoted text (a -Command "...") and the rest are searched.
|
|
44
|
+
function words(text, platform) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const scan = (s) => {
|
|
47
|
+
for (const re of platform === 'win32' ? [WIN_ABS, UNC, DOTDOT] : [WIN_ABS, UNC, DOTDOT, POSIX_ABS]) {
|
|
48
|
+
re.lastIndex = 0;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(s)) && out.length < MAX_WORDS) out.push(m[0]);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const rest = text.replace(/"([^"]*)"|'([^']*)'/g, (m, a, b) => {
|
|
54
|
+
const inner = a != null ? a : b;
|
|
55
|
+
if (WHOLE_PATH.test(inner)) out.push(inner); else scan(inner);
|
|
56
|
+
return ' ';
|
|
57
|
+
});
|
|
58
|
+
scan(rest);
|
|
59
|
+
// A full stop or comma after a path belongs to the sentence; ".." stays itself
|
|
60
|
+
return out.map((w) => w.replace(/[,;:\]}]+$/, '').replace(/([^.])\.$/, '$1')).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function statOf(p) {
|
|
64
|
+
try { return fs.statSync(p); } catch (_) { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The folder a word stands for: the folder itself, the folder of a file, or the folder
|
|
68
|
+
// a new file would go into. Anything else is not a path on this machine.
|
|
69
|
+
function folderOf(word, cwd) {
|
|
70
|
+
const abs = path.resolve(cwd, word);
|
|
71
|
+
const st = statOf(abs);
|
|
72
|
+
if (st) return st.isDirectory() ? abs : path.dirname(abs);
|
|
73
|
+
if (!/\.[A-Za-z0-9]{1,8}$/.test(path.basename(abs))) return null;
|
|
74
|
+
const parent = path.dirname(abs);
|
|
75
|
+
const ps = statOf(parent);
|
|
76
|
+
return ps && ps.isDirectory() && path.parse(parent).root !== parent ? parent : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function outsideFolders(roots, command, { cwd, env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
80
|
+
const shared = roots.okRoots().map((r) => r.real);
|
|
81
|
+
const found = [];
|
|
82
|
+
for (const word of words(expand(command, { env, home, platform }), platform)) {
|
|
83
|
+
const folder = folderOf(word, cwd || home);
|
|
84
|
+
if (!folder) continue;
|
|
85
|
+
const real = realExisting(folder) || folder;
|
|
86
|
+
if (shared.some((r) => isInside(real, r))) continue;
|
|
87
|
+
if (!found.some((f) => isInside(folder, f) && isInside(f, folder))) found.push(folder);
|
|
88
|
+
}
|
|
89
|
+
// A folder inside another one found is covered by it
|
|
90
|
+
const folders = found
|
|
91
|
+
.filter((f) => !found.some((g) => g !== f && isInside(f, g) && !isInside(g, f)))
|
|
92
|
+
.slice(0, MAX_FOLDERS)
|
|
93
|
+
// A whole drive, the home folder or what holds it is too much to share in one click
|
|
94
|
+
.map((f) => ({ path: f, broad: path.parse(f).root === f || isInside(home, f) }));
|
|
95
|
+
const text = folders.length
|
|
96
|
+
? `(outside the shared folders: ${folders.map((f) => f.path).join(', ')})`
|
|
97
|
+
: '(nothing outside the shared folders)';
|
|
98
|
+
return { ok: true, text, folders };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { outsideFolders, expand, words };
|