dsh-session-manager 0.7.4 → 0.7.6

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,110 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { assertSessionId, replaceSessionFile } from "../session-files.js";
6
+
7
+ // rc.1 cache.write() may await sessions.flush() before reaching table.put().
8
+ // A queue barrier alone cannot drain such a delayed writer. Keep a lifecycle
9
+ // tombstone at the table entry point: queued puts precede delete(), later puts
10
+ // of the deleted lifecycle are ignored. A recreated id with a new identity is
11
+ // independent. The guard lives with the table (including across plugin reloads).
12
+ const guards = new WeakMap();
13
+ const lifecycleKey = identity => Number.isSafeInteger(identity?.createdAt)
14
+ ? JSON.stringify([identity.createdAt, identity.cwd ?? null, identity.isSeeded ?? false])
15
+ : undefined;
16
+ export function beginProjectionCacheDeletion(cache, id, header) {
17
+ assertSessionId(id);
18
+ const table = typeof cache?.requireTable === "function" ? cache.requireTable() : cache?.table;
19
+ if (!table || !["get", "put", "delete"].every(key => typeof table[key] === "function")) return undefined;
20
+ let guard = guards.get(table);
21
+ if (!guard) {
22
+ const removed = new Map();
23
+ const put = table.put;
24
+ table.put = function(key, record) {
25
+ if (removed.get(key)?.has(lifecycleKey(record?.identity))) return Promise.resolve();
26
+ return put.call(this, key, record);
27
+ };
28
+ guard = { removed };
29
+ guards.set(table, guard);
30
+ }
31
+ const identity = table.get(id)?.identity;
32
+ const stamps = [header, identity].map(lifecycleKey).filter(key => key !== undefined);
33
+ if (!stamps.length && (header || identity)) throw new Error("无法确认投影缓存的会话生命周期");
34
+ const previous = guard.removed.get(id);
35
+ const blocked = new Set([...(previous ?? []), ...stamps]);
36
+ guard.removed.set(id, blocked);
37
+ return {
38
+ remove: () => table.delete(id),
39
+ legacyRetained: table.host?.unit?.descriptor?.layout === "per-record",
40
+ legacyManaged: Boolean(table.host?.unit?.descriptor) && table.host.unit.descriptor.layout !== "per-record",
41
+ rollback() {
42
+ if (guard.removed.get(id) !== blocked) return;
43
+ if (previous) guard.removed.set(id, previous); else guard.removed.delete(id);
44
+ }
45
+ };
46
+ }
47
+
48
+ async function regular(path, directory = false) {
49
+ const info = await fs.lstat(path);
50
+ if (info.isSymbolicLink() || !(directory ? info.isDirectory() : info.isFile())) {
51
+ throw new Error("投影缓存路径不是安全的普通" + (directory ? "目录" : "文件") + ": " + path);
52
+ }
53
+ }
54
+
55
+ export async function assertProjectionCachePath(home, id) {
56
+ assertSessionId(id);
57
+ const paths = [home, join(home, "storages"), join(home, "storages", "session_projcache"),
58
+ join(home, "storages", "session_projcache", "sessions"), join(home, "storages", "session_projcache", "sessions", `${id}.json`)];
59
+ for (let i = 0; i < paths.length; i++) {
60
+ try { await regular(paths[i], i < paths.length - 1); }
61
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
62
+ }
63
+ }
64
+
65
+ /** rc.1 bootstraps but never writes its retained legacy unit. Serialize our
66
+ * read/modify/publish across deletes and processes; preserve its stamp/schema
67
+ * and every unrelated table/record. Unknown shapes fail without publication.
68
+ */
69
+ export async function removeLegacyProjectionCache(home, id) {
70
+ assertSessionId(id);
71
+ const root = join(home, "storages");
72
+ const path = join(root, "session_projcache.json");
73
+ try {
74
+ await regular(home, true);
75
+ await regular(root, true);
76
+ await regular(path);
77
+ } catch (error) { if (error.code === "ENOENT") return; throw error; }
78
+ const lockPath = join(root, "session_projcache.session-manager.lock");
79
+ const token = randomBytes(16).toString("hex");
80
+ const deadline = Date.now() + 3000;
81
+ let lock;
82
+ while (!lock) {
83
+ try { lock = await fs.open(lockPath, "wx", 0o600); }
84
+ catch (error) {
85
+ if (error.code !== "EEXIST") throw error;
86
+ if (Date.now() >= deadline) throw new Error("投影缓存清理锁正被占用: " + lockPath);
87
+ await delay(25);
88
+ }
89
+ }
90
+ let stamped = false;
91
+ try {
92
+ await lock.writeFile(token);
93
+ stamped = true;
94
+ await regular(root, true);
95
+ await regular(path);
96
+ const document = JSON.parse(await fs.readFile(path, "utf8"));
97
+ const records = document?.tables?.sessions;
98
+ if (document?.unit?.name !== "session_projcache" || ![3, 4, 5, 6, 7].includes(document.unit.version)
99
+ || !records || typeof records !== "object" || Array.isArray(records)) {
100
+ throw new Error("未知的旧版投影缓存格式,未修改文件");
101
+ }
102
+ if (!Object.hasOwn(records, id)) return;
103
+ delete records[id];
104
+ const result = await replaceSessionFile(path, JSON.stringify(document) + "\n");
105
+ if (result.backupPath) throw new Error("旧版投影缓存已更新,但备份清理失败: " + result.backupPath);
106
+ } finally {
107
+ await lock.close();
108
+ if (!stamped || await fs.readFile(lockPath, "utf8").catch(() => undefined) === token) await fs.rm(lockPath, { force: true });
109
+ }
110
+ }
@@ -12,8 +12,10 @@
12
12
  *
