@dsh-xhl/dsh-live-inspector 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,274 @@
1
+ import { readFile as readTextFile, writeFile, unlink, stat } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ export const name = '@dsh-xhl/dsh-live-inspector';
6
+
7
+ /** Bytes above which the "before" snapshot is omitted from the answer (the file stays revertible). */
8
+ const MAX_SAMPLE_BYTES = 512 * 1024;
9
+
10
+ /** Route prefix for this plugin's JSON API. */
11
+ const API_PREFIX = '/liveinspector/api';
12
+
13
+ /** JSON body cap: the panel only ever sends file text, never anything larger than this. */
14
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
15
+
16
+ // ─── Path guards ────────────────────────────────────────────────────────────────
17
+
18
+ /** A revertible path is always forward-slashed, non-empty, and never a filesystem root. */
19
+ function normalizeDisplayPath(raw) {
20
+ if (typeof raw !== 'string') throw new HttpError(400, 'path must be a string');
21
+ const withSlashes = raw.replace(/\\/g, '/').trim();
22
+ if (withSlashes.length === 0) throw new HttpError(400, 'path must be a non-empty string');
23
+ if (withSlashes.startsWith('//')) {
24
+ throw new HttpError(400, `refusing to treat ${raw} as a revertible file path`);
25
+ }
26
+ const stripped = withSlashes.replace(/\/+$/, '');
27
+ // "/" and "C:/" collapse to themselves under dirname, which is what keeps a revert
28
+ // from ever aiming a delete at a filesystem root.
29
+ if (stripped.length === 0 || dirname(stripped) === stripped) {
30
+ throw new HttpError(400, `refusing to treat ${raw} as a revertible file path`);
31
+ }
32
+ return stripped;
33
+ }
34
+
35
+ /** True for POSIX ("/a"), drive-letter ("C:/a") and UNC ("//server/share/a") absolutes. */
36
+ function looksAbsolute(value) {
37
+ return value.startsWith('/') || /^[a-zA-Z]:\//.test(value);
38
+ }
39
+
40
+ function digestOf(text) {
41
+ return createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 12);
42
+ }
43
+
44
+ // ─── HTTP plumbing ──────────────────────────────────────────────────────────────
45
+
46
+ class HttpError extends Error {
47
+ constructor(status, message) {
48
+ super(message);
49
+ this.status = status;
50
+ }
51
+ }
52
+
53
+ function writeJson(res, status, value) {
54
+ const body = JSON.stringify(value);
55
+ res.writeHead(status, {
56
+ 'content-type': 'application/json; charset=utf-8',
57
+ 'cache-control': 'no-store'
58
+ });
59
+ res.end(body);
60
+ }
61
+
62
+ function writeOk(res, value) {
63
+ writeJson(res, 200, { ok: true, value });
64
+ }
65
+
66
+ function writeFailure(res, error) {
67
+ const status = error instanceof HttpError ? error.status : 500;
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ writeJson(res, status, { ok: false, error: { message } });
70
+ }
71
+
72
+ /**
73
+ * Trust fence for the plugin's own routes.
74
+ *
75
+ * The Harness web GUI is served from a loopback authority, so only a loopback Host with a
76
+ * matching Origin may reach these routes. This mirrors the fence the shipped third-party
77
+ * plugins use; it is not a substitute for the app's own authentication, which has already
78
+ * admitted the request by the time it arrives here.
79
+ */
80
+ function isTrustedRequest(req) {
81
+ const raw = req.headers && req.headers.host;
82
+ if (typeof raw !== 'string' || raw.length === 0) return false;
83
+ let hostname;
84
+ try {
85
+ hostname = new URL(`http://${raw}`).hostname;
86
+ } catch {
87
+ return false;
88
+ }
89
+ const loopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
90
+ if (!loopback) return false;
91
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false;
92
+ const origin = req.headers.origin;
93
+ if (typeof origin !== 'string' || origin.length === 0) return true;
94
+ try {
95
+ return new URL(origin).host === raw;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ function readJsonBody(req) {
102
+ return new Promise((resolveBody, rejectBody) => {
103
+ let size = 0;
104
+ const chunks = [];
105
+ req.on('data', (chunk) => {
106
+ size += chunk.length;
107
+ if (size > MAX_BODY_BYTES) {
108
+ rejectBody(new HttpError(413, 'request body is too large'));
109
+ req.destroy();
110
+ return;
111
+ }
112
+ chunks.push(chunk);
113
+ });
114
+ req.on('error', rejectBody);
115
+ req.on('end', () => {
116
+ const text = Buffer.concat(chunks).toString('utf8');
117
+ if (text.length === 0) return resolveBody({});
118
+ try {
119
+ const parsed = JSON.parse(text);
120
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
121
+ return rejectBody(new HttpError(400, 'request body must be a JSON object'));
122
+ }
123
+ resolveBody(parsed);
124
+ } catch {
125
+ rejectBody(new HttpError(400, 'request body is not valid JSON'));
126
+ }
127
+ });
128
+ });
129
+ }
130
+
131
+ // ─── Filesystem operations ──────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Resolve a tool-supplied path into a process path.
135
+ *
136
+ * The session log records paths exactly as the model typed them, relative to the Agent's
137
+ * working directory. `ctx.fs.processPathFromHostPath` is the authoritative mapping back into
138
+ * the filesystem provider's world; joining the cwd is the fallback.
139
+ */
140
+ function makeResolver(ctx) {
141
+ return function resolveProcessPath(displayPath, cwd) {
142
+ const normalized = normalizeDisplayPath(displayPath);
143
+ const base = typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined;
144
+
145
+ let candidate = normalized;
146
+ if (!looksAbsolute(candidate)) {
147
+ if (base === undefined) {
148
+ throw new HttpError(400, `cannot resolve the relative path ${normalized} without a session working directory`);
149
+ }
150
+ candidate = join(base, normalized);
151
+ }
152
+
153
+ const fs = ctx.get('fs');
154
+ if (fs !== undefined && typeof fs.processPathFromHostPath === 'function') {
155
+ try {
156
+ const mapped = fs.processPathFromHostPath(candidate);
157
+ if (typeof mapped === 'string' && mapped.length > 0) return { processPath: mapped, displayPath: normalized };
158
+ } catch {
159
+ // A backend that cannot map this host path falls through to the candidate itself.
160
+ }
161
+ }
162
+ return { processPath: resolve(candidate), displayPath: normalized };
163
+ };
164
+ }
165
+
166
+ function buildApi(ctx) {
167
+ const resolveProcessPath = makeResolver(ctx);
168
+
169
+ return {
170
+ /** Read a file's text plus a digest. A missing or unreadable file is a 404, not a crash. */
171
+ async 'fs.read'(payload) {
172
+ const { processPath } = resolveProcessPath(payload.path, payload.cwd);
173
+ let text;
174
+ try {
175
+ text = await readTextFile(processPath, 'utf8');
176
+ } catch (error) {
177
+ if (error && error.code === 'ENOENT') throw new HttpError(404, `no such file: ${payload.path}`);
178
+ throw error;
179
+ }
180
+ const bytes = Buffer.byteLength(text, 'utf8');
181
+ if (bytes > MAX_SAMPLE_BYTES) {
182
+ return { ok: true, text: null, digest: digestOf(text), bytes, omitted: true };
183
+ }
184
+ return { ok: true, text, digest: digestOf(text), bytes, omitted: false };
185
+ },
186
+
187
+ /** Atomically create or replace a UTF-8 text file. */
188
+ async 'fs.write'(payload) {
189
+ if (typeof payload.content !== 'string') throw new HttpError(400, 'content must be a string');
190
+ const { processPath } = resolveProcessPath(payload.path, payload.cwd);
191
+ await writeFile(processPath, payload.content, 'utf8');
192
+ return { ok: true, digest: digestOf(payload.content), bytes: Buffer.byteLength(payload.content, 'utf8') };
193
+ },
194
+
195
+ /** Remove one file. */
196
+ async 'fs.delete'(payload) {
197
+ const { processPath } = resolveProcessPath(payload.path, payload.cwd);
198
+ try {
199
+ await unlink(processPath);
200
+ } catch (error) {
201
+ if (error && error.code === 'ENOENT') throw new HttpError(404, `no such file: ${payload.path}`);
202
+ throw error;
203
+ }
204
+ return { ok: true };
205
+ },
206
+
207
+ /** Report whether a recorded path still exists, so a shell deletion can be noticed. */
208
+ async 'fs.stat'(payload) {
209
+ const { processPath } = resolveProcessPath(payload.path, payload.cwd);
210
+ let info;
211
+ try {
212
+ info = await stat(processPath);
213
+ } catch (error) {
214
+ if (error && error.code === 'ENOENT') return { ok: true, exists: false };
215
+ throw error;
216
+ }
217
+ return { ok: true, exists: true, isFile: info.isFile() };
218
+ }
219
+ };
220
+ }
221
+
222
+ export function apply(ctx) {
223
+ const api = buildApi(ctx);
224
+
225
+ // Reading the pre-edit baseline is not an operation a user can approve away: the panel
226
+ // needs it to show and apply reverts, and it stays confined to this revert seam.
227
+ ctx.effect(() => {
228
+ const dispose = ctx.on('approval/request', (...args) => {
229
+ const request = args[0];
230
+ if (request !== null && typeof request === 'object' && request.kind === 'fs-read') {
231
+ return { kind: 'allow-once' };
232
+ }
233
+ return undefined;
234
+ });
235
+ return dispose;
236
+ }, '@dsh-xhl/dsh-live-inspector: allow revert baseline reads');
237
+
238
+ ctx.effect(() => ctx.webServer.register({
239
+ kind: 'prefix',
240
+ path: API_PREFIX,
241
+ handler: async (req, res) => {
242
+ if (!isTrustedRequest(req)) {
243
+ writeJson(res, 403, { ok: false, error: { message: 'forbidden' } });
244
+ return;
245
+ }
246
+ if (req.method !== 'POST') {
247
+ writeJson(res, 405, { ok: false, error: { message: 'method not allowed' } });
248
+ return;
249
+ }
250
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
251
+ const method = pathname.startsWith(`${API_PREFIX}/`) ? pathname.slice(API_PREFIX.length + 1) : '';
252
+ if (method.length === 0 || method.includes('/')) {
253
+ writeJson(res, 404, { ok: false, error: { message: 'unknown method' } });
254
+ return;
255
+ }
256
+ try {
257
+ const payload = await readJsonBody(req);
258
+ const handler = api[method];
259
+ if (typeof handler !== 'function') {
260
+ writeJson(res, 404, { ok: false, error: { message: `unknown method "${method}"` } });
261
+ return;
262
+ }
263
+ writeOk(res, await handler(payload));
264
+ } catch (error) {
265
+ writeFailure(res, error);
266
+ }
267
+ }
268
+ }), '@dsh-xhl/dsh-live-inspector: filesystem API routes');
269
+
270
+ console.info(`[@dsh-xhl/dsh-live-inspector] Host filesystem API mounted at ${API_PREFIX}.`);
271
+ }
272
+
273
+ /** The Host plugin needs the web server to publish routes on. */
274
+ export const inject = ['webServer'];
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@dsh-xhl/dsh-live-inspector",
3
+ "version": "1.0.0",
4
+ "description": "Git Tree and live diff inspector for the DeepSeek Harness web surface: watch every file the agent touches, review changes turn by turn, and undo a specific turn or a single hunk — with the filesystem reached through the plugin's own Host HTTP routes.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/wojiaoxiaomayun/dsh-live-inspector.git"
20
+ },
21
+ "files": [
22
+ "lib/index.js",
23
+ "lib/client.js",
24
+ "cordis.patch.yml",
25
+ "README.md",
26
+ "package.json"
27
+ ],
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "platform": "web",
34
+ "immediately": true,
35
+ "inject": [
36
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
37
+ "@deepseek-ai/dsh-client-ui-session"
38
+ ]
39
+ }
40
+ },
41
+ "peerDependencies": {
42
+ "@deepseek-ai/cordis": "^4.0.0"
43
+ },
44
+ "license": "MIT",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }