dsh-cindy-host 0.1.30 → 0.1.32

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/CHANGELOG.md CHANGED
@@ -1,11 +1,30 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.30 — 2026-10-29
3
+ ## 0.1.32 — 2026-11-03
4
4
 
5
- - Match Cindy native local media fetching: baseDir/workdir constraints are optional; absolute file references no longer fail with no-root. Explicit constraints, sensitive-path checks, regular-file checks and byte limits remain enforced.
6
- - Fix the shared parser/resolver for all file types and original, thumbnail, prepareOnly and file-peer reads; no PNG-specific workaround or controller changes.
7
- - Add contract regressions for 15 extensions, POSIX/Windows paths, both file/audio schemes, real filesystem preparation and rootless peer bytes/EOF.
8
- - Minimal patch on published 0.1.29; preserves its runtime and queue/input-delivery behavior. Requires the same DSH 0.2.1-rc.3 carrier and Node >=22.16. Linux handset end-to-end acceptance requires deployment and is not implied by publication.
5
+ - Remove keytar and its native npm build chain. Linux/macOS keep system credential vaults through secret-tool/security; Windows encrypts with built-in DPAPI CurrentUser and persists ciphertext through the native DSH credential record service. Login, renewal and logout share one installation-scoped store; failures never fall back to plaintext or pretend a stored login is absent. Windows upgrades may require one explicit login; legacy vault entries are left untouched.
6
+
7
+ - Add exact DSH `0.2.2-alpha.2` session/settings peers and its Schemastery `3.18.6-alpha.1` peer, retaining the existing rc.3/rc.4/rc.5 carriers. Alpha's own Node.js >=24.9 requirement still applies.
8
+ - Replace removed `standingKeyFor` draft discovery with the current preset revision lease (`acquireScope`), released after successful or failed reads; prefer isolated live-Agent skill services and retain older-carrier fallback.
9
+ - Add lease-lifecycle regressions and a reproducible isolated-alpha core-contract smoke covering real compatibility admission, Loader activation, Session message/input identity and preset skill visibility. Controller/settings/archive method checks are structural only; alpha Web, persistent settings, relay and handset acceptance remain separate gates. The core-contract smoke does not modify or reload a production profile.
10
+
11
+ - Connect archive/restore metadata to native DSH workspace-registry durability, with native activity checks, cross-device committed archive updates, list-cache invalidation, and explicit unpin acknowledgements. Preserve legacy controller archive flags until an explicit operation retires them; deletion remains non-destructive and Host-local.
12
+
13
+ - Delegate remote model switching to native DSH when its selection API is composed. The Host now advertises model-window authority so Cindy does not reject DSH selections with its legacy Pi/unknown-capacity precheck. Running-turn selections and selecting the original model again use the same DSH next-request API; no Cindy or DSH core patch is required.
14
+ - Keep missing native selection unavailable and preserve native refusal propagation. Regression coverage includes capability/catalog degradation, all three client aliases, switching back, and the unmodified Cindy consumer guard.
15
+
16
+ ## 0.1.31 — 2026-10-31
17
+
18
+ - Merge `main` into the DSH 0.2.1 carrier. `main` was a single working-tree snapshot that had diverged since the 0.2.1 peers landed; every conflict was resolved by keeping the newer, functionally wider side and re-taking `main`'s unique fixes rather than picking a whole file.
19
+ - **Behavioral change from `main`:** media and file-peer fetch accept Cindy's native URL shape — a project-folder export sends only `path` and an optional `v`, with no directory roots. Containment is now enforced only when the controller names roots; the never-serve denylist (`.ssh`, `.aws`, `.gnupg`, `.env`, `id_rsa`, …), the regular-file check, the 25 MiB cap and the authenticated-controller policy are unchanged.
20
+ - Add the native-contract fixture matrix (15 extensions × POSIX/Windows roots, offline) and a file-peer test that pins the rootless transfer and its EOF end to end.
21
+ - Keep DSH 0.2.1-rc.3/rc.4/rc.5 as the accepted peer set; the verified isolated runtime for this release is **DSH 0.2.1-rc.5** on Node.js 24.12.
22
+ - Device deletion keeps the bearer-renewal retry added on this carrier (401 → renew once through the Host owner, then re-validate the target before retrying); `doc/device-management.md` now states that exception instead of claiming deletion never renews anything.
23
+ - `doc/` stays out of the published package, as it has since 0.1.28; `doc/publishing.md` no longer documents the opposite.
24
+
25
+ ## 0.1.30 — 2026-10-31
26
+
27
+ - Accept `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-settings` `0.2.1-rc.4` and `0.2.1-rc.5` in addition to `0.2.1-rc.3`. The pinned single-version peers made `dsh plugin add` reject the install on a host that had already moved to rc.5. No runtime behavior change.
9
28
 
10
29
  ## 0.1.29 — 2026-10-29
11
30
 
package/README.md CHANGED
@@ -4,14 +4,61 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
4
4
 
5
5
  ## Requirements and installation