13
13
  * @module dsh-session-manager/compat/zstd-frames
14
14
  */
15
- import { zstdDecompress } from "node:zlib";
15
+ import { zstdDecompress, createZstdDecompress } from "node:zlib";
16
16
  import { promisify } from "node:util";
17
+ import { Readable } from "node:stream";
18
+ import { pipeline } from "node:stream/promises";
17
19
 
18
20
  const zstdDecompressAsync = promisify(zstdDecompress);
19
21
  const ZSTD_MAGIC = 0xfd2fb528;
@@ -76,6 +78,81 @@ export async function decompressZstdFrame(frame, options) {
76
78
  return zstdDecompressAsync(frame, options);
77
79
  }
78
80
 
81
+ export function decompressZstdFirstLine(frame, limit) {
82
+ return new Promise((resolve, reject) => {
83
+ const stream = createZstdDecompress();
84
+ const chunks = [];
85
+ let size = 0;
86
+ let settled = false;
87
+ let lineFound = false;
88
+ const finish = (error, value) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ stream.destroy();
92
+ if (error) reject(error); else resolve(value);
93
+ };
94
+ stream.on("error", error => finish(error));
95
+ stream.on("end", () => lineFound
96
+ ? finish(null, Buffer.concat(chunks, size))
97
+ : finish(new Error("会话头行不完整或超过大小限制")));
98
+ stream.on("data", chunk => {
99
+ if (lineFound) return;
100
+ const newline = chunk.indexOf(10);
101
+ const part = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
102
+ size += part.length;
103
+ if (size > limit) return finish(new Error("会话头行不完整或超过大小限制"));
104
+ chunks.push(part);
105
+ if (newline >= 0) lineFound = true;
106
+ });
107
+ if (Buffer.isBuffer(frame)) stream.end(frame);
108
+ else pipeline(Readable.from(frame), stream).catch(error => finish(error));
109
+ });
110
+ }
111
+
112
+ /** Feed exactly the first complete frame, with bounded reads even for huge batches.
113
+ * Keep consuming after the first line to validate the frame/checksum before discovery.
114
+ */
115
+ export async function* firstZstdFrameChunks(handle) {
116
+ let position = 0;
117
+ const exact = async size => {
118
+ const bytes = Buffer.alloc(size);
119
+ let offset = 0;
120
+ while (offset < size) {
121
+ const { bytesRead } = await handle.read(bytes, offset, size - offset, position);
122
+ if (!bytesRead) throw new Error("会话日志存在截断的 Zstd 帧");
123
+ offset += bytesRead;
124
+ position += bytesRead;
125
+ }
126
+ return bytes;
127
+ };
128
+ const prefix = await exact(5);
129
+ if (prefix.readUInt32LE(0) !== ZSTD_MAGIC) throw new Error("corrupt Zstandard session log: invalid frame magic");
130
+ const descriptor = prefix[4];
131
+ if (descriptor & 0x18) throw new Error("corrupt Zstandard session log: reserved frame-header bit");
132
+ yield prefix;
133
+ const sizeFlag = descriptor >>> 6;
134
+ const singleSegment = Boolean(descriptor & 0x20);
135
+ const dictionaryFlag = descriptor & 3;
136
+ const headerSize = (singleSegment ? 0 : 1) + (dictionaryFlag === 3 ? 4 : dictionaryFlag)
137
+ + (sizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << sizeFlag);
138
+ if (headerSize) yield await exact(headerSize);
139
+ for (;;) {
140
+ const header = await exact(3);
141
+ const block = header.readUIntLE(0, 3);
142
+ const type = (block >>> 1) & 3;
143
+ if (type === 3) throw new Error("corrupt Zstandard session log: reserved block type");
144
+ yield header;
145
+ let remaining = type === 1 ? 1 : block >>> 3;
146
+ while (remaining) {
147
+ const count = Math.min(remaining, 64 * 1024);
148
+ yield await exact(count);
149
+ remaining -= count;
150
+ }
151
+ if (block & 1) break;
152
+ }
153
+ if (descriptor & 4) yield await exact(4);
154
+ }
155
+
79
156
  export async function decompressAllZstdFrames(buffer) {
80
157
  const scan = scanZstdFrames(buffer);
81
158
  if (scan.frames.length === 0) {
package/lib/index.js CHANGED
@@ -28,7 +28,8 @@ import { basename, dirname, join } from "node:path";
28
28
  import { randomBytes } from "node:crypto";
29
29
  import { createAnnotationStore } from "./annotation-store.js";
30
30
  import { createWorkspaceArchiveStore, assertWorkspaceId } from "./workspace-archive-store.js";
31
- import { createDshAdapter, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
31
+ import { createDshAdapter, getService, normalizeSessionHeader, compareSemver, detectPluginSource, isHmrActive, resolveProfileDir } from "./compat/dsh-adapter.js";
32
+ import { assertProjectionCachePath, beginProjectionCacheDeletion, removeLegacyProjectionCache } from "./compat/projection-cache-delete.js";
32
33
  import { ARTIFACT_NAMES, assertSessionId, assertSessionDirectory, candidateSessionDirs, encodeFsSessionSegment, decodeFsSessionSegment, encodeProjectSegment, encodeSessionSegment, parseSessionGenerationFromFilename, readSessionHeader, readSessionFile, writeTempFile, replaceSessionFile } from "./session-files.js";
33
34
 
34
35
  export const name = "dsh-session-manager";
@@ -39,7 +40,8 @@ export const inject = [
39
40
  "sessions",
40
41
  "agents",
41
42
  "sessionPersistence",
42
- "agentPresets"
43
+ "agentPresets",
44
+ "connection"
43
45
  ];
44
46
 
45
47
  /** Like JSON.stringify but swallows circular-reference/BigInt errors so the
@@ -385,7 +387,7 @@ function verifyInstalledPackage(profileDir, expectedVersion) {
385
387
 
386
388
  export function apply(ctx) {
387
389
  try {
388
- const buildId = "dsh-session-manager v0.7.4 (workspace-archive)";
390
+ const buildId = "dsh-session-manager v0.7.6 (workspace-archive)";
389
391
  if (typeof ctx?.logger?.info === "function") ctx.logger.info(buildId);
390
392
  else if (typeof console !== "undefined" && console?.info) console.info(buildId);
391
393
  } catch (_) { /* ignore logging failures */ }
@@ -1044,6 +1046,115 @@ ${rest}`, "utf8");
1044
1046
  return await run();
1045
1047
  };
1046
1048
 
1049
+ /**
1050
+ * Issue #42: record the working-directory change a move implies.
1051
+ *
1052
+ * A cross-workspace move rewrites the artifact header's `cwd`, but DSH
1053
+ * folds the `workingDirectory` projection from the session log: the unit
1054
+ * initializes from `header.cwd` only while it has no checkpoint, and both
1055
+ * the projection cache and every `working-directory/change` event
1056
+ * supersede that initial value. A live session therefore keeps reporting
1057
+ * its pre-move directory -- the projection state is never re-initialized --
1058
+ * and the cache checkpoints that stale state under the NEW header identity,
1059
+ * so a rebuild cannot recover the target either. Route the change through
1060
+ * the host's own path (the `workingDirectory` service is what a user `cd`
1061
+ * uses) so the log gains the event the projection folds from; without that
1062
+ * service, append the equivalent event directly, which is the same shape
1063
+ * the host's `commit()` writes.
1064
+ *
1065
+ * @param agent - the live Agent owning the moved session, when one exists.
1066
+ * @param session - the live Session owning the moved log.
1067
+ * @param targetPath - the destination workspace directory.
1068
+ * @returns `{status}`: "synced" (host service committed the change),
1069
+ * "recorded" (event appended directly) or "restart-required" (the host
1070
+ * rejected the change; the move itself has already succeeded).
1071
+ */
1072
+ const rehomeLiveWorkingDirectory = async ({ agent, session, targetPath }) => {
1073
+ const service = getService(ctx, "workingDirectory");
1074
+ if (service !== undefined && typeof service.set === "function" && agent !== undefined) {
1075
+ try {
1076
+ const cwd = await service.set(agent, targetPath);
1077
+ return { status: "synced", cwd: typeof cwd === "string" && cwd !== "" ? cwd : targetPath };
1078
+ } catch (error) {
1079
+ const isFiberError = error?.code === "INACTIVE_EFFECT" ||
1080
+ String(error?.message || error).toLowerCase().includes("inactive") ||
1081
+ String(error?.message || error).toLowerCase().includes("fiber");
1082
+ if (!isFiberError) {
1083
+ ctx.logger.warn(`session-manager: moved "${safeJson(session?.id)}" but the host rejected the working-directory change: ${safeErrorMessage(error)}`);
1084
+ return { status: "restart-required", reason: safeErrorMessage(error) };
1085
+ }
1086
+ }
1087
+ }
1088
+ if (session === undefined || session === null || typeof session.append !== "function") {
1089
+ return { status: "restart-required", reason: "no live session to record the working-directory change" };
1090
+ }
1091
+ try {
1092
+ session.append("working-directory/change", { cwd: targetPath });
1093
+ return { status: "recorded", cwd: targetPath };
1094
+ } catch (error) {
1095
+ ctx.logger.warn(`session-manager: moved "${safeJson(session.id)}" but could not record the working-directory change: ${safeErrorMessage(error)}`);
1096
+ return { status: "restart-required", reason: safeErrorMessage(error) };
1097
+ }
1098
+ };
1099
+
1100
+ /**
1101
+ * Issue #42 (cold sessions): append the `working-directory/change` event a
1102
+ * live move records, but only when the stored log already carries one --
1103
+ * otherwise the rewritten `header.cwd` alone initializes the projection on
1104
+ * the next resume and touching the log would be gratuitous. A stale change
1105
+ * event would otherwise fold the pre-move directory back in. Rows whose
1106
+ * `seq` does not match their position (packed physical rows) cannot be
1107
+ * extended without the persistence codec, so the body is left untouched and
1108
+ * the caller reports that a restart is required.
1109
+ *
1110
+ * @param rest - the artifact body (everything after the header line).
1111
+ * @param targetPath - the destination workspace directory.
1112
+ * @returns `{rest, status}` with the body to publish.
1113
+ */
1114
+ const rehomeColdWorkingDirectory = (rest, targetPath) => {
1115
+ const nl = String.fromCharCode(10);
1116
+ const terminated = rest.endsWith(nl);
1117
+ const body = terminated ? rest.slice(0, -1) : rest;
1118
+ if (body === "") return { rest, status: "header-only" };
1119
+ const lines = body.split(nl);
1120
+ let lastChange;
1121
+ let hasPackedOrUnparseableRows = false;
1122
+ let hasNonSequentialSeq = false;
1123
+ for (let index = 0; index < lines.length; index += 1) {
1124
+ let event;
1125
+ try {
1126
+ event = JSON.parse(lines[index]);
1127
+ } catch {
1128
+ hasPackedOrUnparseableRows = true;
1129
+ continue;
1130
+ }
1131
+ if (event === null || typeof event !== "object") {
1132
+ hasPackedOrUnparseableRows = true;
1133
+ continue;
1134
+ }
1135
+ if (event.seq !== index) {
1136
+ hasNonSequentialSeq = true;
1137
+ }
1138
+ if (event.type === "working-directory/change") {
1139
+ lastChange = event.data?.cwd;
1140
+ }
1141
+ }
1142
+ if (lastChange === undefined) return { rest, status: "header-only" };
1143
+ if (lastChange === targetPath) return { rest, status: "already-target" };
1144
+ if (hasPackedOrUnparseableRows || hasNonSequentialSeq) {
1145
+ const error = new Error("无法安全同步会话工作目录(会话日志包含压缩行且已有工作目录变更历史),为防止工作目录不一致,已取消移动。原始会话保持原状");
1146
+ error.code = "working-directory-sync-unsafe";
1147
+ throw error;
1148
+ }
1149
+ const appended = JSON.stringify({
1150
+ type: "working-directory/change",
1151
+ seq: lines.length,
1152
+ time: Date.now(),
1153
+ data: { cwd: targetPath }
1154
+ });
1155
+ return { rest: `${body}${nl}${appended}${nl}`, status: "recorded" };
1156
+ };
1157
+
1047
1158
  /**
1048
1159
  * TRUE cross-workspace move. See the module docstring for the full design:
1049
1160
  * re-home the stored cwd, migrate the artifact, then swap accounting.
@@ -1196,7 +1307,14 @@ ${rest}`, "utf8");
1196
1307
  throw new Error("目标位置已有会话工件,拒绝覆盖");
1197
1308
  } catch (error) { if (error.code !== "ENOENT") throw error; }
