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.
@@ -0,0 +1,65 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { VideoStore, VALID_ID } from './store.mjs';
4
+ import { createVideoRoute, PREFIX } from './http.mjs';
5
+ import { videoPresentation } from './presentation.mjs';
6
+
7
+ const metadata = { readOnly: false, concurrent: true, visible: true, requiresApproval: false, maxResultSizeChars: 20000 };
8
+
9
+ export function createPlugin(context = {}) {
10
+ const env = context.env || process.env;
11
+ const directory = env.NEO_VIDEO_SHARE_DIR || path.join(context.appDataDir || path.join(os.homedir(), '.neo-video-share'), 'video-share');
12
+ const store = new VideoStore(directory);
13
+ let origin = '';
14
+ if (env.NEO_VIDEO_SHARE_PUBLIC_ORIGIN) {
15
+ const url = new URL(env.NEO_VIDEO_SHARE_PUBLIC_ORIGIN);
16
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
17
+ throw new Error('NEO_VIDEO_SHARE_PUBLIC_ORIGIN must be an HTTP(S) origin, without credentials or a path');
18
+ }
19
+ origin = url.origin;
20
+ }
21
+ const expose = {
22
+ name: 'expose_videos',
23
+ description: 'Display existing local videos inline in the conversation using embedded players. Persists only original absolute-path mappings, with no copies, directory restrictions or automatic expiration. Playback reads the original file; moving or deleting it invalidates the link. The UI embeds the player automatically. Do not include video links, URLs, Markdown links, HTML video tags or iframes in your textual reply; only acknowledge inline playback. Anyone with a link can view it. Recommend MP4 H.264/AAC or WebM; no transcoding is performed.',
24
+ inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 20, description: 'Absolute video paths (.mp4, .m4v, .mov, .webm, .ogv).' } }, required: ['paths'], additionalProperties: false },
25
+ metadata,
26
+ validate(input) {
27
+ if (!Array.isArray(input?.paths) || !input.paths.length || input.paths.length > 20 || input.paths.some((p) => typeof p !== 'string' || !path.isAbsolute(p))) throw new Error('paths must contain 1–20 absolute video paths');
28
+ return { paths: [...new Set(input.paths)] };
29
+ },
30
+ async execute(input) {
31
+ const videos = [], errors = [];
32
+ for (const source of expose.validate(input).paths) {
33
+ try {
34
+ const entry = await store.publish(source);
35
+ const url = `${origin}${PREFIX}${entry.id}`;
36
+ videos.push({ id: entry.id, filename: entry.filename, sizeBytes: entry.sizeBytes, contentType: entry.contentType,
37
+ url, mediaUrl: `${url}/media`, expiresAt: null });
38
+ } catch (error) { errors.push({ path: source, error: error.message }); }
39
+ }
40
+ return { ok: !errors.length, output: { usage: 'The UI automatically embeds video players in the conversation. Do not repeat video URLs or Markdown/HTML video links in the textual response. Simply acknowledge the videos are ready inline. There is no automatic expiration.', videos, errors,
41
+ _ui: videoPresentation({ videos, errors }) },
42
+ summary: `Published ${videos.length} video(s); ${errors.length} failed. No automatic expiration.` };
43
+ },
44
+ };
45
+ const revoke = {
46
+ name: 'revoke_videos', description: 'Revoke video links by removing plugin mappings only. Never delete or modify source videos. Only call when the user requests revocation.',
47
+ inputSchema: { type: 'object', properties: { ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 20 } }, required: ['ids'], additionalProperties: false },
48
+ metadata: { ...metadata, requiresApproval: true },
49
+ validate(input) {
50
+ if (!Array.isArray(input?.ids) || !input.ids.length || input.ids.length > 20 || input.ids.some((id) => typeof id !== 'string' || !VALID_ID.test(id))) throw new Error('ids must contain 1–20 valid video ids');
51
+ return { ids: [...new Set(input.ids)] };
52
+ },
53
+ async execute(input) {
54
+ const results = [];
55
+ for (const id of revoke.validate(input).ids) results.push({ id, revoked: await store.revoke(id) });
56
+ return { ok: true, output: { results }, summary: 'Video links revoked; original source files untouched.' };
57
+ },
58
+ };
59
+ return { tools: [expose, revoke], route: createVideoRoute(store),
60
+ presentToolResult({ toolName, output }) {
61
+ return toolName === 'expose_videos' ? videoPresentation(output) : undefined;
62
+ },
63
+ promptSections: [{ name: 'Inline Video Playback', cacheStable: true, requiresTools: ['expose_videos'],
64
+ content: 'For local video files that the user wants to watch, use expose_videos instead of a generic download tool. The plugin automatically embeds HTML5 players inside the conversation, without opening a new page. Do not put video links, raw video URLs, Markdown video links, HTML video tags or iframe markup in your textual response. Reply only with a brief acknowledgement or relevant explanation; the player is already visible. Do not also expose the same video as a download unless the user explicitly requests a download. There is no automatic expiration. Playback depends on browser codec support; this plugin does not transcode. Publications are zero-copy original-path references with persistent mappings; anyone with a link can view them. Source movement or deletion invalidates the link; do not claim a snapshot is stored. Use revoke_videos only when requested.' }] };
65
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "protocol": "neo-plugin/v1",
3
+ "id": "video-share",
4
+ "name": "视频播放",
5
+ "version": "2.1.0",
6
+ "entry": "index.mjs",
7
+ "defaultEnabled": true,
8
+ "description": "聊天消息内嵌 HTML5 视频播放、Range 流式播放、零复制路径映射、无自动过期链接及手动撤销。"
9
+ }
@@ -0,0 +1,23 @@
1
+ import { VALID_ID } from './store.mjs';
2
+ import { PREFIX } from './http.mjs';
3
+
4
+ // Host-neutral neo-plugin/v1 presentation. Never use a model-supplied URL for an iframe.
5
+ // Always embed on the current origin, even when direct URLs use a public origin.
6
+ export function videoPresentation(output) {
7
+ const videos = Array.isArray(output?.videos)
8
+ ? output.videos.filter((v) => v && typeof v.id === 'string' && VALID_ID.test(v.id)) : [];
9
+ if (!videos.length) return undefined;
10
+ return {
11
+ title: '视频播放',
12
+ text: Array.isArray(output.errors) && output.errors.length
13
+ ? `已嵌入 ${videos.length} 个视频,另有 ${output.errors.length} 个未能加载。`
14
+ : `已嵌入 ${videos.length} 个视频,可直接播放。`,
15
+ presentationLevel: 'primary',
16
+ resources: videos.map((v) => ({
17
+ kind: 'embed',
18
+ url: `${PREFIX}${v.id}?embed=1`,
19
+ label: typeof v.filename === 'string' ? v.filename : '视频播放',
20
+ height: 480,
21
+ })),
22
+ };
23
+ }
@@ -0,0 +1,76 @@
1
+ import { workspaceFs, openWorkspaceRead, containerMode } from '../../execution-backend.mjs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+
6
+ export const VALID_ID = /^[a-f0-9]{48}$/;
7
+ export const VIDEO_TYPES = Object.freeze({
8
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime',
9
+ '.webm': 'video/webm', '.ogv': 'video/ogg',
10
+ });
11
+
12
+ // Each publication has its own atomic directory: no shared mutable index or host imports.
13
+ export class VideoStore {
14
+ constructor(directory) { this.directory = path.resolve(directory); }
15
+
16
+ async publish(source) {
17
+ if (typeof source !== 'string' || !path.isAbsolute(source)) throw new Error('Video path must be absolute');
18
+ const type = VIDEO_TYPES[path.extname(source).toLowerCase()];
19
+ if (!type) throw new Error('Supported video extensions: .mp4, .m4v, .mov, .webm, .ogv');
20
+ const sourceStat = await workspaceFs.stat(source);
21
+ if (!sourceStat.isFile() || sourceStat.size === 0) throw new Error('Video must be a non-empty regular file');
22
+ await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
23
+ const id = randomBytes(24).toString('hex');
24
+ const staging = path.join(this.directory, `.pending-${id}`);
25
+ await fs.mkdir(staging, { mode: 0o700 });
26
+ try {
27
+ // Validate only a small header, then persist the original path, never a copy.
28
+ const header = Buffer.alloc(64);
29
+ if (containerMode) {
30
+ const handle = await openWorkspaceRead(source);
31
+ try { let offset = 0; for await (const chunk of handle.createReadStream({ start: 0, end: 63 })) { chunk.copy(header, offset); offset += chunk.length; } } finally { await handle.close(); }
32
+ } else {
33
+ const handle = await fs.open(source, 'r');
34
+ try { await handle.read(header, 0, header.length, 0); } finally { await handle.close(); }
35
+ }
36
+ const extension = path.extname(source).toLowerCase();
37
+ const valid = extension === '.webm' ? header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
38
+ : extension === '.ogv' ? header.toString('ascii', 0, 4) === 'OggS'
39
+ : ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(header.toString('ascii', 4, 8));
40
+ if (!valid) throw new Error('File does not have a recognized video container signature');
41
+ const entry = { version: 2, id, absolutePath: path.resolve(source), filename: path.basename(source), contentType: type, sizeBytes: sourceStat.size, createdAt: new Date().toISOString() };
42
+ await fs.writeFile(path.join(staging, 'entry.json'), JSON.stringify(entry), { mode: 0o600 });
43
+ await fs.rename(staging, path.join(this.directory, id));
44
+ return entry;
45
+ } catch (error) {
46
+ await fs.rm(staging, { recursive: true, force: true });
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ async get(id) {
52
+ if (!VALID_ID.test(id)) return undefined;
53
+ try {
54
+ const entry = JSON.parse(await fs.readFile(path.join(this.directory, id, 'entry.json'), 'utf8'));
55
+ // Old v1 snapshots have no source path; do not silently serve a copy.
56
+ if (entry.version === 1) return undefined;
57
+ if (entry.version !== 2 || typeof entry.absolutePath !== 'string' || !path.isAbsolute(entry.absolutePath)
58
+ || entry.id !== id || typeof entry.filename !== 'string'
59
+ || !Object.values(VIDEO_TYPES).includes(entry.contentType)) throw new Error('Invalid video metadata');
60
+ return { ...entry, mediaPath: entry.absolutePath };
61
+ } catch (error) {
62
+ if (error.code === 'ENOENT') return undefined;
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ async revoke(id) {
68
+ if (!VALID_ID.test(id)) throw new Error('Invalid video id');
69
+ const tombstone = path.join(this.directory, `.revoked-${id}-${randomBytes(6).toString('hex')}`);
70
+ try { await fs.rename(path.join(this.directory, id), tombstone); }
71
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
72
+ // Renaming removes the public mapping first; never reuse a revoked token.
73
+ await fs.rm(tombstone, { recursive: true, force: true });
74
+ return true;
75
+ }
76
+ }
@@ -0,0 +1,182 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createPlugin } from './index.mjs';
9
+ import { VideoStore } from './store.mjs';
10
+ import { parseRange, playerPage } from './http.mjs';
11
+
12
+ async function setup(t) {
13
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'video-share-test-'));
14
+ const source = path.join(root, '样例 video.mp4');
15
+ const bytes = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from('ftypisom'), Buffer.alloc(1024, 7)]);
16
+ await fs.writeFile(source, bytes);
17
+ const context = { appDataDir: path.join(root, 'data'), env: {} };
18
+ let plugin = createPlugin(context);
19
+ const server = http.createServer(async (req, res) => {
20
+ try { if (!await plugin.route(req, res, new URL(req.url, 'http://localhost'))) { res.statusCode = 404; res.end(); } }
21
+ catch (error) { res.statusCode = 500; res.end(error.message); }
22
+ });
23
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
24
+ t.after(async () => { server.closeAllConnections(); await new Promise((r) => server.close(r)); await fs.rm(root, { recursive: true, force: true }); });
25
+ const base = `http://127.0.0.1:${server.address().port}`;
26
+ return { root, source, bytes, context, base, plugin, restart() { plugin = createPlugin(context); return plugin; } };
27
+ }
28
+
29
+ function expose(plugin, paths) { return plugin.tools.find((t) => t.name === 'expose_videos').execute({ paths }); }
30
+
31
+ test('standalone publication, player, inline media and HEAD work without host imports', async (t) => {
32
+ const f = await setup(t);
33
+ const result = await expose(f.plugin, [f.source]);
34
+ assert.equal(result.ok, true);
35
+ const v = result.output.videos[0];
36
+ assert.equal(v.expiresAt, null);
37
+ assert.equal(result.output._ui.resources[0].kind, 'embed');
38
+ assert.equal(result.output._ui.presentationLevel, 'primary');
39
+ assert.equal(v.markdown, undefined);
40
+ assert.equal(v.reference, undefined);
41
+ assert.equal(result.output._ui.resources[0].downloadName, undefined);
42
+ const page = await fetch(f.base + v.url);
43
+ assert.equal(page.status, 200);
44
+ assert.match(page.headers.get('content-security-policy'), /default-src 'none'/);
45
+ assert.match(await page.text(), /<video controls playsinline preload="metadata"/);
46
+ const media = await fetch(f.base + v.mediaUrl);
47
+ assert.equal(media.headers.get('content-type'), 'video/mp4');
48
+ assert.match(media.headers.get('content-disposition'), /^inline;/);
49
+ assert.equal(media.headers.get('accept-ranges'), 'bytes');
50
+ assert.deepEqual(Buffer.from(await media.arrayBuffer()), f.bytes);
51
+ const head = await fetch(f.base + v.mediaUrl, { method: 'HEAD' });
52
+ assert.equal(head.status, 200);
53
+ assert.equal(Number(head.headers.get('content-length')), f.bytes.length);
54
+ assert.equal((await head.arrayBuffer()).byteLength, 0);
55
+ });
56
+
57
+ test('byte range seeking: bounded, open-ended, suffix, clamped, invalid, If-Range', async (t) => {
58
+ const f = await setup(t), v = (await expose(f.plugin, [f.source])).output.videos[0];
59
+ for (const [range, start, end] of [['bytes=2-9', 2, 9], ['bytes=1000-', 1000, f.bytes.length - 1], ['bytes=-9', f.bytes.length - 9, f.bytes.length - 1], ['bytes=2-999999', 2, f.bytes.length - 1]]) {
60
+ const r = await fetch(f.base + v.mediaUrl, { headers: { Range: range } });
61
+ assert.equal(r.status, 206);
62
+ assert.equal(r.headers.get('content-range'), `bytes ${start}-${end}/${f.bytes.length}`);
63
+ assert.deepEqual(Buffer.from(await r.arrayBuffer()), f.bytes.subarray(start, end + 1));
64
+ }
65
+ for (const range of ['bytes=99999-', 'bytes=4-2', 'bytes=-0', 'bytes=-', 'bytes=x-y']) {
66
+ const r = await fetch(f.base + v.mediaUrl, { headers: { Range: range } });
67
+ assert.equal(r.status, 416); assert.equal(r.headers.get('content-range'), `bytes */${f.bytes.length}`); await r.text();
68
+ }
69
+ for (const headers of [{ Range: 'bytes=0-1,4-5' }, { Range: 'bytes=0-2', 'If-Range': '"stale"' }]) {
70
+ const r = await fetch(f.base + v.mediaUrl, { headers }); assert.equal(r.status, 200); await r.arrayBuffer();
71
+ }
72
+ const full = await fetch(f.base + v.mediaUrl); const etag = full.headers.get('etag'); await full.arrayBuffer();
73
+ const r = await fetch(f.base + v.mediaUrl, { headers: { Range: 'bytes=0-1', 'If-Range': etag } });
74
+ assert.equal(r.status, 206); await r.arrayBuffer();
75
+ assert.equal(parseRange('bytes=0-0', 0), false);
76
+ });
77
+
78
+ test('zero-copy original-path mapping survives restart and a fresh Node process without TTL', async (t) => {
79
+ const f = await setup(t), v = (await expose(f.plugin, [f.source])).output.videos[0];
80
+ const recordDir = path.join(f.context.appDataDir, 'video-share', v.id);
81
+ assert.deepEqual(await fs.readdir(recordDir), ['entry.json']);
82
+ const record = JSON.parse(await fs.readFile(path.join(recordDir, 'entry.json'), 'utf8'));
83
+ assert.equal(record.absolutePath, f.source);
84
+ f.restart();
85
+ const r = await fetch(f.base + v.mediaUrl); assert.equal(r.status, 200); assert.deepEqual(Buffer.from(await r.arrayBuffer()), f.bytes);
86
+ const script = `import { VideoStore } from ${JSON.stringify(new URL('./store.mjs', import.meta.url).href)}; Date.now = () => 9999999999999; const e = await new VideoStore(${JSON.stringify(path.join(f.context.appDataDir, 'video-share'))}).get(${JSON.stringify(v.id)}); if (!e || 'expiresAt' in e) process.exit(1); console.log(e.id);`;
87
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' });
88
+ assert.equal(child.status, 0, child.stderr); assert.match(child.stdout, new RegExp(v.id));
89
+ });
90
+
91
+ test('revoke persists across restarts; guessing, traversal, writes and missing blobs fail closed', async (t) => {
92
+ const f = await setup(t), v = (await expose(f.plugin, [f.source])).output.videos[0];
93
+ for (const suffix of ['f'.repeat(48), '%2e%2e%2fsecret', v.id + '/media/extra']) {
94
+ const r = await fetch(f.base + '/api/video-share/' + suffix); assert.equal(r.status, 404); await r.text();
95
+ }
96
+ const post = await fetch(f.base + v.url, { method: 'POST' }); assert.equal(post.status, 405); await post.text();
97
+ const revoke = f.plugin.tools.find((t) => t.name === 'revoke_videos');
98
+ assert.equal((await revoke.execute({ ids: [v.id] })).output.results[0].revoked, true);
99
+ assert.equal((await revoke.execute({ ids: [v.id] })).output.results[0].revoked, false);
100
+ f.restart(); const gone = await fetch(f.base + v.mediaUrl); assert.equal(gone.status, 404); await gone.text();
101
+ assert.equal((await fs.stat(f.source)).isFile(), true);
102
+ const next = (await expose(f.plugin, [f.source])).output.videos[0];
103
+ await fs.rm(f.source);
104
+ const missing = await fetch(f.base + next.mediaUrl); assert.equal(missing.status, 404); await missing.text();
105
+ });
106
+
107
+ test('validation, partial batches, signatures, escaping and public origin', async (t) => {
108
+ const f = await setup(t), tool = f.plugin.tools[0];
109
+ for (const paths of [[], ['relative.mp4'], [12], Array(21).fill(f.source)]) assert.throws(() => tool.validate({ paths }));
110
+ const fake = path.join(f.root, 'fake.mp4'); await fs.writeFile(fake, '<script>not video</script>');
111
+ const result = await expose(f.plugin, [f.source, fake, path.join(f.root, 'a.txt')]);
112
+ assert.equal(result.ok, false); assert.equal(result.output.videos.length, 1); assert.equal(result.output.errors.length, 2);
113
+ const html = playerPage({ filename: '<script>alert(1)</script>', id: 'a'.repeat(48) });
114
+ assert.ok(!html.includes('<script>')); assert.match(html, /&lt;script&gt;/);
115
+ const publicPlugin = createPlugin({ ...f.context, env: { NEO_VIDEO_SHARE_PUBLIC_ORIGIN: 'https://videos.example.com' } });
116
+ const v = (await expose(publicPlugin, [f.source])).output.videos[0];
117
+ assert.ok(v.url.startsWith('https://videos.example.com/api/video-share/')); assert.equal(v.reference, undefined);
118
+ assert.ok((await expose(publicPlugin, [f.source])).output._ui.resources[0].url.startsWith('/api/video-share/'));
119
+ assert.throws(() => createPlugin({ env: { NEO_VIDEO_SHARE_PUBLIC_ORIGIN: 'https://host/path' } }));
120
+ });
121
+
122
+ test('concurrent publishers use independent atomic records', async (t) => {
123
+ const f = await setup(t);
124
+ const store = new VideoStore(path.join(f.context.appDataDir, 'video-share'));
125
+ const entries = await Promise.all(Array.from({ length: 8 }, () => store.publish(f.source)));
126
+ assert.equal(new Set(entries.map((e) => e.id)).size, 8);
127
+ for (const e of entries) assert.equal((await store.get(e.id)).id, e.id);
128
+ assert.ok((await fs.readdir(store.directory)).every((s) => !s.startsWith('.pending')));
129
+ });
130
+
131
+ test('source move invalidates player and media after restart, no fallback copy', async (t) => {
132
+ const f = await setup(t), v = (await expose(f.plugin, [f.source])).output.videos[0];
133
+ await fs.rename(f.source, path.join(f.root, 'moved.mp4'));
134
+ f.restart();
135
+ for (const url of [v.url, v.mediaUrl]) {
136
+ const r = await fetch(f.base + url); assert.equal(r.status, 404); await r.text();
137
+ }
138
+ assert.deepEqual(await fs.readdir(path.join(f.context.appDataDir, 'video-share', v.id)), ['entry.json']);
139
+ });
140
+
141
+ test('same-path updates serve current bytes and invalidate old If-Range validators', async (t) => {
142
+ const f = await setup(t), v = (await expose(f.plugin, [f.source])).output.videos[0];
143
+ const before = await fetch(f.base + v.mediaUrl); const oldTag = before.headers.get('etag'); await before.arrayBuffer();
144
+ const updated = Buffer.from(f.bytes); updated[100] = 42;
145
+ await fs.writeFile(f.source, updated);
146
+ await fs.utimes(f.source, new Date(), new Date(Date.now() + 5000));
147
+ const r = await fetch(f.base + v.mediaUrl, { headers: { Range: 'bytes=0-9', 'If-Range': oldTag } });
148
+ assert.equal(r.status, 200); assert.notEqual(r.headers.get('etag'), oldTag);
149
+ assert.deepEqual(Buffer.from(await r.arrayBuffer()), updated);
150
+ });
151
+
152
+ test('v1 copied publications are not silently used as zero-copy sources', async (t) => {
153
+ const f = await setup(t), id = 'a'.repeat(48);
154
+ const dir = path.join(f.context.appDataDir, 'video-share', id);
155
+ await fs.mkdir(dir, { recursive: true });
156
+ await fs.writeFile(path.join(dir, 'entry.json'), JSON.stringify({ version: 1, id, filename: 'old.mp4', contentType: 'video/mp4' }));
157
+ await fs.writeFile(path.join(dir, 'media'), f.bytes);
158
+ const r = await fetch(f.base + '/api/video-share/' + id + '/media'); assert.equal(r.status, 404); await r.text();
159
+ assert.deepEqual(await fs.readFile(path.join(dir, 'media')), f.bytes); // no automatic user-data deletion
160
+ });
161
+
162
+ test('presenter embeds valid videos including partial failures and historical results, no text links', async (t) => {
163
+ const f = await setup(t);
164
+ const result = await expose(f.plugin, [f.source, path.join(f.root, 'missing.mp4')]);
165
+ assert.equal(result.ok, false);
166
+ const shown = f.plugin.presentToolResult({ toolName: 'expose_videos', output: result.output, ok: false });
167
+ assert.equal(shown.presentationLevel, 'primary');
168
+ assert.equal(shown.resources.length, 1);
169
+ assert.equal(shown.resources[0].kind, 'embed');
170
+ assert.match(shown.resources[0].url, /\?embed=1$/);
171
+ assert.ok(!shown.text.includes('/api/'));
172
+ assert.match(result.output.usage, /Do not repeat video URLs/);
173
+ const old = { videos: [{ ...result.output.videos[0], url: 'https://untrusted.example/', markdown: '[old](https://untrusted.example/)' }] };
174
+ assert.ok(f.plugin.presentToolResult({ toolName: 'expose_videos', output: old, ok: true }).resources[0].url.startsWith('/api/video-share/'));
175
+ assert.equal(f.plugin.presentToolResult({ toolName: 'revoke_videos', output: result.output }), undefined);
176
+ assert.equal(f.plugin.presentToolResult({ toolName: 'expose_videos', output: { videos: [{ id: '../escape' }] } }), undefined);
177
+ const page = await fetch(f.base + shown.resources[0].url + '&theme=light');
178
+ const html = await page.text();
179
+ assert.equal(page.status, 200); assert.match(html, /data-theme="light"/);
180
+ assert.ok(!/<a\b|target=|window\.open/.test(html));
181
+ assert.match(html, /<video controls playsinline/);
182
+ });
@@ -55,16 +55,16 @@ export function renderXhsEditorPage(artifact, apiUrl) {
55
55
  </section>
56
56
  </main>
57
57
  <script>
58
- const initial=${serialize(artifact)};const apiUrl=${serialize(apiUrl)};let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
58
+ const initial=${serialize(artifact)};const appBase=${serialize((process.env.NEO_WEB_BASE_PATH || '').replace(/\/$/, ''))};const appUrl=value=>typeof value==='string'&&value.startsWith('/api/')?appBase+value:value;const apiUrl=appUrl(${serialize(apiUrl)});let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
59
59
  const params=new URLSearchParams(location.search);const requestedTheme=params.get('theme');document.documentElement.dataset.theme=requestedTheme==='dark'?'dark':requestedTheme==='light'?'light':matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
60
60
  function clone(v){return JSON.parse(JSON.stringify(v))}function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function tags(v){return [...new Set(String(v||'').split(/[\\s,,、]+/).filter(Boolean).map(x=>x.startsWith('#')?x:'#'+x))]}
61
61
  function field(id,key){$(id).value=key==='hashtags'?(draft[key]||[]).join(' '):draft[key]||'';$(id).addEventListener('input',e=>{draft[key]=key==='hashtags'?tags(e.target.value):e.target.value;renderPreview();scheduleSave()})}
62
62
  field('title','title');field('body','body');field('interactionInput','interaction');field('hashtags','hashtags');field('review','review');
63
- function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(image.url)+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
64
- function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(im.url)+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(im.url)+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
63
+ function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(appUrl(image.url))+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
64
+ function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(appUrl(im.url))+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(appUrl(im.url))+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
65
65
  $('images').addEventListener('input',e=>{const row=e.target.closest('[data-index]');if(!row||!e.target.dataset.key)return;draft.images[+row.dataset.index][e.target.dataset.key]=e.target.value;renderPreview();scheduleSave()});$('images').addEventListener('click',e=>{const pick=e.target.closest('[data-pick]');if(pick){active=+pick.dataset.pick;renderPreview()}const remove=e.target.closest('[data-remove]');if(remove){draft.images.splice(+remove.dataset.remove,1);renderImages();renderPreview();scheduleSave()}const upload=e.target.closest('[data-upload]');if(upload){$('files').dataset.target=upload.dataset.upload;$('files').click()}});
66
66
  $('prev').onclick=()=>{active=(active-1+(draft.images||[]).length)%(draft.images||[]).length;renderPreview()};$('next').onclick=()=>{active=(active+1)%(draft.images||[]).length;renderPreview()};let wheelLock=0;$('mediaStage').addEventListener('wheel',e=>{const images=draft.images||[];if(images.length<2)return;e.preventDefault();const now=Date.now();if(now<wheelLock)return;wheelLock=now+320;const delta=Math.abs(e.deltaY)>=Math.abs(e.deltaX)?e.deltaY:e.deltaX;active=(active+(delta>=0?1:-1)+images.length)%images.length;renderPreview()},{passive:false});$('add').onclick=()=>{(draft.images||(draft.images=[])).push({url:'',caption:'配图 '+((draft.images?.length||0)+1),overlay:'',note:''});renderImages();scheduleSave()};$('upload').onclick=()=>{$('files').dataset.target='';$('files').click()};
67
- $('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch('/api/uploads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
67
+ $('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch(appUrl('/api/uploads')+new URL(apiUrl,location.href).search,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
68
68
  function setMode(mode){localStorage.setItem('neoctl.plugin.xhs.mode.v2',mode);document.querySelectorAll('[data-mode]').forEach(b=>b.classList.toggle('active',b.dataset.mode===mode));$('grid').className='grid '+mode;$('preview').classList.toggle('hidden',mode==='edit');$('form').classList.toggle('hidden',mode==='preview');resize()}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
69
69
  $('fullscreen').onclick=async()=>{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()};document.addEventListener('fullscreenchange',()=>{$('fullscreen').textContent=document.fullscreenElement?'退出全屏':'全屏'});
70
70
  function payload(){return{title:draft.title||'',body:draft.body||'',interaction:draft.interaction||'',hashtags:draft.hashtags||[],images:draft.images||[],review:draft.review||''}}
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
- import { mkdir, readFile, readdir, rmdir, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir as localMkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { workspaceFs, workspaceHome, containerMode } from './execution-backend.mjs';
4
+ const { mkdir, readdir, rmdir, stat } = workspaceFs;
3
5
  import os from 'node:os';
4
6
  import { QueryEngine, WebRepl } from './core-runtime.mjs';
5
7
 
@@ -370,7 +372,7 @@ export class SessionWorkspaceRegistry {
370
372
  : options.cwdNoticePending === true,
371
373
  };
372
374
  this.writeQueue = this.writeQueue.then(async () => {
373
- await mkdir(path.dirname(this.file), { recursive: true });
375
+ await localMkdir(path.dirname(this.file), { recursive: true });
374
376
  await writeFile(this.file, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
375
377
  });
376
378
  await this.writeQueue;
@@ -406,7 +408,7 @@ export async function browseWorkspace(value, currentCwd) {
406
408
  requested,
407
409
  fallback: path.resolve(current) !== path.resolve(requested),
408
410
  parent: current === path.parse(current).root ? undefined : path.dirname(current),
409
- home: os.homedir(),
411
+ home: (workspaceHome() || os.homedir()),
410
412
  locations,
411
413
  entries: entries
412
414
  .filter((entry) => entry.isDirectory())
@@ -437,8 +439,9 @@ let workspaceLocationsCache;
437
439
  let workspaceLocationsCachedAt = 0;
438
440
 
439
441
  export async function discoverWorkspaceLocations() {
442
+ if (containerMode) return [{ id: "workspace", label: "workspace", path: "/workspace", kind: "favorite" }, { id: "root", label: "/", path: "/", kind: "root" }, { id: "home", label: "root", path: "/root", kind: "home" }];
440
443
  if (workspaceLocationsCache && Date.now() - workspaceLocationsCachedAt < 5000) return workspaceLocationsCache;
441
- const home = os.homedir();
444
+ const home = (workspaceHome() || os.homedir());
442
445
  const candidates = [
443
446
  { id: 'home', label: '主目录', path: home, kind: 'home' },
444
447
  ...[
@@ -486,8 +489,8 @@ export async function discoverWorkspaceLocations() {
486
489
  export function resolveWorkspaceInput(value, currentCwd) {
487
490
  let input = String(value || '').trim().replace(/^["']|["']$/g, '');
488
491
  if (!input) return path.resolve(currentCwd || process.cwd());
489
- if (input === '~') input = os.homedir();
490
- else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join(os.homedir(), input.slice(2));
492
+ if (input === '~') input = (workspaceHome() || os.homedir());
493
+ else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join((workspaceHome() || os.homedir()), input.slice(2));
491
494
  input = input.replace(/[\\/]+/g, path.sep);
492
495
  if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(input)) input += path.sep;
493
496
  return path.resolve(currentCwd || process.cwd(), input);
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { hashPassword, usernameKey, validUsername } from '../isolation-auth.mjs';
4
+
5
+ const [filename, username, role] = process.argv.slice(2);
6
+ if (!filename || !validUsername(username) || (role !== undefined && !['user', 'admin'].includes(role))) {
7
+ console.error('用法: node scripts/isolation-user.mjs <配置文件> <用户名> [user|admin]');
8
+ process.exit(1);
9
+ }
10
+
11
+ async function readPassword() {
12
+ if (!process.stdin.isTTY) {
13
+ let text = '';
14
+ for await (const chunk of process.stdin) { text += chunk; if (text.length > 4096) throw new Error('输入过长'); }
15
+ return text.replace(/\r?\n$/, '');
16
+ }
17
+ process.stderr.write('密码: ');
18
+ process.stdin.setRawMode(true);
19
+ process.stdin.resume();
20
+ process.stdin.setEncoding('utf8');
21
+ return new Promise((resolve, reject) => {
22
+ let value = '';
23
+ const done = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); };
24
+ function onData(chunk) {
25
+ for (const c of chunk) {
26
+ if (c === '\u0003') { done(); reject(new Error('已取消')); return; }
27
+ if (c === '\r' || c === '\n') { done(); resolve(value); return; }
28
+ if (c === '\u007f' || c === '\b') value = value.slice(0, -1);
29
+ else if (value.length < 1024) value += c;
30
+ }
31
+ }
32
+ process.stdin.on('data', onData);
33
+ });
34
+ }
35
+
36
+ const file = path.resolve(filename);
37
+ let config;
38
+ try { config = JSON.parse(await fs.readFile(file, 'utf8')); }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT') throw error;
41
+ config = { enabled: false, secureCookie: false, cookiePath: '/', sessionHours: 12, retiredUsernames: [], users: [] };
42
+ }
43
+ config.users ||= [];
44
+ config.retiredUsernames ||= [];
45
+ const key = usernameKey(username);
46
+ if (config.users.some(user => user.id !== undefined && user.id !== user.username)) throw new Error('旧用户 ID 与用户名不同,请先迁移');
47
+ if (config.retiredUserIds?.length) throw new Error('请先将 retiredUserIds 迁移为 retiredUsernames');
48
+ if (config.retiredUsernames.some(value => usernameKey(value) === key)) throw new Error('已删除的用户名不可复用');
49
+ const existing = config.users.find(user => usernameKey(user.username) === key);
50
+ const nextRole = role || existing?.role || 'user';
51
+ const next = { username, role: nextRole };
52
+ if (nextRole === 'admin') next.passwordHash = await hashPassword(await readPassword());
53
+ else if (existing?.passwordHash) next.passwordHash = existing.passwordHash;
54
+ const users = config.users.map(user => {
55
+ const { id: _oldId, ...account } = user;
56
+ return account;
57
+ });
58
+ const index = users.findIndex(user => usernameKey(user.username) === key);
59
+ if (index >= 0) users[index] = next; else users.push(next);
60
+ const { retiredUserIds: _oldRetiredUserIds, ...stored } = config;
61
+ stored.users = users;
62
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
63
+ const pending = file + `.tmp-${process.pid}`;
64
+ try {
65
+ await fs.writeFile(pending, JSON.stringify(stored, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
66
+ await fs.rename(pending, file);
67
+ } finally { await fs.rm(pending, { force: true }); }
68
+ console.log(`用户已保存: ${username}。修改 enabled 后重启 Web 生效。`);