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 +13 -0
- package/README.md +57 -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 +78 -31
- 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 +85 -12
- package/src/login.js +5 -8
- package/src/session-flags.js +5 -4
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.
|
|
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
|
+
|
|
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.
|
|
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 || 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": {
|
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();
|
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
|
-
//
|
|
46
|
-
//
|
|
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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
* @
|
|
233
|
+
* @param read - the scoped catalog operation; must settle before lease disposal.
|
|
234
|
+
* @returns the catalog operation's result.
|
|
226
235
|
*/
|
|
227
|
-
async function
|
|
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?.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
/**
|
|
1015
|
-
|
|
1016
|
-
|
|
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 `
|
|
1090
|
+
* preset's layer is visible at all — see `withAgentScope`.
|
|
1065
1091
|
*/
|
|
1066
|
-
skills: async ({ cwd, sessionId }) => {
|
|
1067
|
-
const
|
|
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
|
-
//
|
|
1745
|
-
//
|
|
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
|
+
}
|
package/src/host-models.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/host-routes.js
CHANGED
|
@@ -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
|
/**
|
package/src/host-settings.js
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
/**
|
package/src/host-version.js
CHANGED
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
|
-
*
|
|
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] =
|
|
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 =
|
|
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
|
|
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:
|
|
1272
|
-
sessionHidden:
|
|
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 && !
|
|
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 =
|
|
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
|
-
|
|
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) {
|
package/src/session-flags.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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`).
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
*
|