dsh-archived 0.3.0 → 0.3.1
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/client.js +13 -9
- package/lib/host/trust.js +61 -13
- package/lib/index.js +75 -38
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -165,7 +165,7 @@ window.__ModuleLoader__.load({
|
|
|
165
165
|
: [];
|
|
166
166
|
|
|
167
167
|
var [snapshot, setSnapshot] = useState(null);
|
|
168
|
-
var [failed, setFailed] = useState(
|
|
168
|
+
var [failed, setFailed] = useState(null);
|
|
169
169
|
var [query, setQuery] = useState("");
|
|
170
170
|
var [selected, setSelected] = useState({});
|
|
171
171
|
var [expanded, setExpanded] = useState({});
|
|
@@ -179,13 +179,17 @@ window.__ModuleLoader__.load({
|
|
|
179
179
|
function reload() {
|
|
180
180
|
return request(STATE_PATH)
|
|
181
181
|
.then(function (payload) {
|
|
182
|
-
if (!payload || payload.ok !== true)
|
|
182
|
+
if (!payload || payload.ok !== true) {
|
|
183
|
+
throw new Error(payload && payload.error ? String(payload.error) : "state");
|
|
184
|
+
}
|
|
183
185
|
setSnapshot(payload);
|
|
184
|
-
setFailed(
|
|
186
|
+
setFailed(null);
|
|
185
187
|
return payload;
|
|
186
188
|
})
|
|
187
|
-
.catch(function () {
|
|
188
|
-
|
|
189
|
+
.catch(function (error) {
|
|
190
|
+
// Say why: a bare "cannot read host state" is undebuggable from the
|
|
191
|
+
// outside, which is exactly how the Desktop-app failure hid.
|
|
192
|
+
setFailed(String(error && error.message ? error.message : error));
|
|
189
193
|
setSnapshot(null);
|
|
190
194
|
});
|
|
191
195
|
}
|
|
@@ -366,10 +370,10 @@ window.__ModuleLoader__.load({
|
|
|
366
370
|
});
|
|
367
371
|
}
|
|
368
372
|
|
|
369
|
-
if (failed) {
|
|
373
|
+
if (failed !== null) {
|
|
370
374
|
return h("div", { className: "dasm_section" }, [
|
|
371
375
|
h("h2", { className: "dasm_title", key: "title" }, t("nav")),
|
|
372
|
-
h("p", { className: "dasm_status", key: "failed" }, t("hostUnavailable")),
|
|
376
|
+
h("p", { className: "dasm_status", key: "failed" }, t("hostUnavailable", { reason: failed })),
|
|
373
377
|
]);
|
|
374
378
|
}
|
|
375
379
|
|
|
@@ -751,7 +755,7 @@ window.__ModuleLoader__.load({
|
|
|
751
755
|
nav: "已归档",
|
|
752
756
|
search: "搜索已归档会话",
|
|
753
757
|
loading: "正在读取会话…",
|
|
754
|
-
hostUnavailable: "
|
|
758
|
+
hostUnavailable: "读不到宿主状态({reason}):请确认插件已随宿主加载,然后刷新页面。",
|
|
755
759
|
apiMismatch: "插件的前后端版本不一致:请重启 DSH 后再试。",
|
|
756
760
|
empty: "暂无已归档会话。",
|
|
757
761
|
emptySearch: "没有匹配的会话。",
|
|
@@ -820,7 +824,7 @@ window.__ModuleLoader__.load({
|
|
|
820
824
|
nav: "Archived",
|
|
821
825
|
search: "Search archived sessions",
|
|
822
826
|
loading: "Reading sessions…",
|
|
823
|
-
hostUnavailable: "Cannot read host state — check that the plugin loaded, then refresh.",
|
|
827
|
+
hostUnavailable: "Cannot read host state ({reason}) — check that the plugin loaded, then refresh.",
|
|
824
828
|
apiMismatch: "The plugin's two halves disagree on the API version — restart DSH and try again.",
|
|
825
829
|
empty: "No archived sessions.",
|
|
826
830
|
emptySearch: "No matching sessions.",
|
package/lib/host/trust.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
// Request trust for the destructive routes.
|
|
2
2
|
//
|
|
3
|
-
// Loopback,
|
|
3
|
+
// Loopback, and provably not cross-site. The routes are destructive, so they
|
|
4
4
|
// refuse anything that is not this browser talking to this server.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// The shipped web client is not the only client: the Desktop app talks to the
|
|
7
|
+
// same host, and a request that reaches us through the app's own pipeline can
|
|
8
|
+
// arrive without the Fetch Metadata headers a page-initiated fetch carries.
|
|
9
|
+
// Requiring `sec-fetch-site: same-origin` outright therefore rejected the
|
|
10
|
+
// Desktop app's perfectly legitimate same-origin call. What actually has to
|
|
11
|
+
// hold is: loopback, not cross-site, and either the marker header or a proven
|
|
12
|
+
// same-origin signal.
|
|
13
|
+
//
|
|
14
|
+
// * cross-site is always refused — that is the CSRF case, and every browser
|
|
15
|
+
// that matters sends `sec-fetch-site` for it;
|
|
16
|
+
// * `Origin`, when present, must match `Host` (browsers omit it on
|
|
17
|
+
// same-origin GETs, which is why it cannot be required);
|
|
18
|
+
// * a request with no signals at all needs the marker header, which a
|
|
19
|
+
// cross-origin page cannot set without a preflight this server never
|
|
20
|
+
// approves.
|
|
9
21
|
|
|
10
22
|
export const HEADER = "x-dsh-archived";
|
|
11
23
|
|
|
24
|
+
/** The pre-rename marker, still accepted so an open tab survives the rename. */
|
|
25
|
+
export const LEGACY_HEADER = "x-dsh-archived-sessions";
|
|
26
|
+
|
|
12
27
|
export function header(request, key) {
|
|
13
28
|
const value = request.headers?.[key];
|
|
14
29
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -25,15 +40,8 @@ export function isLoopbackAddress(value) {
|
|
|
25
40
|
);
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
|
|
31
|
-
const site = header(request, "sec-fetch-site");
|
|
32
|
-
if (site !== undefined && site !== "same-origin") return false;
|
|
33
|
-
const host = header(request, "host");
|
|
34
|
-
if (!host) return false;
|
|
35
|
-
const origin = header(request, "origin");
|
|
36
|
-
if (origin === undefined) return site === "same-origin";
|
|
43
|
+
/** Whether the request's `Origin` names this very host. */
|
|
44
|
+
function originMatchesHost(origin, host) {
|
|
37
45
|
try {
|
|
38
46
|
const url = new URL(origin);
|
|
39
47
|
return (
|
|
@@ -46,6 +54,46 @@ export function isTrustedRequest(request) {
|
|
|
46
54
|
}
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The full trust decision, with the evidence that produced it.
|
|
59
|
+
*
|
|
60
|
+
* @returns `{ trusted, ... }` — the reasons are kept so a refused request can
|
|
61
|
+
* be reported instead of vanishing, which is what made the Desktop-app failure
|
|
62
|
+
* invisible for a whole round of debugging.
|
|
63
|
+
*/
|
|
64
|
+
export function trustReport(request) {
|
|
65
|
+
const marker = header(request, HEADER) ?? header(request, LEGACY_HEADER);
|
|
66
|
+
const site = header(request, "sec-fetch-site");
|
|
67
|
+
const host = header(request, "host");
|
|
68
|
+
const origin = header(request, "origin");
|
|
69
|
+
const loopback = isLoopbackAddress(request.socket?.remoteAddress);
|
|
70
|
+
// A present Origin or Fetch Metadata signal is authoritative and cannot be
|
|
71
|
+
// overridden by the marker: a mismatch is fatal on its own.
|
|
72
|
+
const originOk = origin === undefined || originMatchesHost(origin, host);
|
|
73
|
+
const siteOk = site === undefined || site === "same-origin";
|
|
74
|
+
// A same-origin signal is something a cross-origin page cannot produce, so it
|
|
75
|
+
// proves the caller on its own. The marker covers the case where no Fetch
|
|
76
|
+
// Metadata header arrives at all (a non-browser client, or the Desktop app's
|
|
77
|
+
// own request pipeline).
|
|
78
|
+
const provenSameOrigin = (origin !== undefined && originOk) || site === "same-origin";
|
|
79
|
+
const trusted = loopback && Boolean(host) && originOk && siteOk && (marker === "1" || provenSameOrigin);
|
|
80
|
+
return {
|
|
81
|
+
trusted,
|
|
82
|
+
marker: marker ?? null,
|
|
83
|
+
loopback,
|
|
84
|
+
host: host ?? null,
|
|
85
|
+
site: site ?? null,
|
|
86
|
+
origin: origin ?? null,
|
|
87
|
+
originOk,
|
|
88
|
+
siteOk,
|
|
89
|
+
provenSameOrigin,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isTrustedRequest(request) {
|
|
94
|
+
return trustReport(request).trusted;
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
export function json(response, statusCode, value) {
|
|
50
98
|
response.writeHead(statusCode, {
|
|
51
99
|
"content-type": "application/json; charset=utf-8",
|
package/lib/index.js
CHANGED
|
@@ -54,18 +54,29 @@ import {
|
|
|
54
54
|
quarantineSession,
|
|
55
55
|
restoreSession,
|
|
56
56
|
} from "./host/quarantine.js";
|
|
57
|
-
import { failureDetail,
|
|
57
|
+
import { failureDetail, json, readJsonBody, trustReport } from "./host/trust.js";
|
|
58
58
|
|
|
59
59
|
export const name = "dsh-archived";
|
|
60
60
|
export const inject = ["webServer", "workspaceRegistry", "agents", "sessions"];
|
|
61
61
|
|
|
62
62
|
const BASE = "/api/dsh-archived";
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The pre-rename prefix, still served so a tab that loaded the 0.2.0 client
|
|
66
|
+
* keeps working across the rename instead of stranding the user on an error
|
|
67
|
+
* page until they reload. The 0.2.0 client used the same six verbs, so the
|
|
68
|
+
* alias is a prefix swap and nothing more.
|
|
69
|
+
*/
|
|
70
|
+
const LEGACY_BASE = "/api/dsh-archived-sessions";
|
|
71
|
+
/** Route suffixes, registered under both the current and the legacy prefix. */
|
|
72
|
+
const SUFFIX = {
|
|
73
|
+
state: "/state",
|
|
74
|
+
delete: "/delete",
|
|
75
|
+
deleteAll: "/delete-all",
|
|
76
|
+
restore: "/restore",
|
|
77
|
+
empty: "/empty-quarantine",
|
|
78
|
+
reveal: "/reveal",
|
|
79
|
+
};
|
|
69
80
|
|
|
70
81
|
/** Wire version of the route contract, so the panel can detect a half-upgraded host. */
|
|
71
82
|
export const API_VERSION = 2;
|
|
@@ -82,6 +93,26 @@ const sweeps = new Set();
|
|
|
82
93
|
/** Session ids with a delete in flight, so two tabs cannot interleave on one row. */
|
|
83
94
|
const inFlight = new Set();
|
|
84
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The last few refused requests, with the evidence that refused them. A 403
|
|
98
|
+
* that leaves no trace is undebuggable from the outside — which is exactly how
|
|
99
|
+
* the Desktop app ended up looking broken while every probe said it worked.
|
|
100
|
+
*/
|
|
101
|
+
const refusals = [];
|
|
102
|
+
const MAX_REFUSALS = 20;
|
|
103
|
+
|
|
104
|
+
function recordRefusal(path, request, report) {
|
|
105
|
+
refusals.push({
|
|
106
|
+
at: new Date().toISOString(),
|
|
107
|
+
path,
|
|
108
|
+
method: request.method,
|
|
109
|
+
remote: request.socket?.remoteAddress ?? null,
|
|
110
|
+
userAgent: String(request.headers?.["user-agent"] ?? "").slice(0, 80),
|
|
111
|
+
...report,
|
|
112
|
+
});
|
|
113
|
+
if (refusals.length > MAX_REFUSALS) refusals.splice(0, refusals.length - MAX_REFUSALS);
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
/** The archive set's owner. `ctx.get` is the documented safe read; the property stays as a fallback. */
|
|
86
117
|
function registryOf(ctx) {
|
|
87
118
|
try {
|
|
@@ -155,6 +186,7 @@ async function stateOf(ctx) {
|
|
|
155
186
|
home: dshHome(),
|
|
156
187
|
items,
|
|
157
188
|
quarantine: { count: parked.length, bytes: sumBytes(parked), items: parked },
|
|
189
|
+
refusals: refusals.slice().reverse(),
|
|
158
190
|
legacy: {
|
|
159
191
|
present: existsSync(legacyAggregateFile()),
|
|
160
192
|
rows: legacyRows === null ? null : legacyRows.size,
|
|
@@ -331,8 +363,11 @@ function route(ctx, { path, method, handle }) {
|
|
|
331
363
|
json(response, 405, { error: "method-not-allowed" });
|
|
332
364
|
return;
|
|
333
365
|
}
|
|
334
|
-
|
|
335
|
-
|
|
366
|
+
const report = trustReport(request);
|
|
367
|
+
if (!report.trusted) {
|
|
368
|
+
recordRefusal(path, request, report);
|
|
369
|
+
ctx.logger?.warn?.(`[dsh-archived] refused ${request.method} ${path}: ${JSON.stringify(report)}`);
|
|
370
|
+
json(response, 403, { error: "forbidden", detail: report });
|
|
336
371
|
return;
|
|
337
372
|
}
|
|
338
373
|
try {
|
|
@@ -350,37 +385,39 @@ function route(ctx, { path, method, handle }) {
|
|
|
350
385
|
}
|
|
351
386
|
|
|
352
387
|
export function apply(ctx) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
route(ctx, {
|
|
362
|
-
path: DELETE_ALL_PATH,
|
|
363
|
-
method: "POST",
|
|
364
|
-
handle: (body) => {
|
|
365
|
-
const mode = body?.mode === "forever" ? "forever" : "quarantine";
|
|
366
|
-
const ids = Array.isArray(body?.ids) ? body.ids : archivedIds(ctx);
|
|
367
|
-
return deleteMany(ctx, ids, mode);
|
|
388
|
+
const specs = [
|
|
389
|
+
{ suffix: SUFFIX.state, method: "GET", handle: () => stateOf(ctx) },
|
|
390
|
+
{
|
|
391
|
+
suffix: SUFFIX.delete,
|
|
392
|
+
method: "POST",
|
|
393
|
+
handle: (body) => deleteOne(ctx, body?.sessionId, body?.mode === "forever" ? "forever" : "quarantine"),
|
|
368
394
|
},
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const parked = await listQuarantine();
|
|
378
|
-
for (const entry of parked) await purgeQuarantineEntry(entry.id);
|
|
379
|
-
return { ok: true, purged: parked.length, bytes: sumBytes(parked) };
|
|
395
|
+
{
|
|
396
|
+
suffix: SUFFIX.deleteAll,
|
|
397
|
+
method: "POST",
|
|
398
|
+
handle: (body) => {
|
|
399
|
+
const mode = body?.mode === "forever" ? "forever" : "quarantine";
|
|
400
|
+
const ids = Array.isArray(body?.ids) ? body.ids : archivedIds(ctx);
|
|
401
|
+
return deleteMany(ctx, ids, mode);
|
|
402
|
+
},
|
|
380
403
|
},
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
404
|
+
{ suffix: SUFFIX.restore, method: "POST", handle: (body) => restoreOne(ctx, body?.sessionId) },
|
|
405
|
+
{
|
|
406
|
+
suffix: SUFFIX.empty,
|
|
407
|
+
method: "POST",
|
|
408
|
+
handle: async () => {
|
|
409
|
+
const parked = await listQuarantine();
|
|
410
|
+
for (const entry of parked) await purgeQuarantineEntry(entry.id);
|
|
411
|
+
return { ok: true, purged: parked.length, bytes: sumBytes(parked) };
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
{ suffix: SUFFIX.reveal, method: "POST", handle: (body) => revealOne(ctx, body?.sessionId) },
|
|
415
|
+
];
|
|
416
|
+
for (const spec of specs) {
|
|
417
|
+
for (const base of [BASE, LEGACY_BASE]) {
|
|
418
|
+
route(ctx, { path: base + spec.suffix, method: spec.method, handle: spec.handle });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
384
421
|
|
|
385
422
|
// A parked session is recoverable for a bounded window, then it expires on
|
|
386
423
|
// its own so the quarantine can never become a second archive nobody drains.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-archived",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Archived-sessions page for DeepSeek Harness: per-row and batch deletion that clears the session log, the projection cache and the archive index together, with a 30-day recycle bin, subagent-aware refusals and a per-row disk footprint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|