@livedesk/hub 0.1.40 → 0.1.42
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 +2 -2
- package/src/agents/agent-device-scope.js +29 -29
- package/src/agents/agent-manager.js +103 -103
- package/src/agents/agent-permission-store.js +78 -78
- package/src/agents/agent-runtime-error.js +9 -9
- package/src/agents/agent-settings.js +71 -71
- package/src/agents/codex-agent-runtime.js +594 -594
- package/src/agents/codex-mcp-server.js +100 -100
- package/src/filesystem/directory-reader.js +187 -187
- package/src/filesystem/hub-filesystem.js +78 -78
- package/src/filesystem/path-registry.js +78 -78
- package/src/filesystem/roots.js +115 -115
- package/src/frame-packet-contract.mjs +427 -427
- package/src/http/hub-ui-session.js +119 -119
- package/src/live-capture-transition-retry.mjs +297 -297
- package/src/live-desk-update.js +167 -108
- package/src/live-stream-monitor-contract.js +38 -38
- package/src/lzo1x.js +109 -109
- package/src/mode4-atlas-pool.js +137 -137
- package/src/mode4-atlas-sizing.js +32 -32
- package/src/mode4-atlas-worker.js +599 -599
- package/src/mode4-atlas.js +306 -306
- package/src/remote-audio-subscription-contract.mjs +109 -109
- package/src/remote-audio-subscription-contract.test.mjs +72 -72
- package/src/remote-hub.js +140 -111
- package/src/server.js +196 -180
- package/src/settings/effective-device-policy.js +16 -16
- package/src/transport/udp-protocol.js +453 -453
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
import readline from 'node:readline';
|
|
2
|
-
import { AGENT_TOOL_DEFINITIONS } from './agent-tool-registry.js';
|
|
3
|
-
|
|
4
|
-
const hubUrl = String(process.env.LIVEDESK_AGENT_MCP_URL || '').replace(/\/+$/, '');
|
|
5
|
-
const token = String(process.env.LIVEDESK_AGENT_MCP_TOKEN || '');
|
|
6
|
-
const protocolVersion = '2025-06-18';
|
|
7
|
-
|
|
8
|
-
const toolDefinitions = AGENT_TOOL_DEFINITIONS.map(tool => ({
|
|
9
|
-
name: tool.name,
|
|
10
|
-
description: tool.description,
|
|
11
|
-
inputSchema: { $schema: 'https://json-schema.org/draft/2020-12/schema', ...tool.inputSchema },
|
|
12
|
-
annotations: {
|
|
13
|
-
readOnlyHint: tool.readOnly === true,
|
|
14
|
-
// Codex exec cannot service an interactive MCP approval prompt. Device
|
|
15
|
-
// mutations are proposals at this boundary and remain fail-closed behind
|
|
16
|
-
// LiveDesk's own signed permission policy and Hub approval workflow.
|
|
17
|
-
destructiveHint: false,
|
|
18
|
-
idempotentHint: tool.readOnly === true || tool.reversible === true,
|
|
19
|
-
openWorldHint: false
|
|
20
|
-
}
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
function response(id, result) {
|
|
24
|
-
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function errorResponse(id, code, message) {
|
|
28
|
-
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function callHub(name, args) {
|
|
32
|
-
if (!hubUrl || !token) throw new Error('LiveDesk MCP session is not configured.');
|
|
33
|
-
const result = await fetch(`${hubUrl}/api/internal/agent-mcp/tool`, {
|
|
34
|
-
method: 'POST',
|
|
35
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
36
|
-
body: JSON.stringify({ name, arguments: args && typeof args === 'object' ? args : {} })
|
|
37
|
-
});
|
|
38
|
-
const body = await result.json().catch(() => ({}));
|
|
39
|
-
if (!result.ok || body.ok !== true) throw new Error(String(body.error || `LiveDesk tool failed (${result.status}).`));
|
|
40
|
-
return body.result;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function handle(message) {
|
|
44
|
-
const id = message?.id;
|
|
45
|
-
const method = String(message?.method || '');
|
|
46
|
-
if (!method) return;
|
|
47
|
-
if (method === 'initialize') {
|
|
48
|
-
response(id, {
|
|
49
|
-
protocolVersion,
|
|
50
|
-
capabilities: { tools: { listChanged: false } },
|
|
51
|
-
serverInfo: { name: 'livedesk', version: '1.0.0' }
|
|
52
|
-
});
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
if (method === 'notifications/initialized' || method === 'ping') {
|
|
56
|
-
if (id !== undefined) response(id, {});
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (method === 'tools/list') {
|
|
60
|
-
response(id, { tools: toolDefinitions });
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (method === 'tools/call') {
|
|
64
|
-
const name = String(message?.params?.name || '');
|
|
65
|
-
if (!toolDefinitions.some(tool => tool.name === name)) {
|
|
66
|
-
errorResponse(id, -32602, 'Unknown LiveDesk tool.');
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
try {
|
|
70
|
-
const result = await callHub(name, message?.params?.arguments || {});
|
|
71
|
-
response(id, {
|
|
72
|
-
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
73
|
-
structuredContent: result,
|
|
74
|
-
isError: false
|
|
75
|
-
});
|
|
76
|
-
} catch (error) {
|
|
77
|
-
response(id, {
|
|
78
|
-
content: [{ type: 'text', text: String(error?.message || 'LiveDesk tool failed.') }],
|
|
79
|
-
isError: true
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (id !== undefined) errorResponse(id, -32601, `Unsupported MCP method: ${method}`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
88
|
-
input.on('line', line => {
|
|
89
|
-
const trimmed = String(line || '').trim();
|
|
90
|
-
if (!trimmed) return;
|
|
91
|
-
let message;
|
|
92
|
-
try {
|
|
93
|
-
message = JSON.parse(trimmed);
|
|
94
|
-
} catch {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
void handle(message).catch(error => {
|
|
98
|
-
if (message?.id !== undefined) errorResponse(message.id, -32603, String(error?.message || 'MCP server error.'));
|
|
99
|
-
});
|
|
100
|
-
});
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { AGENT_TOOL_DEFINITIONS } from './agent-tool-registry.js';
|
|
3
|
+
|
|
4
|
+
const hubUrl = String(process.env.LIVEDESK_AGENT_MCP_URL || '').replace(/\/+$/, '');
|
|
5
|
+
const token = String(process.env.LIVEDESK_AGENT_MCP_TOKEN || '');
|
|
6
|
+
const protocolVersion = '2025-06-18';
|
|
7
|
+
|
|
8
|
+
const toolDefinitions = AGENT_TOOL_DEFINITIONS.map(tool => ({
|
|
9
|
+
name: tool.name,
|
|
10
|
+
description: tool.description,
|
|
11
|
+
inputSchema: { $schema: 'https://json-schema.org/draft/2020-12/schema', ...tool.inputSchema },
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: tool.readOnly === true,
|
|
14
|
+
// Codex exec cannot service an interactive MCP approval prompt. Device
|
|
15
|
+
// mutations are proposals at this boundary and remain fail-closed behind
|
|
16
|
+
// LiveDesk's own signed permission policy and Hub approval workflow.
|
|
17
|
+
destructiveHint: false,
|
|
18
|
+
idempotentHint: tool.readOnly === true || tool.reversible === true,
|
|
19
|
+
openWorldHint: false
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
function response(id, result) {
|
|
24
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function errorResponse(id, code, message) {
|
|
28
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function callHub(name, args) {
|
|
32
|
+
if (!hubUrl || !token) throw new Error('LiveDesk MCP session is not configured.');
|
|
33
|
+
const result = await fetch(`${hubUrl}/api/internal/agent-mcp/tool`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ name, arguments: args && typeof args === 'object' ? args : {} })
|
|
37
|
+
});
|
|
38
|
+
const body = await result.json().catch(() => ({}));
|
|
39
|
+
if (!result.ok || body.ok !== true) throw new Error(String(body.error || `LiveDesk tool failed (${result.status}).`));
|
|
40
|
+
return body.result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function handle(message) {
|
|
44
|
+
const id = message?.id;
|
|
45
|
+
const method = String(message?.method || '');
|
|
46
|
+
if (!method) return;
|
|
47
|
+
if (method === 'initialize') {
|
|
48
|
+
response(id, {
|
|
49
|
+
protocolVersion,
|
|
50
|
+
capabilities: { tools: { listChanged: false } },
|
|
51
|
+
serverInfo: { name: 'livedesk', version: '1.0.0' }
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (method === 'notifications/initialized' || method === 'ping') {
|
|
56
|
+
if (id !== undefined) response(id, {});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (method === 'tools/list') {
|
|
60
|
+
response(id, { tools: toolDefinitions });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (method === 'tools/call') {
|
|
64
|
+
const name = String(message?.params?.name || '');
|
|
65
|
+
if (!toolDefinitions.some(tool => tool.name === name)) {
|
|
66
|
+
errorResponse(id, -32602, 'Unknown LiveDesk tool.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const result = await callHub(name, message?.params?.arguments || {});
|
|
71
|
+
response(id, {
|
|
72
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
73
|
+
structuredContent: result,
|
|
74
|
+
isError: false
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
response(id, {
|
|
78
|
+
content: [{ type: 'text', text: String(error?.message || 'LiveDesk tool failed.') }],
|
|
79
|
+
isError: true
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (id !== undefined) errorResponse(id, -32601, `Unsupported MCP method: ${method}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
88
|
+
input.on('line', line => {
|
|
89
|
+
const trimmed = String(line || '').trim();
|
|
90
|
+
if (!trimmed) return;
|
|
91
|
+
let message;
|
|
92
|
+
try {
|
|
93
|
+
message = JSON.parse(trimmed);
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
void handle(message).catch(error => {
|
|
98
|
+
if (message?.id !== undefined) errorResponse(message.id, -32603, String(error?.message || 'MCP server error.'));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
4
|
-
import { isPathWithinRoot } from './path-registry.js';
|
|
5
|
-
|
|
6
|
-
function filesystemError(message, code = 'FILESYSTEM_ERROR', status = 400) {
|
|
7
|
-
const error = new Error(message);
|
|
8
|
-
error.code = code;
|
|
9
|
-
error.status = status;
|
|
10
|
-
return error;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function safeEntryName(name) {
|
|
14
|
-
return String(name || '').replace(/[\0\r\n\t]/g, ' ').slice(0, 512);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function toPublicEntry(entry) {
|
|
18
|
-
return {
|
|
19
|
-
id: entry.id,
|
|
20
|
-
parentId: entry.parentId,
|
|
21
|
-
name: entry.name,
|
|
22
|
-
type: entry.type,
|
|
23
|
-
size: entry.size || 0,
|
|
24
|
-
modifiedAt: entry.modifiedAt || null,
|
|
25
|
-
extension: entry.type === 'file' ? path.extname(entry.name).toLowerCase() : '',
|
|
26
|
-
hasChildren: entry.type !== 'file' && entry.hasChildren === true,
|
|
27
|
-
displayPath: entry.displayPath,
|
|
28
|
-
locked: entry.locked === true
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function readEntryStat(absolutePath) {
|
|
33
|
-
try {
|
|
34
|
-
return await fs.lstat(absolutePath);
|
|
35
|
-
} catch (error) {
|
|
36
|
-
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
37
|
-
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') throw filesystemError('filesystem-entry-unavailable', 'NOT_FOUND', 404);
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function readDirectoryEntries(registry, folderId) {
|
|
43
|
-
const folder = registry.resolve(folderId);
|
|
44
|
-
if (folder.type === 'file') throw filesystemError('filesystem-entry-is-not-folder', 'NOT_A_FOLDER', 400);
|
|
45
|
-
if (folder.locked) throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
46
|
-
let entries;
|
|
47
|
-
try {
|
|
48
|
-
entries = await fs.readdir(folder.absolutePath, { withFileTypes: true });
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
51
|
-
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') throw filesystemError('filesystem-entry-unavailable', 'NOT_FOUND', 404);
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const output = [];
|
|
56
|
-
for (const dirent of entries) {
|
|
57
|
-
const name = safeEntryName(dirent.name);
|
|
58
|
-
if (!name || name === '.' || name === '..') continue;
|
|
59
|
-
const absolutePath = path.resolve(folder.absolutePath, name);
|
|
60
|
-
if (!isPathWithinRoot(folder.rootPath, absolutePath)) continue;
|
|
61
|
-
const stat = await readEntryStat(absolutePath).catch(error => {
|
|
62
|
-
if (error?.code === 'ACCESS_DENIED' || error?.code === 'NOT_FOUND') return null;
|
|
63
|
-
throw error;
|
|
64
|
-
});
|
|
65
|
-
if (!stat) continue;
|
|
66
|
-
const isSymlink = dirent.isSymbolicLink() || stat.isSymbolicLink();
|
|
67
|
-
const type = !isSymlink && (dirent.isDirectory() || stat.isDirectory()) ? 'folder' : 'file';
|
|
68
|
-
const child = registry.register({
|
|
69
|
-
absolutePath,
|
|
70
|
-
rootPath: folder.rootPath,
|
|
71
|
-
rootId: folder.rootId,
|
|
72
|
-
parentId: folder.id,
|
|
73
|
-
name,
|
|
74
|
-
type,
|
|
75
|
-
displayPath: path.join(folder.displayPath, name),
|
|
76
|
-
locked: isSymlink
|
|
77
|
-
});
|
|
78
|
-
output.push(toPublicEntry({
|
|
79
|
-
...child,
|
|
80
|
-
size: type === 'file' ? stat.size : 0,
|
|
81
|
-
modifiedAt: stat.mtime?.toISOString(),
|
|
82
|
-
hasChildren: type === 'folder' && !isSymlink
|
|
83
|
-
}));
|
|
84
|
-
}
|
|
85
|
-
output.sort((left, right) => {
|
|
86
|
-
if (left.type !== right.type) return left.type === 'folder' ? -1 : 1;
|
|
87
|
-
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
88
|
-
});
|
|
89
|
-
return {
|
|
90
|
-
folder: toPublicEntry({ ...folder, hasChildren: true }),
|
|
91
|
-
entries: output
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function walkDirectory(registry, entry, relativePrefix, output, visited, counters) {
|
|
96
|
-
const realPath = await fs.realpath(entry.absolutePath).catch(() => entry.absolutePath);
|
|
97
|
-
if (visited.has(realPath)) return;
|
|
98
|
-
visited.add(realPath);
|
|
99
|
-
let dirents;
|
|
100
|
-
try {
|
|
101
|
-
dirents = await fs.readdir(entry.absolutePath, { withFileTypes: true });
|
|
102
|
-
} catch (error) {
|
|
103
|
-
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
for (const dirent of dirents) {
|
|
107
|
-
const name = safeEntryName(dirent.name);
|
|
108
|
-
if (!name || name === '.' || name === '..' || dirent.isSymbolicLink()) continue;
|
|
109
|
-
const absolutePath = path.resolve(entry.absolutePath, name);
|
|
110
|
-
if (!isPathWithinRoot(entry.rootPath, absolutePath)) continue;
|
|
111
|
-
const stat = await readEntryStat(absolutePath);
|
|
112
|
-
const relativePath = path.posix.join(relativePrefix, name);
|
|
113
|
-
if (stat.isDirectory()) {
|
|
114
|
-
counters.folders += 1;
|
|
115
|
-
const child = registry.register({
|
|
116
|
-
absolutePath,
|
|
117
|
-
rootPath: entry.rootPath,
|
|
118
|
-
rootId: entry.rootId,
|
|
119
|
-
parentId: entry.id,
|
|
120
|
-
name,
|
|
121
|
-
type: 'folder',
|
|
122
|
-
displayPath: path.join(entry.displayPath, name)
|
|
123
|
-
});
|
|
124
|
-
await walkDirectory(registry, child, relativePath, output, visited, counters);
|
|
125
|
-
} else if (stat.isFile()) {
|
|
126
|
-
const child = registry.register({
|
|
127
|
-
absolutePath,
|
|
128
|
-
rootPath: entry.rootPath,
|
|
129
|
-
rootId: entry.rootId,
|
|
130
|
-
parentId: entry.id,
|
|
131
|
-
name,
|
|
132
|
-
type: 'file',
|
|
133
|
-
displayPath: path.join(entry.displayPath, name)
|
|
134
|
-
});
|
|
135
|
-
output.push({
|
|
136
|
-
id: child.id,
|
|
137
|
-
absolutePath,
|
|
138
|
-
relativePath,
|
|
139
|
-
name,
|
|
140
|
-
size: stat.size,
|
|
141
|
-
modifiedMs: stat.mtimeMs,
|
|
142
|
-
modifiedAt: stat.mtime?.toISOString() || null
|
|
143
|
-
});
|
|
144
|
-
counters.files += 1;
|
|
145
|
-
counters.totalBytes += stat.size;
|
|
146
|
-
}
|
|
147
|
-
if ((counters.files + counters.folders) % 100 === 0) await yieldToEventLoop();
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export async function scanSelection(registry, itemIds) {
|
|
152
|
-
const ids = [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 5000);
|
|
153
|
-
if (ids.length === 0) throw filesystemError('no-filesystem-items-selected', 'NO_SELECTION', 400);
|
|
154
|
-
const selected = ids.map(id => registry.resolve(id));
|
|
155
|
-
const selectedPaths = selected.filter(entry => entry.type === 'folder').map(entry => path.resolve(entry.absolutePath));
|
|
156
|
-
const topLevel = selected.filter(entry => !selectedPaths.some(parent => parent !== entry.absolutePath && isPathWithinRoot(parent, entry.absolutePath)));
|
|
157
|
-
const files = [];
|
|
158
|
-
const counters = { files: 0, folders: 0, totalBytes: 0 };
|
|
159
|
-
const visited = new Set();
|
|
160
|
-
for (const entry of topLevel) {
|
|
161
|
-
const stat = await readEntryStat(entry.absolutePath);
|
|
162
|
-
if (entry.type === 'file' || stat.isFile()) {
|
|
163
|
-
files.push({
|
|
164
|
-
id: entry.id,
|
|
165
|
-
absolutePath: entry.absolutePath,
|
|
166
|
-
relativePath: entry.name,
|
|
167
|
-
name: entry.name,
|
|
168
|
-
size: stat.size,
|
|
169
|
-
modifiedMs: stat.mtimeMs,
|
|
170
|
-
modifiedAt: stat.mtime?.toISOString() || null
|
|
171
|
-
});
|
|
172
|
-
counters.files += 1;
|
|
173
|
-
counters.totalBytes += stat.size;
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
counters.folders += 1;
|
|
177
|
-
await walkDirectory(registry, entry, entry.name, files, visited, counters);
|
|
178
|
-
}
|
|
179
|
-
files.sort((left, right) => left.relativePath.localeCompare(right.relativePath, undefined, { numeric: true }));
|
|
180
|
-
return { ...counters, files };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function toFilesystemHttpError(error) {
|
|
184
|
-
return error?.status
|
|
185
|
-
? error
|
|
186
|
-
: filesystemError('filesystem-operation-failed', 'FILESYSTEM_ERROR', 500);
|
|
187
|
-
}
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
4
|
+
import { isPathWithinRoot } from './path-registry.js';
|
|
5
|
+
|
|
6
|
+
function filesystemError(message, code = 'FILESYSTEM_ERROR', status = 400) {
|
|
7
|
+
const error = new Error(message);
|
|
8
|
+
error.code = code;
|
|
9
|
+
error.status = status;
|
|
10
|
+
return error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safeEntryName(name) {
|
|
14
|
+
return String(name || '').replace(/[\0\r\n\t]/g, ' ').slice(0, 512);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toPublicEntry(entry) {
|
|
18
|
+
return {
|
|
19
|
+
id: entry.id,
|
|
20
|
+
parentId: entry.parentId,
|
|
21
|
+
name: entry.name,
|
|
22
|
+
type: entry.type,
|
|
23
|
+
size: entry.size || 0,
|
|
24
|
+
modifiedAt: entry.modifiedAt || null,
|
|
25
|
+
extension: entry.type === 'file' ? path.extname(entry.name).toLowerCase() : '',
|
|
26
|
+
hasChildren: entry.type !== 'file' && entry.hasChildren === true,
|
|
27
|
+
displayPath: entry.displayPath,
|
|
28
|
+
locked: entry.locked === true
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readEntryStat(absolutePath) {
|
|
33
|
+
try {
|
|
34
|
+
return await fs.lstat(absolutePath);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
37
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') throw filesystemError('filesystem-entry-unavailable', 'NOT_FOUND', 404);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readDirectoryEntries(registry, folderId) {
|
|
43
|
+
const folder = registry.resolve(folderId);
|
|
44
|
+
if (folder.type === 'file') throw filesystemError('filesystem-entry-is-not-folder', 'NOT_A_FOLDER', 400);
|
|
45
|
+
if (folder.locked) throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = await fs.readdir(folder.absolutePath, { withFileTypes: true });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
51
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') throw filesystemError('filesystem-entry-unavailable', 'NOT_FOUND', 404);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const output = [];
|
|
56
|
+
for (const dirent of entries) {
|
|
57
|
+
const name = safeEntryName(dirent.name);
|
|
58
|
+
if (!name || name === '.' || name === '..') continue;
|
|
59
|
+
const absolutePath = path.resolve(folder.absolutePath, name);
|
|
60
|
+
if (!isPathWithinRoot(folder.rootPath, absolutePath)) continue;
|
|
61
|
+
const stat = await readEntryStat(absolutePath).catch(error => {
|
|
62
|
+
if (error?.code === 'ACCESS_DENIED' || error?.code === 'NOT_FOUND') return null;
|
|
63
|
+
throw error;
|
|
64
|
+
});
|
|
65
|
+
if (!stat) continue;
|
|
66
|
+
const isSymlink = dirent.isSymbolicLink() || stat.isSymbolicLink();
|
|
67
|
+
const type = !isSymlink && (dirent.isDirectory() || stat.isDirectory()) ? 'folder' : 'file';
|
|
68
|
+
const child = registry.register({
|
|
69
|
+
absolutePath,
|
|
70
|
+
rootPath: folder.rootPath,
|
|
71
|
+
rootId: folder.rootId,
|
|
72
|
+
parentId: folder.id,
|
|
73
|
+
name,
|
|
74
|
+
type,
|
|
75
|
+
displayPath: path.join(folder.displayPath, name),
|
|
76
|
+
locked: isSymlink
|
|
77
|
+
});
|
|
78
|
+
output.push(toPublicEntry({
|
|
79
|
+
...child,
|
|
80
|
+
size: type === 'file' ? stat.size : 0,
|
|
81
|
+
modifiedAt: stat.mtime?.toISOString(),
|
|
82
|
+
hasChildren: type === 'folder' && !isSymlink
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
output.sort((left, right) => {
|
|
86
|
+
if (left.type !== right.type) return left.type === 'folder' ? -1 : 1;
|
|
87
|
+
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
folder: toPublicEntry({ ...folder, hasChildren: true }),
|
|
91
|
+
entries: output
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function walkDirectory(registry, entry, relativePrefix, output, visited, counters) {
|
|
96
|
+
const realPath = await fs.realpath(entry.absolutePath).catch(() => entry.absolutePath);
|
|
97
|
+
if (visited.has(realPath)) return;
|
|
98
|
+
visited.add(realPath);
|
|
99
|
+
let dirents;
|
|
100
|
+
try {
|
|
101
|
+
dirents = await fs.readdir(entry.absolutePath, { withFileTypes: true });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error?.code === 'EACCES' || error?.code === 'EPERM') throw filesystemError('filesystem-access-denied', 'ACCESS_DENIED', 403);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
for (const dirent of dirents) {
|
|
107
|
+
const name = safeEntryName(dirent.name);
|
|
108
|
+
if (!name || name === '.' || name === '..' || dirent.isSymbolicLink()) continue;
|
|
109
|
+
const absolutePath = path.resolve(entry.absolutePath, name);
|
|
110
|
+
if (!isPathWithinRoot(entry.rootPath, absolutePath)) continue;
|
|
111
|
+
const stat = await readEntryStat(absolutePath);
|
|
112
|
+
const relativePath = path.posix.join(relativePrefix, name);
|
|
113
|
+
if (stat.isDirectory()) {
|
|
114
|
+
counters.folders += 1;
|
|
115
|
+
const child = registry.register({
|
|
116
|
+
absolutePath,
|
|
117
|
+
rootPath: entry.rootPath,
|
|
118
|
+
rootId: entry.rootId,
|
|
119
|
+
parentId: entry.id,
|
|
120
|
+
name,
|
|
121
|
+
type: 'folder',
|
|
122
|
+
displayPath: path.join(entry.displayPath, name)
|
|
123
|
+
});
|
|
124
|
+
await walkDirectory(registry, child, relativePath, output, visited, counters);
|
|
125
|
+
} else if (stat.isFile()) {
|
|
126
|
+
const child = registry.register({
|
|
127
|
+
absolutePath,
|
|
128
|
+
rootPath: entry.rootPath,
|
|
129
|
+
rootId: entry.rootId,
|
|
130
|
+
parentId: entry.id,
|
|
131
|
+
name,
|
|
132
|
+
type: 'file',
|
|
133
|
+
displayPath: path.join(entry.displayPath, name)
|
|
134
|
+
});
|
|
135
|
+
output.push({
|
|
136
|
+
id: child.id,
|
|
137
|
+
absolutePath,
|
|
138
|
+
relativePath,
|
|
139
|
+
name,
|
|
140
|
+
size: stat.size,
|
|
141
|
+
modifiedMs: stat.mtimeMs,
|
|
142
|
+
modifiedAt: stat.mtime?.toISOString() || null
|
|
143
|
+
});
|
|
144
|
+
counters.files += 1;
|
|
145
|
+
counters.totalBytes += stat.size;
|
|
146
|
+
}
|
|
147
|
+
if ((counters.files + counters.folders) % 100 === 0) await yieldToEventLoop();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function scanSelection(registry, itemIds) {
|
|
152
|
+
const ids = [...new Set((Array.isArray(itemIds) ? itemIds : []).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 5000);
|
|
153
|
+
if (ids.length === 0) throw filesystemError('no-filesystem-items-selected', 'NO_SELECTION', 400);
|
|
154
|
+
const selected = ids.map(id => registry.resolve(id));
|
|
155
|
+
const selectedPaths = selected.filter(entry => entry.type === 'folder').map(entry => path.resolve(entry.absolutePath));
|
|
156
|
+
const topLevel = selected.filter(entry => !selectedPaths.some(parent => parent !== entry.absolutePath && isPathWithinRoot(parent, entry.absolutePath)));
|
|
157
|
+
const files = [];
|
|
158
|
+
const counters = { files: 0, folders: 0, totalBytes: 0 };
|
|
159
|
+
const visited = new Set();
|
|
160
|
+
for (const entry of topLevel) {
|
|
161
|
+
const stat = await readEntryStat(entry.absolutePath);
|
|
162
|
+
if (entry.type === 'file' || stat.isFile()) {
|
|
163
|
+
files.push({
|
|
164
|
+
id: entry.id,
|
|
165
|
+
absolutePath: entry.absolutePath,
|
|
166
|
+
relativePath: entry.name,
|
|
167
|
+
name: entry.name,
|
|
168
|
+
size: stat.size,
|
|
169
|
+
modifiedMs: stat.mtimeMs,
|
|
170
|
+
modifiedAt: stat.mtime?.toISOString() || null
|
|
171
|
+
});
|
|
172
|
+
counters.files += 1;
|
|
173
|
+
counters.totalBytes += stat.size;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
counters.folders += 1;
|
|
177
|
+
await walkDirectory(registry, entry, entry.name, files, visited, counters);
|
|
178
|
+
}
|
|
179
|
+
files.sort((left, right) => left.relativePath.localeCompare(right.relativePath, undefined, { numeric: true }));
|
|
180
|
+
return { ...counters, files };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function toFilesystemHttpError(error) {
|
|
184
|
+
return error?.status
|
|
185
|
+
? error
|
|
186
|
+
: filesystemError('filesystem-operation-failed', 'FILESYSTEM_ERROR', 500);
|
|
187
|
+
}
|