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 CHANGED
@@ -165,7 +165,7 @@ window.__ModuleLoader__.load({
165
165
  : [];
166
166
 
167
167
  var [snapshot, setSnapshot] = useState(null);
168
- var [failed, setFailed] = useState(false);
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) throw new Error("state");
182
+ if (!payload || payload.ok !== true) {
183
+ throw new Error(payload && payload.error ? String(payload.error) : "state");
184
+ }
183
185
  setSnapshot(payload);
184
- setFailed(false);
186
+ setFailed(null);
185
187
  return payload;
186
188
  })
187
- .catch(function () {
188
- setFailed(true);
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, marker-header, same-origin. The routes are destructive, so they
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
- // `Origin` is authoritative when present, but browsers omit it on same-origin
7
- // GETs — so that case falls back to Fetch Metadata's `sec-fetch-site`, which a
8
- // cross-site caller cannot forge. A request with neither signal is refused.
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
- export function isTrustedRequest(request) {
29
- if (header(request, HEADER) !== "1") return false;
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, isTrustedRequest, json, readJsonBody } from "./host/trust.js";
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
- const STATE_PATH = BASE + "/state";
64
- const DELETE_PATH = BASE + "/delete";
65
- const DELETE_ALL_PATH = BASE + "/delete-all";
66
- const RESTORE_PATH = BASE + "/restore";
67
- const EMPTY_PATH = BASE + "/empty-quarantine";
68
- const REVEAL_PATH = BASE + "/reveal";
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
- if (!isTrustedRequest(request)) {
335
- json(response, 403, { error: "forbidden" });
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
- route(ctx, { path: STATE_PATH, method: "GET", handle: () => stateOf(ctx) });
354
-
355
- route(ctx, {
356
- path: DELETE_PATH,
357
- method: "POST",
358
- handle: (body) => deleteOne(ctx, body?.sessionId, body?.mode === "forever" ? "forever" : "quarantine"),
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
- route(ctx, { path: RESTORE_PATH, method: "POST", handle: (body) => restoreOne(ctx, body?.sessionId) });
372
-
373
- route(ctx, {
374
- path: EMPTY_PATH,
375
- method: "POST",
376
- handle: async () => {
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
- route(ctx, { path: REVEAL_PATH, method: "POST", handle: (body) => revealOne(ctx, body?.sessionId) });
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.0",
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",