dsh-cindy-host 0.1.31 → 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,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.32 — 2026-11-03
4
+
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
+
3
16
  ## 0.1.31 — 2026-10-31
4
17
 
5
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.
package/README.md CHANGED
@@ -4,14 +4,57 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
4
4
 
5
5
  ## Requirements and installation
6
6
 
7
- Version 0.1.31 targets the verified **DSH 0.2.1-rc.5** service contracts (also accepting `0.2.1-rc.3` and `0.2.1-rc.4`), with **Node.js >=22.16**. Its DSH peer versions are pinned to those carrier versions 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.31
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
+
15
58
  ## 0.1.31: merged `main`, native rootless media fetch
16
59
 
17
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.
@@ -36,6 +79,18 @@ The ledger lives under the active DSH profile state directory. Pending payloads
36
79
 
37
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.
38
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
+
39
94
  ## Previous release and rollback
40
95
 
41
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.31",
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 || 0.2.1-rc.4 || 0.2.1-rc.5",
75
- "@deepseek-ai/dsh-settings": "0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5",
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();
package/src/dsh-plugin.js CHANGED
@@ -6,6 +6,7 @@ import { createDshInputDeliveryBackend } from './dsh-input-delivery.js';
6
6
  import { DEFAULT_HOST_SETTINGS, SETTINGS_NAMESPACE, validateHostSettings } from './host-settings.js';
7
7
  import { DshHostSource } from './dsh-host-source.js';
8
8
  import { createSessionControllerSource } from './dsh-session-source.js';
9
+ import { createDshSessionArchive } from './dsh-session-archive.js';
9
10
  import { readActiveSessionSummaries } from './dsh-active-sessions.js';
10
11
  import { createMessageCounter, createMessageReader, foldSessionEvent, isHarnessNotice, promptRpcIdOf } from './dsh-message-fold.js';
11
12
  import { createHistoryViewController } from './host-history-view.js';
@@ -18,6 +19,7 @@ import { refusalError } from './cindy-channels.js';
18
19
  import { enrichModelCatalog } from './host-model-catalog.js';
19
20
  import { hydrateImageAttachments } from './cindy-message-row.js';
20
21
  import { API_PREFIX, createHostRoutes } from './host-routes.js';
22
+ import { createCredentialStore } from './credential-store.js';
21
23
 
22
24
  export const name = 'dsh-cindy-host';
23
25
  export const inject = ['settings'];
@@ -42,8 +44,8 @@ function settingsNamespace(value) {
42
44
  return value;
43
45
  }
44
46
  const ControllerSchema = Schema.object({ state: Schema.union(['authorized', 'revoked']), displayName: Schema.string(), grantedAt: Schema.string(), revokedAt: Schema.string(), grantRevision: Schema.natural() });
45
- // The archive/delete/pin flags a controller writes: this Host's own answer to three
46
- // actions DSH has no concept of. `pinnedAt: ''` is a remembered unpin (settings
47
+ // Legacy archive and controller-only delete/pin metadata. Native DSH owns new
48
+ // archive writes. `pinnedAt: ''` is a remembered unpin (settings
47
49
  // reject `null`, and an absent field cannot be told apart from "never pinned").
48
50
  const SessionFlagSchema = Schema.object({ status: Schema.union(['active', 'archived', 'deleted']), pinnedAt: Schema.string() });
49
51
 