1198
1309
  if (oldPath === newPath) throw new Error("会话源路径与目标路径相同,但 cwd 不一致,拒绝覆盖");
1199
- const bytes = await encodeArtifact(JSON.stringify(newHeader), rest, newPath.endsWith(".zstd"));
1310
+ // Issue #42: a cold log is the only authority its working-directory
1311
+ // projection folds from on the next resume, so re-home it together
1312
+ // with the artifact header. Live sessions get the equivalent event
1313
+ // through the host path once the artifact has been published.
1314
+ const bodyRehome = liveSession === void 0
1315
+ ? rehomeColdWorkingDirectory(rest, targetPath)
1316
+ : { rest, status: "live" };
1317
+ const bytes = await encodeArtifact(JSON.stringify(newHeader), bodyRehome.rest, newPath.endsWith(".zstd"));
1200
1318
  await mkdir(dirname(newPath), { recursive: true });
1201
1319
  const tempNew = await writeTempFile(newPath, bytes);
1202
1320
  const oldHidden = `${oldPath}.${randomBytes(6).toString("hex")}.tmp`;
@@ -1297,7 +1415,7 @@ ${rest}`, "utf8");
1297
1415
  await rm(dirname(oldPath), { recursive: true, force: true });
1298
1416
  } catch (error) { ctx.logger.warn(`session-manager: old directory retained: ${String(error)}`); }
1299
1417
 
1300
- movedArtifact = { header: newHeader, rest };
1418
+ movedArtifact = { header: newHeader, rest: bodyRehome.rest, workingDirectory: bodyRehome.status };
1301
1419
 
1302
1420
  return {
1303
1421
  ok: true,
@@ -1329,7 +1447,34 @@ ${rest}`, "utf8");
1329
1447
  ctx.logger.warn(`session-manager: post-move cache refresh failed for "${sessionId}": ${String(error)}`);
