dsh-zen-remote 1.1.16 → 2.0.0
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/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// Behaviour check for the T33b(+fix) session-sharing client data
|
|
2
|
+
// (src/client-data/shares.ts), driven against the REAL admin/shares shapes
|
|
3
|
+
// src/admin-routes.ts answers: `GET` → { ok, shares:[{ sessionId, sharedAt,
|
|
4
|
+
// lastActivityAt, busy, remainingMs(null=忙碌), viewers, title }] }, `POST`
|
|
5
|
+
// → { action, sessionId? } answered with { ok }. Covers the tolerant body
|
|
6
|
+
// parse, describeShare's three states and countdown text (hours / minutes /
|
|
7
|
+
// busy, with local decay from the parse stamp), and the shared store against
|
|
8
|
+
// a FAKE fetch: the role gate (unknown wires nothing / client never polls /
|
|
9
|
+
// host pulls immediately and a client→host flip recovers), latest-wins
|
|
10
|
+
// sequencing, one-failed-round-only error semantics (404 included),
|
|
11
|
+
// visibility-shaped polling, and POST-then-refresh for all three actions.
|
|
12
|
+
//
|
|
13
|
+
// Run: node scripts/check-shares.mjs (needs Node >= 23.6 type stripping)
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import {
|
|
16
|
+
ADMIN_SHARES_ROUTE,
|
|
17
|
+
createSharesStore,
|
|
18
|
+
createZhShareFormatter,
|
|
19
|
+
describeShare,
|
|
20
|
+
documentVisibility,
|
|
21
|
+
parseSharesBody,
|
|
22
|
+
shareFailText,
|
|
23
|
+
SHARES_POLL_MS,
|
|
24
|
+
} from '../src/client-data/shares.ts'
|
|
25
|
+
|
|
26
|
+
// Tiny sequential runner so the file stays plain node:assert (no node:test import).
|
|
27
|
+
let testChain = Promise.resolve()
|
|
28
|
+
function test(name, fn) {
|
|
29
|
+
testChain = testChain
|
|
30
|
+
.then(fn)
|
|
31
|
+
.then(() => { console.log(`ok - ${name}`) })
|
|
32
|
+
.catch((error) => {
|
|
33
|
+
console.error(`not ok - ${name}`)
|
|
34
|
+
throw error
|
|
35
|
+
})
|
|
36
|
+
return testChain
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NOW = 1_700_000_000_000
|
|
40
|
+
const HOUR = 3_600_000
|
|
41
|
+
const MINUTE = 60_000
|
|
42
|
+
const t = createZhShareFormatter()
|
|
43
|
+
|
|
44
|
+
// ---- 1. parseSharesBody -----------------------------------------------------
|
|
45
|
+
|
|
46
|
+
test('parseSharesBody: tolerant of garbage, drops broken rows, stamps asOf', () => {
|
|
47
|
+
assert.deepEqual(parseSharesBody(undefined, NOW), [])
|
|
48
|
+
assert.deepEqual(parseSharesBody(null, NOW), [])
|
|
49
|
+
assert.deepEqual(parseSharesBody({ ok: true }, NOW), [])
|
|
50
|
+
assert.deepEqual(parseSharesBody({ ok: true, shares: 'nope' }, NOW), [])
|
|
51
|
+
const entries = parseSharesBody({
|
|
52
|
+
ok: true,
|
|
53
|
+
shares: [
|
|
54
|
+
{
|
|
55
|
+
sessionId: 's1', sharedAt: NOW - 99, lastActivityAt: NOW - 5 * MINUTE,
|
|
56
|
+
busy: false, remainingMs: 31.4 * HOUR, viewers: 2, title: 'Refactor the gateway',
|
|
57
|
+
},
|
|
58
|
+
{ sessionId: 's2', busy: true, remainingMs: null, title: null },
|
|
59
|
+
{ sessionId: '', title: 'dropped' },
|
|
60
|
+
null,
|
|
61
|
+
{ title: 'no id' },
|
|
62
|
+
{ sessionId: 's3', busy: 'yes', remainingMs: 'x', viewers: -3, title: 42 },
|
|
63
|
+
],
|
|
64
|
+
}, NOW)
|
|
65
|
+
assert.equal(entries.length, 3)
|
|
66
|
+
assert.deepEqual(entries[0], {
|
|
67
|
+
sessionId: 's1', sharedAt: NOW - 99, lastActivityAt: NOW - 5 * MINUTE,
|
|
68
|
+
busy: false, remainingMs: 31.4 * HOUR, viewers: 2, title: 'Refactor the gateway', asOf: NOW,
|
|
69
|
+
})
|
|
70
|
+
// busy: remainingMs null reads busy even without the flag.
|
|
71
|
+
assert.equal(entries[1].busy, true)
|
|
72
|
+
assert.equal(entries[1].title, null)
|
|
73
|
+
// Garbage fields fall back, never throw. A garbage remainingMs reads null,
|
|
74
|
+
// and null is the wire's busy mark — so the row counts as busy, matching
|
|
75
|
+
// how respondShares encodes it.
|
|
76
|
+
assert.equal(entries[2].busy, true)
|
|
77
|
+
assert.equal(entries[2].remainingMs, null)
|
|
78
|
+
assert.equal(entries[2].viewers, 0)
|
|
79
|
+
assert.equal(entries[2].title, null)
|
|
80
|
+
assert.equal(entries[2].asOf, NOW)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ---- 2. describeShare -------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const entry = (over = {}) => ({
|
|
86
|
+
sessionId: 's1', sharedAt: NOW - 99, lastActivityAt: NOW - 5 * MINUTE,
|
|
87
|
+
busy: false, remainingMs: 31.4 * HOUR, viewers: 0, title: 'Refactor', asOf: NOW, ...over,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('describeShare: no entry reads off', () => {
|
|
91
|
+
assert.deepEqual(describeShare(undefined, NOW, t), { state: 'off', remainingText: '未开启远程' })
|
|
92
|
+
assert.deepEqual(describeShare(null, NOW, t), { state: 'off', remainingText: '未开启远程' })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('describeShare: idle countdown in hours and minutes', () => {
|
|
96
|
+
assert.deepEqual(describeShare(entry(), NOW, t), { state: 'on', remainingText: '剩余闲置时间 31 小时' })
|
|
97
|
+
assert.equal(describeShare(entry({ remainingMs: 45 * MINUTE }), NOW, t).remainingText, '剩余闲置时间 45 分钟')
|
|
98
|
+
// Exactly the hour boundary flips the unit; a tie rounds to nearest.
|
|
99
|
+
assert.equal(describeShare(entry({ remainingMs: HOUR }), NOW, t).remainingText, '剩余闲置时间 1 小时')
|
|
100
|
+
assert.equal(describeShare(entry({ remainingMs: 1.5 * HOUR }), NOW, t).remainingText, '剩余闲置时间 2 小时')
|
|
101
|
+
// Sub-minute leftovers still read as one minute, never zero.
|
|
102
|
+
assert.equal(describeShare(entry({ remainingMs: 30_000 }), NOW, t).remainingText, '剩余闲置时间 1 分钟')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('describeShare: busy reads the no-countdown copy', () => {
|
|
106
|
+
assert.deepEqual(describeShare(entry({ busy: true, remainingMs: null }), NOW, t), {
|
|
107
|
+
state: 'on', remainingText: '运行中,不计时',
|
|
108
|
+
})
|
|
109
|
+
assert.equal(describeShare(entry({ remainingMs: null }), NOW, t).remainingText, '运行中,不计时')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('describeShare: viewers promote on -> watched, countdown stays in the text', () => {
|
|
113
|
+
assert.equal(describeShare(entry({ viewers: 1 }), NOW, t).state, 'watched')
|
|
114
|
+
assert.equal(describeShare(entry({ viewers: 3 }), NOW, t).remainingText, '剩余闲置时间 31 小时')
|
|
115
|
+
const busyWatched = describeShare(entry({ busy: true, remainingMs: null, viewers: 1 }), NOW, t)
|
|
116
|
+
assert.equal(busyWatched.state, 'watched')
|
|
117
|
+
assert.equal(busyWatched.remainingText, '运行中,不计时')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('describeShare: the countdown decays locally from the parse stamp', () => {
|
|
121
|
+
// 55 min left as of 10 min ago -> 45 min now.
|
|
122
|
+
assert.equal(
|
|
123
|
+
describeShare(entry({ remainingMs: 55 * MINUTE, asOf: NOW - 10 * MINUTE }), NOW, t).remainingText,
|
|
124
|
+
'剩余闲置时间 45 分钟',
|
|
125
|
+
)
|
|
126
|
+
// Expired locally clamps at one minute instead of going negative.
|
|
127
|
+
assert.equal(
|
|
128
|
+
describeShare(entry({ remainingMs: MINUTE, asOf: NOW - 10 * MINUTE }), NOW, t).remainingText,
|
|
129
|
+
'剩余闲置时间 1 分钟',
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ---- 3. the store -----------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** One fake Response-shaped body. */
|
|
136
|
+
const jsonRes = (status, body) => ({
|
|
137
|
+
ok: status >= 200 && status < 300,
|
|
138
|
+
status,
|
|
139
|
+
json: async () => body,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/** A fake fetch recording every call; programs replies from a queue. */
|
|
143
|
+
function fakeFetch(replies) {
|
|
144
|
+
const calls = []
|
|
145
|
+
const queue = [...replies]
|
|
146
|
+
const fetchImpl = async (input, init) => {
|
|
147
|
+
calls.push({ input, init: init ?? null })
|
|
148
|
+
const next = queue.shift()
|
|
149
|
+
if (typeof next === 'function') return next(calls.length)
|
|
150
|
+
if (next !== undefined) return next
|
|
151
|
+
return jsonRes(200, { ok: true, shares: [] })
|
|
152
|
+
}
|
|
153
|
+
fetchImpl.calls = calls
|
|
154
|
+
fetchImpl.queue = queue
|
|
155
|
+
return fetchImpl
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const bodyOf = (calls, index) => JSON.parse(calls[index]?.init?.body ?? 'null')
|
|
159
|
+
|
|
160
|
+
const SHARES = [
|
|
161
|
+
{ sessionId: 's1', sharedAt: NOW, lastActivityAt: NOW - MINUTE, busy: false, remainingMs: 2 * HOUR, viewers: 0, title: 'One' },
|
|
162
|
+
{ sessionId: 's2', sharedAt: NOW, lastActivityAt: NOW, busy: true, remainingMs: null, viewers: 1, title: 'Two' },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
test('refresh: ok body lands as ready, entries parsed', async () => {
|
|
166
|
+
const store = createSharesStore(fakeFetch([jsonRes(200, { ok: true, shares: SHARES })]))
|
|
167
|
+
store.setRole('host')
|
|
168
|
+
assert.equal(store.getSnapshot().ready, false)
|
|
169
|
+
assert.equal(await store.refresh(), true)
|
|
170
|
+
const snap = store.getSnapshot()
|
|
171
|
+
assert.equal(snap.ready, true)
|
|
172
|
+
assert.deepEqual(snap.entries.map((row) => row.sessionId), ['s1', 's2'])
|
|
173
|
+
assert.equal(snap.entries[1].busy, true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('refresh: latest-wins — a late stale body never overwrites a newer table', async () => {
|
|
177
|
+
// First GET hangs until we release it; the second GET answers immediately.
|
|
178
|
+
let releaseFirst
|
|
179
|
+
const first = new Promise((resolve) => { releaseFirst = () => resolve(jsonRes(200, { ok: true, shares: [{ sessionId: 'STALE' }] })) })
|
|
180
|
+
const store = createSharesStore(fakeFetch([() => first, jsonRes(200, { ok: true, shares: SHARES })]))
|
|
181
|
+
store.setRole('host')
|
|
182
|
+
const slow = store.refresh()
|
|
183
|
+
const fast = store.refresh()
|
|
184
|
+
assert.equal(await fast, true)
|
|
185
|
+
assert.deepEqual(store.getSnapshot().entries.map((row) => row.sessionId), ['s1', 's2'])
|
|
186
|
+
releaseFirst()
|
|
187
|
+
assert.equal(await slow, false, 'the late stale request is dropped')
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
store.getSnapshot().entries.map((row) => row.sessionId),
|
|
190
|
+
['s1', 's2'],
|
|
191
|
+
'the stale body did not land',
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('refresh: a failed GET keeps the last ready table (one failed round, no latch)', async () => {
|
|
196
|
+
const store = createSharesStore(fakeFetch([
|
|
197
|
+
jsonRes(200, { ok: true, shares: SHARES }),
|
|
198
|
+
jsonRes(500, { ok: false }),
|
|
199
|
+
jsonRes(200, { ok: false }),
|
|
200
|
+
jsonRes(200, { ok: true, shares: [{ ...SHARES[0] }] }),
|
|
201
|
+
]))
|
|
202
|
+
store.setRole('host')
|
|
203
|
+
await store.refresh()
|
|
204
|
+
assert.equal(await store.refresh(), false)
|
|
205
|
+
assert.equal(store.getSnapshot().ready, true, 'last ready data kept')
|
|
206
|
+
assert.deepEqual(store.getSnapshot().entries.map((row) => row.sessionId), ['s1', 's2'])
|
|
207
|
+
// A 200 whose body is not ok:true is a failed load too.
|
|
208
|
+
assert.equal(await store.refresh(), false)
|
|
209
|
+
assert.equal(store.getSnapshot().ready, true)
|
|
210
|
+
// The next round asks again and lands — nothing latched anywhere.
|
|
211
|
+
assert.equal(await store.refresh(), true)
|
|
212
|
+
assert.deepEqual(store.getSnapshot().entries.map((row) => row.sessionId), ['s1'])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('refresh: 404 (plugin reload window) fails its own round and the next round recovers', async () => {
|
|
216
|
+
const fetchImpl = fakeFetch([
|
|
217
|
+
jsonRes(404, { ok: false }),
|
|
218
|
+
jsonRes(404, { ok: false }),
|
|
219
|
+
jsonRes(200, { ok: true, shares: SHARES }),
|
|
220
|
+
])
|
|
221
|
+
const store = createSharesStore(fetchImpl)
|
|
222
|
+
store.setRole('host')
|
|
223
|
+
assert.equal(await store.refresh(), false)
|
|
224
|
+
assert.equal(await store.refresh(), false, 'the second round still asks')
|
|
225
|
+
assert.equal(store.getSnapshot().ready, false, 'nothing wrong was cached')
|
|
226
|
+
assert.equal(await store.refresh(), true, 'the round after the reload answers')
|
|
227
|
+
assert.deepEqual(store.getSnapshot().entries.map((row) => row.sessionId), ['s1', 's2'])
|
|
228
|
+
assert.equal(fetchImpl.calls.length, 3, 'no request was ever suppressed')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('role: unknown wires nothing — no polling, parts would render nothing', async () => {
|
|
232
|
+
const fetchImpl = fakeFetch([jsonRes(200, { ok: true, shares: SHARES })])
|
|
233
|
+
const store = createSharesStore(fetchImpl)
|
|
234
|
+
const seen = []
|
|
235
|
+
const off = store.subscribe(() => { seen.push('x') })
|
|
236
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
237
|
+
assert.equal(store.getSnapshot().role, 'unknown')
|
|
238
|
+
assert.equal(fetchImpl.calls.length, 0, 'no role wired, no request — not even the first pull')
|
|
239
|
+
assert.deepEqual(seen, [])
|
|
240
|
+
off()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('role: client stops polling and hides; host starts with an immediate pull and recovers', async () => {
|
|
244
|
+
const fetchImpl = fakeFetch([
|
|
245
|
+
jsonRes(200, { ok: true, shares: SHARES }),
|
|
246
|
+
// The client→host flip pulls immediately, then the cadence answers.
|
|
247
|
+
jsonRes(200, { ok: true, shares: [] }),
|
|
248
|
+
jsonRes(200, { ok: true, shares: [] }),
|
|
249
|
+
])
|
|
250
|
+
// 5 ms cadence: the client phases below span several tick periods, so a
|
|
251
|
+
// timer that survived the client flip cannot hide behind the 30 s default.
|
|
252
|
+
const store = createSharesStore(fetchImpl, documentVisibility, 5)
|
|
253
|
+
store.setRole('client')
|
|
254
|
+
const seen = []
|
|
255
|
+
const off = store.subscribe(() => { seen.push('x') })
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
257
|
+
assert.equal(fetchImpl.calls.length, 0, 'a client deployment never asks — not across several tick periods')
|
|
258
|
+
assert.equal(store.getSnapshot().role, 'client')
|
|
259
|
+
// Flip to host: immediate pull, table lands, parts may render.
|
|
260
|
+
store.setRole('host')
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
262
|
+
assert.ok(fetchImpl.calls.length >= 2, `the host flip pulled at once and the cadence ticks (saw ${fetchImpl.calls.length})`)
|
|
263
|
+
assert.equal(store.getSnapshot().role, 'host')
|
|
264
|
+
assert.equal(store.getSnapshot().ready, true)
|
|
265
|
+
// Flipping back to client hides; several tick periods later no request
|
|
266
|
+
// has fired.
|
|
267
|
+
store.setRole('client')
|
|
268
|
+
assert.equal(store.getSnapshot().role, 'client')
|
|
269
|
+
const clientAt = fetchImpl.calls.length
|
|
270
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
271
|
+
assert.equal(fetchImpl.calls.length, clientAt, 'client: timer torn down, no more rounds')
|
|
272
|
+
off()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('visibility: hidden starts nothing; becoming visible pulls once immediately', async () => {
|
|
276
|
+
let visible = false
|
|
277
|
+
const visibilityFans = []
|
|
278
|
+
const visibility = {
|
|
279
|
+
visible: () => visible,
|
|
280
|
+
subscribe(listener) { visibilityFans.push(listener); return () => { visibilityFans.splice(visibilityFans.indexOf(listener), 1) } },
|
|
281
|
+
}
|
|
282
|
+
const fetchImpl = fakeFetch([jsonRes(200, { ok: true, shares: SHARES })])
|
|
283
|
+
// 5 ms cadence: the hidden phase below spans several real ticks, so a
|
|
284
|
+
// tick that kept running while hidden CANNOT hide behind the 30 s default.
|
|
285
|
+
const store = createSharesStore(fetchImpl, visibility, 5)
|
|
286
|
+
store.setRole('host')
|
|
287
|
+
const off = store.subscribe(() => {})
|
|
288
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
289
|
+
assert.equal(fetchImpl.calls.length, 0, 'hidden: no first pull, timer not started')
|
|
290
|
+
// Become visible: the source fires its listeners, the store pulls at once,
|
|
291
|
+
// and the cadence really runs — let a few ticks through.
|
|
292
|
+
visible = true
|
|
293
|
+
for (const fan of [...visibilityFans]) fan()
|
|
294
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
295
|
+
assert.ok(fetchImpl.calls.length >= 2, `the cadence ticks while visible (saw ${fetchImpl.calls.length})`)
|
|
296
|
+
// Hide again: the timer is really stopped — several tick periods later
|
|
297
|
+
// the count must not have moved.
|
|
298
|
+
visible = false
|
|
299
|
+
for (const fan of [...visibilityFans]) fan()
|
|
300
|
+
const hiddenAt = fetchImpl.calls.length
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
302
|
+
assert.equal(fetchImpl.calls.length, hiddenAt, 'no request fires while the page is hidden')
|
|
303
|
+
// Back to visible: one immediate pull, then the cadence resumes.
|
|
304
|
+
visible = true
|
|
305
|
+
for (const fan of [...visibilityFans]) fan()
|
|
306
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
307
|
+
assert.ok(fetchImpl.calls.length > hiddenAt, 'becoming visible pulls immediately and resumes the cadence')
|
|
308
|
+
off()
|
|
309
|
+
assert.deepEqual(visibilityFans, [], 'unsubscribed: the visibility listener went with it')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('subscribe: with a role wired, the first listener starts ONE poll, the last unsubscriber stops it', async () => {
|
|
313
|
+
const fetchImpl = fakeFetch([jsonRes(200, { ok: true, shares: SHARES })])
|
|
314
|
+
// 5 ms cadence: the post-unsubscribe window below spans several tick
|
|
315
|
+
// periods, so a surviving timer cannot hide behind the 30 s default.
|
|
316
|
+
const store = createSharesStore(fetchImpl, documentVisibility, 5)
|
|
317
|
+
store.setRole('host')
|
|
318
|
+
const seen = []
|
|
319
|
+
const offA = store.subscribe(() => { seen.push('a') })
|
|
320
|
+
const offB = store.subscribe(() => { seen.push('b') })
|
|
321
|
+
// Poll cadence matches the spec; the immediate pull is already landing.
|
|
322
|
+
assert.equal(SHARES_POLL_MS, 30_000)
|
|
323
|
+
await Promise.resolve()
|
|
324
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
325
|
+
assert.equal(fetchImpl.calls.length, 1, 'exactly one poll loop for two subscribers')
|
|
326
|
+
assert.deepEqual(seen, ['a', 'b'], 'both listeners heard the snapshot change')
|
|
327
|
+
// While BOTH listen the cadence really runs.
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
329
|
+
assert.ok(fetchImpl.calls.length >= 2, `the cadence ticks while subscribed (saw ${fetchImpl.calls.length})`)
|
|
330
|
+
offA()
|
|
331
|
+
offB()
|
|
332
|
+
const unsubscribedAt = fetchImpl.calls.length
|
|
333
|
+
// Several tick periods later the count must not have moved: the last
|
|
334
|
+
// unsubscriber really stopped the loop.
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
336
|
+
assert.equal(fetchImpl.calls.length, unsubscribedAt, 'unsubscribed: the timer is gone, no further rounds fire')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('share / unshare / unshareAll: POST the documented shapes, then refresh immediately', async () => {
|
|
340
|
+
const fetchImpl = fakeFetch([
|
|
341
|
+
jsonRes(200, { ok: true, shares: [] }),
|
|
342
|
+
// share POST reply, then the follow-up GET.
|
|
343
|
+
jsonRes(200, { ok: true }),
|
|
344
|
+
jsonRes(200, { ok: true, shares: [{ ...SHARES[0] }] }),
|
|
345
|
+
// unshare POST reply, then the follow-up GET.
|
|
346
|
+
jsonRes(200, { ok: true }),
|
|
347
|
+
jsonRes(200, { ok: true, shares: [] }),
|
|
348
|
+
// unshare-all POST reply, then the follow-up GET.
|
|
349
|
+
jsonRes(200, { ok: true }),
|
|
350
|
+
jsonRes(200, { ok: true, shares: [] }),
|
|
351
|
+
])
|
|
352
|
+
const store = createSharesStore(fetchImpl)
|
|
353
|
+
store.setRole('host')
|
|
354
|
+
await store.refresh()
|
|
355
|
+
assert.deepEqual(await store.share('s1'), { ok: true })
|
|
356
|
+
assert.equal(fetchImpl.calls[1].init.method, 'POST')
|
|
357
|
+
assert.deepEqual(bodyOf(fetchImpl.calls, 1), { action: 'share', sessionId: 's1' })
|
|
358
|
+
assert.equal(fetchImpl.calls[2].init.method, undefined, 'the follow-up is a GET')
|
|
359
|
+
assert.deepEqual(store.getSnapshot().entries.map((row) => row.sessionId), ['s1'])
|
|
360
|
+
|
|
361
|
+
assert.deepEqual(await store.unshare('s1'), { ok: true })
|
|
362
|
+
assert.deepEqual(bodyOf(fetchImpl.calls, 3), { action: 'unshare', sessionId: 's1' })
|
|
363
|
+
assert.deepEqual(store.getSnapshot().entries, [])
|
|
364
|
+
|
|
365
|
+
assert.deepEqual(await store.unshareAll(), { ok: true })
|
|
366
|
+
assert.deepEqual(bodyOf(fetchImpl.calls, 5), { action: 'unshare-all' })
|
|
367
|
+
assert.equal(fetchImpl.calls.length, 7, 'every action refreshed right after landing')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('actions: a refused POST carries the server error code, no refresh', async () => {
|
|
371
|
+
const fetchImpl = fakeFetch([
|
|
372
|
+
jsonRes(400, { ok: false, error: { code: 'subagent-session', message: 'no' } }),
|
|
373
|
+
jsonRes(500, { ok: false, error: { code: 'internal', message: 'boom' } }),
|
|
374
|
+
jsonRes(200, { ok: false }),
|
|
375
|
+
])
|
|
376
|
+
const store = createSharesStore(fetchImpl)
|
|
377
|
+
store.setRole('host')
|
|
378
|
+
assert.deepEqual(await store.share('sub-1'), { ok: false, code: 'subagent-session', message: 'no' })
|
|
379
|
+
assert.deepEqual(await store.unshare('gone'), { ok: false, code: 'internal', message: 'boom' })
|
|
380
|
+
assert.deepEqual(await store.unshareAll(), { ok: false })
|
|
381
|
+
assert.equal(fetchImpl.calls.length, 3, 'refused actions do not refresh')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('shareFailText maps the server codes to the alert copy', () => {
|
|
385
|
+
assert.equal(shareFailText({ ok: false, code: 'subagent-session' }, 'share', t), '开启远程失败:子智能体会话不能单独开启')
|
|
386
|
+
assert.equal(shareFailText({ ok: false, code: 'no-session' }, 'share', t), '开启远程失败:会话暂无内容')
|
|
387
|
+
assert.equal(shareFailText({ ok: false }, 'share', t), '操作失败,请稍后再试')
|
|
388
|
+
// Even a mappable code reads generic on unshare.
|
|
389
|
+
assert.equal(shareFailText({ ok: false, code: 'subagent-session' }, 'unshare', t), '操作失败,请稍后再试')
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('the route constant matches the T33a wire path', () => {
|
|
393
|
+
assert.equal(ADMIN_SHARES_ROUTE, '/_dsh/zen-remote/admin/shares')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
await testChain
|
|
397
|
+
console.log('check-shares: ok')
|
|
@@ -157,9 +157,10 @@ try {
|
|
|
157
157
|
await rm(trapWorkspace, { recursive: true, force: true })
|
|
158
158
|
|
|
159
159
|
// Every rejection that reached the body is logged once, so a failing phone
|
|
160
|
-
// leaves a trail: 2x413, 404, 3x400 query, 1x400 symlink. The 405 and
|
|
161
|
-
// short-circuit before the try block and
|
|
162
|
-
//
|
|
160
|
+
// leaves a trail: 2x413, 404, 3x400 query, 1x400 symlink. The 405, 403 and
|
|
161
|
+
// (T17) the admission refusals short-circuit before the try block and
|
|
162
|
+
// deliberately log nothing — they are what an unpaired scanner can trigger
|
|
163
|
+
// at will, and they would otherwise flood the log.
|
|
163
164
|
assert.equal(logged, 7, `expected the rejections to be logged, saw ${logged}`)
|
|
164
165
|
|
|
165
166
|
// ---- the workspace holds exactly what was uploaded, nothing partial ------
|
|
@@ -167,6 +168,61 @@ try {
|
|
|
167
168
|
'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
168
169
|
])
|
|
169
170
|
|
|
171
|
+
// ---- T17: the admission wall the apply() wiring passes in ----------------
|
|
172
|
+
// The direct drives above pass no admit; production always does (the
|
|
173
|
+
// connection service, same first wall as the admin/client routes). A
|
|
174
|
+
// refusing admit answers before anything else — no log, no disk write; an
|
|
175
|
+
// admitting one leaves every behavior unchanged.
|
|
176
|
+
// The symlink trap section pointed sessions elsewhere; point them back.
|
|
177
|
+
ctx.sessions.get = (id) => (id === 'session-live' ? { header: { cwd: workspace } } : undefined)
|
|
178
|
+
let admitted = 0
|
|
179
|
+
const admits = (admission) => createServer((req, res) => {
|
|
180
|
+
void handleUpload(ctx, MAX_BYTES, req, res, () => {
|
|
181
|
+
if (admission === 'peer') {
|
|
182
|
+
admitted += 1
|
|
183
|
+
return { peer: {} }
|
|
184
|
+
}
|
|
185
|
+
return { rejection: admission }
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
const admitServers = [admits(401), admits(403), admits('peer')].map((server) => server)
|
|
189
|
+
const admitBases = []
|
|
190
|
+
for (const server of admitServers) {
|
|
191
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
192
|
+
admitBases.push(`http://127.0.0.1:${server.address().port}`)
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
for (const [i, status] of [401, 403].entries()) {
|
|
196
|
+
const denied = await fetch(`${admitBases[i]}/_dsh/mobile-nav/upload?session=session-live&name=denied.txt`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { origin: admitBases[i] },
|
|
199
|
+
body: 'x',
|
|
200
|
+
duplex: 'half',
|
|
201
|
+
})
|
|
202
|
+
assert.equal(denied.status, status)
|
|
203
|
+
const body = await denied.json()
|
|
204
|
+
assert.equal(body.ok, false)
|
|
205
|
+
assert.equal(body.error.code, status === 401 ? 'unauthorized' : 'forbidden')
|
|
206
|
+
}
|
|
207
|
+
assert.deepEqual(await readdir(join(workspace, UPLOAD_DIR)), [
|
|
208
|
+
'blob.bin', 'evil.txt', 'notes-1.txt', 'notes.txt',
|
|
209
|
+
], 'a refused admit writes nothing')
|
|
210
|
+
assert.equal(logged, 7, 'the admission wall short-circuits before the logger, like the 405/403')
|
|
211
|
+
|
|
212
|
+
const allowed = await fetch(`${admitBases[2]}/_dsh/mobile-nav/upload?session=session-live&name=admitted.txt`, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { origin: admitBases[2] },
|
|
215
|
+
body: 'through the wall',
|
|
216
|
+
duplex: 'half',
|
|
217
|
+
})
|
|
218
|
+
assert.equal(allowed.status, 201)
|
|
219
|
+
assert.equal((await allowed.json()).relPath, `${UPLOAD_DIR}/admitted.txt`)
|
|
220
|
+
assert.equal(admitted, 1, 'the admitting admit was consulted exactly once')
|
|
221
|
+
assert.equal(await readFile(join(workspace, UPLOAD_DIR, 'admitted.txt'), 'utf8'), 'through the wall')
|
|
222
|
+
} finally {
|
|
223
|
+
for (const server of admitServers) await new Promise((resolve) => server.close(resolve))
|
|
224
|
+
}
|
|
225
|
+
|
|
170
226
|
console.log('UPLOAD ENDPOINT CHECK OK')
|
|
171
227
|
} finally {
|
|
172
228
|
await new Promise((resolve) => server.close(resolve))
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Also usable standalone: `node scripts/sync-doc-version.mjs [--check]`.
|
|
15
15
|
* `--check` reports drift without writing, for CI.
|
|
16
|
+
*
|
|
17
|
+
* The README's top badge row went all-dynamic (npm version / downloads /
|
|
18
|
+
* workflow status — no hardcoded version left to drift), so the only
|
|
19
|
+
* version-bearing marker left is the profile dependency example.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
@@ -25,23 +29,12 @@ const checkOnly = process.argv.includes('--check')
|
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* Each target names the file, what it looks for, and what it becomes. Both
|
|
28
|
-
* READMEs carry the same
|
|
29
|
-
*
|
|
30
|
-
*
|
|
32
|
+
* READMEs carry the same marker, and both are required: a missing one means
|
|
33
|
+
* the docs moved and this script has to be updated, which is the whole point
|
|
34
|
+
* of failing loudly. (Until the badge row went all-dynamic there were two
|
|
35
|
+
* more — a hardcoded release badge and its alt text; both are gone.)
|
|
31
36
|
*/
|
|
32
37
|
const targets = ['README.md', 'README.zh-CN.md'].flatMap((file) => [
|
|
33
|
-
{
|
|
34
|
-
file,
|
|
35
|
-
label: 'release badge',
|
|
36
|
-
pattern: /badge\/release-v\d+\.\d+\.\d+-/,
|
|
37
|
-
replace: `badge/release-v${version}-`,
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
file,
|
|
41
|
-
label: 'release badge alt text',
|
|
42
|
-
pattern: /alt="v\d+\.\d+\.\d+"/,
|
|
43
|
-
replace: `alt="v${version}"`,
|
|
44
|
-
},
|
|
45
38
|
{
|
|
46
39
|
file,
|
|
47
40
|
label: 'profile dependency example',
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Live fingerprint verification (T42) — NOT part of `pnpm test`.
|
|
2
|
+
//
|
|
3
|
+
// Computes the interface fingerprints of a REAL DSH closure the way the
|
|
4
|
+
// plugin's registry tier normalizes them (the exact same
|
|
5
|
+
// lib/fingerprint.js code path: descriptors minus sourceLocation, schemas
|
|
6
|
+
// projected to JSON Schema, keys sorted, sha256 truncated to 12 hex chars)
|
|
7
|
+
// and prints one JSON document per closure root, so the acceptance check
|
|
8
|
+
// can diff an npm install against a packaged App side by side:
|
|
9
|
+
//
|
|
10
|
+
// node scripts/verify-fingerprint-live.mjs # this checkout's dev closure
|
|
11
|
+
// node scripts/verify-fingerprint-live.mjs /path/to/App/node_modules \
|
|
12
|
+
// /path/to/npm/node_modules
|
|
13
|
+
//
|
|
14
|
+
// Expected verdict for same-interface closures (docs/spike-relay.md §2.3):
|
|
15
|
+
// every group's hash is IDENTICAL across roots — including across DSH
|
|
16
|
+
// 0.1.7 / 0.2.0, whose remote interfaces are byte-identical even though the
|
|
17
|
+
// versions differ. The spike recorded be9ab393695a there for the
|
|
18
|
+
// session-controller group under its key-sorting-only normalization; this
|
|
19
|
+
// canonicalization is strictly stronger (recursive schema-key sorting), so
|
|
20
|
+
// the comparable anchor over the 0.1.7-rc.2 dev closure is r:211083a84f52 —
|
|
21
|
+
// the cross-root identity verdict below is the property that matters here.
|
|
22
|
+
//
|
|
23
|
+
// The descriptors come from each package's generated
|
|
24
|
+
// `lib/typert.remote-client.js` — inside a real DSH process the registry
|
|
25
|
+
// tier reads `ctx.typert.local.list()` instead, but the spike verified the
|
|
26
|
+
// generated files are byte-identical between the App and npm for the same
|
|
27
|
+
// interfaces, so this offline stand-in measures the same thing (and is the
|
|
28
|
+
// only way to check a closure without starting DSH).
|
|
29
|
+
|
|
30
|
+
import { createHash } from 'node:crypto'
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { dirname, join, resolve } from 'node:path'
|
|
33
|
+
import { pathToFileURL, fileURLToPath } from 'node:url'
|
|
34
|
+
|
|
35
|
+
const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
36
|
+
// The fingerprint helpers import node: builtins only — safe to load from the
|
|
37
|
+
// built lib. A missing build gets a loud, actionable failure instead.
|
|
38
|
+
const { FINGERPRINT_GROUP_PACKAGES, EVENTS_GROUP, fingerprintDescriptors, fingerprintForwardedEvents } = await import(
|
|
39
|
+
pathToFileURL(join(pluginRoot, 'lib', 'fingerprint.js')).href
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const roots = process.argv.slice(2)
|
|
43
|
+
const effectiveRoots = roots.length > 0 ? roots : [join(pluginRoot, 'node_modules')]
|
|
44
|
+
|
|
45
|
+
/** sha256 over the stripped remote-client file — the task's documented
|
|
46
|
+
* file-tier fallback, printed beside the normalized hash for reference. */
|
|
47
|
+
function fileTierHash(packageDir) {
|
|
48
|
+
const file = join(packageDir, 'lib', 'typert.remote-client.js')
|
|
49
|
+
if (!existsSync(file)) return null
|
|
50
|
+
const text = readFileSync(file, 'utf8')
|
|
51
|
+
return createHash('sha256').update(text.replace(/sourceLocation:\s*\{[^{}]*\}/gu, ''), 'utf8').digest('hex').slice(0, 12)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const report = {}
|
|
55
|
+
for (const root of effectiveRoots) {
|
|
56
|
+
const entry = {}
|
|
57
|
+
for (const [group, packageName] of Object.entries(FINGERPRINT_GROUP_PACKAGES)) {
|
|
58
|
+
const packageDir = join(root, packageName)
|
|
59
|
+
const remoteClient = join(packageDir, 'lib', 'typert.remote-client.js')
|
|
60
|
+
if (!existsSync(remoteClient)) {
|
|
61
|
+
entry[group] = { package: packageName, fingerprint: 'unavailable', note: 'lib/typert.remote-client.js not found in this closure' }
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
const mod = await import(pathToFileURL(remoteClient).href)
|
|
65
|
+
const descriptors = mod.default?.descriptors
|
|
66
|
+
if (!Array.isArray(descriptors)) {
|
|
67
|
+
entry[group] = { package: packageName, fingerprint: 'unavailable', note: 'no descriptors export' }
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
entry[group] = {
|
|
71
|
+
package: packageName,
|
|
72
|
+
endpoints: descriptors.length,
|
|
73
|
+
fingerprint: fingerprintDescriptors(descriptors),
|
|
74
|
+
fileTier: fileTierHash(packageDir),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// The events whitelist from dsh-api-remotes — the same input the runtime
|
|
78
|
+
// events group hashes.
|
|
79
|
+
const remotesFile = join(root, '@deepseek-ai', 'dsh-api-remotes', 'lib', 'index.js')
|
|
80
|
+
if (existsSync(remotesFile)) {
|
|
81
|
+
const remotes = await import(pathToFileURL(remotesFile).href)
|
|
82
|
+
const events = remotes.API_REMOTE_FORWARDED_EVENTS
|
|
83
|
+
entry[EVENTS_GROUP] = Array.isArray(events)
|
|
84
|
+
? {
|
|
85
|
+
package: '@deepseek-ai/dsh-api-remotes',
|
|
86
|
+
endpoints: events.length,
|
|
87
|
+
fingerprint: fingerprintForwardedEvents(events),
|
|
88
|
+
}
|
|
89
|
+
: { package: '@deepseek-ai/dsh-api-remotes', fingerprint: 'unavailable', note: 'API_REMOTE_FORWARDED_EVENTS is not an array' }
|
|
90
|
+
} else {
|
|
91
|
+
entry[EVENTS_GROUP] = { package: '@deepseek-ai/dsh-api-remotes', fingerprint: 'unavailable', note: 'package not found in this closure' }
|
|
92
|
+
}
|
|
93
|
+
report[root] = entry
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(JSON.stringify(report, null, 2))
|
|
97
|
+
|
|
98
|
+
// Cross-root verdict: groups present in EVERY root must hash identically —
|
|
99
|
+
// that is the property the version-tolerance layer stands on.
|
|
100
|
+
if (effectiveRoots.length > 1) {
|
|
101
|
+
const groups = Object.keys(FINGERPRINT_GROUP_PACKAGES).concat(EVENTS_GROUP)
|
|
102
|
+
const mismatches = []
|
|
103
|
+
for (const group of groups) {
|
|
104
|
+
const values = effectiveRoots.map((root) => report[root][group]?.fingerprint)
|
|
105
|
+
if (values.some((value) => value === undefined || value === 'unavailable')) continue
|
|
106
|
+
if (new Set(values).size > 1) mismatches.push(group)
|
|
107
|
+
}
|
|
108
|
+
console.error(mismatches.length === 0
|
|
109
|
+
? `verify-fingerprint-live: all comparable groups identical across ${effectiveRoots.length} closures`
|
|
110
|
+
: `verify-fingerprint-live: DIFFERING groups: ${mismatches.join(', ')}`)
|
|
111
|
+
}
|