dsh-file-activity 0.5.7 → 0.5.9
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/CHANGELOG.md +50 -0
- package/README.md +29 -18
- package/lib/api-route.js +72 -76
- package/lib/bash-ops.js +65 -54
- package/lib/bash-parse.js +154 -162
- package/lib/client.js +739 -361
- package/lib/client.src.js +28 -21
- package/lib/cwd.js +6 -6
- package/lib/index.js +68 -83
- package/lib/media-route.js +119 -90
- package/lib/observer.js +30 -24
- package/lib/parts/api.part.js +1 -5
- package/lib/parts/apply.part.js +91 -82
- package/lib/parts/auto-open.part.js +83 -60
- package/lib/parts/document-previews.part.js +62 -0
- package/lib/parts/format.part.js +1 -2
- package/lib/parts/i18n.part.js +15 -1
- package/lib/parts/interceptor.part.js +16 -27
- package/lib/parts/preview-data.part.js +111 -0
- package/lib/parts/preview-float.part.js +276 -0
- package/lib/parts/rows.part.js +1 -11
- package/lib/parts/settings.part.js +71 -0
- package/lib/parts/store.part.js +19 -0
- package/lib/parts/styles.part.js +49 -3
- package/lib/parts/tree.part.js +1 -2
- package/lib/parts/view.part.js +72 -18
- package/lib/persist.js +137 -0
- package/lib/quota-trim.js +121 -0
- package/lib/quota.js +164 -0
- package/lib/state.js +336 -136
- package/lib/store.js +249 -76
- package/package.json +10 -6
- package/lib/parts/preview.part.js +0 -308
package/lib/client.src.js
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-file-activity — client half (browser).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
4
|
+
* A native right-Sidebar tab ("文件活动 / File Activity") built on the HOST's
|
|
5
|
+
* own extension points (issue #187 batch 2 — no third-party sidebar service):
|
|
6
|
+
* - the tab type registers into `ctx.sidebarRightTabs` (stage one) and its
|
|
7
|
+
* body / chip title into the keyed `sidebar.right.pane.tab` and
|
|
8
|
+
* `sidebar.right.pane.tab.title` seats (stage two);
|
|
9
|
+
* - clicking a file opens a FLOATING preview implemented by this plugin inside
|
|
10
|
+
* the `shell.overlay` list seat: recent access history (agent + plugin
|
|
11
|
+
* routes), per-file create/modify/read counts flattened by folder, and the
|
|
12
|
+
* preview window itself — clicking outside / Esc / × closes it;
|
|
13
|
+
* - a document-preview descriptor registers with `ctx.documentPreviews`
|
|
14
|
+
* (metadata only: the native document owner reads the bytes);
|
|
15
|
+
* - auto-opens once per session by default, with its toggle in the Web
|
|
16
|
+
* Settings → Plugins tab (`settings.plugins.tab`).
|
|
15
17
|
*
|
|
16
18
|
* Data source: the plugin host half (fs/observed for agent tools) + this
|
|
17
|
-
* half's fetch interception for
|
|
18
|
-
*
|
|
19
|
-
* /file-activity/api/stats.
|
|
19
|
+
* half's fetch interception for the plugin's own file routes, both persisted
|
|
20
|
+
* host-side; the tab polls /file-activity/api/stats.
|
|
20
21
|
*
|
|
21
|
-
* Styling follows the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* Styling follows the DSH design language: all colors ride the DSH semantic
|
|
23
|
+
* tokens (--dsw-alias-*), typography rides the font roles (--dsw-font-*),
|
|
24
|
+
* motion rides --ds-*. Flat surfaces (no box-shadow), hairline borders, 28px
|
|
25
|
+
* circular icon controls with hover fills, and 8px-radius rows with hover
|
|
26
|
+
* fills. The stylesheet is injected once per activation and torn down with the
|
|
27
|
+
* fiber, so HMR/disable leaves no residue.
|
|
27
28
|
*
|
|
28
29
|
* BUILD NOTE: this file is the SOURCE TEMPLATE. scripts/build.mjs splices the
|
|
29
30
|
* `lib/parts/*.part.js` pieces into the PART placeholder markers below (each
|
|
@@ -40,8 +41,11 @@ window.__ModuleLoader__.load({
|
|
|
40
41
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
41
42
|
const { createElement, useEffect, useMemo, useState, useSyncExternalStore } = require('react')
|
|
42
43
|
|
|
43
|
-
const TAB_ID = 'file-activity
|
|
44
|
+
const TAB_ID = 'dsh-file-activity'
|
|
45
|
+
const TAB_KIND = 'file-activity'
|
|
46
|
+
const PREVIEW_ID = 'dsh-file-activity-preview'
|
|
44
47
|
const AUTO_OPEN_KEY = 'dsh-file-activity:auto-opened:'
|
|
48
|
+
const AUTO_OPEN_PREF_KEY = 'dsh-file-activity:autoOpen'
|
|
45
49
|
const POLL_MS = 6000
|
|
46
50
|
|
|
47
51
|
// ── parts (injected by scripts/build.mjs; keep this exact order — the
|
|
@@ -53,11 +57,14 @@ window.__ModuleLoader__.load({
|
|
|
53
57
|
__PART_API__
|
|
54
58
|
__PART_INTERCEPTOR__
|
|
55
59
|
__PART_AUTO_OPEN__
|
|
60
|
+
__PART_DOCUMENT_PREVIEWS__
|
|
56
61
|
__PART_ICONS__
|
|
57
62
|
__PART_STYLES__
|
|
58
63
|
__PART_ROWS__
|
|
59
64
|
__PART_VIEW__
|
|
65
|
+
__PART_PREVIEW_DATA__
|
|
60
66
|
__PART_PREVIEW__
|
|
67
|
+
__PART_SETTINGS__
|
|
61
68
|
__PART_APPLY__
|
|
62
69
|
|
|
63
70
|
return module.exports
|
package/lib/cwd.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* 从 http.js 拆出(issue #45:http.js 的 JSON 工具已抽到 dsh-shared,
|
|
5
5
|
* sessionCwdOf 为 file-activity 特有,保留本地)。
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
/** Session working directory, mirroring better-sidebar's resolution. */
|
|
7
|
+
/** Session working directory, mirroring the host's own session resolution. */
|
|
9
8
|
export function sessionCwdOf(ctx, sessionId) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const session = ctx.sessions.get(sessionId);
|
|
10
|
+
const headerCwd = session?.header?.cwd;
|
|
11
|
+
if (typeof headerCwd === 'string' && headerCwd !== '')
|
|
12
|
+
return headerCwd;
|
|
13
|
+
return process.cwd();
|
|
14
14
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,116 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-file-activity — host half.
|
|
3
3
|
*
|
|
4
|
-
* Tracks file activity for
|
|
4
|
+
* Tracks file activity for the DSH right sidebar:
|
|
5
5
|
* - agent tool file operations arrive as `fs/observed` events (read / write /
|
|
6
6
|
* edit / str_replace_editor / read_image ...), with the tool execution as
|
|
7
7
|
* the actor (name + parsed arguments + owning agent).
|
|
8
8
|
* - bash tool calls carry file-touching commands (rm/touch/mv/…): the
|
|
9
9
|
* `tools/pre-execute` observer parses them and records the intents.
|
|
10
|
-
* - sidebar operations (files opened / saved through the
|
|
10
|
+
* - sidebar operations (files opened / saved through the host's own
|
|
11
11
|
* explorer & editor) are reported by our client half through the
|
|
12
12
|
* `/file-activity/api/record` route.
|
|
13
13
|
*
|
|
14
14
|
* State (recent history + per-file counts) is kept per session and persisted
|
|
15
|
-
* to $DSH_HOME/file-activity.json
|
|
15
|
+
* to $DSH_HOME/file-activity.json as **JSON Lines**: 每条记录追加一行
|
|
16
|
+
* (落盘字节 ≈ 事件本体字节),累计行数达自适应阈值时原子 compact(issue #197:
|
|
17
|
+
* 旧实现每次防抖全量重写,写放大 3,665×)。入口三维上限(会话 / 每会话路径 /
|
|
18
|
+
* 全局路径)超限按 LRU 淘汰并告警 —— 内存与状态文件均有界。
|
|
16
19
|
*/
|
|
17
|
-
import { isTrustedApiRequest } from 'dsh-shared'
|
|
18
|
-
import { createApiHandler } from './api-route.js'
|
|
19
|
-
import { createMediaHandler } from './media-route.js'
|
|
20
|
-
import { createFsObserver } from './observer.js'
|
|
21
|
-
import { createStore } from './store.js'
|
|
22
|
-
import { parseBashFileOps } from './bash-parse.js'
|
|
23
|
-
import { sessionCwdOf } from './cwd.js'
|
|
24
|
-
|
|
25
|
-
export const
|
|
26
|
-
|
|
27
|
-
export const inject = ['webServer', 'sessions', 'webRuntime']
|
|
28
|
-
|
|
20
|
+
import { isTrustedApiRequest } from 'dsh-shared';
|
|
21
|
+
import { createApiHandler } from './api-route.js';
|
|
22
|
+
import { createMediaHandler } from './media-route.js';
|
|
23
|
+
import { createFsObserver } from './observer.js';
|
|
24
|
+
import { createStore } from './store.js';
|
|
25
|
+
import { parseBashFileOps } from './bash-parse.js';
|
|
26
|
+
import { sessionCwdOf } from './cwd.js';
|
|
27
|
+
export const name = 'dsh-file-activity';
|
|
28
|
+
export const inject = ['webServer', 'sessions', 'webRuntime'];
|
|
29
29
|
/** 观察型监听:只读取 bash 命令隐含的文件操作并上报,不拦截(返回 next())。 */
|
|
30
30
|
function observeBashIntents(exec, ctx, store) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
const sessionId = bashSessionOf(exec);
|
|
32
|
+
const command = bashCommandOf(exec);
|
|
33
|
+
if (sessionId === '' || command === '')
|
|
34
|
+
return;
|
|
35
|
+
const baseDir = bashBaseDirOf(exec, ctx, sessionId);
|
|
36
|
+
for (const touched of parseBashFileOps(command, baseDir)) {
|
|
37
|
+
store.record(sessionId, touched.path, touched.op, Date.now());
|
|
38
|
+
}
|
|
38
39
|
}
|
|
39
|
-
|
|
40
40
|
/** 会话 id:仅 bash 工具、agent id 非空时返回。 */
|
|
41
41
|
function bashSessionOf(exec) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
if (exec?.name !== 'bash')
|
|
43
|
+
return '';
|
|
44
|
+
const id = exec.agent?.id;
|
|
45
|
+
return typeof id === 'string' && id !== '' ? id : '';
|
|
45
46
|
}
|
|
46
|
-
|
|
47
47
|
/** 命令文本:仅非空字符串时返回。 */
|
|
48
48
|
function bashCommandOf(exec) {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const command = exec.arguments?.command;
|
|
50
|
+
return typeof command === 'string' && command !== '' ? command : '';
|
|
51
51
|
}
|
|
52
|
-
|
|
53
52
|
/** 相对路径基准:workdir 参数优先,否则会话 cwd。 */
|
|
54
53
|
function bashBaseDirOf(exec, ctx, sessionId) {
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
const workdir = exec.arguments?.workdir;
|
|
55
|
+
return typeof workdir === 'string' && workdir !== '' ? workdir : sessionCwdOf(ctx, sessionId);
|
|
57
56
|
}
|
|
58
|
-
|
|
59
57
|
export function apply(ctx) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts)
|
|
71
|
-
|
|
72
|
-
ctx.effect(
|
|
73
|
-
() =>
|
|
74
|
-
ctx.webServer.register({
|
|
58
|
+
const store = createStore(ctx);
|
|
59
|
+
// ── agent-side file operations ──────────────────────────────────────────
|
|
60
|
+
ctx.on('fs/observed', createFsObserver(store.record));
|
|
61
|
+
ctx.on('tools/pre-execute', (exec, next) => {
|
|
62
|
+
observeBashIntents(exec, ctx, store);
|
|
63
|
+
return next();
|
|
64
|
+
});
|
|
65
|
+
// ── routes ──────────────────────────────────────────────────────────────
|
|
66
|
+
const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts);
|
|
67
|
+
ctx.effect(() => ctx.webServer.register({
|
|
75
68
|
kind: 'prefix',
|
|
76
69
|
path: '/file-activity/api',
|
|
77
70
|
handler: createApiHandler({ ctx, store, fence }),
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Media route for the floating preview (see media-route.js for the
|
|
83
|
-
// authorization model: recorded paths only, same trust fence).
|
|
84
|
-
ctx.effect(
|
|
85
|
-
() =>
|
|
86
|
-
ctx.webServer.register({
|
|
71
|
+
}), 'dsh-file-activity: /file-activity/api routes');
|
|
72
|
+
// Media route for the floating preview (see media-route.js for the
|
|
73
|
+
// authorization model: recorded paths only, same trust fence).
|
|
74
|
+
ctx.effect(() => ctx.webServer.register({
|
|
87
75
|
kind: 'prefix',
|
|
88
76
|
path: '/file-activity/file',
|
|
89
77
|
handler: createMediaHandler({ ctx, store, fence }),
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Tear down on unload: flush pending persistence.
|
|
115
|
-
ctx.effect(() => store.dispose, 'dsh-file-activity: persistence teardown')
|
|
78
|
+
}), 'dsh-file-activity: /file-activity/file media route');
|
|
79
|
+
// 插件状态查询(#155 聚合层)
|
|
80
|
+
ctx.on('plugin:status-query', ({ plugin }) => {
|
|
81
|
+
if (plugin !== 'dsh-file-activity')
|
|
82
|
+
return undefined;
|
|
83
|
+
const sessions = store.state?.sessions ?? {};
|
|
84
|
+
const totalFiles = Object.values(sessions).reduce((n, s) => n + Object.keys(s.known ?? {}).length, 0);
|
|
85
|
+
const allRecent = Object.values(sessions).flatMap((s) => s.recent ?? []);
|
|
86
|
+
allRecent.sort((a, b) => (b.time ?? 0) - (a.time ?? 0));
|
|
87
|
+
const lastActions = allRecent.slice(0, 5).map((e) => ({ time: e.time, type: e.op ?? 'activity', detail: e.path }));
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
value: {
|
|
91
|
+
plugin: 'dsh-file-activity',
|
|
92
|
+
config: { keys: [] },
|
|
93
|
+
running: true,
|
|
94
|
+
stats: { sessions: Object.keys(sessions).length, totalFiles },
|
|
95
|
+
lastActions,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
// Tear down on unload: flush pending persistence.
|
|
100
|
+
ctx.effect(() => store.dispose, 'dsh-file-activity: persistence teardown');
|
|
116
101
|
}
|
package/lib/media-route.js
CHANGED
|
@@ -13,110 +13,136 @@
|
|
|
13
13
|
* fence), the client falls back to this route and receives an fs.read-shaped
|
|
14
14
|
* JSON payload ({ ok, value: { content } }) so the viewer mounts unchanged.
|
|
15
15
|
*/
|
|
16
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
17
|
-
import { basename, isAbsolute, join } from 'node:path'
|
|
18
|
-
import { writeJson } from 'dsh-shared'
|
|
19
|
-
import { sessionCwdOf } from './cwd.js'
|
|
20
|
-
import { isRecordedPath } from './state.js'
|
|
21
|
-
|
|
16
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
17
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
18
|
+
import { writeJson } from 'dsh-shared';
|
|
19
|
+
import { sessionCwdOf } from './cwd.js';
|
|
20
|
+
import { isRecordedPath } from './state.js';
|
|
22
21
|
/** Cap for the plugin's own media route (bytes): images / PDFs only. */
|
|
23
|
-
const MEDIA_LIMIT = 64 * 1024 * 1024
|
|
24
|
-
|
|
22
|
+
const MEDIA_LIMIT = 64 * 1024 * 1024;
|
|
25
23
|
/** Cap for `as=text` payloads (characters): text previews, not archives. */
|
|
26
|
-
const TEXT_LIMIT = 2 * 1024 * 1024
|
|
27
|
-
|
|
24
|
+
const TEXT_LIMIT = 2 * 1024 * 1024;
|
|
28
25
|
/** Content types served by /file-activity/file (mirrors the sidebar's set). */
|
|
29
26
|
const MEDIA_TYPES = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
27
|
+
'.png': 'image/png',
|
|
28
|
+
'.jpg': 'image/jpeg',
|
|
29
|
+
'.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif',
|
|
31
|
+
'.webp': 'image/webp',
|
|
32
|
+
'.svg': 'image/svg+xml',
|
|
33
|
+
'.bmp': 'image/bmp',
|
|
34
|
+
'.ico': 'image/x-icon',
|
|
35
|
+
'.avif': 'image/avif',
|
|
36
|
+
'.pdf': 'application/pdf',
|
|
37
|
+
// HTML documents (issue #266 C): rendered in a sandboxed iframe by the
|
|
38
|
+
// floating preview, so they must be served as documents, not as text.
|
|
39
|
+
'.html': 'text/html',
|
|
40
|
+
'.htm': 'text/html',
|
|
41
|
+
'.xhtml': 'application/xhtml+xml',
|
|
42
|
+
};
|
|
43
|
+
/** HTML suffixes: always served through the sandboxing CSP below. */
|
|
44
|
+
const HTML_TYPES = new Set(['.html', '.htm', '.xhtml']);
|
|
45
|
+
/**
|
|
46
|
+
* Content-Security-Policy for a previewed HTML document (issue #266 C).
|
|
47
|
+
*
|
|
48
|
+
* `sandbox` with NO `allow-same-origin` forces an opaque origin: the document
|
|
49
|
+
* cannot read cookies, storage, or same-origin APIs, and it cannot script the
|
|
50
|
+
* embedding page. Scripts/forms stay enabled so interactive documents render.
|
|
51
|
+
* The header (not only the iframe's own `sandbox` attribute) is what keeps a
|
|
52
|
+
* DIRECT visit to this URL sandboxed too — the preview iframe is not the only
|
|
53
|
+
* way to reach it.
|
|
54
|
+
*/
|
|
55
|
+
const HTML_SANDBOX_CSP = 'sandbox allow-scripts allow-forms';
|
|
56
|
+
/** 小写后缀(含点号;无后缀返回空串)。 */
|
|
57
|
+
function suffixOf(path) {
|
|
58
|
+
const dot = path.lastIndexOf('.');
|
|
59
|
+
return dot === -1 ? '' : path.slice(dot).toLowerCase();
|
|
40
60
|
}
|
|
41
|
-
|
|
42
61
|
function mediaTypeForPath(path) {
|
|
43
|
-
|
|
44
|
-
const ext = dot === -1 ? '' : path.slice(dot).toLowerCase()
|
|
45
|
-
return MEDIA_TYPES[ext] ?? 'application/octet-stream'
|
|
62
|
+
return MEDIA_TYPES[suffixOf(path)] ?? 'application/octet-stream';
|
|
46
63
|
}
|
|
47
|
-
|
|
48
64
|
/** Error carrying an HTTP status, for the media route's catch-all. */
|
|
49
65
|
function mediaError(status, message) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
const error = new Error(message);
|
|
67
|
+
error.status = status;
|
|
68
|
+
return error;
|
|
53
69
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
export function createMediaHandler({ ctx, store, fence, }) {
|
|
71
|
+
return async (request, response) => {
|
|
72
|
+
if (!fence(request)) {
|
|
73
|
+
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (request.method !== 'GET') {
|
|
77
|
+
writeJson(response, 405, { ok: false, error: { message: 'method not allowed' } });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await serveRecordedFile(request, response, ctx, store);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const status = typeof error?.status === 'number' ? error.status : 400;
|
|
85
|
+
writeJson(response, status, {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
};
|
|
75
91
|
}
|
|
76
|
-
|
|
77
92
|
/**
|
|
78
93
|
* Resolve a recorded path (authorized per session) and serve it: bytes
|
|
79
94
|
* (images / PDFs, `download=1` supported) or, with `as=text`, an
|
|
80
95
|
* fs.read-shaped JSON payload for the floating preview's text fallback.
|
|
81
96
|
*/
|
|
82
97
|
async function serveRecordedFile(request, response, ctx, store) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
const url = new URL(request.url ?? '/', 'http://dsh.internal');
|
|
99
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
100
|
+
const raw = url.searchParams.get('path');
|
|
101
|
+
assertMediaParams(sessionId, raw);
|
|
102
|
+
if (!isRecordedPath(store.state, sessionId, raw))
|
|
103
|
+
throw mediaError(403, "path is not in this session's file activity");
|
|
104
|
+
const abs = isAbsolute(raw) ? raw : join(sessionCwdOf(ctx, sessionId), raw);
|
|
105
|
+
if (url.searchParams.get('as') === 'text') {
|
|
106
|
+
await serveText(response, abs);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await serveMedia(response, abs, url);
|
|
94
110
|
}
|
|
95
|
-
|
|
96
111
|
/** Both query parameters are required for a media request. */
|
|
97
112
|
function assertMediaParams(sessionId, raw) {
|
|
98
|
-
|
|
113
|
+
if (sessionId === null || raw === null || raw === '')
|
|
114
|
+
throw mediaError(400, 'sessionId and path are required');
|
|
99
115
|
}
|
|
100
|
-
|
|
101
116
|
/** stat + read + respond with the file's bytes (bounded by MEDIA_LIMIT). */
|
|
102
117
|
async function serveMedia(response, abs, url) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
let info;
|
|
119
|
+
try {
|
|
120
|
+
info = await stat(abs);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw mediaError(404, 'file not found');
|
|
124
|
+
}
|
|
125
|
+
if (!info.isFile())
|
|
126
|
+
throw mediaError(400, 'not a file');
|
|
127
|
+
if (info.size > MEDIA_LIMIT)
|
|
128
|
+
throw mediaError(413, 'file too large');
|
|
129
|
+
const body = await readFile(abs);
|
|
130
|
+
const headers = {
|
|
131
|
+
'content-type': mediaTypeForPath(abs),
|
|
132
|
+
'cache-control': 'no-cache',
|
|
133
|
+
};
|
|
134
|
+
// Untrusted document: the sandboxing CSP travels with the bytes, so the
|
|
135
|
+
// protection does not depend on the caller embedding it in a sandboxed frame.
|
|
136
|
+
if (HTML_TYPES.has(suffixOf(abs))) {
|
|
137
|
+
headers['content-security-policy'] = HTML_SANDBOX_CSP;
|
|
138
|
+
headers['x-content-type-options'] = 'nosniff';
|
|
139
|
+
}
|
|
140
|
+
if (url.searchParams.get('download') === '1') {
|
|
141
|
+
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(abs))}`;
|
|
142
|
+
}
|
|
143
|
+
response.writeHead(200, headers);
|
|
144
|
+
response.end(body);
|
|
118
145
|
}
|
|
119
|
-
|
|
120
146
|
/**
|
|
121
147
|
* `as=text` mode: serve a recorded file's text content as an fs.read-shaped
|
|
122
148
|
* JSON payload ({ ok, value: { content } }) so the floating preview's text
|
|
@@ -125,14 +151,17 @@ async function serveMedia(response, abs, url) {
|
|
|
125
151
|
* recorded paths only.
|
|
126
152
|
*/
|
|
127
153
|
async function serveText(response, abs) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
154
|
+
let info;
|
|
155
|
+
try {
|
|
156
|
+
info = await stat(abs);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw mediaError(404, 'file not found');
|
|
160
|
+
}
|
|
161
|
+
if (!info.isFile())
|
|
162
|
+
throw mediaError(400, 'not a file');
|
|
163
|
+
if (info.size > TEXT_LIMIT)
|
|
164
|
+
throw mediaError(413, 'file too large');
|
|
165
|
+
const content = await readFile(abs, 'utf8');
|
|
166
|
+
writeJson(response, 200, { ok: true, value: { content } });
|
|
138
167
|
}
|
package/lib/observer.js
CHANGED
|
@@ -4,38 +4,44 @@
|
|
|
4
4
|
* (backend displayPath preferred, falling back to the file_path argument)
|
|
5
5
|
* and records it through the store's record().
|
|
6
6
|
*/
|
|
7
|
-
import { mapOp } from './state.js'
|
|
8
|
-
|
|
7
|
+
import { mapOp } from './state.js';
|
|
9
8
|
export function createFsObserver(record) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
9
|
+
return (target, observation, actor) => {
|
|
10
|
+
// Only authoritative PRESENT observations mean a file was actually
|
|
11
|
+
// touched; absent observations (e.g. a failed read of a missing file)
|
|
12
|
+
// are noise.
|
|
13
|
+
if (!isPresentObservation(observation))
|
|
14
|
+
return;
|
|
15
|
+
if (!isValidActor(actor))
|
|
16
|
+
return;
|
|
17
|
+
const sessionId = actor.agent?.id;
|
|
18
|
+
if (typeof sessionId !== 'string' || sessionId === '')
|
|
19
|
+
return;
|
|
20
|
+
// Prefer the backend-resolved absolute path; fall back to the raw argument.
|
|
21
|
+
const rawPath = resolveObservedPath(target, actor);
|
|
22
|
+
if (rawPath === '')
|
|
23
|
+
return;
|
|
24
|
+
record(sessionId, rawPath, mapOp(actor.name), Date.now());
|
|
25
|
+
};
|
|
23
26
|
}
|
|
24
|
-
|
|
25
27
|
/** A PRESENT observation is the only authoritative "file was touched" signal. */
|
|
26
28
|
function isPresentObservation(observation) {
|
|
27
|
-
|
|
29
|
+
return observation !== undefined && observation !== null && observation.kind === 'present';
|
|
28
30
|
}
|
|
29
|
-
|
|
30
31
|
/** The actor must exist and carry a non-empty string tool name. */
|
|
31
32
|
function isValidActor(actor) {
|
|
32
|
-
|
|
33
|
+
if (actor === undefined || actor === null)
|
|
34
|
+
return false;
|
|
35
|
+
const name = actor.name;
|
|
36
|
+
return typeof name === 'string' && name !== '';
|
|
33
37
|
}
|
|
34
|
-
|
|
35
38
|
/** Resolve the touched path: backend displayPath preferred, else file_path. */
|
|
36
39
|
function resolveObservedPath(target, actor) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const displayPath = target?.displayPath;
|
|
41
|
+
if (typeof displayPath === 'string' && displayPath !== '')
|
|
42
|
+
return displayPath;
|
|
43
|
+
const args = actor.arguments;
|
|
44
|
+
if (args !== null && typeof args === 'object' && typeof args.file_path === 'string')
|
|
45
|
+
return args.file_path;
|
|
46
|
+
return '';
|
|
41
47
|
}
|