1330
1448
  }
1331
1449
  }
1332
- // The agent is still live (no teardown). The session's in-memory
1450
+ // Issue #42: the artifact header was rewritten, but the folded
1451
+ // `workingDirectory` projection was not. Align it now -- after the
1452
+ // artifact has been published (a live session's change event must land in
1453
+ // the moved log) and after the registry swap.
1454
+ let workingDirectory = movedArtifact?.workingDirectory ?? "not-needed";
1455
+ let restartRequired = false;
1456
+ if (liveSession !== void 0) {
1457
+ const rehomed = await rehomeLiveWorkingDirectory({ agent: liveAgent, session: liveSession, targetPath });
1458
+ workingDirectory = rehomed.status;
1459
+ if (rehomed.status === "restart-required") restartRequired = true;
1460
+ if (rehomed.status === "synced" || rehomed.status === "recorded") {
1461
+ // The refold above seeds from the projection cache, so it cannot see
1462
+ // a change committed after the move: checkpoint what the live
1463
+ // session now actually folds (this flushes the new event first).
1464
+ if (cache !== void 0 && typeof cache.write === "function") {
1465
+ try {
1466
+ await cache.write(liveSession);
1467
+ } catch (error) {
1468
+ ctx.logger.warn(`session-manager: post-move working-directory checkpoint failed for "${sessionId}": ${safeErrorMessage(error)}`);
1469
+ }
1470
+ }
1471
+ }
1472
+ } else if (workingDirectory === "restart-required") {
1473
+ restartRequired = true;
1474
+ }
1475
+ result.workingDirectory = workingDirectory;
1476
+ result.restartRequired = restartRequired;
1477
+ // The agent is still live (no teardown). The session's in-memory
1333
1478
  // header was rewritten above and the workspace registry updated;
