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 +24 -5
- package/README.md +61 -2
- package/package.json +5 -8
- package/src/auth-session.js +4 -3
- package/src/cindy-channels.js +22 -9
- package/src/cindy-session-row.js +8 -9
- package/src/credential-command.js +59 -0
- package/src/credential-store.js +119 -66
- package/src/dsh-plugin.js +2012 -1965
- package/src/dsh-session-archive.js +49 -0
- package/src/host-models.js +7 -2
- package/src/host-routes.js +4 -4
- package/src/host-settings.js +3 -3
- package/src/host-version.js +1 -1
- package/src/host.js +87 -14
- package/src/login.js +5 -8
- package/src/session-flags.js +5 -4
- package/src/session-publisher.js +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.32 — 2026-11-03
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
- Add
|
|
8
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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": "
|
|
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": {
|
package/src/auth-session.js
CHANGED
|
@@ -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
|
-
//
|
|
96
|
-
// Injected stores
|
|
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
|
|
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;
|
package/src/cindy-channels.js
CHANGED
|
@@ -530,7 +530,7 @@ export function createChannelRouter({
|
|
|
530
530
|
return row;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
|
|
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
|
|
758
|
-
|
|
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,
|
|
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
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/cindy-session-row.js
CHANGED
|
@@ -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
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
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('; ');
|
package/src/credential-store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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();
|