6
6
 
7
- Version 0.1.29 targets the verified **DSH 0.2.1-rc.3** service contracts, with **Node.js >=22.16**. Its DSH peer versions are pinned to this carrier to prevent an installer from mixing a newer prerelease SDK into the running host. The verified Desktop runtime includes Node.js 24.21. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
7
+ Version **0.1.32** additionally supports **DSH 0.2.2-alpha.2**, while retaining `0.2.1-rc.3`, `0.2.1-rc.4` and `0.2.1-rc.5`. Alpha requires **Node.js >=24.9** (tested with 24.12); the older carriers retain this plugin's Node.js >=22.16 minimum. Session/settings peers and alpha Schemastery are explicitly allowed, not a blanket promise for future prereleases. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
8
8
 
9
9
  ```sh
10
- dsh plugin --profile <profile> add dsh-cindy-host@0.1.29
10
+ dsh plugin --profile <profile> add dsh-cindy-host@0.1.32
11
11
  ```
12
12
 
13
13
  Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
14
14
 
15
+ ## 0.1.32: credentials without keytar
16
+
17
+ No native npm credential module or C++ build toolchain is required. Linux uses `secret-tool` with an unlocked Secret Service (headless sessions need their own DBus/keyring setup); macOS uses the system Keychain through `/usr/bin/security`. Both keep the existing `DSH Cindy Host` service and installation-specific account.
18
+
19
+ Windows uses built-in Windows PowerShell/.NET DPAPI (CurrentUser), then stores **only encrypted ciphertext** in DSH’s native `credentials` record service. DSH owns the private file, atomic writes and record removal; tokens never enter settings, child-process arguments or logs. No Cindy/Electron integration is required. DSH without the record API, blocked PowerShell, a locked keychain, missing `secret-tool`, or damaged ciphertext produces a storage error, not a missing login or a plaintext fallback.
20
+
21
+ Windows users upgrading from keytar may need to sign in once through the **DSH settings card**. Old Windows Credential Manager entries are not automatically read or deleted; rollback can still see them, and users can remove them manually after confirming the new login. Windows standalone `npm run login/logout/host` has no DSH credential context: use the settings card instead. Linux/macOS standalone commands keep using the same system keychain. Different DSH homes remain isolated. DPAPI ciphertext is not a portable backup: another OS user/machine generally cannot decrypt it.
22
+
23
+ Native Windows persistence smoke (fake credentials and an isolated temporary DSH document only):
24
+
25
+ ```sh
26
+ node tools/credential-store-native-smoke.mjs --runtime <DSH-runtime-root>
27
+ ```
28
+
29
+ The runtime root must contain compiled DSH SDK packages under node_modules. This checks real DPAPI encryption, native DSH atomic persistence, fresh-context restore, renewal and logout without accessing a daily profile. Linux/macOS command adapters have mock-contract tests; live keychain acceptance must run on those operating systems.
30
+
31
+ ## 0.1.32: DSH 0.2.2-alpha.2 compatibility
32
+
33
+ - Accept the exact alpha session/settings carrier and `@deepseek-ai/schemastery@3.18.6-alpha.1` without installing another DSH runtime as a dependency.
34
+ - Draft skill discovery uses `agentPresets.acquireScope()` and releases the revision after success or failure. Live Agents prefer their isolated preset skill service; older standing-key carriers retain their fallback.
35
+ - Isolated alpha core-contract smoke exercises the real compatibility evaluator, Cordis Loader, Session message folding, and preset-scoped skill discovery. It also checks controller/settings/archive method presence. This is **not** full Web/profile-persistence/relay/handset acceptance; those remain unverified on alpha. No daily profile is modified or restarted.
36
+
37
+ Reproduce without installing into a DSH profile:
38
+
39
+ ```sh
40
+ npm install --prefix <isolated-npm-project> --ignore-scripts \
41
+ @deepseek-ai/dsh-sdk-minimal@0.2.2-alpha.2 \
42
+ @deepseek-ai/dsh-api-session-controller@0.2.2-alpha.2 \
43
+ @deepseek-ai/dsh-settings@0.2.2-alpha.2 \
44
+ @deepseek-ai/dsh-agent-preset-registry@0.2.2-alpha.2 \
45
+ @deepseek-ai/dsh-skill@0.2.2-alpha.2 \
46
+ @deepseek-ai/dsh-workspace@0.2.2-alpha.2 \
47
+ @deepseek-ai/dsh-home-paths@0.2.2-alpha.2 ws@8.21.3 werift@0.24.4
48
+ ```
49
+
50
+ Unpack `npm pack` output beneath that npm project's directory, so its imports use the alpha peers, then run (the smoke asserts that resolution):
51
+
52
+ ```sh
53
+ node tools/dsh-compat-smoke.mjs --runtime <isolated-npm-project> --plugin <unpacked-package>
54
+ ```
55
+
56
+ For a full alpha-dependency regression, additionally copy the repository's test-only `test`, `tools`, `fixtures` directories and the root lockfile into that unpacked package and run `node --test` from it. These fixtures are intentionally not distributed in the npm tarball.
57
+
58
+ ## 0.1.31: merged `main`, native rootless media fetch
59
+
60
+ Absorbs the `main` branch on top of the 0.2.1 carrier. The one behavioral change that came from it: media and file-peer fetch now accept Cindy's **native URL shape**, where a project-folder export sends only `path` plus an optional `v` and no directory roots. Directory containment is therefore enforced only when the controller actually names roots; the never-serve denylist, the regular-file check, the 25 MiB cap and the separate authenticated-controller policy are unchanged. Also carried over: the native-contract fixture matrix (15 extensions × POSIX/Windows), the repository/docs snapshot, and a test that pins the rootless file-peer transfer end to end.
61
+
15
62
  ## 0.1.29: reproducible repository release