1334
1479
  // future writes go through persistence.locate() against the new
1335
1480
  // cwd, so the file at the new location accumulates new events.
@@ -1914,11 +2059,20 @@ ${rest}`, "utf8");
1914
2059
  await assertAndStopActiveSession(sessionId, true);
1915
2060
  }
1916
2061
 
2062
+ let projectionDeletion;
2063
+ let artifactsDeleted = false;
1917
2064
  try {
1918
2065
  // Resolve/validate before cancelling the agent or changing registry state.
1919
2066
  let dir = await sessionDirOf(sessionId);
1920
2067
  const session = ctx.sessions?.get?.(sessionId);
1921
2068
  let agent = safeGetAgent(sessionId);
2069
+ try {
2070
+ await assertProjectionCachePath(dirname(adapter.homePath("storages")), sessionId);
2071
+ const artifact = await readSessionArtifact(sessionId, undefined, { headerOnly: true });
2072
+ projectionDeletion = beginProjectionCacheDeletion(adapter.projectionCache, sessionId, session?.header ?? artifact?.meta);
2073
+ } catch (error) {
2074
+ cleanupWarnings.push("投影缓存回写防护不可用: " + safeErrorMessage(error));
2075
+ }
1922
2076
 
1923
2077
  if (agent !== void 0) {
1924
2078
  // Stop any running turn (disposed-kind suppresses re-wake).
@@ -1979,6 +2133,29 @@ ${rest}`, "utf8");
1979
2133
  await assertSessionDirectory(adapter.homePath("sessions"), dir, sessionId);