@@ -151,7 +153,15 @@ export function buildDshSource(ctx, serviceName, options = {}) {
151
153
  * same trap the original `apiProxy` seam fell into. Neither is required, so
152
154
  * each keeps its own availability instead of failing the whole Host.
153
155
  */
154
- const controls = { planMode: undefined, permissionPresets: undefined, fileUploads: undefined, tokenMeter: undefined, llm: undefined, sessionTitle: undefined, skills: undefined };
156
+ const controls = { planMode: undefined, permissionPresets: undefined, fileUploads: undefined, tokenMeter: undefined, llm: undefined, sessionTitle: undefined, skills: undefined, workspaceRegistry: undefined };
157
+ ctx.inject(['workspaceRegistry'], (workspaceCtx) => {
158
+ controls.workspaceRegistry = workspaceCtx.get('workspaceRegistry');
159
+ return () => { controls.workspaceRegistry = undefined; };
160
+ });
161
+ const sessionArchive = createDshSessionArchive({
162
+ getRegistry: () => controls.workspaceRegistry,
163
+ subscribe: (event, listener) => ctx.on(event, listener),
164
+ });
155
165
  ctx.inject(['planMode'], (planCtx) => {
156
166
  controls.planMode = planCtx.get('planMode');
157
167
  return () => { controls.planMode = undefined; };
@@ -214,33 +224,43 @@ export function buildDshSource(ctx, serviceName, options = {}) {
214
224
  * answered `skills: 6` once it was live. Both lists describe one session; two
215
225
  * different answers there is not purity, it is a missing menu the user sees.
216
226
  *
217
- * A request naming **no** session is the new-task composer, and there the honest
218
- * answer is what a new session would offer: `agentPresets.standingKeyFor()`,
219
- * which DSH documents as "the standing scope key of one preset, for a host
220
- * reader with no agent … ensuring the mount composes plugins but starts no
221
- * agent, no session, and no turn". A session that *is* named but cannot be
222
- * resolved answers nothing: inventing the default composition's skills for an
223
- * unknown session would be a claim about a session this Host cannot see.
227
+ * A request naming **no** session is the new-task composer. Current DSH
228
+ * supplies `agentPresets.acquireScope()` instead of `standingKeyFor()`:
229
+ * keep that revision lease until the asynchronous catalog read settles,
230
+ * then release it even when the read fails. Neither path starts an Agent.
231
+ * Older carriers may still supply the non-owning standing key.
224
232
  * @param sessionId - the session whose agent to use as a viewing scope, if any.
225
- * @returns the viewing scope key, or undefined when none can be named.
233
+ * @param read - the scoped catalog operation; must settle before lease disposal.
234
+ * @returns the catalog operation's result.
226
235
  */
227
- async function agentScopeFor(sessionId) {
236
+ async function withAgentScope(sessionId, read) {
228
237
  const named = typeof sessionId === 'string' && sessionId !== '';
229
238
  const live = liveAgentFor(sessionId);
230
- if (live !== undefined) return live;
239
+ if (live !== undefined) return read(live);
231
240
  if (named) {
232
241
  const resolved = await sessionController.resolveAgent(sessionId);
233
- return resolved?.error === undefined ? resolved.agent : undefined;
242
+ return read(resolved?.error === undefined ? resolved.agent : undefined);
234
243
  }
235
244
  const presets = ctx.get('agentPresets');
236
- if (typeof presets?.standingKeyFor !== 'function') return undefined;
237
- try {
238
- return await presets.standingKeyFor();
239
- } catch {
240
- // An unusable default composition is not a reason to fail a menu; the
241
- // caller then reads the global layer.
242
- return undefined;
245
+ if (typeof presets?.acquireScope === 'function') {
246
+ let lease;
247
+ try {
248
+ lease = await presets.acquireScope();
249
+ } catch {
250
+ // An unusable default composition retains the global-only palette.
251
+ return read(undefined);
252
+ }
253
+ try {
254
+ return await read(lease.key);
255
+ } finally {
256
+ await lease[Symbol.asyncDispose]();
257
+ }
243
258
  }
259
+ let scope;
260
+ if (typeof presets?.standingKeyFor === 'function') {
261
+ try { scope = await presets.standingKeyFor(); } catch { /* Global-only fallback. */ }
262
+ }
263
+ return read(scope);
244
264
  }
245
265
 
246
266
  /**
@@ -719,8 +739,11 @@ export function buildDshSource(ctx, serviceName, options = {}) {
719
739
  }
720
740
  },
721
741
  });
742
+ // The runtime observes native archive commits even when the relay is offline.
743
+ sessionSource.archive = sessionArchive;
722
744
  return {
723
745
  kind: 'session-controller',
746
+ sessionArchive,
724
747
  readMessages,
725
748
  readAllMessages: readMessages === undefined ? undefined : committedRows,
726
749
  inputDeliveryBackend,
@@ -1011,9 +1034,12 @@ export function buildDshSource(ctx, serviceName, options = {}) {
1011
1034
  * the read that can be trusted. The lease is always released.
1012
1035
  */
1013
1036
  readSessionState: readSessionStateNow,
1014
- /** Apply the controller's model choice to one session. */
1015
- selectModel: async ({ sessionId, provider, model, reasoningEffort }) =>
1016
- applyModelSelection({ sessionId, provider, model, reasoningEffort }),
1037
+ /** Native DSH validates and records selections for the next model request,
1038
+ * including during a turn. Do not advertise that authority when absent. */
1039
+ selectModel: typeof sessionController.selectModel === 'function'
1040
+ ? async ({ sessionId, provider, model, reasoningEffort }) =>
1041
+ applyModelSelection({ sessionId, provider, model, reasoningEffort })
1042
+ : undefined,
1017
1043
  /**
1018
1044
  * Change one session's reasoning effort.
1019
1045
  *
@@ -1061,17 +1087,20 @@ export function buildDshSource(ctx, serviceName, options = {}) {
1061
1087
  * empty while its command list was not).
1062
1088
  *
1063
1089
  * The scope comes from the session's own agent, which is the only way a
1064
- * preset's layer is visible at all — see `agentScopeFor`.
1090
+ * preset's layer is visible at all — see `withAgentScope`.
1065
1091
  */
1066
- skills: async ({ cwd, sessionId }) => {
1067
- const skills = controls.skills;
1092
+ skills: async ({ cwd, sessionId }) => withAgentScope(sessionId, async (scope) => {
1093
+ const presets = ctx.get('agentPresets');
1094
+ // A live Agent's preset may isolate its skill service from the Host.
1095
+ const scoped = scope?.ctx !== undefined && typeof presets?.serviceFor === 'function'
1096
+ ? presets.serviceFor(scope, 'skills') : undefined;
1097
+ const skills = scoped ?? controls.skills;
1068
1098
  if (skills === undefined || typeof skills.list !== 'function') return [];
1069
- const scope = await agentScopeFor(sessionId);
1070
1099
  return skills.list({
1071
1100
  ...(scope === undefined ? {} : { scope }),
1072
1101
  ...(cwd === undefined ? {} : { cwd }),
1073
1102
  });
1074
- },
1103
+ }),
1075
1104
  commands: async ({ sessionId }) => {
1076
1105
  const commands = ctx.get('commands');
1077
1106
  if (commands === undefined || typeof commands.list !== 'function') return [];
@@ -1389,6 +1418,7 @@ export function capabilityProvider(sources) {
1389
1418
  ensureAgent: sources.ensureAgent,
1390
1419
  palette: sources.palette,
1391
1420
  sessionControls: sources.sessionControls,
1421
+ sessionArchive: sources.sessionArchive,
1392
1422
  fileBrowser: sources.fileBrowser,
1393
1423
  };
1394
1424
  }
@@ -1618,6 +1648,8 @@ export async function arbitrateInteraction({ request, next, begin, settleLocal }
1618
1648
  export function apply(ctx, config) {
1619
1649
  const scope = createSettingsScope(ctx, settingsNamespace(SETTINGS_NAMESPACE), config);
1620
1650
  let runtime;
1651
+ let nativeCredentials;
1652
+ const credentialPorts = createCredentialStore({ getCredentials: () => nativeCredentials });
1621
1653
  // The source can arrive before OR after the runtime: `startHost` awaits a
1622
1654
  // Cindy session before resolving, while `ctx.inject` fires the moment the DSH
1623
1655
  // service activates. Holding it here and re-applying after the await covers
@@ -1728,8 +1760,22 @@ export function apply(ctx, config) {
1728
1760
  }));
1729
1761
  }
1730
1762
 
1763
+ // Only Windows needs DSH persistence; missing/late service must not break mounting.
1764
+ ctx.inject(['credentials'], (credentialCtx) => {
1765
+ credentialCtx.effect(() => {
1766
+ const provider = credentialCtx.get?.('credentials') ?? credentialCtx.credentials;
1767
+ nativeCredentials = provider;
1768
+ if (runtime && scope.get().transportEnabled) {
1769
+ void Promise.resolve().then(() => runtime.reconnectNow())
1770
+ .catch(error => noteBoundaryError('credential-store-ready', error));
1771
+ }
1772
+ return () => { if (nativeCredentials === provider) nativeCredentials = undefined; };
1773
+ });
1774
+ });
1775
+
1731
1776
  ctx.effect(async () => {
1732
1777
  runtime = await startHost(undefined, scope.get(), {
1778
+ credentialPorts,
1733
1779
  // A provider, not a snapshot: these callbacks come from the injected
1734
1780
  // session service, which activates after `startHost` already awaited a
1735
1781
  // Cindy session. Capturing them here would freeze an empty set and make a
@@ -1741,8 +1787,8 @@ export function apply(ctx, config) {
1741
1787
  files: currentFiles,
1742
1788
  goalWrite: currentGoalWrite,
1743
1789
  }),
1744
- // The controller's archive/delete/pin writes go straight to this plugin's
1745
- // settings section, so the whole flag store reloads with the process.
1790
+ // Delete/pin and old archive overlays persist here; new archives live in
1791
+ // DSH's native workspace registry, not this plugin's settings section.
1746
1792
  persistSessionFlags: sessionFlagsWriter(ctx.settings, settingsNamespace(SETTINGS_NAMESPACE)),
1747
1793
  inputDeliveryDirectory: join(process.env.DSH_PROFILE_DIR || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', process.env.DSH_PROFILE || 'desktop'), 'state', 'dsh-cindy-host'),
1748
1794
  });
@@ -1949,6 +1995,7 @@ export function apply(ctx, config) {
1949
1995
  // in headless compositions, so the row is injected optionally: the runtime and
1950
1996
  // its settings still work, only the browser surface is missing.
1951
1997
  const routes = createHostRoutes({
1998
+ credentialPorts,
1952
1999
  getRuntime: () => runtime,
1953
2000
  getDiagnostics: () => buildDiagnostics({ runtime, sourceKind, seam: currentSeam, listingDiagnostics: currentListingDiagnostics, boundaries: boundaryState() }),
1954
2001
  // The Host's own relay identity outlives the credential: a phone links to the
@@ -0,0 +1,49 @@
1
+ import { refusalError } from './cindy-channels.js';
2
+
3
+ /** Adapt the same durable registry operations used by DSH's workspace controller.
4
+ * Registry availability is resolved per call; no private file or shadow archive is written. */
5
+ export function createDshSessionArchive({ getRegistry, subscribe }) {
6
+ function ids() {
7
+ const value = getRegistry()?.archivedSessionIds;
8
+ return Array.isArray(value) ? new Set(value.map(String)) : null;
9
+ }
10
+ function isArchived(sessionId) {
11
+ const current = ids();
12
+ return current === null ? null : current.has(String(sessionId));
13
+ }
14
+ async function setArchived(sessionId, archived) {
15
+ const registry = getRegistry();
16
+ const method = archived ? 'archiveSession' : 'unarchiveSession';
17
+ if (typeof registry?.[method] !== 'function' || ids() === null) {
18
+ throw refusalError('NOT_AVAILABLE', 'This DSH profile exposes no native session archive API');
19
+ }
20
+ try {
21
+ // No stopActivity: the existing client gesture does not authorize stopping work.
22
+ await registry[method](sessionId);
23
+ } catch (error) {
24
+ if (error?.name === 'WorkspaceUnknownSessionError' || error?.code === 'session/not-found') {
25
+ throw refusalError('NOT_FOUND', String(error.message));
26
+ }
27
+ if (error?.name === 'WorkspaceActiveSessionError' || error?.code === 'workspace/session-active') {
28
+ throw refusalError('NOT_AVAILABLE', String(error.message));
29
+ }
30
+ throw error; // Persistence/transport failures must never become local-only success.
31
+ }
32
+ if (isArchived(sessionId) !== archived) throw new Error('DSH archive state did not match the completed operation');
33
+ return archived;
34
+ }
35
+ function onChange(listener) {
36
+ if (typeof subscribe !== 'function') return () => {};
37
+ let previous = ids();
38
+ return subscribe('domain/changed', (change) => {
39
+ if (change?.domain !== 'workspace' || change.table !== '' || change.operation !== 'put') return;
40
+ const next = ids();
41
+ if (next === null) return;
42
+ const changed = [...new Set([...(previous ?? []), ...next])]
43
+ .filter(id => previous?.has(id) !== next.has(id));
44
+ previous = next;
45
+ if (changed.length > 0) listener(changed);
46
+ });
47
+ }
48
+ return { snapshot: ids, isArchived, setArchived, onChange };
49
+ }
@@ -93,9 +93,10 @@ export function toEffortLevels(availableModels) {
93
93
  /**
94
94
  * The capability payload for one agent kind.
95
95
  * @param catalog - a DSH `ModelCatalog`, or undefined when none loaded.
96
+ * @param options - whether the composed Host forwards model changes to native DSH.
96
97
  * @returns the payload `maker:get-capabilities` answers with.
97
98
  */
98
- export function toAgentCapabilities(catalog) {
99
+ export function toAgentCapabilities(catalog, { nativeModelSelection = false } = {}) {
99
100
  const availableModels = toAvailableModels(catalog);
100
101
  return {
101
102
  availableModels,
@@ -107,7 +108,11 @@ export function toAgentCapabilities(catalog) {
107
108
  // Read as an object by the controller; a boolean here is silently ignored.
108
109
  planMode: { supported: false },
109
110
  supportsSessionAgentSwitch: false,
110
- supportsModelWindowSwitchGuard: false,
111
+ // On the wire this delegates window decisions to the Host. DSH accepts a
112
+ // selection for the next model request and owns context handling itself;
113
+ // Cindy must not impose its legacy Pi/unknown-window veto ahead of DSH.
114
+ // This does not claim Cindy-style remote summary/rebuild support.
115
+ supportsModelWindowSwitchGuard: nativeModelSelection === true,
111
116
  };
112
117
  }
113
118
 
@@ -63,10 +63,10 @@ export async function readJsonBody(req) {
63
63
  * @param options - the runtime accessor and an optional diagnostics producer.
64
64
  * @returns one handler per route, each owning its own response.
65
65
  */
66
- export function createHostRoutes({ getRuntime, getDiagnostics, getSettings, rememberDeviceId, auth = {} }) {
67
- const loadCredential = auth.loadSession ?? loadSession;
68
- const saveCredential = auth.adoptSession ?? adoptSession;
69
- const forgetCredential = auth.forgetSession ?? forgetSession;
66
+ export function createHostRoutes({ getRuntime, getDiagnostics, getSettings, rememberDeviceId, credentialPorts, auth = {} }) {
67
+ const loadCredential = auth.loadSession ?? credentialPorts?.loadSession ?? loadSession;
68
+ const saveCredential = auth.adoptSession ?? (session => adoptSession(session, credentialPorts));
69
+ const forgetCredential = auth.forgetSession ?? (() => forgetSession(credentialPorts));
70
70
  const verifyCredential = auth.verifyLoginCode ?? verifyLoginCode;
71
71
  const selectCredential = auth.selectLoginAccount ?? selectLoginAccount;
72
72
  /**
@@ -2,9 +2,9 @@ export const SETTINGS_NAMESPACE = 'dsh-cindy-host';
2
2
 
3
3
  // Network access is opt-in. A newly mounted bundle only projects local DSH data.
4
4
  //
5
- // `sessionFlags` is the one section a *controller* writes: DSH has no archived,
6
- // deleted or pinned session state, so the phone's 删除/归档/置顶 are this Host's own
7
- // bookkeeping and they have to survive a restart (see `session-flags.js`).
5
+ // Controller-only delete/pin metadata and legacy archive flags survive restart.
6
+ // New archive/unarchive operations persist in DSH's native workspace registry;
7
+ // legacy archived flags are retained until explicitly retired (see `session-flags.js`).
8
8
  export const DEFAULT_HOST_SETTINGS = Object.freeze({ transportEnabled: false, remoteControlEnabled: false, controllers: {}, deviceId: '', deviceName: '', sessionFlags: {} });
9
9
 
10
10
  /**
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export const HOST_APP_VERSION = '0.1.82';
6
6
  /** Actual npm release, separate from the Cindy compatibility identity. */
7
- export const HOST_PLUGIN_VERSION = '0.1.31';
7
+ export const HOST_PLUGIN_VERSION = '0.1.32';
package/src/host.js CHANGED
@@ -26,7 +26,7 @@ import { createInputDeliveryCoordinator } from './host-input-delivery.js';
26
26
  import { realpath as realpathImpl, stat as statImpl } from 'node:fs/promises';
27
27
  import { extname as extnameImpl, resolve as resolvePathImpl } from 'node:path';
28
28
  import { restoreSession } from './auth-session.js';
29
- import { createChannelRouter, invokeError } from './cindy-channels.js';
29
+ import { createChannelRouter, invokeError, refusalError } from './cindy-channels.js';
30
30
  import { createApprovalRegistry } from './host-approvals.js';
31
31
  import { createInputQueueTracker, queuedRowFromController } from './host-input-queue.js';
32
32
  import { createProjectionTracker, toGoalStatusPayload } from './host-goals.js';
@@ -121,7 +121,7 @@ async function fetchDeviceDirectory(session, relayUrl) {
121
121
  */
122
122
  export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS, options = {}) {
123
123
  const {
124
- resolveSession = restoreSession,
124
+ resolveSession = () => restoreSession(options.credentialPorts),
125
125
  openSocket = openRelaySocket,
126
126
  listDevices = fetchDeviceDirectory,
127
127
  relayUrl = RELAY_WS_URL,
@@ -186,7 +186,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
186
186
  sessionRefreshedAt = Date.now();
187
187
  return renewed !== null;
188
188
  }
189
- const result = await restoreSession();
189
+ const result = await restoreSession(options.credentialPorts);
190
190
  sessionRefreshedAt = Date.now();
191
191
  if (stopped || callerGeneration !== generation || exportOwner.capture()) return false;
192
192
  if (result?.ok === true && result.session !== undefined) {
@@ -305,7 +305,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
305
305
  */
306
306
  const remoteOwnedSessions = new Set();
307
307
  /**
308
- * The session metadata DSH has no concept of: archived, deleted, pinned.
308
+ * Controller-only delete/pin metadata and compatibility archive flags.
309
309
  *
310
310
  * Owned by the runtime rather than the plugin because it is per-process state the
311
311
  * channel layer and the row projection must agree on, and it is persisted through
@@ -347,6 +347,69 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
347
347
  (current?.transportEnabled || current?.remoteControlEnabled) && status.ownDeviceId ? session : null, deviceId: status.ownDeviceId });
348
348
  }
349
349
  let source = initialSource;
350
+ let disposeArchiveObservation = null;
351
+ const sessionMetaWrites = new Map();
352
+ function serializeSessionMeta(sessionId, operation) {
353
+ const pending = (sessionMetaWrites.get(sessionId) ?? Promise.resolve()).then(operation);
354
+ const settled = pending.then(() => {}, () => {});
355
+ sessionMetaWrites.set(sessionId, settled);
356
+ void settled.then(() => { if (sessionMetaWrites.get(sessionId) === settled) sessionMetaWrites.delete(sessionId); });
357
+ return pending;
358
+ }
359
+ function projectSessionRows(rows) {
360
+ const archived = source?.archive?.snapshot(); // Read the native set once per page, not once per row.
361
+ return sessionFlags.projectAll(rows).map(row => archived?.has(String(row.id)) === true
362
+ ? { ...row, status: row.status === 'deleted' ? 'deleted' : 'archived', pinnedAt: null }
363
+ : row);
364
+ }
365
+ function sessionIsHidden(sessionId) {
366
+ return sessionFlags.isHidden(sessionId) || source?.archive?.isArchived(sessionId) === true;
367
+ }
368
+ function observeArchiveSource() {
369
+ disposeArchiveObservation?.();
370
+ disposeArchiveObservation = null;
371
+ const observedSource = source;
372
+ if (typeof observedSource?.archive?.onChange !== 'function') return;
373
+ disposeArchiveObservation = observedSource.archive.onChange(ids => {
374
+ routeInvoke.invalidateSessionRows(); // A committed desktop edit invalidates warm phone lists immediately.
375
+ for (const sessionId of ids) {
376
+ void serializeSessionMeta(sessionId, async () => {
377
+ if (stopped || source !== observedSource) return;
378
+ // DSH drops its pin on archive; retire the Host's legacy pin too.
379
+ if (source.archive.isArchived(sessionId) === true && sessionFlags.flagsFor(sessionId)?.pinnedAt) {
380
+ await sessionFlags.apply(sessionId, { pinnedAt: null });
381
+ }
382
+ if (stopped || source !== observedSource) return;
383
+ const cached = sessionRowCache.get(sessionId);
384
+ const [row] = projectSessionRows([{ ...cached, id: sessionId, status: 'active' }]);
385
+ if (cached) sessionRowCache.set(sessionId, row);
386
+ routeInvoke.invalidateSessionRows();
387
+ publishSessionMeta(sessionId, { status: row.status, ...(row.status === 'archived' ? { pinnedAt: null } : {}) });
388
+ }).catch(error => recordHandlerError('native-session-archive', error));
389
+ }
390
+ });
391
+ }
392
+ function applySessionMetadataFlags(sessionId, patch) {
393
+ const owner = source;
394
+ return serializeSessionMeta(sessionId, async () => {
395
+ if (stopped || source !== owner) throw refusalError('NOT_AVAILABLE', 'The DSH session source changed before the metadata write');
396
+ const archive = owner?.archive;
397
+ if (archive && (patch.status === 'archived' || patch.status === 'active')) {
398
+ // Validate other fields before the native write, so malformed metadata has no side effect.
399
+ if (patch.pinnedAt !== undefined && patch.pinnedAt !== null && typeof patch.pinnedAt !== 'string') {
400
+ throw refusalError('BAD_REQUEST', 'pinnedAt must be an ISO string or null');
401
+ }
402
+ const archived = patch.status === 'archived';
403
+ await archive.setArchived(sessionId, archived);
404
+ if (stopped || source !== owner) throw refusalError('NOT_AVAILABLE', 'The DSH session source changed during the archive write');
405
+ // Native state owns new archives; clear old archived/deleted overlays only
406
+ // after success. Keep unrelated legacy flags and never mutate other sessions.
407
+ const local = await sessionFlags.apply(sessionId, { status: 'active', pinnedAt: archived ? null : patch.pinnedAt });
408
+ return { ...local, status: archived ? 'archived' : 'active' };
409
+ }
410
+ return sessionFlags.apply(sessionId, patch);
411
+ });
412
+ }
350
413
  let active = false;
351
414
  let connecting = null; // Generation owning the in-flight connect, not a global latch.
352
415
  let session = null;
@@ -376,6 +439,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
376
439
  let fileExports;
377
440
  const exportOwner = createExportOwner({
378
441
  ...options.exportCredentialOptions, now: () => Number(now()),
442
+ ports: { ...options.credentialPorts, ...options.exportCredentialOptions?.ports },
379
443
  onSession: (next) => { session = next; },
380
444
  onInvalidate: () => { void fileExports?.invalidateOwner(); transferDiagnostics.clear(); },
381
445
  });
@@ -572,6 +636,9 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
572
636
  async function setSource(next) {
573
637
  source = next === undefined ? undefined : next;
574
638
  if (stopped) return;
639
+ routeInvoke.invalidateSessionRows();
640
+ sessionRowCache.clear();
641
+ observeArchiveSource();
575
642
  if (projection !== null) {
576
643
  const closing = projection;
577
644
  projection = null;
@@ -1227,7 +1294,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1227
1294
  ? await source.getSession(sessionId)
1228
1295
  : (await source.listSessions()).find((candidate) => String(candidate.id) === sessionId);
1229
1296
  if (row == null) return undefined;
1230
- const [flagged] = sessionFlags.projectAll([row]);
1297
+ const [flagged] = projectSessionRows([row]);
1231
1298
  if (flagged !== undefined) sessionRowCache.set(String(flagged.id), flagged);
1232
1299
  return flagged;
1233
1300
  },
@@ -1237,7 +1304,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1237
1304
  // The archive/delete/pin flags ride the source row's `status`/`pinnedAt`, which
1238
1305
  // is how they reach the wire row (`cindy-session-row.js`) at every read path —
1239
1306
  // list, get, and the row echoed back to a writer.
1240
- const flagged = sessionFlags.projectAll(rows);
1307
+ const flagged = projectSessionRows(rows);
1241
1308
  sessionRowCache.clear();
1242
1309
  for (const row of flagged) sessionRowCache.set(String(row.id), row);
1243
1310
  return flagged;
@@ -1256,7 +1323,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1256
1323
  listSessionStates: async (requestedSessionId) => {
1257
1324
  if (source === undefined || source === null) throw new Error('no DSH session source is attached');
1258
1325
  const rows = typeof source.listSessionStates === 'function' ? await source.listSessionStates(requestedSessionId) : await source.listSessions();
1259
- return sessionFlags.projectAll(rows);
1326
+ return projectSessionRows(rows);
1260
1327
  },
1261
1328
  // Resolved per request. The approval pair lives here because the registry is
1262
1329
  // this runtime's own; everything else comes from the plugin, whose DSH
@@ -1268,8 +1335,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1268
1335
  resolveInteraction: (requestId, decision) => approvals.settle(requestId, decision),
1269
1336
  // The archive/delete/pin writes, and the one reader that decides whether a
1270
1337
  // session still belongs on the controller's live surfaces.
1271
- applySessionFlags: (sessionId, patch) => sessionFlags.apply(sessionId, patch),
1272
- sessionHidden: (sessionId) => sessionFlags.isHidden(sessionId),
1338
+ applySessionFlags: applySessionMetadataFlags,
1339
+ sessionHidden: sessionIsHidden,
1273
1340
  publishSessionMeta,
1274
1341
  // The controller's only way to *see* a file that lives on this machine: an image
1275
1342
  // the agent produced, or one picked in the file browser. Host-owned because the
@@ -1621,7 +1688,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
1621
1688
  } catch {
1622
1689
  return;
1623
1690
  }
1624
- const running = rows.filter((row) => row?.running === true && !sessionFlags.isHidden(row.id));
1691
+ const running = rows.filter((row) => row?.running === true && !sessionIsHidden(row.id));
1625
1692
  if (running.length === 0) return;
1626
1693
  for (const row of running) {
1627
1694
  const payload = { sessionId: String(row.id), event: runningStatusEvent() };
@@ -2199,8 +2266,10 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2199
2266
  let resolved;
2200
2267
  try {
2201
2268
  resolved = await serializeCredentialOperation(() => resolveSession());
2202
- } catch {
2203
- resolved = { ok: false, message: '读取本机 Cindy 登录态失败' };
2269
+ } catch (error) {
2270
+ resolved = error?.code === 'CREDENTIAL_STORE_UNAVAILABLE'
2271
+ ? { ok: false, reason: error.code, message: error.message }
2272
+ : { ok: false, message: '读取本机 Cindy 登录态失败' };
2204
2273
  }
2205
2274
  if (stopped || !active || myGeneration !== generation) return;
2206
2275
 
@@ -2354,6 +2423,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2354
2423
  /** Full stop: used by plugin teardown, not by the switch. */
2355
2424
  async function stop() {
2356
2425
  stopped = true;
2426
+ disposeArchiveObservation?.();
2427
+ disposeArchiveObservation = null;
2357
2428
  active = false;
2358
2429
  await disconnect();
2359
2430
  await fileExports.stop();
@@ -2363,6 +2434,8 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2363
2434
  status.setState('disconnected');
2364
2435
  }
2365
2436
 
2437
+ /** Follow native metadata even while transport is off; reconnect reads the same registry. */
2438
+ observeArchiveSource();
2366
2439
  /** Start if the resolved settings already ask for transport. */
2367
2440
  await updateSettings(settings);
2368
2441
 
package/src/login.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { loginWithPhone } from "./cindy-login.js";
2
- import { saveSession } from "./credential-store.js";
2
+ import { loadSession, saveSession } from "./credential-store.js";
3
3
 
4
4
  if (process.argv.includes("--help")) {
5
5
  console.log("Usage: CINDY_AUTH_BASE_URL=https://auth.example npm run login");
@@ -10,14 +10,11 @@ const authBaseUrl = process.env.CINDY_AUTH_BASE_URL || readFlag("--auth-base-url
10
10
  const clientType = process.env.CINDY_CLIENT_TYPE || "desktop";
11
11
 
12
12
  try {
13
+ // Fail before contacting auth when persistence is unavailable. Windows CLI
14
+ // has no DSH context: use the DSH settings card, not a plaintext fallback.
15
+ await loadSession();
13
16
  const session = await loginWithPhone({ authBaseUrl, clientType });
14
- // Persist so the settings card sees the same session the terminal just made.
15
- // Best-effort: a host with no OS credential store still gets a usable handle.
16
- try {
17
- await saveSession(session);
18
- } catch {
19
- console.error("Cindy logged in, but the OS credential store is unavailable; the settings card will not see this session");
20
- }
17
+ await saveSession(session);
21
18
  // Deliberately expose only the non-secret local device handle.
22
19
  console.log(JSON.stringify({ status: "authenticated", deviceId: session.deviceId }, null, 2));
23
20
  } catch (error) {
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The three session actions DSH itself has no concept of: archive, delete, pin.
2
+ * Legacy controller-only archive/delete/pin metadata. New archives use native DSH.
3
3
  *
4
4
  * A controller's session list offers 删除 / 归档 / 置顶 / 重命名, and all four are
5
5
  * the *same* write (`local-db:sessions:patch-meta`, see
6
6
  * `Cindy/apps/desktop/src/renderer/lib/sessionService.ts` and
7
- * `Cindy/apps/mobile/src/session/useSessionListActions.ts`). Only 重命名 has a DSH
8
- * counterpart — DSH sessions are a log-backed store with no archived, hidden or
9
- * pinned state — so this module is the Host's own bookkeeping for the other three.
7
+ * `Cindy/apps/mobile/src/session/useSessionListActions.ts`). Native DSH now owns
8
+ * archive/unarchive; this store preserves old archived flags until an explicit
9
+ * operation retires them. Delete remains a non-destructive hide, and controller
10
+ * pin timestamps remain local metadata.
10
11
  *
11
12
  * Three consequences are deliberate and must stay visible:
12
13
  *