16
63
 
17
64
  Runtime behavior is unchanged from the phone- and desktop-accepted 0.1.28. This release synchronizes the verified source, dependency lock and offline native-client contract fixtures into Git. The pinned third-party fixtures are test-only and excluded from the npm package.
@@ -32,6 +79,18 @@ The ledger lives under the active DSH profile state directory. Pending payloads
32
79
 
33
80
  All changes are Host-only: no Cindy or DSH core patch is required. The handshake compatibility stamp is separate from the actual plugin version and explicit v1 capability. Unsupported optional features are refused; this is not a promise of all native Cindy features or exactly-once external tool effects.
34
81
 
82
+ ## Model switching follows DSH
83
+
84
+ When native `sessionController.selectModel` is composed, the Host tells Cindy to delegate model-window decisions to DSH. Users may select another model during a running turn and select the original model again. DSH validates the provider/model/effort and records the selection for the **next model request**; an already-issued request is not interrupted. DSH owns context preparation and compaction, not Cindy's legacy 90%-window precheck or a Host-side summary/rebuild implementation.
85
+
86
+ Missing capacity metadata is not a Host-side veto. Native DSH refusals still propagate (an unavailable route is reported as `NOT_AVAILABLE`), and no switch capability is advertised when the native selection API is absent. This is not a guarantee that every model request will succeed or that compaction is lossless. Re-selecting the original model does not undo any compaction already performed by DSH.
87
+
88
+ ## Native DSH session archive
89
+
90
+ Controller archive/restore metadata now calls the same durable `workspaceRegistry.archiveSession` / `unarchiveSession` operations as DSH itself. Lists, exact reads and activity badges fold the native archive set; committed desktop changes invalidate controller lists and publish row patches. Archiving drops the pin without deleting history or changing workspace membership.
91
+
92
+ The existing archive gesture does not request `stopActivity`, so DSH refuses sessions with running work; stop the work explicitly before archiving. Missing native archive services and storage failures are not downgraded to local-only success. Old controller-only archive flags remain visible until an explicit archive/restore retires that session's flag; upgrades do not bulk-archive native sessions. Delete remains a controller-only hide, and pin timestamps remain Host metadata.
93
+
35
94
  ## Previous release and rollback
36
95
 
37
96
  0.1.26 added editable, locally persistent Host names and desktop-safe plain-text queue content. Reinstalling 0.1.26 is possible, but pause input first and preserve the 0.1.27 ledger: downgrading disables durable-input recovery until 0.1.27 is restored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -61,19 +61,16 @@
61
61
  "test:file-peer": "node --test test/file-peer-rtc.test.js test/host-file-peer.test.js test/host-file-ice.test.js test/host-media-fetch.test.js test/file-peer-integration.test.js",
62
62
  "smoke:file-peer": "node tools/file-peer-rtc-smoke.mjs --manager"
63
63
  },
64
- "//dependencies": "只放宿主不提供的库。DSH 自己的包一律不进 dependencies —— 见下面 peerDependencies 的说明。`keytar` 放 optionalDependencies:它是**原生**模块,没有 C++ 工具链的机器上必然编译失败,而 pnpm 会把普通 dependency 的构建失败当成安装失败(exit 1)—— DSH 随后就不会把这个 bundle 写进 `dsh.profile.bundles`,行也就永远不会出现。可选依赖的构建失败是允许的,与我们本来就有的惰性 require + 降级(凭据能力不可用,插件照常 mount)语义一致。",
64
+ "//dependencies": "只放宿主不提供的库。凭据存储不依赖原生 npm 模块:Linux/macOS 使用系统钥匙串,Windows 使用系统 DPAPI 加密并由 DSH 原生凭据服务持久化密文。",
65
65
  "dependencies": {
66
66
  "werift": "0.24.4",
67
67
  "ws": "^8.21.3"
68
68
  },