1980
2134
  await rm(dir, { recursive: true, force: true });
1981
2135
  }
2136
+ artifactsDeleted = true;
2137
+
2138
+ // Use the host queue to remove both its in-memory row and durable record.
2139
+ // Tombstones installed before disposal also stop writes still awaiting flush.
2140
+ try {
2141
+ await assertProjectionCachePath(dirname(adapter.homePath("storages")), sessionId);
2142
+ if (projectionDeletion) await projectionDeletion.remove();
2143
+ else {
2144
+ const file = adapter.homePath("storages", "session_projcache", "sessions", `${sessionId}.json`);
2145
+ if (existsSync(file)) throw new Error("宿主投影缓存删除接口不可用,缓存尚未清理");
2146
+ }
2147
+ } catch (error) {
2148
+ cleanupWarnings.push("会话已删除,但投影缓存清理失败: " + safeErrorMessage(error));
2149
+ }
2150
+ try {
2151
+ if (projectionDeletion?.legacyRetained) await removeLegacyProjectionCache(dirname(adapter.homePath("storages")), sessionId);
2152
+ else if (!projectionDeletion?.legacyManaged && existsSync(adapter.homePath("storages", "session_projcache.json"))) {
2153
+ throw new Error("无法确认旧版缓存已停止写入,未修改文件");
2154
+ }
2155
+ }
2156
+ catch (error) {
2157
+ cleanupWarnings.push("会话已删除,但旧版投影缓存清理失败: " + safeErrorMessage(error));
2158
+ }
1982
2159
 
