neoctl-web 0.1.13 → 0.1.14
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 +41 -1
- package/chunk-uploads.mjs +125 -0
- package/core-runtime.mjs +1 -0
- package/dist/assets/App--pG9o2Yv.js +105 -0
- package/dist/assets/App-DmtsTXw2.css +1 -0
- package/dist/assets/index-Ckm5fgYu.js +18 -0
- package/dist/assets/index-Dgfhk2Ym.css +1 -0
- package/dist/index.html +2 -2
- package/execution-backend.mjs +74 -0
- package/isolation-auth.mjs +225 -0
- package/isolation.example.json +8 -0
- package/isolation.mjs +385 -0
- package/package.json +9 -2
- package/plugins/downloads/README.md +40 -0
- package/plugins/downloads/downloads.mjs +81 -120
- package/plugins/downloads/downloads.test.mjs +83 -0
- package/plugins/downloads/index.mjs +10 -8
- package/plugins/downloads/neo-plugin.json +2 -2
- package/plugins/video-share/README.md +55 -0
- package/plugins/video-share/http.mjs +86 -0
- package/plugins/video-share/index.mjs +65 -0
- package/plugins/video-share/neo-plugin.json +9 -0
- package/plugins/video-share/presentation.mjs +23 -0
- package/plugins/video-share/store.mjs +76 -0
- package/plugins/video-share/video-share.test.mjs +182 -0
- package/plugins/xhs-artifact/editor-page.mjs +4 -4
- package/runtime-workspaces.mjs +9 -6
- package/scripts/isolation-user.mjs +68 -0
- package/server.mjs +26 -7
- package/dist/assets/index-9hY14np_.css +0 -1
- package/dist/assets/index-BnJuxSfa.js +0 -121
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neoctl-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Neo browser workspace with an embedded agent runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"dev:ui": "vite",
|
|
14
14
|
"test:xhs": "node --test artifacts.test.mjs",
|
|
15
15
|
"test:runtime": "node --test runtime-router-cleanup.test.mjs runtime-workspaces.test.mjs",
|
|
16
|
+
"test:isolation": "npm --prefix ../engine run build && node --test isolation.test.mjs isolation-startup.test.mjs",
|
|
16
17
|
"test:plugins": "npm --prefix ../engine run build && node --test plugins.test.mjs plugin-settings.test.mjs tool-settings.test.mjs",
|
|
17
18
|
"test:monitoring": "node --test cpa-quota.test.mjs memory-monitor.test.mjs",
|
|
18
19
|
"test:control": "node --test control-sync.test.mjs control-transcript.test.mjs",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"@tanstack/vue-virtual": "^3.13.36",
|
|
33
34
|
"highlight.js": "^11.11.1",
|
|
34
35
|
"marked": "^18.0.3",
|
|
35
|
-
"neoctl": "^0.2.
|
|
36
|
+
"neoctl": "^0.2.40",
|
|
36
37
|
"streaming-markdown": "^0.2.15",
|
|
37
38
|
"vue": "^3.5.13"
|
|
38
39
|
},
|
|
@@ -51,6 +52,12 @@
|
|
|
51
52
|
"control-sync.mjs",
|
|
52
53
|
"control-protocol.mjs",
|
|
53
54
|
"core-runtime.mjs",
|
|
55
|
+
"execution-backend.mjs",
|
|
56
|
+
"isolation.mjs",
|
|
57
|
+
"isolation-auth.mjs",
|
|
58
|
+
"isolation.example.json",
|
|
59
|
+
"scripts/isolation-user.mjs",
|
|
60
|
+
"chunk-uploads.mjs",
|
|
54
61
|
"plugins.mjs",
|
|
55
62
|
"plugin-settings.mjs",
|
|
56
63
|
"platform-paths.mjs",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 文件下载插件(2.0)
|
|
2
|
+
|
|
3
|
+
仅依赖 Node.js 标准库,通过 `neo-plugin/v1` 接入。不导入主工程模块,也不依赖视频插件。
|
|
4
|
+
|
|
5
|
+
## 行为
|
|
6
|
+
|
|
7
|
+
- 工具:`expose_downloads({ paths: [绝对路径, ...] })`,每批 1–20 个。
|
|
8
|
+
- **没有工作区或目录白名单限制**:任何位置的现有普通文件,只要服务进程有读取权限,都可暴露;不限制扩展名。
|
|
9
|
+
- **不复制、不移动原文件**。每条链接仅在插件数据目录写入一个很小的 JSON,保存随机 ID 与原始绝对路径的映射。
|
|
10
|
+
- **没有自动过期时间**,`expiresAt` / `expiresAtEpochMs` 为 `null`;映射跨进程重启保留。
|
|
11
|
+
- 访问时从原始路径流式读取,不把整个文件载入内存。
|
|
12
|
+
- 原文件移动或删除后返回 404;无读取权限时请求失败。不搜索新位置,也没有副本兜底。
|
|
13
|
+
- 链接是路径引用,不是版本快照:原路径内容更新后下载的是新内容,响应长度实时获取;若原路径重新出现文件,链接会再次可用。
|
|
14
|
+
- 保持原有 `/api/downloads/<id>`、Markdown 资源协议和附件下载行为,支持 GET / HEAD。
|
|
15
|
+
- 批量操作保留成功项,失败项在 `errors` 中返回。
|
|
16
|
+
|
|
17
|
+
## 存储和安装
|
|
18
|
+
|
|
19
|
+
宿主传入 `appDataDir` 时,默认映射目录为 `<appDataDir>/downloads`;否则为 `~/.neoctl/downloads`。
|
|
20
|
+
|
|
21
|
+
可设置 `NEO_DOWNLOADS_DIR` 为单独的映射目录。这是**映射记录的存储位置**,不是待暴露文件的目录限制。
|
|
22
|
+
|
|
23
|
+
将整个 `downloads` 目录放入宿主扫描的插件根目录,启用后重启服务。迁移机器时,需保留映射目录,并确保记录中的原始路径依然可访问。手动撤销某条链接,可删除映射目录下对应 `<id>` 子目录;这不会删除原文件。
|
|
24
|
+
|
|
25
|
+
## 升级边界和安全
|
|
26
|
+
|
|
27
|
+
旧版本仅在进程内存保存映射,且 UI 已记录旧过期时间;因此旧临时链接无法自动变成永久链接,需对原文件重新调用 `expose_downloads`。
|
|
28
|
+
|
|
29
|
+
“无自动过期”不等于无需服务或存储:服务关闭、插件禁用、映射记录删除、原文件不可用时仍不能下载。
|
|
30
|
+
|
|
31
|
+
链接是持有即访问的凭据,没有逐链接登录验证。不要暴露敏感文件,也不要将具备 Agent 执行能力的整个工作台直接开放到公网。映射 JSON 包含本地绝对路径,应保护其文件系统权限。
|
|
32
|
+
|
|
33
|
+
## 独立测试
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
cd C:\Users\qyq\Desktop\work\neoctl\web\plugins\downloads
|
|
37
|
+
node --test downloads.test.mjs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
测试覆盖跨工作区文件、无副本、无 TTL、跨进程映射恢复、原文件移动/删除、内容更新、HEAD、无效输入、路径穿越和并发暴露。
|
|
@@ -1,147 +1,108 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { workspaceFs, openWorkspaceRead } from '../../execution-backend.mjs';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import crypto from 'node:crypto';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
|
|
8
|
+
const VALID_ID = /^[a-f0-9-]{36}$/;
|
|
5
9
|
|
|
6
10
|
export class DownloadRegistry {
|
|
7
|
-
constructor() {
|
|
8
|
-
this.
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.storageDir = path.resolve(options.storageDir || path.join(os.homedir(), '.neoctl-downloads'));
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
add(entry) {
|
|
15
|
+
async add(entry) {
|
|
16
|
+
const source = entry.absolutePath;
|
|
17
|
+
if (typeof source !== 'string' || !path.isAbsolute(source)) throw new Error('path must be absolute');
|
|
18
|
+
const before = await workspaceFs.stat(source);
|
|
19
|
+
if (!before.isFile()) throw new Error('path is not a regular file');
|
|
20
|
+
await fsp.mkdir(this.storageDir, { recursive: true, mode: 0o700 });
|
|
12
21
|
const id = crypto.randomUUID();
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const pending = path.join(this.storageDir, `.pending-${id}`);
|
|
23
|
+
await fsp.mkdir(pending, { mode: 0o700 });
|
|
24
|
+
try {
|
|
25
|
+
// Persist only a reference. Any existing absolute path is allowed, including
|
|
26
|
+
// paths outside the workspace; never copy or relocate the source file.
|
|
27
|
+
const full = { version: 2, id, createdAt: Date.now(), absolutePath: path.resolve(source),
|
|
28
|
+
filename: path.basename(source), sizeBytes: before.size, expiresAt: null };
|
|
29
|
+
await fsp.writeFile(path.join(pending, 'entry.json'), JSON.stringify(full), { mode: 0o600 });
|
|
30
|
+
await fsp.rename(pending, path.join(this.storageDir, id));
|
|
31
|
+
return full;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
await fsp.rm(pending, { recursive: true, force: true });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
20
36
|
}
|
|
21
37
|
|
|
22
|
-
get(id) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
38
|
+
async get(id) {
|
|
39
|
+
if (typeof id !== 'string' || !VALID_ID.test(id)) return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const entry = JSON.parse(await fsp.readFile(path.join(this.storageDir, id, 'entry.json'), 'utf8'));
|
|
42
|
+
if (entry.version !== 2 || entry.id !== id || typeof entry.filename !== 'string'
|
|
43
|
+
|| typeof entry.absolutePath !== 'string' || !path.isAbsolute(entry.absolutePath)) throw new Error('Invalid download metadata');
|
|
44
|
+
return { ...entry, expiresAt: null };
|
|
45
|
+
} catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
30
46
|
}
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
export function createExposeDownloadsTool(options) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
return {
|
|
50
|
+
const tool = {
|
|
37
51
|
name: 'expose_downloads',
|
|
38
|
-
description:
|
|
39
|
-
'Expose one or more existing local files for the web user to download. Input must be absolute file paths. The result follows neoctl.resource-link.v1: copy each downloads[].markdown value verbatim into the final response, without rewriting or prefixing its link.',
|
|
52
|
+
description: 'Expose existing local files for browser download with no automatic expiration. Only absolute-path mappings are persisted; no file is copied. Any readable regular file anywhere is allowed, including outside the workspace. Links survive restarts but fail when the original path is moved, deleted, or unreadable. Anyone with the link can download. Input must be absolute paths. Copy downloads[].markdown verbatim (neoctl.resource-link.v1).',
|
|
40
53
|
inputSchema: {
|
|
41
54
|
type: 'object',
|
|
42
|
-
properties: {
|
|
43
|
-
|
|
44
|
-
type: 'array',
|
|
45
|
-
items: { type: 'string' },
|
|
46
|
-
description: 'Absolute file paths to expose for browser download.',
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
required: ['paths'],
|
|
50
|
-
additionalProperties: false,
|
|
51
|
-
},
|
|
52
|
-
metadata: {
|
|
53
|
-
readOnly: true,
|
|
54
|
-
concurrent: true,
|
|
55
|
-
visible: true,
|
|
56
|
-
requiresApproval: false,
|
|
57
|
-
maxResultSizeChars: 12000,
|
|
55
|
+
properties: { paths: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 20, description: 'Absolute file paths to publish as permanent browser downloads.' } },
|
|
56
|
+
required: ['paths'], additionalProperties: false,
|
|
58
57
|
},
|
|
58
|
+
metadata: { readOnly: false, concurrent: true, visible: true, requiresApproval: false, maxResultSizeChars: 20000 },
|
|
59
59
|
validate(input) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!paths.length) throw new Error('paths must be a non-empty array');
|
|
64
|
-
if (paths.length > 20) throw new Error('too many paths; maximum is 20');
|
|
65
|
-
for (const filePath of paths) {
|
|
66
|
-
if (!path.isAbsolute(filePath)) throw new Error(`path must be absolute: ${filePath}`);
|
|
67
|
-
}
|
|
68
|
-
return { paths };
|
|
60
|
+
if (!Array.isArray(input?.paths) || !input.paths.length || input.paths.length > 20
|
|
61
|
+
|| input.paths.some((p) => typeof p !== 'string' || !path.isAbsolute(p))) throw new Error('paths must contain 1–20 absolute file paths');
|
|
62
|
+
return { paths: [...new Set(input.paths)] };
|
|
69
63
|
},
|
|
70
|
-
async execute(input, context) {
|
|
71
|
-
const downloads = [];
|
|
72
|
-
for (const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
expiresAt: Date.now() + ttlMs,
|
|
82
|
-
sessionId: context.session?.sessionId,
|
|
83
|
-
});
|
|
84
|
-
const url = `/api/downloads/${encodeURIComponent(entry.id)}`;
|
|
85
|
-
const reference = `sandbox:${url}`;
|
|
86
|
-
downloads.push({
|
|
87
|
-
id: entry.id,
|
|
88
|
-
filename: entry.filename,
|
|
89
|
-
sizeBytes: entry.sizeBytes,
|
|
90
|
-
url,
|
|
91
|
-
reference,
|
|
92
|
-
markdown: `[${entry.filename.replace(/([\\\]])/g, '\\$1')}](${reference})`,
|
|
93
|
-
expiresAt: new Date(entry.expiresAt).toISOString(),
|
|
94
|
-
expiresAtEpochMs: entry.expiresAt,
|
|
95
|
-
});
|
|
64
|
+
async execute(input, context = {}) {
|
|
65
|
+
const downloads = [], errors = [];
|
|
66
|
+
for (const absolutePath of tool.validate(input).paths) {
|
|
67
|
+
try {
|
|
68
|
+
const entry = await options.registry.add({ absolutePath });
|
|
69
|
+
const url = `/api/downloads/${entry.id}`;
|
|
70
|
+
const reference = `sandbox:${url}`;
|
|
71
|
+
const label = entry.filename.replace(/[\r\n]/g, ' ').replace(/([\\[\]<>`*_])/g, '\\$1');
|
|
72
|
+
downloads.push({ id: entry.id, filename: entry.filename, sizeBytes: entry.sizeBytes, url, reference,
|
|
73
|
+
markdown: `[${label}](${reference})`, expiresAt: null, expiresAtEpochMs: null });
|
|
74
|
+
} catch (error) { errors.push({ path: absolutePath, error: error.message }); }
|
|
96
75
|
}
|
|
97
76
|
await options.onExpose?.({ sessionId: context.session?.sessionId, downloads });
|
|
98
|
-
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
resources: downloads.map((item) => ({
|
|
106
|
-
kind: 'download',
|
|
107
|
-
url: item.url,
|
|
108
|
-
reference: item.reference,
|
|
109
|
-
label: item.filename,
|
|
110
|
-
downloadName: item.filename,
|
|
111
|
-
sizeBytes: item.sizeBytes,
|
|
112
|
-
expiresAt: item.expiresAtEpochMs,
|
|
113
|
-
})),
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
summary: `Exposed ${downloads.length} file(s) for browser download.`,
|
|
117
|
-
};
|
|
77
|
+
return { ok: !errors.length, output: {
|
|
78
|
+
resourceProtocol: 'neoctl.resource-link.v1',
|
|
79
|
+
usage: 'Copy downloads[].markdown verbatim. Links have no automatic expiration; expiresAt is null.',
|
|
80
|
+
downloads, errors,
|
|
81
|
+
_ui: { resources: downloads.map((item) => ({ kind: 'download', url: item.url, reference: item.reference,
|
|
82
|
+
label: item.filename, downloadName: item.filename, sizeBytes: item.sizeBytes })) },
|
|
83
|
+
}, summary: `Exposed ${downloads.length} permanent download(s); ${errors.length} failed.` };
|
|
118
84
|
},
|
|
119
85
|
};
|
|
86
|
+
return tool;
|
|
120
87
|
}
|
|
121
88
|
|
|
122
|
-
export function serveDownload(registry, req, res, id) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
res.end(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
res.end('failed to read file');
|
|
142
|
-
} else {
|
|
143
|
-
res.destroy();
|
|
144
|
-
}
|
|
145
|
-
})
|
|
146
|
-
.pipe(res);
|
|
89
|
+
export async function serveDownload(registry, req, res, id) {
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
const entry = await registry.get(id);
|
|
93
|
+
if (!entry) { res.writeHead(404, { 'Cache-Control': 'no-store' }); res.end(req.method === 'HEAD' ? undefined : 'Download not found'); return; }
|
|
94
|
+
handle = await openWorkspaceRead(entry.absolutePath);
|
|
95
|
+
const stat = await handle.stat();
|
|
96
|
+
if (!stat.isFile()) throw Object.assign(new Error('Not a file'), { code: 'ENOENT' });
|
|
97
|
+
res.writeHead(200, {
|
|
98
|
+
'Content-Type': 'application/octet-stream', 'Content-Length': stat.size,
|
|
99
|
+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16))}`,
|
|
100
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
101
|
+
});
|
|
102
|
+
if (req.method === 'HEAD') res.end();
|
|
103
|
+
else await pipeline(handle.createReadStream({ autoClose: false }), res);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (res.headersSent || res.destroyed) { if (!res.destroyed) res.destroy(); }
|
|
106
|
+
else { res.writeHead(error.code === 'ENOENT' ? 404 : 500, { 'Cache-Control': 'no-store' }); res.end(req.method === 'HEAD' ? undefined : 'Unable to read download'); }
|
|
107
|
+
} finally { await handle?.close(); }
|
|
147
108
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { createPlugin } from './index.mjs';
|
|
9
|
+
|
|
10
|
+
async function setup(t) {
|
|
11
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'downloads-test-'));
|
|
12
|
+
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'outside-workspace-'));
|
|
13
|
+
const source = path.join(outside, '中文 [report].txt');
|
|
14
|
+
await fs.writeFile(source, 'original contents');
|
|
15
|
+
const context = { appDataDir: root, env: {} };
|
|
16
|
+
let plugin = createPlugin(context);
|
|
17
|
+
const server = http.createServer(async (req, res) => {
|
|
18
|
+
try { if (!await plugin.route(req, res, new URL(req.url, 'http://localhost'))) { res.statusCode = 404; res.end(); } }
|
|
19
|
+
catch (error) { res.statusCode = 500; res.end(error.message); }
|
|
20
|
+
});
|
|
21
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
22
|
+
t.after(async () => { server.closeAllConnections(); await new Promise((r) => server.close(r)); await fs.rm(root, { recursive: true, force: true }); await fs.rm(outside, { recursive: true, force: true }); });
|
|
23
|
+
return { root, outside, source, plugin, base: `http://127.0.0.1:${server.address().port}`, restart() { plugin = createPlugin(context); } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('arbitrary outside-workspace file: only metadata stored, no copies, no expiry', async (t) => {
|
|
27
|
+
const f = await setup(t);
|
|
28
|
+
const result = await f.plugin.tools[0].execute({ paths: [f.source] }, { cwd: f.root });
|
|
29
|
+
assert.equal(result.ok, true);
|
|
30
|
+
const d = result.output.downloads[0];
|
|
31
|
+
assert.equal(d.expiresAt, null); assert.equal(d.expiresAtEpochMs, null);
|
|
32
|
+
assert.equal(result.output._ui.resources[0].expiresAt, undefined);
|
|
33
|
+
const storedDir = path.join(f.root, 'downloads', d.id);
|
|
34
|
+
assert.deepEqual(await fs.readdir(storedDir), ['entry.json']);
|
|
35
|
+
const record = JSON.parse(await fs.readFile(path.join(storedDir, 'entry.json'), 'utf8'));
|
|
36
|
+
assert.equal(record.absolutePath, f.source);
|
|
37
|
+
const r = await fetch(f.base + d.url);
|
|
38
|
+
assert.equal(r.status, 200); assert.match(r.headers.get('content-disposition'), /^attachment;/);
|
|
39
|
+
assert.equal(await r.text(), 'original contents');
|
|
40
|
+
const head = await fetch(f.base + d.url, { method: 'HEAD' });
|
|
41
|
+
assert.equal(head.status, 200); assert.equal(head.headers.get('content-length'), '17'); assert.equal(await head.text(), '');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('mapping survives restart and future clock; serves current original bytes and length', async (t) => {
|
|
45
|
+
const f = await setup(t), d = (await f.plugin.tools[0].execute({ paths: [f.source] })).output.downloads[0];
|
|
46
|
+
const script = `import { DownloadRegistry } from ${JSON.stringify(new URL('./downloads.mjs', import.meta.url).href)}; Date.now = () => 9999999999999; const e = await new DownloadRegistry({ storageDir: ${JSON.stringify(path.join(f.root, 'downloads'))} }).get(${JSON.stringify(d.id)}); if (!e || e.expiresAt !== null) process.exit(1); console.log(e.id);`;
|
|
47
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' });
|
|
48
|
+
assert.equal(child.status, 0, child.stderr);
|
|
49
|
+
f.restart();
|
|
50
|
+
await fs.writeFile(f.source, 'updated longer original contents');
|
|
51
|
+
const r = await fetch(f.base + d.url); assert.equal(r.status, 200); assert.equal(r.headers.get('content-length'), '32'); assert.equal(await r.text(), 'updated longer original contents');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('moving or deleting the original invalidates the link, no fallback copy', async (t) => {
|
|
55
|
+
const f = await setup(t), d = (await f.plugin.tools[0].execute({ paths: [f.source] })).output.downloads[0];
|
|
56
|
+
const moved = path.join(f.outside, 'moved.txt');
|
|
57
|
+
await fs.rename(f.source, moved);
|
|
58
|
+
const missing = await fetch(f.base + d.url); assert.equal(missing.status, 404); await missing.text();
|
|
59
|
+
f.restart(); const afterRestart = await fetch(f.base + d.url); assert.equal(afterRestart.status, 404); await afterRestart.text();
|
|
60
|
+
const next = (await f.plugin.tools[0].execute({ paths: [moved] })).output.downloads[0];
|
|
61
|
+
await fs.rm(moved);
|
|
62
|
+
const deleted = await fetch(f.base + next.url); assert.equal(deleted.status, 404); await deleted.text();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('invalid paths, directories, missing files, malformed URLs and HTTP writes are rejected', async (t) => {
|
|
66
|
+
const f = await setup(t), tool = f.plugin.tools[0];
|
|
67
|
+
for (const paths of [[], ['relative'], [null], Array(21).fill(f.source)]) assert.throws(() => tool.validate({ paths }));
|
|
68
|
+
const result = await tool.execute({ paths: [f.source, f.outside, path.join(f.outside, 'absent')] });
|
|
69
|
+
assert.equal(result.ok, false); assert.equal(result.output.errors.length, 2); assert.equal(result.output.downloads.length, 1);
|
|
70
|
+
const d = result.output.downloads[0];
|
|
71
|
+
for (const suffix of ['%2e%2e%2fsecret', 'bad%', 'a'.repeat(36), d.id + '/extra']) {
|
|
72
|
+
const r = await fetch(f.base + '/api/downloads/' + suffix); assert.equal(r.status, 404); await r.text();
|
|
73
|
+
}
|
|
74
|
+
const post = await fetch(f.base + d.url, { method: 'POST' }); assert.equal(post.status, 405); await post.text();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('concurrent exposure persists separate tiny mappings', async (t) => {
|
|
78
|
+
const f = await setup(t);
|
|
79
|
+
const results = await Promise.all(Array.from({ length: 8 }, () => f.plugin.tools[0].execute({ paths: [f.source] })));
|
|
80
|
+
const ids = results.map((r) => r.output.downloads[0].id);
|
|
81
|
+
assert.equal(new Set(ids).size, 8);
|
|
82
|
+
for (const id of ids) assert.deepEqual(await fs.readdir(path.join(f.root, 'downloads', id)), ['entry.json']);
|
|
83
|
+
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
1
3
|
import { createExposeDownloadsTool, DownloadRegistry, serveDownload } from './downloads.mjs';
|
|
2
4
|
|
|
3
|
-
export function createPlugin() {
|
|
4
|
-
const
|
|
5
|
+
export function createPlugin(context = {}) {
|
|
6
|
+
const env = context.env || process.env;
|
|
7
|
+
const registry = new DownloadRegistry({ storageDir: env.NEO_DOWNLOADS_DIR || path.join(context.appDataDir || path.join(os.homedir(), '.neoctl'), 'downloads') });
|
|
5
8
|
return {
|
|
6
9
|
tools: [createExposeDownloadsTool({ registry })],
|
|
7
10
|
promptSections: [{
|
|
8
|
-
name: 'Web Downloads',
|
|
9
|
-
|
|
10
|
-
cacheStable: true,
|
|
11
|
-
content: 'When you create, modify, export, package, or identify local files that the web user should receive, call expose_downloads with the relevant absolute paths before the final response. For every link in your response, copy the returned downloads[].markdown value verbatim. This is the neoctl.resource-link.v1 protocol: never construct a link, alter the reference URI, or add/remove a sandbox: prefix. The tool also returns expiresAt as an ISO timestamp.',
|
|
11
|
+
name: 'Web Downloads', requiresTools: ['expose_downloads'], cacheStable: true,
|
|
12
|
+
content: 'When you create, modify, export, package, or identify local files that the web user should receive as downloads, call expose_downloads with the relevant absolute paths before the final response. For every download link, copy downloads[].markdown verbatim (neoctl.resource-link.v1): never construct links or add/remove a sandbox: prefix. Only the link-to-original-absolute-path mapping is persisted, with no file copy and no directory restriction. Links do not automatically expire and expiresAt is null. If the original path is moved, deleted, or unreadable, the link stops working. Anyone with a link can download. Historical links from the old in-memory plugin cannot be restored; re-expose the original file if needed.',
|
|
12
13
|
}],
|
|
13
14
|
async route(req, res, url) {
|
|
14
|
-
if (
|
|
15
|
-
|
|
15
|
+
if (!url.pathname.startsWith('/api/downloads/')) return false;
|
|
16
|
+
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD' }); res.end(); return true; }
|
|
17
|
+
const id = url.pathname.slice('/api/downloads/'.length);
|
|
16
18
|
await serveDownload(registry, req, res, id);
|
|
17
19
|
return true;
|
|
18
20
|
},
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"protocol": "neo-plugin/v1",
|
|
3
3
|
"id": "downloads",
|
|
4
4
|
"name": "文件下载",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "2.0.0",
|
|
6
6
|
"entry": "index.mjs",
|
|
7
7
|
"defaultEnabled": true,
|
|
8
|
-
"description": "
|
|
8
|
+
"description": "仅持久保存原始文件路径映射,无目录限制、无文件复制、无自动过期;原文件移动或删除后链接失效。"
|
|
9
9
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# video-share:独立视频播放插件
|
|
2
|
+
|
|
3
|
+
仅依赖 Node.js 20+ 标准库,无 npm 依赖、不需要构建、不导入宿主或 downloads 插件的任何模块。通过 `neo-plugin/v1` 的工厂上下文、tools、promptSections、presentToolResult 和 route 接口接入;整个目录可复制或移除。播放器 HTML/CSS、文件存储、HTTP Range 逻辑、测试全部在插件内部,不修改 Vue 或宿主路由。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- `expose_videos({ paths: [绝对视频路径, ...] })`:每批最多 20 个,返回视频元数据、供程序使用的 `url` / `mediaUrl`、内嵌展示 `_ui`,以及 `expiresAt: null`。不再返回 Markdown 链接;文字回复不得贴视频 URL、Markdown 链接或 HTML 视频标签,只需说明视频已就绪。
|
|
8
|
+
- `revoke_videos({ ids: [视频 ID, ...] })`:用户要求时手动撤销链接并删除路径映射,不删除或修改原视频。已下载内容和正在传输的数据不能追回。
|
|
9
|
+
- `/api/video-share/<id>`:插件自带 HTML5 播放器,由聊天页面以同源 iframe 直接嵌入,带播放/暂停、拖动、音量、全屏控件;页面中没有新窗口或直链按钮。
|
|
10
|
+
- `/api/video-share/<id>/media`:正确的视频 MIME、`Content-Disposition: inline`,支持 GET、HEAD、单段 Range / 206 / 416 和 If-Range。未知单位或多段 Range 回退完整 200 响应。
|
|
11
|
+
- 无自动过期和定时清理;每条视频以随机 192 位 token 发布,独立原子落盘记录,支持并发发布和跨进程恢复。
|
|
12
|
+
- 接受 `.mp4`、`.m4v`、`.mov`、`.webm`、`.ogv`,检查基本容器签名,但不做解码或转码。推荐 MP4(H.264 / AAC)或 WebM。扩展名和容器合法不保证浏览器支持内部编码。
|
|
13
|
+
- 播放器直接显示在消息中,不需要点击链接,不打开新页面,不触发附件下载。通过现有 `kind: embed` 和 `presentationLevel: primary` 通用协议实现,不改宿主 Vue 代码。部分成功的批量结果也显示成功视频;历史工具结果可经 presenter 重新展示,但历史助手文字不会被自动改写。
|
|
14
|
+
|
|
15
|
+
## 存储行为
|
|
16
|
+
|
|
17
|
+
视频与 downloads 一样采用**零复制**:仅保存原始绝对路径映射,不复制、不移动视频、不创建硬链接或软链接。原视频可在任意目录(需服务具有读取权限),访问时从原路径流式读取,支持 Range 拖动。原视频移动或删除后,播放页和视频直链均返回 404;不可读时请求失败,不查找新位置、无副本兜底。
|
|
18
|
+
|
|
19
|
+
映射跨重启保留、无自动过期。原路径内容更新后播放新内容;若该路径重新出现文件,链接可再次访问。重复发布只新增很小的 JSON 映射。撤销仅删除映射,不触碰原视频。这里的零复制指不创建磁盘副本,网络传输仍采用 Node.js 流。
|
|
20
|
+
|
|
21
|
+
升级说明:旧 v1 记录没有原始路径,不能自动转换;旧视频链接将返回 404,需要重新暴露原视频。不会自动删除旧副本,确认不再需要后可手动清理旧 ID 目录。
|
|
22
|
+
|
|
23
|
+
默认目录是宿主传入的 `<appDataDir>/video-share`;无宿主数据目录时使用 `~/.neo-video-share/video-share`。`NEO_VIDEO_SHARE_DIR` 可覆盖整个存储位置。
|
|
24
|
+
|
|
25
|
+
“无自动过期”仍以服务运行、插件启用、发布数据存在为前提。删除插件数据、撤销链接或停止服务会影响访问。请备份映射目录,并保留原始视频路径,保持链接的域名/端口不变。中断发布可能遗留 `.pending-*`,撤销清理失败可能遗留 `.revoked-*`;这些不是公开记录,可在停止服务后手动清理。
|
|
26
|
+
|
|
27
|
+
## 安装与配置
|
|
28
|
+
|
|
29
|
+
将整个 `video-share` 目录放入宿主扫描的插件根目录(本仓库为 `web/plugins`)。在插件设置中启用并重启服务;如果 `NEO_WEB_PLUGINS` 使用显式列表,也要加入 `video-share`。
|
|
30
|
+
|
|
31
|
+
可选环境变量:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
$env:NEO_VIDEO_SHARE_DIR = 'D:\neo-data\published-videos'
|
|
35
|
+
$env:NEO_VIDEO_SHARE_PUBLIC_ORIGIN = 'https://videos.example.com'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`NEO_VIDEO_SHARE_PUBLIC_ORIGIN` 只接受 HTTP(S) origin,不含路径、查询参数或凭据。该配置仅影响程序使用的 `url` / `mediaUrl`;聊天内嵌资源始终使用同源相对路径,避免跨域 CSP 拒绝嵌入。设置后直链返回完整 URL,需由你配置该域名到插件路由的反向代理,插件不会自动开放网络或修改防火墙。
|
|
39
|
+
|
|
40
|
+
非 neo 宿主也可以直接 `import { createPlugin } from './index.mjs'`,传入 `{ appDataDir, env }`,将返回的 `route(req, res, new URL(...))` 接到 Node HTTP 服务,并调用返回工具的 `execute`。宿主渲染时使用 `_ui` 或 `presentToolResult` 的 embed 资源。无宿主 SDK 依赖。
|
|
41
|
+
|
|
42
|
+
## 安全边界
|
|
43
|
+
|
|
44
|
+
持有链接即可访问,没有逐视频登录验证。只有明确暴露的文件可访问;URL 不接受本地路径,没有公开目录列表或发布/撤销 HTTP 接口。文件名经过 HTML 转义,播放器使用 CSP 和 no-referrer。`Cache-Control: no-store` 只控制缓存,并非链接过期。
|
|
45
|
+
|
|
46
|
+
保护存储目录的文件系统权限;不要分享敏感视频。远程共享时仅代理视频路由并使用 HTTPS,不要把整个具备文件修改和执行命令能力的 Agent 工作台直接暴露到公网。
|
|
47
|
+
|
|
48
|
+
## 独立测试
|
|
49
|
+
|
|
50
|
+
```powershell
|
|
51
|
+
cd C:\Users\qyq\Desktop\work\neoctl\web\plugins\video-share
|
|
52
|
+
node --test video-share.test.mjs
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
测试使用带容器头的合成字节验证 HTTP、Range、HEAD、零复制、移动/删除失效、原文件更新、持久性、撤销安全、旧版边界、输入校验、转义、并发和独立进程恢复;它不是浏览器真实视频解码测试。
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { openWorkspaceRead } from '../../execution-backend.mjs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { pipeline } from 'node:stream/promises';
|
|
4
|
+
|
|
5
|
+
export const PREFIX = '/api/video-share/';
|
|
6
|
+
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
7
|
+
|
|
8
|
+
export function playerPage(entry, options = {}) {
|
|
9
|
+
const title = escapeHtml(entry.filename);
|
|
10
|
+
const src = `${(process.env.NEO_WEB_BASE_PATH || "").replace(/\/$/, "")}${PREFIX}${entry.id}/media`;
|
|
11
|
+
const theme = options.theme === 'light' ? 'light' : 'dark';
|
|
12
|
+
return `<!doctype html><html lang="zh-CN" data-theme="${theme}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>
|
|
13
|
+
:root{color-scheme:dark;--bg:#11151d;--fg:#eee;--muted:#aab2c1}html[data-theme=light]{color-scheme:light;--bg:#fff;--fg:#222;--muted:#667085}*{box-sizing:border-box}html,body{height:100%;margin:0}body{background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}main{height:100%;display:flex;flex-direction:column;gap:10px;padding:14px}h1{margin:0;font-size:16px;overflow-wrap:anywhere}video{display:block;flex:1;min-height:0;width:100%;background:#000;border-radius:8px}p{margin:0;color:var(--muted);font-size:12px;line-height:1.5}
|
|
14
|
+
</style></head><body><main><h1>${title}</h1><video controls playsinline preload="metadata" src="${src}">浏览器不支持视频播放。</video></main></body></html>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A single byte range is sufficient for HTML5 media. Ignore unknown/multipart ranges.
|
|
18
|
+
export function parseRange(value, size) {
|
|
19
|
+
if (!value || !value.startsWith('bytes=') || value.includes(',')) return null;
|
|
20
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
|
21
|
+
if (!match || (!match[1] && !match[2])) return false;
|
|
22
|
+
if (!size) return false;
|
|
23
|
+
let start, end;
|
|
24
|
+
if (!match[1]) {
|
|
25
|
+
const suffix = Number(match[2]);
|
|
26
|
+
if (!Number.isSafeInteger(suffix) || suffix <= 0) return false;
|
|
27
|
+
start = Math.max(0, size - suffix); end = size - 1;
|
|
28
|
+
} else {
|
|
29
|
+
start = Number(match[1]); end = match[2] ? Number(match[2]) : size - 1;
|
|
30
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= size || start > end) return false;
|
|
31
|
+
end = Math.min(end, size - 1);
|
|
32
|
+
}
|
|
33
|
+
return { start, end };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function text(res, status, message, head = false) {
|
|
37
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
38
|
+
res.end(head ? undefined : message);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createVideoRoute(store) {
|
|
42
|
+
return async (req, res, url) => {
|
|
43
|
+
if (!url.pathname.startsWith(PREFIX)) return false;
|
|
44
|
+
const head = req.method === 'HEAD';
|
|
45
|
+
if (req.method !== 'GET' && !head) { res.setHeader('Allow', 'GET, HEAD'); text(res, 405, 'Method not allowed'); return true; }
|
|
46
|
+
const match = /^\/api\/video-share\/([a-f0-9]{48})(\/media)?$/.exec(url.pathname);
|
|
47
|
+
if (!match) { text(res, 404, 'Video not found', head); return true; }
|
|
48
|
+
let handle;
|
|
49
|
+
try {
|
|
50
|
+
const entry = await store.get(match[1]);
|
|
51
|
+
if (!entry) { text(res, 404, 'Video not found', head); return true; }
|
|
52
|
+
handle = await openWorkspaceRead(entry.mediaPath);
|
|
53
|
+
const stat = await handle.stat();
|
|
54
|
+
if (!stat.isFile()) { text(res, 404, 'Video not found', head); return true; }
|
|
55
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
56
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
57
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
58
|
+
if (!match[2]) {
|
|
59
|
+
const body = playerPage(entry, { theme: url.searchParams.get('theme') });
|
|
60
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(body),
|
|
61
|
+
'Content-Security-Policy': "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'self'" });
|
|
62
|
+
res.end(head ? undefined : body);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
const etag = `"${entry.id}-${stat.size}-${stat.mtimeMs}-${stat.ctimeMs}"`;
|
|
66
|
+
const rangeHeader = req.headers['if-range'] && req.headers['if-range'] !== etag ? undefined : req.headers.range;
|
|
67
|
+
const range = head ? null : parseRange(rangeHeader, stat.size);
|
|
68
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
69
|
+
res.setHeader('ETag', etag);
|
|
70
|
+
if (range === false) { res.setHeader('Content-Range', `bytes */${stat.size}`); text(res, 416, 'Range not satisfiable', head); return true; }
|
|
71
|
+
const length = range ? range.end - range.start + 1 : stat.size;
|
|
72
|
+
res.setHeader('Content-Type', entry.contentType);
|
|
73
|
+
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(entry.filename).replace(/['()*]/g, (c) => '%' + c.charCodeAt(0).toString(16))}`);
|
|
74
|
+
res.setHeader('Content-Length', length);
|
|
75
|
+
if (range) res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
76
|
+
res.statusCode = range ? 206 : 200;
|
|
77
|
+
if (head) res.end();
|
|
78
|
+
else await pipeline(handle.createReadStream({ ...(range || {}), autoClose: false }), res);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (res.headersSent || res.destroyed) { if (!res.destroyed) res.destroy(); }
|
|
82
|
+
else text(res, error.code === 'ENOENT' ? 404 : 500, error.code === 'ENOENT' ? 'Video not found' : 'Unable to serve video', head);
|
|
83
|
+
return true;
|
|
84
|
+
} finally { await handle?.close(); }
|
|
85
|
+
};
|
|
86
|
+
}
|