69
- "optionalDependencies": {
70
- "keytar": "^7.9.0"
71
- },
72
69
  "//peerDependencies": "DSH 自己的包必须由宿主提供,绝不能声明成 dependency/optionalDependency:pnpm 会把它们**连同整代依赖图**装进 profile,宿主再组合这个混合体就起不来(实测:`^0.1.1-rc.2` 会把 28 个 0.1.1-rc.2 的包装进一个 0.1.5-rc.2 的宿主,profile 启动失败于 dsh-client-file-upload / dsh-session)。区间必须覆盖宿主那一代(semver 的预发布规则:`^0.1.1-rc.2` 匹配不到 `0.1.5-rc.2`),与生态里 dsh-codex / dsh-cost-meter 的写法一致;`0.2.0-rc.2` 需要 `>=0.2.0-rc.1 <0.3.0-0`。",
73
70
  "peerDependencies": {
74
- "@deepseek-ai/dsh-session": "0.2.1-rc.3",
75
- "@deepseek-ai/dsh-settings": "0.2.1-rc.3",
76
- "@deepseek-ai/schemastery": "^3.18.1"
71
+ "@deepseek-ai/dsh-session": "0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2",
72
+ "@deepseek-ai/dsh-settings": "0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2",
73
+ "@deepseek-ai/schemastery": "^3.18.1 || 3.18.6-alpha.1"
77
74
  },
78
75
  "//peerDependenciesMeta": "**故意没有这个字段。** `@deepseek-ai/dsh-host-apiproxy` 曾经在这里标 optional,但 0.2 已经删掉了这个包,而 `evaluatePluginCompatibility` 会遍历 `peerDependencies` 的**每一个键且不查 `peerDependenciesMeta`**,所以只要它出现在 peers 里,`dsh plugin add` 就会直接拒绝安装(`installation rejected`),启动时 bundle 也会被静默跳过、`dsh-cindy-host` 行根本不会出现。该适配器本来就走惰性 `require` + 失败降级(见 src/dsh-plugin.js 的 apiProxy 分支),从 peers 里移除不改变任何运行时行为,只是让两道门禁都能正确判定。",