1983
2160
  try {
1984
2161
  await annotations().remove(sessionId);
@@ -2008,6 +2185,7 @@ ${rest}`, "utf8");
2008
2185
  subagentCount: deletedSubagentIds.length
2009
2186
  };
2010
2187
  } catch (parentErr) {
2188
+ if (!artifactsDeleted) projectionDeletion?.rollback();
2011
2189
  const err = deletedSubagentIds.length > 0
2012
2190
  ? new Error(`删除主会话 '${sessionId}' 失败: ${safeErrorMessage(parentErr)}`)
2013
2191
  : parentErr;
@@ -2295,9 +2473,80 @@ ${rest}`, "utf8");
2295
2473
  if (isBlank) { const e = new Error("blank"); e.code = "blank-skip"; throw e; }
2296
2474
  };
2297
2475
 
2298
- ctx.effect(() => ctx.webServer.register({ kind: "prefix",
2476
+ /**
2477
+ * Issue #43: Validate incoming request with DSH connection authentication.
2478
+ * Reuses DSH official Connection/Gateway authentication and authorization mechanisms.
2479
+ * Strict fail-closed: requires connection.admit() to succeed with a valid Owner peer.
2480
+ * Must execute before request body reading, JSON parsing, or business operations.
2481
+ */
2482
+ const authenticateApiRequest = (req) => {
2483
+ const connection = getService(ctx, "connection");
2484
+ if (!connection || typeof connection.admit !== "function") {
2485
+ return {
2486
+ ok: false,
2487
+ status: 503,
2488
+ code: "auth-unavailable",
2489
+ error: "DSH 认证服务未就绪或不可用,为保护数据安全已拒绝请求"
2490
+ };
2491
+ }
2492
+ try {
2493
+ const admission = connection.admit(req);
2494
+ if (!admission || typeof admission !== "object") {
2495
+ return {
2496
+ ok: false,
2497
+ status: 401,
2498
+ code: "unauthorized",
2499
+ error: "未授权:需要有效的 DSH 会话凭证"
2500
+ };
2501
+ }
2502
+ if ("rejection" in admission) {
2503
+ const status = typeof admission.rejection === "number" ? admission.rejection : 401;
2504
+ const error = status === 403
2505
+ ? "禁止访问:请求未通过 DSH 信任检查或跨站防护"
2506
+ : "未授权:需要有效的 DSH 会话凭证";
2507
+ return { ok: false, status, code: status === 403 ? "forbidden" : "unauthorized", error };
2508
+ }
2509
+ const peer = admission.peer;
2510
+ if (!peer || typeof peer !== "object") {
2511
+ return {
2512
+ ok: false,
2513
+ status: 401,
2514
+ code: "unauthorized",
2515
+ error: "未授权:缺少有效 Peer 凭证"
2516
+ };
2517
+ }
2518
+ const principalKind = peer.principal?.kind;
2519
+ if (principalKind !== "owner") {
2520
+ return {
2521
+ ok: false,
2522
+ status: 403,
2523
+ code: "forbidden",
2524
+ error: "禁止访问:会话管理属于特权操作,需要 owner 权限"
2525
+ };
2526
+ }
2527
+ return { ok: true, peer };
2528
+ } catch (err) {
2529
+ return {
2530
+ ok: false,
2531
+ status: 403,
2532
+ code: "auth-error",
2533
+ error: "认证检查失败: " + safeErrorMessage(err)
2534
+ };
2535
+ }
2536
+ };
2537
+
2538
+ ctx.effect(() => ctx.webServer.register({
2539
+ kind: "prefix",
2299
2540
  path: API_PREFIX,
2300
2541
  handler: async (req, res) => {
2542
+ const auth = authenticateApiRequest(req);
2543
+ if (!auth.ok) {
2544
+ return send(res, auth.status, {
2545
+ ok: false,
2546
+ code: auth.code,
2547
+ error: auth.error
2548
+ });
2549
+ }
2301
2550
  try {
2302
2551
  const url = new URL(req.url ?? "/", "http://localhost");
2303
2552
  const path = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) || "/" : "/";
@@ -2528,7 +2777,7 @@ ${rest}`, "utf8");
2528
2777
  return send(res, 200, { ok: true, result });
2529
2778
  }
2530
2779
  if (req.method === "GET" && path === "/update/check") {
2531
- const currentVersion = adapter.pluginVersion() || "0.7.4";
2780
+ const currentVersion = adapter.pluginVersion() || "0.7.6";
2532
2781
  const runningVersion = currentVersion;
2533
2782
  let installedVersion = runningVersion;
2534
2783
  const profileDir = resolveProfileDir(adapter, ctx);
@@ -2712,7 +2961,7 @@ ${rest}`, "utf8");
2712
2961
  };
2713
2962
  setActiveTask(taskRecord);
2714
2963
 
2715
- const currentVersion = adapter.pluginVersion() || "0.7.4";
2964
+ const currentVersion = adapter.pluginVersion() || "0.7.6";
2716
2965
  let installedVersion = currentVersion;
2717
2966
  if (profileDir) {
2718
2967
  const pkgFile = join(profileDir, "node_modules", "dsh-session-manager", "package.json");
@@ -3242,7 +3491,7 @@ ${rest}`, "utf8");
3242
3491
  }
3243
3492
  if (req.method === "GET" && path === "/update/status") {
3244
3493
  const profileDir = resolveProfileDir(adapter, ctx);
3245
- const currentVersion = adapter.pluginVersion() || "0.7.4";
3494
+ const currentVersion = adapter.pluginVersion() || "0.7.6";
3246
3495
  let installedVersion = currentVersion;
3247
3496
  if (profileDir) {
3248
3497
  const diskCheck = verifyInstalledPackage(profileDir);
@@ -3317,6 +3566,7 @@ ${rest}`, "utf8");
3317
3566
  ctx.logger.warn("session-manager: api error: " + safeErrorMessage(error) + "; json=" + safeJson(error));
3318
3567
  const status = error?.code === "bad-request" ? 400 : error?.code === "body-too-large" ? 413
3319
3568
  : error?.code === "annotation-conflict" || error?.code === "annotations-busy" ? 409
3569
+ : error?.code === "working-directory-sync-unsafe" ? 409
3320
3570
  : error?.code === "session-not-found" ? 404 : error?.code?.startsWith("annotations-") ? 503
3321
3571
  : error?.code === "workspace-membership-conflict" ? 409
3322
3572
  : error?.code === "workspace-attach-unavailable" ? 501 : 500;
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { decompressAllZstdFrames, decompressZstdFrame, scanZstdFrames } from "./compat/zstd-frames.js";
4
+ import { decompressAllZstdFrames, decompressZstdFirstLine, firstZstdFrameChunks } from "./compat/zstd-frames.js";
5
5
 
6
6
  /**
7
7
  * Filenames tried in this order to locate a session's durable artifact.
@@ -190,20 +190,17 @@ export async function readSessionHeader(path) {
190
190
  await assertRegularFile(path);
191
191
  const handle = await fs.open(path, "r");
192
192
  try {
193
+ if (path.endsWith(".zstd")) {
194
+ const decoded = await decompressZstdFirstLine(firstZstdFrameChunks(handle), MAX_HEADER_BYTES);
195
+ return parseHeader(decoded.toString("utf8"));
196
+ }
193
197
  let buffer = Buffer.alloc(0);
194
198
  while (buffer.length < MAX_HEADER_BYTES) {
195
199
  const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HEADER_BYTES - buffer.length));
196
200
  const { bytesRead } = await handle.read(chunk, 0, chunk.length, buffer.length);
197
201
  if (bytesRead === 0) break;
198
202
  buffer = Buffer.concat([buffer, chunk.subarray(0, bytesRead)]);
199
- if (path.endsWith(".zstd")) {
200
- const { frames } = scanZstdFrames(buffer, 1);
201
- if (frames.length) {
202
- const frame = frames[0];
203
- const decoded = await decompressZstdFrame(buffer.subarray(frame.start, frame.end), { maxOutputLength: MAX_HEADER_BYTES });
204
- return parseHeader(decoded.toString("utf8"));
205
- }
206
- } else if (buffer.includes(10)) {
203
+ if (buffer.includes(10)) {
207
204
  return parseHeader(buffer.toString("utf8"));
208
205
  }
209
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-manager",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "DeepSeek Harness session and workspace manager —— Workspaces: archive and visibility. Sessions: move, migrate presets, delete, favorite, review later, prioritize, tag, and annotate. Batch operations. | DSH 会话与工作区管理——工作区:归档与显示。会话:跨工作区移动、预设迁移、删除;收藏、待回看、优先级、标签、备注。批量操作。",
5
5
  "license": "MIT",
6
6
  "keywords": [