dsh-zen-remote 2.3.5 → 2.3.7
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 +15 -5
- package/CHANGELOG.md +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +12 -6
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +9 -3
- package/docs/remote-access.md +9 -3
- package/lan-gate.mjs +7 -0
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +589 -97
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +306 -45
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +10 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +6 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +24 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +295 -43
- package/src/intercept.ts +46 -0
- package/src/log-ring.ts +101 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- package/src/plugin-tunnel.ts +230 -20
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* model spend (commit-message generation), documented as such; NO path
|
|
16
16
|
* fencing; `open.external` refuses for a remote session; `settings.*` and
|
|
17
17
|
* `/sidebar/bundle` stay local; the adapter registers as whole-machine
|
|
18
|
-
* data (consent per plugin, default off). PB-15
|
|
19
|
-
* `/sidebar/ws/sidechat`
|
|
18
|
+
* data (consent per plugin, default off). PB-15 left the 0.26.0 WebSocket
|
|
19
|
+
* `/sidebar/ws/sidechat` undeclared; PB-26b bridges it — its ids ride the
|
|
20
|
+
* post-handshake JSON frames (`{"op":"watch","threadIds":[...]}`), which
|
|
21
|
+
* the json-body upgrade gate locates, classifies and rewrites frame by
|
|
22
|
+
* frame on both halves.
|
|
20
23
|
*
|
|
21
24
|
* The `cwd` handling is the one subtle part: better-sidebar resolves a
|
|
22
25
|
* request's working directory as the session's own `header.cwd` first and
|
|
@@ -147,6 +150,16 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
|
|
|
147
150
|
// file paths, not session ids).
|
|
148
151
|
{ path: '/sidebar/ws/agent-opens', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
|
|
149
152
|
{ path: '/sidebar/ws/fs-watch', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'query', name: 'sessionId' } },
|
|
153
|
+
// The side-chat push feed (PB-26b; lib/index.js's third registerUpgrade):
|
|
154
|
+
// the upgrade URL carries NO query — the browser's first frame after the
|
|
155
|
+
// handshake is `{"op":"watch","threadIds":[...]}` and the plugin pushes
|
|
156
|
+
// `{"threadId":"..."}` per event. The json-body locator rides the FRAME
|
|
157
|
+
// gate: the client half classifies and rewrites every watch frame (an
|
|
158
|
+
// empty list — unsubscribe-all — crosses untouched), the server's send
|
|
159
|
+
// gate share-checks each frame before the plugin sees it, and the
|
|
160
|
+
// client.response hook below virtualizes the pushed threadId so the
|
|
161
|
+
// browser's `wantedRef.includes(frame.threadId)` comparison fires.
|
|
162
|
+
{ path: '/sidebar/ws/sidechat', match: 'exact', methods: ['GET'], upgrade: true, locator: { in: 'json-body', fields: ['threadIds'] } },
|
|
150
163
|
],
|
|
151
164
|
client: {
|
|
152
165
|
request: (ctx) => {
|
|
@@ -160,6 +173,22 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
|
|
|
160
173
|
return { allow: true }
|
|
161
174
|
},
|
|
162
175
|
response: (ctx) => {
|
|
176
|
+
// PB-26b: the sidechat ws PUSH frames name their thread on the TOP
|
|
177
|
+
// level — `{"threadId":"<real id>"}`. The browser compares
|
|
178
|
+
// `wantedRef.current.includes(frame.threadId)` against the VIRTUAL ids
|
|
179
|
+
// it watched, so a real id here would silently miss every push; the
|
|
180
|
+
// virtualized form rides in its place. A frame with no usable tid (a
|
|
181
|
+
// non-object, or an id that is already virtual) crosses untouched.
|
|
182
|
+
if (ctx.pathname === '/sidebar/ws/sidechat') {
|
|
183
|
+
if (ctx.body !== null && typeof ctx.body === 'object' && !Array.isArray(ctx.body)) {
|
|
184
|
+
const record = ctx.body as Record<string, unknown>
|
|
185
|
+
const tid = record.threadId
|
|
186
|
+
if (typeof tid === 'string' && tid !== '' && !ctx.isVirtual(tid)) {
|
|
187
|
+
return { ...record, threadId: ctx.virtualize(tid) }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return ctx.body
|
|
191
|
+
}
|
|
163
192
|
const name = apiMethodOf(ctx.pathname)
|
|
164
193
|
if (name === '') return ctx.body
|
|
165
194
|
const spec = SIDEBAR_METHODS[name]
|
|
@@ -100,6 +100,17 @@ function formatDuration(ms: number): string {
|
|
|
100
100
|
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Decode-throughput figure: whole tokens from ten up, one decimal below.
|
|
105
|
+
* Ported digit-for-digit from dsh-client-ui-chat lib/client.js
|
|
106
|
+
* formatTokensPerSecond (DSH 0.2.x TurnUsagePanel; verified 2026-10-30),
|
|
107
|
+
* which also owns the "{tps} tok/s" unit the cell appends.
|
|
108
|
+
*/
|
|
109
|
+
function formatTokensPerSecond(tps: number): string {
|
|
110
|
+
const clamped = Math.max(0, tps)
|
|
111
|
+
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
/** Sum of the three disjoint prompt-side billing buckets. */
|
|
104
115
|
function billedInputTokens(usage: { uncachedInputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }): number {
|
|
105
116
|
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
|
@@ -448,8 +459,24 @@ export function MobileSessionInfo({
|
|
|
448
459
|
}
|
|
449
460
|
|
|
450
461
|
const cells: Array<{ label: string; value: string; sub: string | undefined }> = [
|
|
451
|
-
|
|
452
|
-
|
|
462
|
+
/* Turns and steps share one cell ("3 / 12") so the grid stays 2×3 with
|
|
463
|
+
output speed taking the freed slot (UI-1, 2026-10-30). */
|
|
464
|
+
{
|
|
465
|
+
label: t('infoStatTurnsSteps'),
|
|
466
|
+
value: stats === undefined ? NA : `${stats.turns} / ${stats.steps}`,
|
|
467
|
+
sub: undefined,
|
|
468
|
+
},
|
|
469
|
+
/* Same guard and formula as the official TurnUsagePanel: decodeMs > 0
|
|
470
|
+
is its "we have a throughput reading" test, so a session with no
|
|
471
|
+
usage-reporting step shows the placeholder, never "0 tok/s". */
|
|
472
|
+
{
|
|
473
|
+
label: t('infoStatSpeed'),
|
|
474
|
+
value:
|
|
475
|
+
stats === undefined || stats.decodeMs <= 0
|
|
476
|
+
? NA
|
|
477
|
+
: `${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`,
|
|
478
|
+
sub: undefined,
|
|
479
|
+
},
|
|
453
480
|
{
|
|
454
481
|
label: t('infoStatTtft'),
|
|
455
482
|
value: stats === undefined || stats.ttftSteps === 0 ? NA : formatDuration(stats.ttftMs / stats.ttftSteps),
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
-
import { decideRemoteDrop, rewrapFiles } from '../../client-data/remote-drop.ts'
|
|
3
|
-
import type { RemoteDropItemDescriptor } from '../../client-data/remote-drop.ts'
|
|
2
|
+
import { decideRemoteDrop, decideRemoteIntake, isRemoteSessionId, rewrapFiles } from '../../client-data/remote-drop.ts'
|
|
3
|
+
import type { RemoteDropItemDescriptor, RemoteIntakeDescriptor } from '../../client-data/remote-drop.ts'
|
|
4
4
|
import { CURRENT_SESSION_STORAGE_KEY, parseCurrentSessionStorage } from '../../client-data/current-session.ts'
|
|
5
5
|
import { en, zh } from '../locales.ts'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The remote-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* The remote attachment-entry guard (RD-2 + AU-2): document-level CAPTURE-phase
|
|
9
|
+
* listeners on the three entries a desktop file reaches the composer through —
|
|
10
|
+
* the `drop` event, the composer's hidden picker (`change` on the unlabelled
|
|
11
|
+
* `<input type=file multiple hidden>`), and the editor's `paste` — that
|
|
12
|
+
* re-shape each intake landing in a remote session before the host's own
|
|
13
|
+
* handlers see it.
|
|
11
14
|
*
|
|
12
15
|
* The desktop app's zero-copy fast path is the problem: a File dragged from
|
|
13
16
|
* the OS keeps its disk path, the host turns it into an `@/absolute/path`
|
|
@@ -22,14 +25,22 @@ import { en, zh } from '../locales.ts'
|
|
|
22
25
|
* cleanup, busy rejection, directory detection and the addFiles branch all
|
|
23
26
|
* stay the host's own code; nothing downstream is reimplemented here.
|
|
24
27
|
*
|
|
25
|
-
* Gates (all three must pass, decided by RD-1's pure
|
|
28
|
+
* Gates (all three must pass, decided by RD-1's pure functions): the event
|
|
26
29
|
* is trusted (our redispatch is untrusted — that alone makes the guard
|
|
27
30
|
* loop-free), the Electron bridge exists (phone / plain-browser hosts are
|
|
28
|
-
* already on the upload branch
|
|
31
|
+
* already on the upload branch — the mount's capability check declines, so
|
|
32
|
+
* no listener exists at all there), and the current session (`dsh.sessions.
|
|
29
33
|
* current`, read fresh at event time) is a `zr~` virtual id. A missing
|
|
30
34
|
* signal counts as a local session — the conservative direction: the only
|
|
31
|
-
* misfire a stale signal can cause is uploading bytes for a local
|
|
32
|
-
* never losing one.
|
|
35
|
+
* misfire a stale signal can cause is uploading bytes for a local intake,
|
|
36
|
+
* never losing one. The two NEW entries (picker / paste) additionally
|
|
37
|
+
* intervene only on the zero-copy bad branch — a file that CARRIES a disk
|
|
38
|
+
* path and is NOT an image; pathless and image files already upload bytes
|
|
39
|
+
* and pass through untouched (issue #49: only that branch breaks remotely).
|
|
40
|
+
* Counting is per-entry too: the two NEW entries check the remote gate
|
|
41
|
+
* BEFORE counting, so everyday local picker/paste traffic never shows up in
|
|
42
|
+
* the diagnostics; the drop entry keeps its 2.2.6 behavior, where a local
|
|
43
|
+
* file drop still records one passthrough.
|
|
33
44
|
*
|
|
34
45
|
* Degrades to not-installed when DragEvent / DataTransfer construction is
|
|
35
46
|
* unavailable — back to exactly today's behavior (RD-4's interface.md
|
|
@@ -41,26 +52,41 @@ interface StorageLike {
|
|
|
41
52
|
getItem(key: string): string | null
|
|
42
53
|
}
|
|
43
54
|
|
|
55
|
+
/** Which entry a counted event came through. */
|
|
56
|
+
type IntakeEntry = 'drop' | 'picker' | 'paste'
|
|
57
|
+
|
|
58
|
+
export interface RemoteDropEntryCounts {
|
|
59
|
+
drop: number
|
|
60
|
+
picker: number
|
|
61
|
+
paste: number
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
export interface RemoteDropDiagnostics {
|
|
45
|
-
/** The
|
|
65
|
+
/** The listeners are mounted and armed (capability check passed, effect
|
|
46
66
|
* not yet disposed). */
|
|
47
67
|
installed: boolean
|
|
48
68
|
/** Why the capability check declined to install; `undefined` while
|
|
49
69
|
* installed (and after a dispose, which keeps the counters readable). */
|
|
50
70
|
notInstalledReason: string | undefined
|
|
51
|
-
/**
|
|
52
|
-
* the
|
|
71
|
+
/** Intakes this guard stopped from reaching the host (both the blocked and
|
|
72
|
+
* the redirected kind) — the sum of the three entries below. */
|
|
53
73
|
intercepted: number
|
|
54
|
-
/** Synthetic path-stripped drops actually dispatched. */
|
|
74
|
+
/** Synthetic path-stripped drops actually dispatched (= redirected sum). */
|
|
55
75
|
redispatched: number
|
|
56
|
-
/**
|
|
57
|
-
* dispatched). */
|
|
76
|
+
/** Intakes stopped because they carried a folder (toast shown, nothing
|
|
77
|
+
* dispatched) (= blocked sum). */
|
|
58
78
|
blockedDirectories: number
|
|
59
|
-
/**
|
|
79
|
+
/** Intakes judged not-ours and released untouched (= passthrough sum). */
|
|
60
80
|
passthrough: number
|
|
61
81
|
/** Redispatches aborted because a re-wrap or a DataTransfer add threw —
|
|
62
82
|
* the original event was released instead (宁坏勿丢). */
|
|
63
83
|
rewrapFailed: number
|
|
84
|
+
/** The same four counters split by entry (AU-2); the scalars above are
|
|
85
|
+
* their sums, so existing readers keep working unchanged. */
|
|
86
|
+
interceptedBy: RemoteDropEntryCounts
|
|
87
|
+
redirectedBy: RemoteDropEntryCounts
|
|
88
|
+
blockedBy: RemoteDropEntryCounts
|
|
89
|
+
passthroughBy: RemoteDropEntryCounts
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
/** Module-level singleton: the bundle has one document, so one guard state.
|
|
@@ -68,13 +94,36 @@ export interface RemoteDropDiagnostics {
|
|
|
68
94
|
let diagnostics: RemoteDropDiagnostics = freshDiagnostics()
|
|
69
95
|
|
|
70
96
|
function freshDiagnostics(): RemoteDropDiagnostics {
|
|
71
|
-
return {
|
|
97
|
+
return {
|
|
98
|
+
installed: false,
|
|
99
|
+
notInstalledReason: undefined,
|
|
100
|
+
intercepted: 0,
|
|
101
|
+
redispatched: 0,
|
|
102
|
+
blockedDirectories: 0,
|
|
103
|
+
passthrough: 0,
|
|
104
|
+
rewrapFailed: 0,
|
|
105
|
+
interceptedBy: { drop: 0, picker: 0, paste: 0 },
|
|
106
|
+
redirectedBy: { drop: 0, picker: 0, paste: 0 },
|
|
107
|
+
blockedBy: { drop: 0, picker: 0, paste: 0 },
|
|
108
|
+
passthroughBy: { drop: 0, picker: 0, paste: 0 },
|
|
109
|
+
}
|
|
72
110
|
}
|
|
73
111
|
|
|
74
112
|
/** A snapshot for the diagnostics carriers (settings page). Copying keeps
|
|
75
|
-
* callers from holding a live reference into a later mount's state
|
|
113
|
+
* callers from holding a live reference into a later mount's state — the
|
|
114
|
+
* per-entry objects are copied too, or a caller would keep counting. */
|
|
76
115
|
export function remoteDropDiagnostics(): RemoteDropDiagnostics {
|
|
77
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
...diagnostics,
|
|
118
|
+
interceptedBy: { ...diagnostics.interceptedBy },
|
|
119
|
+
redirectedBy: { ...diagnostics.redirectedBy },
|
|
120
|
+
blockedBy: { ...diagnostics.blockedBy },
|
|
121
|
+
passthroughBy: { ...diagnostics.passthroughBy },
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function bump(counts: RemoteDropEntryCounts, entry: IntakeEntry): void {
|
|
126
|
+
counts[entry] += 1
|
|
78
127
|
}
|
|
79
128
|
|
|
80
129
|
/** The contextBridge global's existence only — never touched otherwise
|
|
@@ -83,6 +132,24 @@ function hostPathsOf(): unknown {
|
|
|
83
132
|
return (globalThis as { __DSH_HOST_PATHS__?: unknown }).__DSH_HOST_PATHS__
|
|
84
133
|
}
|
|
85
134
|
|
|
135
|
+
/** The bridge's `pathFor` — the preload's `webUtils.getPathForFile`. A
|
|
136
|
+
* non-empty string means the File still carries its disk path, the exact
|
|
137
|
+
* fact the host's zero-copy branch keys on. Anything else (bridge shape,
|
|
138
|
+
* non-string answer, a throw) reads as "no path" — the conservative
|
|
139
|
+
* direction: the file is treated as already byte-uploading and stays
|
|
140
|
+
* untouched. */
|
|
141
|
+
function bridgePathFor(bridge: unknown, file: File): string {
|
|
142
|
+
if (bridge === null || typeof bridge !== 'object') return ''
|
|
143
|
+
const pathFor = (bridge as { pathFor?: unknown }).pathFor
|
|
144
|
+
if (typeof pathFor !== 'function') return ''
|
|
145
|
+
try {
|
|
146
|
+
const result = (pathFor as (file: File) => unknown).call(bridge, file)
|
|
147
|
+
return typeof result === 'string' ? result : ''
|
|
148
|
+
} catch {
|
|
149
|
+
return ''
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
86
153
|
/** The current-session signal, read AT EVENT TIME (the selection changes
|
|
87
154
|
* while the page is open; a mount-time snapshot would go stale). Any
|
|
88
155
|
* storage failure reads as "no signal" = local session = passthrough. */
|
|
@@ -141,6 +208,28 @@ function dismissHostDropOverlay(): void {
|
|
|
141
208
|
}
|
|
142
209
|
}
|
|
143
210
|
|
|
211
|
+
/** Build the redispatch payload WITHOUT touching any event: re-wrap the files
|
|
212
|
+
* (path-stripped) and pack them into a fresh DataTransfer. `undefined` means
|
|
213
|
+
* the file list was empty (nothing to dispatch — the caller passes through);
|
|
214
|
+
* a throw means a re-wrap or an items.add failed (0-byte / exotic File) and
|
|
215
|
+
* the caller releases the original event untouched (宁坏勿丢). Shared by all
|
|
216
|
+
* three entries — the payload shape is the redispatch contract, and the
|
|
217
|
+
* drop entry's behavior is frozen by its existing tests. */
|
|
218
|
+
function buildDropPayload(files: readonly File[]): DataTransfer | undefined {
|
|
219
|
+
const rewrapped = rewrapFiles(files)
|
|
220
|
+
if (rewrapped.length === 0) return undefined
|
|
221
|
+
const payload = new DataTransfer()
|
|
222
|
+
for (const file of rewrapped) payload.items.add(file)
|
|
223
|
+
return payload
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Dispatch the host-shaped synthetic drop on document, bubble phase — the
|
|
227
|
+
* host's own drop handler (mask reset, busy rejection, addFiles) digests it;
|
|
228
|
+
* our capture listener skips it via the isTrusted gate. */
|
|
229
|
+
function dispatchSyntheticDrop(payload: DataTransfer): void {
|
|
230
|
+
document.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: payload }))
|
|
231
|
+
}
|
|
232
|
+
|
|
144
233
|
export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
145
234
|
ctx.effect(() => {
|
|
146
235
|
// Fresh state per mount: a plugin reload re-runs apply() and must not
|
|
@@ -216,6 +305,7 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
216
305
|
})
|
|
217
306
|
if (decision.action === 'passthrough') {
|
|
218
307
|
diagnostics.passthrough += 1
|
|
308
|
+
bump(diagnostics.passthroughBy, 'drop')
|
|
219
309
|
return
|
|
220
310
|
}
|
|
221
311
|
if (decision.action === 'block-directories') {
|
|
@@ -225,7 +315,9 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
225
315
|
event.stopPropagation()
|
|
226
316
|
dismissHostDropOverlay()
|
|
227
317
|
diagnostics.intercepted += 1
|
|
318
|
+
bump(diagnostics.interceptedBy, 'drop')
|
|
228
319
|
diagnostics.blockedDirectories += 1
|
|
320
|
+
bump(diagnostics.blockedBy, 'drop')
|
|
229
321
|
showToast()
|
|
230
322
|
return
|
|
231
323
|
}
|
|
@@ -234,22 +326,23 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
234
326
|
// host still receives today's (path-chip) behavior instead of
|
|
235
327
|
// nothing at all.
|
|
236
328
|
try {
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
329
|
+
const payload = buildDropPayload(Array.from(transfer.files))
|
|
330
|
+
if (payload === undefined) {
|
|
239
331
|
diagnostics.passthrough += 1
|
|
332
|
+
bump(diagnostics.passthroughBy, 'drop')
|
|
240
333
|
return
|
|
241
334
|
}
|
|
242
|
-
const payload = new DataTransfer()
|
|
243
|
-
for (const file of rewrapped) payload.items.add(file)
|
|
244
335
|
event.preventDefault()
|
|
245
336
|
event.stopPropagation()
|
|
246
337
|
diagnostics.intercepted += 1
|
|
338
|
+
bump(diagnostics.interceptedBy, 'drop')
|
|
247
339
|
// The host's drop handler sits on document, bubble phase (F4): a
|
|
248
340
|
// bubbling synthetic drop on document reaches it. Its own handler
|
|
249
341
|
// does the preventDefault / mask reset / addFiles work; our capture
|
|
250
342
|
// listener skips this event via the isTrusted gate above.
|
|
251
|
-
|
|
343
|
+
dispatchSyntheticDrop(payload)
|
|
252
344
|
diagnostics.redispatched += 1
|
|
345
|
+
bump(diagnostics.redirectedBy, 'drop')
|
|
253
346
|
} catch {
|
|
254
347
|
// A re-wrap or a single items.add threw (0-byte / exotic File):
|
|
255
348
|
// count it and let the original event flow — 宁坏勿丢.
|
|
@@ -258,8 +351,162 @@ export function mountRemoteDropGuard(ctx: ClientContext): void {
|
|
|
258
351
|
}
|
|
259
352
|
document.addEventListener('drop', onDrop, true)
|
|
260
353
|
|
|
354
|
+
// --- the picker entry (AU-2): the composer's hidden file selector -------
|
|
355
|
+
// F1: the host composer's picker is an `input[type=file][multiple][hidden]`
|
|
356
|
+
// with NO accept attribute, and its React onChange feeds intakeFiles →
|
|
357
|
+
// addFiles — the same zero-copy branch the drop entry guards. Judged by
|
|
358
|
+
// STRUCTURE, never by class names (hash classes cannot be anchored): an
|
|
359
|
+
// input with an accept attribute (import / restore pickers), a visible
|
|
360
|
+
// one, or a `webkitdirectory` folder picker (a third-party composition
|
|
361
|
+
// could combine all four) is somebody else's and passes through. The
|
|
362
|
+
// host composer has none of those, so the hit is unchanged.
|
|
363
|
+
const onChange = (event: Event): void => {
|
|
364
|
+
if (!event.isTrusted) return
|
|
365
|
+
const input = event.target
|
|
366
|
+
if (!(input instanceof HTMLInputElement)) return
|
|
367
|
+
if (input.type !== 'file' || !input.multiple || !input.hidden || input.hasAttribute('accept') || input.webkitdirectory) return
|
|
368
|
+
const files = Array.from(input.files ?? [])
|
|
369
|
+
if (files.length === 0) return
|
|
370
|
+
// The remote gate first, BEFORE counting: a local session's everyday
|
|
371
|
+
// picker use must not show up in the diagnostics at all (the counters
|
|
372
|
+
// are a record of what the guard saw in remote sessions).
|
|
373
|
+
if (!isRemoteSessionId(readCurrentSessionId())) return
|
|
374
|
+
const bridge = hostPathsOf()
|
|
375
|
+
const items: RemoteIntakeDescriptor[] = files.map((file) => ({
|
|
376
|
+
entry: 'picker',
|
|
377
|
+
// A picker yields plain files; directories are structurally
|
|
378
|
+
// impossible, so the block-directories branch is unreachable here
|
|
379
|
+
// (defensive passthrough below).
|
|
380
|
+
isDirectory: false,
|
|
381
|
+
hasPath: bridgePathFor(bridge, file) !== '',
|
|
382
|
+
isImage: file.type.startsWith('image/'),
|
|
383
|
+
}))
|
|
384
|
+
const decision = decideRemoteIntake({
|
|
385
|
+
hasHostPaths: bridge !== undefined,
|
|
386
|
+
currentSessionId: readCurrentSessionId(),
|
|
387
|
+
trusted: event.isTrusted,
|
|
388
|
+
items,
|
|
389
|
+
})
|
|
390
|
+
if (decision.action !== 'redirect') {
|
|
391
|
+
// passthrough, or the defensive block branch that cannot happen for
|
|
392
|
+
// a picker — either way zero intervention.
|
|
393
|
+
diagnostics.passthrough += 1
|
|
394
|
+
bump(diagnostics.passthroughBy, 'picker')
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
// Build the payload BEFORE stopping the event (宁坏勿丢): a re-wrap
|
|
398
|
+
// failure releases the change untouched — the host keeps today's
|
|
399
|
+
// (path-chip) behavior instead of losing the pick entirely.
|
|
400
|
+
try {
|
|
401
|
+
const payload = buildDropPayload(files)
|
|
402
|
+
if (payload === undefined) {
|
|
403
|
+
diagnostics.passthrough += 1
|
|
404
|
+
bump(diagnostics.passthroughBy, 'picker')
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
// stopPropagation keeps the event from reaching React's delegated
|
|
408
|
+
// onChange at the root container (F5); clearing the value lets the
|
|
409
|
+
// SAME file trigger a later change again (the host's own onPickFiles
|
|
410
|
+
// habit, applied here since the host's handler no longer runs).
|
|
411
|
+
event.stopPropagation()
|
|
412
|
+
input.value = ''
|
|
413
|
+
diagnostics.intercepted += 1
|
|
414
|
+
bump(diagnostics.interceptedBy, 'picker')
|
|
415
|
+
dispatchSyntheticDrop(payload)
|
|
416
|
+
diagnostics.redispatched += 1
|
|
417
|
+
bump(diagnostics.redirectedBy, 'picker')
|
|
418
|
+
} catch {
|
|
419
|
+
diagnostics.rewrapFailed += 1
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
document.addEventListener('change', onChange, true)
|
|
423
|
+
|
|
424
|
+
// --- the paste entry (AU-2): the editor's clipboard files ---------------
|
|
425
|
+
// F2: the composer editor (a contenteditable root) reads file items off
|
|
426
|
+
// clipboardData; a path-carrying non-image lands on the same zero-copy
|
|
427
|
+
// branch. Pasting into any other target (settings fields, search) is not
|
|
428
|
+
// ours. clipboardData is read-only, so a redirect can only re-dispatch
|
|
429
|
+
// — preventDefault keeps the editor from inserting anything.
|
|
430
|
+
const onPaste = (event: ClipboardEvent): void => {
|
|
431
|
+
if (!event.isTrusted) return
|
|
432
|
+
const transfer = event.clipboardData
|
|
433
|
+
if (transfer === null) return
|
|
434
|
+
const firstElement = event.composedPath().find((node): node is Element => node instanceof Element)
|
|
435
|
+
if (firstElement === undefined || firstElement.closest('[contenteditable="true"]') === null) return
|
|
436
|
+
// The remote gate BEFORE any per-item work (same rationale as picker):
|
|
437
|
+
// a local session's paste — including pasted FILES — returns here, so
|
|
438
|
+
// it never runs bridgePathFor per file and never reaches the counters.
|
|
439
|
+
// Everything after this point still runs inside this one synchronous
|
|
440
|
+
// handler, within the dispatch lifetime clipboardData requires.
|
|
441
|
+
if (!isRemoteSessionId(readCurrentSessionId())) return
|
|
442
|
+
const bridge = hostPathsOf()
|
|
443
|
+
const files: File[] = []
|
|
444
|
+
const items: RemoteIntakeDescriptor[] = []
|
|
445
|
+
for (const item of Array.from(transfer.items)) {
|
|
446
|
+
if (item.kind !== 'file') continue
|
|
447
|
+
const file = item.getAsFile()
|
|
448
|
+
if (file === null) continue
|
|
449
|
+
// Directory detection with the host's own default: no entry API
|
|
450
|
+
// reads as "not a directory". Items without a File behind them never
|
|
451
|
+
// reach the descriptors, so a plain-text paste arrives as an empty
|
|
452
|
+
// list — the pure no-files passthrough.
|
|
453
|
+
const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null
|
|
454
|
+
files.push(file)
|
|
455
|
+
items.push({
|
|
456
|
+
entry: 'paste',
|
|
457
|
+
isDirectory: entry?.isDirectory === true,
|
|
458
|
+
hasPath: bridgePathFor(bridge, file) !== '',
|
|
459
|
+
isImage: file.type.startsWith('image/'),
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
const decision = decideRemoteIntake({
|
|
463
|
+
hasHostPaths: bridge !== undefined,
|
|
464
|
+
currentSessionId: readCurrentSessionId(),
|
|
465
|
+
trusted: event.isTrusted,
|
|
466
|
+
items,
|
|
467
|
+
})
|
|
468
|
+
if (decision.action === 'block-directories') {
|
|
469
|
+
// A folder paste cannot be fixed by single-file upload: same toast
|
|
470
|
+
// semantics as the drop branch (no overlay to dismiss — paste never
|
|
471
|
+
// opened one).
|
|
472
|
+
event.preventDefault()
|
|
473
|
+
event.stopPropagation()
|
|
474
|
+
diagnostics.intercepted += 1
|
|
475
|
+
bump(diagnostics.interceptedBy, 'paste')
|
|
476
|
+
diagnostics.blockedDirectories += 1
|
|
477
|
+
bump(diagnostics.blockedBy, 'paste')
|
|
478
|
+
showToast()
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
if (decision.action !== 'redirect') {
|
|
482
|
+
diagnostics.passthrough += 1
|
|
483
|
+
bump(diagnostics.passthroughBy, 'paste')
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const payload = buildDropPayload(files)
|
|
488
|
+
if (payload === undefined) {
|
|
489
|
+
diagnostics.passthrough += 1
|
|
490
|
+
bump(diagnostics.passthroughBy, 'paste')
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
event.preventDefault()
|
|
494
|
+
event.stopPropagation()
|
|
495
|
+
diagnostics.intercepted += 1
|
|
496
|
+
bump(diagnostics.interceptedBy, 'paste')
|
|
497
|
+
dispatchSyntheticDrop(payload)
|
|
498
|
+
diagnostics.redispatched += 1
|
|
499
|
+
bump(diagnostics.redirectedBy, 'paste')
|
|
500
|
+
} catch {
|
|
501
|
+
diagnostics.rewrapFailed += 1
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
document.addEventListener('paste', onPaste, true)
|
|
505
|
+
|
|
261
506
|
return () => {
|
|
262
507
|
document.removeEventListener('drop', onDrop, true)
|
|
508
|
+
document.removeEventListener('change', onChange, true)
|
|
509
|
+
document.removeEventListener('paste', onPaste, true)
|
|
263
510
|
removeToast()
|
|
264
511
|
// Keep the counters readable after a dispose (the settings page may
|
|
265
512
|
// still be open mid-reload); `installed` is the armed flag only.
|
package/src/client/locales.ts
CHANGED
|
@@ -35,6 +35,8 @@ export const zh = {
|
|
|
35
35
|
'activityOpen': '查看子代理与后台任务',
|
|
36
36
|
'infoStatTurns': '轮次',
|
|
37
37
|
'infoStatSteps': '步骤',
|
|
38
|
+
'infoStatTurnsSteps': '轮次 / 步数',
|
|
39
|
+
'infoStatSpeed': '输出速度',
|
|
38
40
|
'infoStatTtft': '首字延迟',
|
|
39
41
|
'infoStatLlm': '模型耗时',
|
|
40
42
|
'infoStatTool': '工具耗时',
|
|
@@ -253,15 +255,23 @@ export const zh = {
|
|
|
253
255
|
'settings.client.diagRecentFailures': '最近的远程调用失败',
|
|
254
256
|
'settings.client.diagCompatGroups': '指纹有差异的组:{groups}',
|
|
255
257
|
'settings.client.diagCompatCalls': '最近的不兼容调用',
|
|
256
|
-
// --- RD-3
|
|
257
|
-
'settings.client.diagRemoteDrop': '
|
|
258
|
+
// --- RD-3/AU-3 附件入口守卫的诊断行(浏览器端计数,渲染时现读) ---
|
|
259
|
+
'settings.client.diagRemoteDrop': '附件入口守卫',
|
|
258
260
|
'settings.client.diagRemoteDropOk': '已安装 · 拦截 {intercepted} · 重派发 {redispatched} · 文件夹 {blocked} · 放行 {passthrough} · 重包失败 {failed}',
|
|
261
|
+
'settings.client.diagRemoteDropEntries': '分入口 · 拦截 拖拽 {dropI} / 回形针 {pickerI} / 粘贴 {pasteI} · 放行 拖拽 {dropP} / 回形针 {pickerP} / 粘贴 {pasteP}',
|
|
259
262
|
'settings.client.diagRemoteDropOff': '未安装({reason})',
|
|
260
263
|
// --- RD-6 上传改道(fetch-route 拦截)的诊断行:读 remote-status 的 serviceProbe ---
|
|
261
264
|
'settings.client.diagUploadRedirect': '上传改道',
|
|
262
265
|
'settings.client.diagUploadRedirectOk': '已安装 · 上传已接管',
|
|
263
266
|
'settings.client.diagUploadRedirectRefused': '拒装:{reason}',
|
|
264
267
|
'settings.client.diagUploadRedirectPending': '服务注入未触发 · connection {connection} · fileUploads {fileUploads}',
|
|
268
|
+
// --- CU-4 诊断信息导出(设置页最底部,两种角色都有) ---
|
|
269
|
+
'settings.debug.title': '诊断信息',
|
|
270
|
+
'settings.debug.export': '导出诊断信息',
|
|
271
|
+
'settings.debug.exporting': '正在生成…',
|
|
272
|
+
'settings.debug.hint': '下载一份脱敏后的诊断文件(版本、配置形态、连接诊断、插件日志)。令牌与密钥、对话内容、附件和完整文件路径一律不包含,服务器地址只保留类型。建议从机与主服务器各导一份,可直接附到 GitHub issue。',
|
|
273
|
+
'settings.debug.done': '诊断文件已下载',
|
|
274
|
+
'settings.debug.fail': '导出失败:无法生成诊断报告',
|
|
265
275
|
// --- PB-5 插件桥:主服务端同意小节 + 子客户端诊断小节 ---
|
|
266
276
|
'settings.bridge.title': '插件桥',
|
|
267
277
|
'settings.bridge.empty': '暂无插件登记插件桥',
|
|
@@ -549,6 +559,8 @@ export const en: Record<MobileNavKey, string> = {
|
|
|
549
559
|
'activityOpen': 'View subagents and background tasks',
|
|
550
560
|
'infoStatTurns': 'Turns',
|
|
551
561
|
'infoStatSteps': 'Steps',
|
|
562
|
+
'infoStatTurnsSteps': 'Turns / Steps',
|
|
563
|
+
'infoStatSpeed': 'Output speed',
|
|
552
564
|
'infoStatTtft': 'TTFT',
|
|
553
565
|
'infoStatLlm': 'LLM time',
|
|
554
566
|
'infoStatTool': 'Tool time',
|
|
@@ -762,16 +774,24 @@ export const en: Record<MobileNavKey, string> = {
|
|
|
762
774
|
'settings.client.diagRecentFailures': 'Recent remote call failures',
|
|
763
775
|
'settings.client.diagCompatGroups': 'Groups with fingerprint differences: {groups}',
|
|
764
776
|
'settings.client.diagCompatCalls': 'Recent incompatible calls',
|
|
765
|
-
// --- RD-3: the
|
|
777
|
+
// --- RD-3/AU-3: the attachment-entry guard's diagnostics line (browser-side
|
|
766
778
|
// counters, read at render time) ---
|
|
767
|
-
'settings.client.diagRemoteDrop': '
|
|
779
|
+
'settings.client.diagRemoteDrop': 'Attachment entry guard',
|
|
768
780
|
'settings.client.diagRemoteDropOk': 'Installed · intercepted {intercepted} · redispatched {redispatched} · folders {blocked} · passthrough {passthrough} · rewrap failed {failed}',
|
|
781
|
+
'settings.client.diagRemoteDropEntries': 'By entry · intercepted drop {dropI} / picker {pickerI} / paste {pasteI} · passthrough drop {dropP} / picker {pickerP} / paste {pasteP}',
|
|
769
782
|
'settings.client.diagRemoteDropOff': 'Not installed ({reason})',
|
|
770
783
|
// --- RD-6: the upload-redirect (fetch-route intercept) diagnostics row ---
|
|
771
784
|
'settings.client.diagUploadRedirect': 'Upload redirect',
|
|
772
785
|
'settings.client.diagUploadRedirectOk': 'Installed · upload wrapped',
|
|
773
786
|
'settings.client.diagUploadRedirectRefused': 'Refused: {reason}',
|
|
774
787
|
'settings.client.diagUploadRedirectPending': 'Service inject never fired · connection {connection} · fileUploads {fileUploads}',
|
|
788
|
+
// --- CU-4: the diagnostics export (bottom of the settings page, both roles) ---
|
|
789
|
+
'settings.debug.title': 'Diagnostics',
|
|
790
|
+
'settings.debug.export': 'Export diagnostics',
|
|
791
|
+
'settings.debug.exporting': 'Generating…',
|
|
792
|
+
'settings.debug.hint': 'Download a redacted diagnostics file (versions, config shape, connection diagnostics, plugin log). Tokens and secrets, conversation content, attachments and full file paths are never included; server addresses keep only their type. Export one on the client and one on the server — both can be attached to a GitHub issue as-is.',
|
|
793
|
+
'settings.debug.done': 'Diagnostics file downloaded',
|
|
794
|
+
'settings.debug.fail': 'Export failed: the diagnostics report could not be generated',
|
|
775
795
|
// --- PB-5 plugin bridge: host consent section + sub-client diagnostics ---
|
|
776
796
|
'settings.bridge.title': 'Plugin bridge',
|
|
777
797
|
'settings.bridge.empty': 'No plugin has registered a bridge yet',
|