79
76
  "devDependencies": {
@@ -92,14 +92,14 @@ export async function refreshOwnedCredential(captured, ports = {}) {
92
92
  */
93
93
  export async function restoreSession(ports = {}) {
94
94
  const load = ports.loadSession ?? loadSession;
95
- // A missing native dependency says nothing about whether the OS holds a login.
96
- // Injected stores do not depend on this machine's keytar unless they opt in.
95
+ // An unavailable backend says nothing about whether a stored login exists.
96
+ // Injected stores own their availability check.
97
97
  const available = ports.credentialStoreAvailable ?? (load === loadSession ? credentialStoreAvailable : undefined);
98
98
  if (available && !available()) {
99
99
  return {
100
100
  ok: false,
101
101
  reason: 'CREDENTIAL_STORE_UNAVAILABLE',
102
- message: '本机 Cindy 凭据存储不可用,无法读取已有登录态。请修复当前 DSH 配置中的 keytar 原生模块(允许安装构建脚本或重新构建),然后重启 DSH;请勿因此清除登录态或重复登录。',
102
+ message: '本机 Cindy 凭据存储不可用。Linux 请检查 secret-tool 和已解锁的 Secret Service;macOS 请检查钥匙串授权;Windows 请检查系统 PowerShell 和 DSH 原生凭据服务,然后重启 DSH。请勿因此清除已有登录态。',
103
103
  };
104
104
  }
105
105
  const save = ports.saveSession ?? saveSession;
@@ -181,6 +181,7 @@ export async function forgetSession(ports = {}) {
181
181
  export async function getAuthenticatedSession() {
182
182
  const restored = await restoreSession();
183
183
  if (restored.ok) return restored.session;
184
+ if (restored.reason === 'CREDENTIAL_STORE_UNAVAILABLE') throw Object.assign(new Error(restored.message), { code: restored.reason });
184
185
  const session = await loginWithPhone({ clientType: 'desktop' });
185
186
  await adoptSession(session);
186
187
  return session;
@@ -530,7 +530,7 @@ export function createChannelRouter({
530
530
  return row;
531
531
  }
532
532
 
533
- return async function handleInvoke(request) {
533
+ const handleInvoke = async function handleInvoke(request) {
534
534
  // A reply is addressed by `id` + `src`; without both there is nothing the
535
535
  // relay could route, so this answers nothing rather than fabricating a frame.
536
536
  if (request === null || typeof request !== 'object') return null;
@@ -754,8 +754,13 @@ export function createChannelRouter({
754
754
  // The catalog is a live read, not a constant: it is what the picker lists,
755
755
  // and answering from a frozen copy would keep offering models that this
756
756
  // Host can no longer route to.
757
- const readCatalog = capabilitiesNow().modelCatalog;
758
- if (typeof readCatalog !== 'function') return invokeResult(request, DSH_AGENT_CAPABILITIES);
757
+ const composed = capabilitiesNow();
758
+ // DSH, not the controller's legacy Pi/90%-window heuristic, owns whether
759
+ // a model may be selected. Advertise only when that native path is composed.
760
+ const nativeModelSelection = typeof composed.selectModel === 'function';
761
+ const fallback = { ...DSH_AGENT_CAPABILITIES, supportsModelWindowSwitchGuard: nativeModelSelection };
762
+ const readCatalog = composed.modelCatalog;
763
+ if (typeof readCatalog !== 'function') return invokeResult(request, fallback);
759
764
  let catalog;
760
765
  try {
761
766
  catalog = await readCatalog();
@@ -764,13 +769,13 @@ export function createChannelRouter({
764
769
  agentCatalog = null;
765
770
  catalogDefaultModel = null;
766
771
  invalidateSessionRows();
767
- return invokeResult(request, DSH_AGENT_CAPABILITIES);
772
+ return invokeResult(request, fallback);
768
773
  }
769
774
  agentCatalog = catalog;
770
775
  const defaultModel = catalog?.default?.model;
771
776
  catalogDefaultModel = typeof defaultModel === 'string' && defaultModel !== '' ? defaultModel : null;
772
777
  invalidateSessionRows();
773
- const capabilities = toAgentCapabilities(catalog);
778
+ const capabilities = toAgentCapabilities(catalog, { nativeModelSelection });
774
779
  // These two fields are what make the controls appear at all: the controller
775
780
  // draws the permission picker only when presets are advertised, and the
776
781
  // plan-mode switch only when support is — and DSH documents a missing
@@ -1055,9 +1060,9 @@ export function createChannelRouter({
1055
1060
  // therefore not a refusal — it is an instruction to revert the user's edit,
1056
1061
  // which is how these three actions used to look like dead buttons.
1057
1062
  //
1058
- // Only the title has a DSH counterpart; the other three are this Host's own
1059
- // bookkeeping (`session-flags.js`), so the reply is built from the source row
1060
- // with the flags folded back on — the same fold every read path uses.
1063
+ // The runtime delegates archive/restore to native DSH and keeps delete/pin
1064
+ // compatibility metadata. The reply uses that same effective projection
1065
+ // as every list/get read, never just the requested status.
1061
1066
  const capabilities = capabilitiesNow();
1062
1067
  const rename = capabilities.renameSession;
1063
1068
  let wrote = false;
@@ -1071,7 +1076,13 @@ export function createChannelRouter({
1071
1076
  if (writesMeta) {
1072
1077
  const applyFlags = capabilities.applySessionFlags;
1073
1078
  if (typeof applyFlags !== 'function') return invokeError(request, 'NOT_AVAILABLE', 'This DSH Host cannot archive, delete or pin sessions');
1074
- const applied = await applyFlags(sessionId, { status: patch.status, pinnedAt: patch.pinnedAt });
1079
+ let applied;
1080
+ try {
1081
+ applied = await applyFlags(sessionId, { status: patch.status, pinnedAt: patch.pinnedAt });
1082
+ } catch (error) {
1083
+ if (error?.refusalCode !== undefined) return invokeError(request, refusalCodeOf(error), String(error.message));
1084
+ throw error;
1085
+ }
1075
1086
  wrote = true;
1076
1087
  // Echo only what was written, with this Host's effective value: that is the
1077
1088
  // same patch the controller applied optimistically, so other devices converge
@@ -1771,4 +1782,6 @@ export function createChannelRouter({
1771
1782
  // Fail closed and say so. The controller degrades on CHANNEL_NOT_ALLOWED.
1772
1783
  return invokeError(request);
1773
1784
  };
1785
+ handleInvoke.invalidateSessionRows = invalidateSessionRows;
1786
+ return handleInvoke;
1774
1787
  }
@@ -81,10 +81,9 @@ export function toCindySessionListRow(item, { now = () => new Date(), device, de
81
81
  // `'active' | 'archived' | 'deleted'`; the controller's default status filter
82
82
  // is `'active'`, so this field decides whether a row is visible at all.
83
83
  //
84
- // DSH has no archived or deleted state, so the value can only come from this
85
- // Host's own flags (`session-flags.js`), folded onto the source row before it
86
- // reaches here. A row that carries none is active, which is what every row was
87
- // before the flags existed.
84
+ // Native DSH archives and legacy controller metadata are folded together
85
+ // before reaching this wire adapter. A row with no status is active; delete
86
+ // remains a Host-only hide and never destroys DSH history.
88
87
  status: item.status === 'archived' || item.status === 'deleted' ? item.status : 'active',
89
88
  agentKind: agentKindForModel({ model: item.model ?? defaultModel, provider: item.providerId, catalog }),
90
89
  // The controller looks the current model up by id in the catalog it fetched
@@ -134,11 +133,11 @@ export function toCindySessionListRow(item, { now = () => new Date(), device, de
134
133
  // moment the user selects this Host — which is the only way to see it.
135
134
  deviceLinkDeviceId: device?.deviceId ?? null,
136
135
  deviceLinkDeviceName: device?.deviceName ?? null,
137
- // Present only when a controller pinned this session here (the flag store's
138
- // doing, not DSH's). The controller reads a missing field as "not pinned" — and
139
- // on a write reply it reads a missing field as "keep what I have", which is
140
- // exactly how an unpin stays applied without this row asserting `null`.
141
- ...(typeof item.pinnedAt === 'string' && item.pinnedAt !== '' ? { pinnedAt: item.pinnedAt } : {}),
136
+ // Preserve a known unpin (including native archival). A missing field is
137
+ // 'keep what I have' on a write reply, so dropping explicit null can retain
138
+ // the controller's old pin even though DSH has archived the session.
139
+ ...(item.pinnedAt === null ? { pinnedAt: null }
140
+ : typeof item.pinnedAt === 'string' && item.pinnedAt !== '' ? { pinnedAt: item.pinnedAt } : {}),
142
141
  };
143
142
  }
144
143
 
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export class CredentialStoreUnavailableError extends Error {
4
+ constructor(message = '本机凭据存储不可用。Linux 请检查 secret-tool 和已解锁的 Secret Service;macOS 请检查钥匙串授权;Windows 请检查系统 PowerShell 和 DSH 原生凭据服务。') {
5
+ super(message);
6
+ this.name = 'CredentialStoreUnavailableError';
7
+ this.code = 'CREDENTIAL_STORE_UNAVAILABLE';
8
+ }
9
+ }
10
+
11
+ /** No shell, no secret argv, bounded time/output, and never expose child diagnostics. */
12
+ export function runCredentialCommand(file, args, input = '', { timeoutMs = 15_000, maxBytes = 1024 * 1024 } = {}) {
13
+ return new Promise((resolve, reject) => {
14
+ let child;
15
+ try { child = spawn(file, args, { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); }
16
+ catch { reject(new CredentialStoreUnavailableError()); return; }
17
+ let done = false;
18
+ let bytes = 0;
19
+ const stdout = [];
20
+ const stderr = [];
21
+ const fail = () => {
22
+ if (done) return;
23
+ done = true;
24
+ clearTimeout(timer);
25
+ child.kill();
26
+ reject(new CredentialStoreUnavailableError());
27
+ };
28
+ const timer = setTimeout(fail, timeoutMs);
29
+ child.on('error', fail);
30
+ child.stdin.on('error', fail);
31
+ for (const [stream, chunks] of [[child.stdout, stdout], [child.stderr, stderr]]) {
32
+ stream.on('error', fail);
33
+ stream.on('data', chunk => {
34
+ bytes += chunk.length;
35
+ if (bytes > maxBytes) { fail(); return; }
36
+ chunks.push(chunk);
37
+ });
38
+ }
39
+ child.on('close', code => {
40
+ if (done) return;
41
+ done = true;
42
+ clearTimeout(timer);
43
+ resolve({ code, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') });
44
+ });
45
+ child.stdin.end(input);
46
+ });
47
+ }
48
+
49
+ // System .NET assembly, not C# compilation. All data (including entropy) travels on stdin.
50
+ export const DPAPI_SCRIPT = [
51
+ "$ErrorActionPreference = 'Stop'",
52
+ 'Add-Type -AssemblyName System.Security',
53
+ '$r = [Console]::In.ReadToEnd() | ConvertFrom-Json',
54
+ '$data = [Convert]::FromBase64String($r.data)',
55
+ '$entropy = [Convert]::FromBase64String($r.entropy)',
56
+ "$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser",
57
+ "if ($r.operation -eq 'protect') { $result = [System.Security.Cryptography.ProtectedData]::Protect($data, $entropy, $scope) } elseif ($r.operation -eq 'unprotect') { $result = [System.Security.Cryptography.ProtectedData]::Unprotect($data, $entropy, $scope) } else { throw 'Invalid operation' }",
58
+ '[Console]::Out.Write([Convert]::ToBase64String($result))',
59
+ ].join('; ');
@@ -1,55 +1,12 @@
1
- import { createRequire } from 'node:module';
2
1
  import { createHash } from 'node:crypto';
3
2
  import { homedir } from 'node:os';
4
- import { resolve as resolvePath } from 'node:path';
3
+ import { resolve as resolvePath, win32 } from 'node:path';
4
+ import { runCredentialCommand, DPAPI_SCRIPT, CredentialStoreUnavailableError } from './credential-command.js';
5
+ export { CredentialStoreUnavailableError } from './credential-command.js';
5
6
 
6
7
  const SERVICE = 'DSH Cindy Host';
8
+ const FORMAT = 'dpapi-current-user-v1';
7
9
 
8
- /**
9
- * `keytar` is a **native** module, so it is loaded lazily and its absence is not fatal.
10
- *
11
- * Measured on a clean install (a packed tarball into a fresh `DSH_HOME`): pnpm 10 does not run
12
- * dependency build scripts by default, so `keytar.node` was never built, and a top-level import
13
- * here made the whole plugin fail to load — the profile would not boot over a *credential store*
14
- * it may never need. An optional capability must not be able to break mounting, so the module is
15
- * required the moment it is used and a failure degrades to "no credential store": a read answers
16
- * "no stored session" (the card asks for a login), and a write throws a named error instead of
17
- * pretending to have saved.
18
- *
19
- * @returns the keytar module, or null when it cannot be loaded on this machine.
20
- */
21
- let keytarModule;
22
- function keytar() {
23
- if (keytarModule === undefined) {
24
- try {
25
- keytarModule = createRequire(import.meta.url)('keytar');
26
- } catch {
27
- keytarModule = null;
28
- }
29
- }
30
- return keytarModule;
31
- }
32
-
33
- /** Whether a credential store is usable here, for callers that want to say so. */
34
- export function credentialStoreAvailable() {
35
- return keytar() !== null;
36
- }
37
-
38
- /** The error a write raises when this machine has no usable credential store. */
39
- export class CredentialStoreUnavailableError extends Error {
40
- constructor() {
41
- super('this machine has no usable credential store (keytar is not built; on pnpm 10 approve the keytar build script)');
42
- this.code = 'CREDENTIAL_STORE_UNAVAILABLE';
43
- }
44
- }
45
-
46
- /**
47
- * The credential-store entry the default installation has always used.
48
- *
49
- * The OS keeps these per (service, account), and the account is what separates two DSH
50
- * installations on one machine — not a folder, because there is no folder: one machine,
51
- * one credential store.
52
- */
53
10
  export const DEFAULT_ACCOUNT = 'session-v1';
54
11
 
55
12
  /** DSH's own default home: `~/.dsh` (the same precedence `dsh-home-paths` documents). */
@@ -92,7 +49,7 @@ function sameHome(left, right) {
92
49
  *
93
50
  * @param env - environment to read (injectable for tests).
94
51
  * @param defaultHome - the home treated as the default installation.
95
- * @returns the keytar account name for this process.
52
+ * @returns the OS credential account name for this process.
96
53
  */
97
54
  export function credentialAccount(env = process.env, defaultHome = defaultDshHome()) {
98
55
  const home = typeof env?.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
@@ -103,26 +60,122 @@ export function credentialAccount(env = process.env, defaultHome = defaultDshHom
103
60
  return `${DEFAULT_ACCOUNT}@${digest}`;
104
61
  }
105
62
 
106
- export async function loadSession(ports = {}) {
107
- const account = ports.account ?? credentialAccount();
108
- const store = keytar();
109
- if (store === null) return null; // no store here: "no stored session", never a crash
110
- const value = await store.getPassword(SERVICE, account);
111
- return value ? JSON.parse(value) : null;
63
+
64
+ function parseSession(text) {
65
+ const value = JSON.parse(text);
66
+ if (!value || typeof value !== 'object' || Array.isArray(value)
67
+ || !['deviceId', 'authBaseUrl', 'accessToken', 'refreshToken'].every(key => typeof value[key] === 'string' && value[key].length > 0)) {
68
+ throw new CredentialStoreUnavailableError();
69
+ }
70
+ return value;
112
71
  }
113
72
 
114
- export async function saveSession(session, ports = {}) {
115
- const account = ports.account ?? credentialAccount();
116
- const store = keytar();
117
- if (store === null) throw new CredentialStoreUnavailableError();
118
- await store.setPassword(SERVICE, account, JSON.stringify(session));
73
+ function base64(value) {
74
+ if (typeof value !== 'string' || !value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new CredentialStoreUnavailableError();
75
+ return value;
119
76
  }
120
77
 
121
- export async function clearSession(ports = {}) {
122
- const account = ports.account ?? credentialAccount();
123
- const store = keytar();
124
- // Clearing what does not exist is the outcome the caller wanted, so a missing store is not an
125
- // error here — unlike a save, which must not report success it cannot deliver.
126
- if (store === null) return;
127
- await store.deletePassword(SERVICE, account);
78
+ // security's interactive command parser accepts quoted arguments. Secrets stay off argv.
79
+ const securityQuote = value => '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
80
+
81
+ /** One installation's store. No process-global registration and no native npm modules.
82
+ * Linux/macOS keep the historical service/account, so existing keychain records remain readable.
83
+ * Windows stores ONLY DPAPI ciphertext in DSH's native record service, never in settings.
84
+ */
85
+ export function createCredentialStore({ platform = process.platform, env = process.env, account = credentialAccount(env),
86
+ getCredentials = () => undefined, run = runCredentialCommand } = {}) {
87
+ if (!/^session-v1(?:@[0-9a-f]{12})?$/.test(account)) throw new TypeError('Invalid credential account');
88
+ const recordKey = 'dsh-cindy-host/' + account.replace('@', '-home-');
89
+ const attributes = ['service', SERVICE, 'account', account];
90
+ function provider() {
91
+ const value = getCredentials();
92
+ if (!value || !['readRecord', 'modifyRecord', 'deleteRecord'].every(key => typeof value[key] === 'function')) throw new CredentialStoreUnavailableError();
93
+ return value;
94
+ }
95
+ async function command(file, args, input = '') {
96
+ return run(file, args, input);
97
+ }
98
+ async function dpapi(operation, data) {
99
+ if (!env.SystemRoot) throw new CredentialStoreUnavailableError();
100
+ const file = win32.join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
101
+ const result = await command(file, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', DPAPI_SCRIPT], JSON.stringify({
102
+ operation, data, entropy: Buffer.from(SERVICE + '/' + account).toString('base64'),
103
+ }));
104
+ if (result.code !== 0) throw new CredentialStoreUnavailableError();
105
+ return base64(result.stdout.trim());
106
+ }
107
+ async function read() {
108
+ if (platform === 'win32') {
109
+ const record = await provider().readRecord(recordKey);
110
+ if (record === undefined) return null;
111
+ if (record?.kind !== 'grant' || record.payload?.format !== FORMAT) throw new CredentialStoreUnavailableError();
112
+ const clear = await dpapi('unprotect', base64(record.payload.ciphertext));
113
+ return parseSession(Buffer.from(clear, 'base64').toString('utf8'));
114
+ }
115
+ if (platform === 'linux') {
116
+ const result = await command('secret-tool', ['lookup', ...attributes]);
117
+ if (result.code === 1 && !result.stdout && !result.stderr.trim()) return null;
118
+ if (result.code !== 0) throw new CredentialStoreUnavailableError();
119
+ return parseSession(result.stdout.trimEnd());
120
+ }
121
+ if (platform === 'darwin') {
122
+ const result = await command('/usr/bin/security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
123
+ if (result.code === 44) return null; // errSecItemNotFound, not a locked/denied keychain
124
+ if (result.code !== 0) throw new CredentialStoreUnavailableError();
125
+ return parseSession(result.stdout.trimEnd());
126
+ }
127
+ throw new CredentialStoreUnavailableError();
128
+ }
129
+ async function write(session) {
130
+ const text = JSON.stringify(session);
131
+ parseSession(text);
132
+ if (Buffer.byteLength(text) > 512 * 1024) throw new CredentialStoreUnavailableError();
133
+ if (platform === 'win32') {
134
+ const credentials = provider();
135
+ const ciphertext = await dpapi('protect', Buffer.from(text).toString('base64'));
136
+ await credentials.modifyRecord(recordKey, () => ({ kind: 'grant', payload: { format: FORMAT, ciphertext } }));
137
+ return;
138
+ }
139
+ let result;
140
+ if (platform === 'linux') result = await command('secret-tool', ['store', '--label=' + SERVICE, ...attributes], text);
141
+ else if (platform === 'darwin') {
142
+ const args = ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w', text];
143
+ result = await command('/usr/bin/security', ['-i'], args.map(securityQuote).join(' ') + '\n');
144
+ // security -i may exit zero after a failed subcommand. Its diagnostics must still fail closed.
145
+ if ([result.stdout, result.stderr].some(text => text.replace(/security>\s*/g, '').trim())) throw new CredentialStoreUnavailableError();
146
+ } else throw new CredentialStoreUnavailableError();
147
+ if (result.code !== 0) throw new CredentialStoreUnavailableError();
148
+ }
149
+ async function clear() {
150
+ if (platform === 'win32') { await provider().deleteRecord(recordKey); return; }
151
+ let result;
152
+ if (platform === 'linux') {
153
+ result = await command('secret-tool', ['clear', ...attributes]);
154
+ if (result.code === 1 && !result.stderr.trim()) return;
155
+ } else if (platform === 'darwin') {
156
+ result = await command('/usr/bin/security', ['delete-generic-password', '-s', SERVICE, '-a', account]);
157
+ if (result.code === 44) return;
158
+ } else throw new CredentialStoreUnavailableError();
159
+ if (result.code !== 0) throw new CredentialStoreUnavailableError();
160
+ }
161
+ // Neither child diagnostics nor JSON parse errors may carry credentials into logs/UI.
162
+ const guarded = action => async (...args) => {
163
+ try { return await action(...args); }
164
+ catch { throw new CredentialStoreUnavailableError(); }
165
+ };
166
+ return {
167
+ loadSession: guarded(read), saveSession: guarded(write), clearSession: guarded(clear),
168
+ credentialStoreAvailable: () => {
169
+ if (platform === 'linux' || platform === 'darwin') return true;
170
+ if (platform !== 'win32' || !env.SystemRoot) return false;
171
+ try { provider(); return true; } catch { return false; }
172
+ },
173
+ };
128
174
  }
175
+
176
+ // Standalone Linux/macOS CLIs use the same system keychain. Windows needs a DSH
177
+ // credential service; without a host context fail explicitly, never write plaintext.
178
+ export const loadSession = (ports = {}) => createCredentialStore(ports).loadSession();
179
+ export const saveSession = (session, ports = {}) => createCredentialStore(ports).saveSession(session);
180
+ export const clearSession = (ports = {}) => createCredentialStore(ports).clearSession();
181
+ export const credentialStoreAvailable = (ports = {}) => createCredentialStore(ports).credentialStoreAvailable();