dsh-msg9-kit 0.2.0 → 0.2.2

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 CHANGED
@@ -280,7 +280,7 @@ function deleteContact(apiUrl, apiKey, address, signal) {
280
280
  }
281
281
 
282
282
  // src/host/store.ts
283
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
283
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
284
284
  import { homedir } from "node:os";
285
285
  import { dirname, join } from "node:path";
286
286
  function stateFilePath() {
@@ -324,11 +324,73 @@ async function saveState(state) {
324
324
  }
325
325
  var writeQueue = Promise.resolve();
326
326
  function enqueueWrite(task) {
327
- const run = writeQueue.then(task);
327
+ const run = writeQueue.then(async () => {
328
+ const release = await acquireStateLock();
329
+ try {
330
+ return await task();
331
+ } finally {
332
+ await release();
333
+ }
334
+ });
328
335
  writeQueue = run.catch(() => {
329
336
  });
330
337
  return run;
331
338
  }
339
+ var LOCK_STALE_MS = 3e4;
340
+ var LOCK_RETRY_MS = 100;
341
+ var LOCK_MAX_ATTEMPTS = 50;
342
+ async function acquireStateLock() {
343
+ const lockPath = `${stateFilePath()}.lock`;
344
+ await mkdir(dirname(lockPath), { recursive: true });
345
+ for (let attempt = 0; ; attempt += 1) {
346
+ let handle;
347
+ try {
348
+ handle = await open(lockPath, "wx", 384);
349
+ await handle.writeFile(JSON.stringify({ pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() }));
350
+ await handle.close();
351
+ return async () => {
352
+ await rm(lockPath, { force: true });
353
+ };
354
+ } catch (error) {
355
+ await handle?.close().catch(() => {
356
+ });
357
+ if (error.code !== "EEXIST") throw error;
358
+ if (await isStaleLock(lockPath)) {
359
+ await rm(lockPath, { force: true });
360
+ continue;
361
+ }
362
+ if (attempt >= LOCK_MAX_ATTEMPTS) {
363
+ throw new Error(
364
+ `msg9-kit state file is locked by another process (${lockPath}); multiple dsh instances sharing one DSH_HOME are not supported`
365
+ );
366
+ }
367
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
368
+ }
369
+ }
370
+ }
371
+ async function isStaleLock(lockPath) {
372
+ let info;
373
+ try {
374
+ info = await stat(lockPath);
375
+ } catch {
376
+ return true;
377
+ }
378
+ if (Date.now() - info.mtimeMs > LOCK_STALE_MS) return true;
379
+ const raw = await readFile(lockPath, "utf8").catch(() => "");
380
+ let pid = NaN;
381
+ try {
382
+ pid = Number(JSON.parse(raw).pid);
383
+ } catch {
384
+ }
385
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
386
+ try {
387
+ process.kill(pid, 0);
388
+ } catch {
389
+ return true;
390
+ }
391
+ }
392
+ return false;
393
+ }
332
394
  async function getOwner() {
333
395
  const envKey = process.env.MSG9_OWNER_KEY;
334
396
  if (envKey) {
@@ -359,10 +421,11 @@ async function getWorkspaceInbox(key) {
359
421
  const state = await loadState();
360
422
  return state.workspaces[key];
361
423
  }
362
- async function upsertWorkspaceInbox(key, inbox) {
424
+ async function upsertWorkspaceInbox(key, patch) {
363
425
  return enqueueWrite(async () => {
364
426
  const state = await loadState();
365
- state.workspaces[key] = { ...state.workspaces[key] ?? {}, ...inbox };
427
+ const clean = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
428
+ state.workspaces[key] = { ...state.workspaces[key] ?? {}, ...clean };
366
429
  await saveState(state);
367
430
  });
368
431
  }
@@ -591,10 +654,12 @@ function maskKey(key) {
591
654
 
592
655
  // src/host/service.ts
593
656
  var DEFAULT_WORKSPACE = { key: "default", title: "default", path: "(unknown)" };
657
+ var OWNER_PROBE_RETRY_MS = 6e4;
658
+ var ownerProbeFailedAt = 0;
594
659
  async function ownerContext() {
595
660
  const owner = await getOwner();
596
661
  const apiUrl = owner?.api_url || defaultApiUrl();
597
- if (owner?.api_key && (owner.slug === void 0 || owner.address_domain === void 0) && !process.env.MSG9_OWNER_KEY) {
662
+ if (owner?.api_key && (owner.slug === void 0 || owner.address_domain === void 0) && !process.env.MSG9_OWNER_KEY && Date.now() - ownerProbeFailedAt >= OWNER_PROBE_RETRY_MS) {
598
663
  try {
599
664
  const me = await ownerMe(apiUrl, owner.api_key);
600
665
  const probed = {
@@ -604,8 +669,10 @@ async function ownerContext() {
604
669
  address_domain: typeof me.address_domain === "string" ? me.address_domain : null
605
670
  };
606
671
  await setOwner(probed);
672
+ ownerProbeFailedAt = 0;
607
673
  return { owner: probed, apiUrl };
608
674
  } catch {
675
+ ownerProbeFailedAt = Date.now();
609
676
  }
610
677
  }
611
678
  return { owner, apiUrl };
@@ -704,31 +771,39 @@ async function migrateInbox(workspace, oldInbox, oldOwnerKey) {
704
771
  throw new Error(L("\u8FD8\u6CA1\u6709\u7ED1\u5B9A\u79DF\u6237\uFF0C\u65E0\u6CD5\u8FC1\u79FB\u3002", "No tenant is bound; cannot migrate."));
705
772
  }
706
773
  const agent = await provisionUnderOwner(apiUrl, owner, workspace, workspaceProfile(workspace));
707
- const inbox = await ensureSigningKey(workspace.key, {
774
+ let signingSeed;
775
+ try {
776
+ const material = generateSigningMaterial();
777
+ await setSigningKey(apiUrl, agent.api_key, material.publicKey);
778
+ signingSeed = material.seed;
779
+ } catch {
780
+ }
781
+ const inbox = {
708
782
  address: agent.address,
709
783
  api_key: agent.api_key,
710
784
  api_url: apiUrl,
711
785
  title: workspace.title,
712
- path: workspace.path
713
- });
714
- await replaceWorkspaceInbox(workspace.key, inbox);
786
+ path: workspace.path,
787
+ ...signingSeed ? { signing_seed: signingSeed } : {}
788
+ };
715
789
  if (oldInbox.address === agent.address) {
790
+ await replaceWorkspaceInbox(workspace.key, inbox);
716
791
  return { inbox, oldDisabled: false, forwarding: false, movedMail: null };
717
792
  }
718
- let forwarding = false;
719
- let note;
720
793
  try {
721
794
  await setForwarding(oldInbox.api_url, oldInbox.api_key, agent.address);
722
- forwarding = true;
723
795
  } catch (error) {
724
- note = L(
725
- "\u65E7\u5730\u5740\u8F6C\u53D1\u8BBE\u7F6E\u5931\u8D25\uFF08{reason}\uFF09\u2014\u2014\u65E7\u4FE1\u7BB1\u4ECD\u53EF\u80FD\u6536\u5230\u65B0\u90AE\u4EF6\u3002",
726
- "Could not set forwarding on the old address ({reason}) \u2014 new mail may still arrive there.",
727
- { reason: error?.message ?? String(error) }
728
- );
796
+ throw new Error(L(
797
+ "\u65E7\u5730\u5740 {old} \u7684\u8F6C\u53D1\u8BBE\u7F6E\u5931\u8D25\uFF08{reason}\uFF09\uFF0C\u8FC1\u79FB\u5DF2\u4E2D\u6B62\uFF1A\u672C\u5730\u914D\u7F6E\u672A\u6539\u52A8\uFF0C\u4ECD\u6307\u5411\u65E7\u4FE1\u7BB1\u3002",
798
+ "Could not set forwarding on the old address {old} ({reason}); migration aborted \u2014 local state still points at the old inbox.",
799
+ { old: oldInbox.address, reason: error?.message ?? String(error) }
800
+ ));
729
801
  }
802
+ await replaceWorkspaceInbox(workspace.key, inbox);
803
+ const forwarding = true;
730
804
  let oldDisabled = false;
731
805
  let movedMail = null;
806
+ let note;
732
807
  if (oldOwnerKey) {
733
808
  try {
734
809
  const moved = await ownerMoveMail(oldInbox.api_url, oldOwnerKey, oldInbox.address, { to: agent.address });
@@ -774,26 +849,51 @@ function createBridgeEventBus() {
774
849
  size: () => listeners.size
775
850
  };
776
851
  }
777
- async function computeUnread(deps, signal) {
778
- const state = await deps.loadState();
779
- const rows = Object.entries(state.workspaces).filter(([, inbox]) => Boolean(inbox.api_key));
780
- const settled = await Promise.allSettled(
781
- rows.map(async ([key, inbox]) => {
782
- const page = await deps.api.listInbox(inbox.api_url, inbox.api_key, { folder: "all", limit: 1 }, signal);
783
- return [key, Number(page?.unread_count ?? 0), Number(page?.total ?? (page?.messages ?? []).length)];
784
- })
785
- );
786
- const byKey = {};
787
- const totalByKey = {};
788
- let total = 0;
789
- for (const result of settled) {
790
- if (result.status !== "fulfilled") continue;
791
- const [key, count, mailboxSize] = result.value;
792
- byKey[key] = count;
793
- totalByKey[key] = mailboxSize;
794
- total += count;
795
- }
796
- return { total, byKey, totalByKey };
852
+ var UNREAD_TTL_MS = 1e4;
853
+ var unreadCache;
854
+ var unreadInflight;
855
+ function invalidateUnreadCache() {
856
+ unreadCache = void 0;
857
+ }
858
+ async function computeUnread(deps, signal, options) {
859
+ const ttl = options?.ttlMs ?? UNREAD_TTL_MS;
860
+ if (unreadInflight) return unreadInflight;
861
+ if (unreadCache && Date.now() - unreadCache.at < ttl) return unreadCache.view;
862
+ void signal;
863
+ unreadInflight = (async () => {
864
+ const state = await deps.loadState();
865
+ const rows = Object.entries(state.workspaces).filter(([, inbox]) => Boolean(inbox.api_key));
866
+ const settled = await Promise.allSettled(
867
+ rows.map(async ([key, inbox]) => {
868
+ const page = await deps.api.listInbox(inbox.api_url, inbox.api_key, { folder: "all", limit: 1 });
869
+ return [key, Number(page?.unread_count ?? 0), Number(page?.total ?? (page?.messages ?? []).length)];
870
+ })
871
+ );
872
+ const previous = unreadCache?.view;
873
+ const byKey = {};
874
+ const totalByKey = {};
875
+ let total = 0;
876
+ for (let index = 0; index < rows.length; index += 1) {
877
+ const [key] = rows[index];
878
+ const result = settled[index];
879
+ if (result.status === "fulfilled") {
880
+ const [, count, mailboxSize] = result.value;
881
+ byKey[key] = count;
882
+ totalByKey[key] = mailboxSize;
883
+ total += count;
884
+ } else if (previous && key in previous.byKey) {
885
+ byKey[key] = previous.byKey[key];
886
+ totalByKey[key] = previous.totalByKey[key] ?? 0;
887
+ total += byKey[key];
888
+ }
889
+ }
890
+ const view = { total, byKey, totalByKey };
891
+ unreadCache = { at: Date.now(), view };
892
+ return view;
893
+ })().finally(() => {
894
+ unreadInflight = void 0;
895
+ });
896
+ return unreadInflight;
797
897
  }
798
898
  function defaultBridgeDeps(ctx, override = {}) {
799
899
  return {
@@ -860,19 +960,35 @@ function hostnameOf(host) {
860
960
  function isLoopbackHostname(hostname) {
861
961
  return hostname === "localhost" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1" || /^127(\.\d{1,3}){3}$/.test(hostname);
862
962
  }
963
+ function isLoopbackAddress(address) {
964
+ const normalized = address.toLowerCase().replace(/^::ffff:/, "");
965
+ return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1" || /^127(\.\d{1,3}){3}$/.test(normalized);
966
+ }
863
967
  function isTrustedRequest(req) {
864
968
  const host = hostnameOf(req.headers.host);
865
969
  if (!host) return false;
866
970
  const origin = req.headers.origin;
867
971
  if (origin) return isSameOrigin(origin, req.headers.host ?? "");
972
+ const remote = req.socket?.remoteAddress;
973
+ if (remote && !isLoopbackAddress(remote)) return false;
868
974
  return isLoopbackHostname(host);
869
975
  }
976
+ function portOf(hostHeader) {
977
+ if (hostHeader.startsWith("[")) {
978
+ const end = hostHeader.indexOf("]");
979
+ if (end < 0) return void 0;
980
+ const rest = hostHeader.slice(end + 1);
981
+ return rest.startsWith(":") ? rest.slice(1) : void 0;
982
+ }
983
+ const colon = hostHeader.lastIndexOf(":");
984
+ return colon > 0 ? hostHeader.slice(colon + 1) : void 0;
985
+ }
870
986
  function isSameOrigin(origin, hostHeader) {
871
987
  try {
872
988
  const parsed = new URL(origin);
873
- if (parsed.hostname.toLowerCase() !== hostnameOf(hostHeader)) return false;
874
- const colon = hostHeader.lastIndexOf(":");
875
- const expected = colon > 0 ? hostHeader.slice(colon + 1) : parsed.protocol === "https:" ? "443" : "80";
989
+ const originHost = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
990
+ if (originHost !== hostnameOf(hostHeader)) return false;
991
+ const expected = portOf(hostHeader) ?? (parsed.protocol === "https:" ? "443" : "80");
876
992
  const actual = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
877
993
  return actual === expected;
878
994
  } catch {
@@ -1104,7 +1220,7 @@ function createMsg9Bridge(deps) {
1104
1220
  description: row.profile?.description ?? null,
1105
1221
  capabilities: row.profile?.capabilities ?? []
1106
1222
  }));
1107
- return ok(res, { agents, total: page.total ?? agents.length });
1223
+ return ok(res, { agents, total: page.total ?? agents.length, org_id: page.org_id ?? null, org_label: page.org_label ?? null });
1108
1224
  }
1109
1225
  if (method === "GET" && path === `${BRIDGE_PREFIX}/directory`) {
1110
1226
  const { apiUrl } = await ownerContext();
@@ -1255,6 +1371,7 @@ function createMsg9Bridge(deps) {
1255
1371
  const { inbox } = await inboxFor(key, signal);
1256
1372
  await deps.api.markRead(inbox.api_url, inbox.api_key, messageId, "human", signal);
1257
1373
  await setMessageMark(key, messageId, { read_by: "human" });
1374
+ invalidateUnreadCache();
1258
1375
  deps.events?.emit("read");
1259
1376
  return ok(res, { message_id: messageId, read: true });
1260
1377
  }
@@ -1272,6 +1389,7 @@ function createMsg9Bridge(deps) {
1272
1389
  });
1273
1390
  }
1274
1391
  await setMessageMark(key, messageId, { read_by: "human", processed_by: "human" });
1392
+ invalidateUnreadCache();
1275
1393
  deps.events?.emit("done");
1276
1394
  return ok(res, { message_id: messageId, processed: true });
1277
1395
  }
@@ -1301,6 +1419,7 @@ function createMsg9Bridge(deps) {
1301
1419
  const mailDomain = typeof me.mail_domain === "string" ? me.mail_domain : void 0;
1302
1420
  const addressDomain2 = typeof me.address_domain === "string" ? me.address_domain : null;
1303
1421
  await setOwner({ api_key: ownerKey, api_url: apiUrl, id, name: name2, slug, mail_domain: mailDomain, address_domain: addressDomain2 });
1422
+ invalidateUnreadCache();
1304
1423
  return ok(res, {
1305
1424
  owner: {
1306
1425
  name: name2 ?? null,
@@ -1322,6 +1441,7 @@ function createMsg9Bridge(deps) {
1322
1441
  if (!existing?.api_key) throw new BridgeError(404, "unknown-workspace", `no inbox is registered as "${key}"`);
1323
1442
  const workspace = deps.listWorkspaces().find((row) => row.key === key) ?? { key, title: existing.title, path: existing.path };
1324
1443
  const result = await migrateInbox(workspace, existing, str(body.old_owner_key));
1444
+ invalidateUnreadCache();
1325
1445
  deps.log(`migrated ${key}: ${existing.address} -> ${result.inbox.address}`);
1326
1446
  deps.events?.emit("migrate");
1327
1447
  return ok(res, {
@@ -1347,6 +1467,7 @@ function createMsg9Bridge(deps) {
1347
1467
  title ? { ...workspace, title } : workspace,
1348
1468
  signal
1349
1469
  );
1470
+ if (provisioned) invalidateUnreadCache();
1350
1471
  return ok(res, { key: workspace.key, address: inbox.address, provisioned });
1351
1472
  }
1352
1473
  if (method === "POST" && path === `${BRIDGE_PREFIX}/resolve`) {
@@ -1782,7 +1903,7 @@ ${formatMessage(message, Math.max(0, args.body_limit ?? 0))}`;
1782
1903
  );
1783
1904
  }
1784
1905
  const { api_key } = await ownerRotateAgentKey(owner.api_url, owner.api_key, inbox.address, exec?.signal);
1785
- await upsertWorkspaceInbox(workspace.key, { ...inbox, api_key });
1906
+ await upsertWorkspaceInbox(workspace.key, { api_key });
1786
1907
  return L(
1787
1908
  "\u5DF2\u8F6E\u6362\u300C{title}\u300D({address}) \u7684 key\uFF0C\u65B0 key \u5DF2\u4FDD\u5B58\u3002",
1788
1909
  'Rotated the key for "{title}" ({address}); the new key is saved.',
@@ -2006,6 +2127,16 @@ function unseenMessages(messages, lastSeenId, lastSeenAt) {
2006
2127
  }
2007
2128
  return messages;
2008
2129
  }
2130
+ function createNonReentrant(task) {
2131
+ let running = false;
2132
+ return () => {
2133
+ if (running) return;
2134
+ running = true;
2135
+ void task().finally(() => {
2136
+ running = false;
2137
+ });
2138
+ };
2139
+ }
2009
2140
  async function pollOnce(deps, rt) {
2010
2141
  const state = await deps.loadState();
2011
2142
  for (const [key, inbox] of Object.entries(state.workspaces)) {
@@ -2014,6 +2145,13 @@ async function pollOnce(deps, rt) {
2014
2145
  await pollInbox(deps, rt, key, inbox);
2015
2146
  } catch (error) {
2016
2147
  deps.log(`watch poll failed for ${key}: ${error?.message ?? String(error)}`);
2148
+ const status = error?.status;
2149
+ if (status === 429) {
2150
+ const serverWait = error?.retryAfter;
2151
+ const backoff = typeof serverWait === "number" && serverWait > 0 ? serverWait * 1e3 : 6e4;
2152
+ deps.log(`watch poll rate-limited for ${key}; backing off ${backoff / 1e3}s`);
2153
+ await (deps.sleep ?? defaultSleep)(backoff);
2154
+ }
2017
2155
  }
2018
2156
  }
2019
2157
  }
@@ -2094,7 +2232,10 @@ async function pollInbox(deps, rt, key, inbox) {
2094
2232
  const all = page.messages ?? [];
2095
2233
  if (page.next_cursor) {
2096
2234
  await deps.setWatchState(key, { watch_cursor: page.next_cursor });
2097
- if (all[0]) await deps.setWatchState(key, { watch_last_message_id: all[0].message_id });
2235
+ if (all[0]) await deps.setWatchState(key, {
2236
+ watch_last_message_id: all[0].message_id,
2237
+ ...all[0].created_at ? { watch_last_seen_at: all[0].created_at } : {}
2238
+ });
2098
2239
  return;
2099
2240
  }
2100
2241
  const fresh = unseenMessages(all, inbox.watch_last_message_id, inbox.watch_last_seen_at);
@@ -2249,6 +2390,7 @@ function startWatcher(ctx, agents, getRegistry, log, events, reconcileUnread) {
2249
2390
  isPaused: () => getNotifyPaused(),
2250
2391
  resolveAgentById: (id) => agents.get(id),
2251
2392
  batchWindowMs: Math.max(0, Number(process.env.MSG9_WATCH_BATCH_MS ?? 12e3) || 12e3),
2393
+ sleep: defaultSleep,
2252
2394
  resolveAgent: async ({ inbox }) => {
2253
2395
  const registry = getRegistry();
2254
2396
  if (registry?.resolveByPath) {
@@ -2285,7 +2427,7 @@ function startWatcher(ctx, agents, getRegistry, log, events, reconcileUnread) {
2285
2427
  loopControllers.clear();
2286
2428
  };
2287
2429
  const startPolling = () => {
2288
- pollTimer ??= setInterval(() => void pollOnce(deps, rt), WATCH_POLL_MS);
2430
+ pollTimer ??= setInterval(createNonReentrant(() => pollOnce(deps, rt)), WATCH_POLL_MS);
2289
2431
  };
2290
2432
  const reconcile = async () => {
2291
2433
  if (streamUnsupported || master.signal.aborted) return;
@@ -2371,15 +2513,18 @@ export {
2371
2513
  computeUnread,
2372
2514
  createBridgeEventBus,
2373
2515
  createMsg9Bridge,
2516
+ createNonReentrant,
2374
2517
  createWatchRuntime,
2375
2518
  defaultBridgeDeps,
2376
2519
  ensureInbox,
2377
2520
  flushBatch,
2378
2521
  inject,
2522
+ invalidateUnreadCache,
2379
2523
  isTrustedRequest,
2380
2524
  listWorkspaces,
2381
2525
  loadState,
2382
2526
  matchWorkspaceByPath,
2527
+ migrateInbox,
2383
2528
  name,
2384
2529
  ownerContext,
2385
2530
  pluginNotice,
@@ -2387,8 +2532,10 @@ export {
2387
2532
  renderMailNotice,
2388
2533
  resolveInbox,
2389
2534
  resolveWorkspace,
2535
+ setOwner,
2390
2536
  setWorkspaceRegistry,
2391
2537
  stateFilePath,
2392
2538
  streamInboxLoop,
2393
- unseenMessages
2539
+ unseenMessages,
2540
+ upsertWorkspaceInbox
2394
2541
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-msg9-kit",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "dsh (DeepSeek Harness) msg9 kit: one msg9.io inbox per workspace. Model tools (send/receive/read/contacts/resolve) plus a /msg9 command in the agent, and a ✉ sidebar icon that opens the same mailbox in the GUI — inbox, outbox and address book, with an unread badge. 给每个 dsh workspace 一个 msg9.io 收件箱:Agent 用工具收发,界面里点 ✉ 就能看同一份邮件。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -59,7 +59,7 @@
59
59
  "scripts": {
60
60
  "build": "node scripts/build.mjs",
61
61
  "typecheck": "tsc -p tsconfig.json --noEmit",
62
- "test": "node tests/smoke.test.mjs && node tests/client.test.mjs && node tests/cordis.test.mjs && node tests/watch.test.mjs && node tests/watch-integration.test.mjs",
62
+ "test": "node tests/smoke.test.mjs && node tests/client.test.mjs && node tests/cordis.test.mjs && node tests/watch.test.mjs && node tests/watch-integration.test.mjs && node tests/host-fixes.test.mjs",
63
63
  "prepare": "npm run build",
64
64
  "prepack": "npm run build"
65
65
  },