@linxin666/dsh-ssh 0.3.23 → 0.4.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.
@@ -16,8 +16,8 @@ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-co
16
16
  /** Stable data attribute identifying the injected entry row. */
17
17
  export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
18
18
 
19
- /** Inline terminal glyph sized to the shell's current sidebar navigation icons. */
20
- const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
19
+ /** Inline terminal glyph sized to the shell's panel-row navigation icons. */
20
+ const ICON = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
21
21
 
22
22
  /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
23
23
  export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
package/src/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or
package/src/index.ts CHANGED
@@ -8,9 +8,8 @@
8
8
  * no dsh source changes.
9
9
  */
10
10
 
11
- import type { Context } from '@deepseek-ai/cordis'
12
- import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
13
- import z from 'schemastery'
11
+ import type { Context, Fiber } from '@deepseek-ai/cordis'
12
+ import z from '@deepseek-ai/schemastery'
14
13
  import type {} from '@deepseek-ai/dsh-host-webserver'
15
14
  import type {} from '@deepseek-ai/dsh-system-prompt'
16
15
  import type {} from '@deepseek-ai/dsh-tools'
@@ -26,18 +25,23 @@ export const name = 'ssh'
26
25
  /** Services required before the SSH surfaces can mount. */
27
26
  export const inject = ['webServer', 'tools', 'systemPrompt']
28
27
 
29
- /**
30
- * Settings namespace of the SSH capability — the section the web settings
31
- * surface edits. Spelled here rather than imported: the browser half spells
32
- * the same value and must not depend on a Host package.
33
- */
34
- export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh' as SettingsNamespace
28
+ declare module '@deepseek-ai/cordis' {
29
+ interface Events {
30
+ /**
31
+ * Volatile config values were committed into the running instance without a
32
+ * remount (cordis-plugin-loader); dispatched to the owning fiber only.
33
+ * @param paths - the changed config paths, as key arrays.
34
+ * @mode emit
35
+ */
36
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void
37
+ }
38
+ }
35
39
 
36
- /** Plugin config, validated by the same-named schemastery schema. */
40
+ /** Plugin config as a profile patch declares it, before schema resolution. */
37
41
  export interface Config {
38
42
  /**
39
- * When true (default), a system-prompt section announces the SSH plugin to
40
- * every agent (tools + host store). Set false to keep it silent.
43
+ * When true, a system-prompt section announces the SSH plugin to every agent
44
+ * (tools + host store). Off by default to keep prompts clean.
41
45
  */
42
46
  announceToAgent?: boolean
43
47
  /** Master switch for the plugin (routes, tools, prompt section). */
@@ -51,14 +55,67 @@ export interface Config {
51
55
  terminalFontFamily?: string
52
56
  }
53
57
 
54
- export const Config: z<Config> = z.object({
55
- announceToAgent: z.boolean().default(false),
56
- enabled: z.boolean().default(true),
57
- terminalFontFamily: z.string().default(''),
58
+ /** The stable reference a `volatile()` config field resolves to; its owner updates it in place. */
59
+ interface ConfigRef<T> {
60
+ /** @returns the field's current value. */
61
+ get(): T
62
+ }
63
+
64
+ /** One resolved config field: a live reference, or a plain value from a hand-built context. */
65
+ type ConfigField<T> = ConfigRef<T> | T
66
+
67
+ /** The config the Host hands to {@link apply} — the runtime face of {@link Config}. */
68
+ export interface ResolvedConfig {
69
+ announceToAgent?: ConfigField<boolean>
70
+ enabled?: ConfigField<boolean>
71
+ terminalFontFamily?: ConfigField<string>
72
+ }
73
+
74
+ /**
75
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
76
+ * entry's settings page: the Host derives one form per profile entry from it
77
+ * and serves it through the shared configuration forms. Every field is
78
+ * `volatile()`, which is what puts it on that page and what lets an edit reach
79
+ * a running instance without a remount: the loader commits the new value into
80
+ * the field's reference and announces `loader/volatile-update` on this fiber.
81
+ */
82
+ export const Config = z.object({
83
+ announceToAgent: z.boolean().default(false).volatile(),
84
+ enabled: z.boolean().default(true).volatile(),
85
+ terminalFontFamily: z.string().default('').volatile(),
58
86
  })
59
87
 
60
- /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
88
+ /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
61
89
  const DEFAULT_ANNOUNCE = false
90
+ const DEFAULT_ENABLED = true
91
+
92
+ /** Read one resolved config field, following the live reference the schema produces. */
93
+ function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
94
+ if (field === undefined) return fallback
95
+ if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
96
+ const value = (field as ConfigRef<T>).get()
97
+ return value === undefined ? fallback : value
98
+ }
99
+ return field as T
100
+ }
101
+
102
+ /**
103
+ * The loader's own record of the profile entry this instance was activated
104
+ * from, which carries the row's raw config — the user's own declaration, as
105
+ * opposed to the schema defaults and the inherited bundle layers. Absent on a
106
+ * host without the loader, which reads as "the profile declares nothing".
107
+ */
108
+ interface LoaderEntry {
109
+ options?: { config?: unknown }
110
+ }
111
+
112
+ /** Raw config the profile declares for this entry, when the loader exposes it. */
113
+ function profileConfig(ctx: Context): Record<string, unknown> | undefined {
114
+ const entry = (ctx.fiber as Fiber & { entry?: LoaderEntry }).entry
115
+ const config = entry?.options?.config
116
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) return undefined
117
+ return config as Record<string, unknown>
118
+ }
62
119
 
63
120
  /** Order of the announcement section within the tool-guidance band. */
64
121
  const SECTION_ORDER = 150
@@ -73,28 +130,22 @@ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运
73
130
  */
74
131
  export const apply = mountOnce('@linxin666/dsh-ssh', applyImpl)
75
132
 
76
- function applyImpl(ctx: Context, config?: Config): void {
77
- // The live source the surfaces read: the settings section once the web
78
- // settings surface is served, the composition entry otherwise.
79
- let current: () => Config = () => config ?? {}
80
- let isSettingsBound = false
81
-
133
+ function applyImpl(ctx: Context, config?: ResolvedConfig): void {
82
134
  const store = new HostStore()
83
135
  const engine = new SshEngine(store)
84
136
  ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine')
85
137
 
86
- const resolve = (): Config => {
87
- const value = current()
88
- let enabled = value.enabled ?? true
89
- // When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile line),
90
- // but the user has never explicitly configured settings (not bound yet)
91
- // AND already has active host records in dsh-ssh.json, keep the plugin enabled so
138
+ const resolve = (): { announceToAgent: boolean; enabled: boolean } => {
139
+ let enabled = readConfigField(config?.enabled, DEFAULT_ENABLED)
140
+ // When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile
141
+ // line), but the profile's own entry config never set the switch AND the user
142
+ // already has active host records in dsh-ssh.json, keep the plugin enabled so
92
143
  // existing users are not broken upon upgrade (#1250).
93
- if (enabled === false && !isSettingsBound && store.list().length > 0) {
144
+ if (enabled === false && profileConfig(ctx)?.enabled === undefined && store.list().length > 0) {
94
145
  enabled = true
95
146
  }
96
147
  return {
97
- announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
148
+ announceToAgent: readConfigField(config?.announceToAgent, DEFAULT_ANNOUNCE),
98
149
  enabled,
99
150
  }
100
151
  }
@@ -117,7 +168,7 @@ function applyImpl(ctx: Context, config?: Config): void {
117
168
  // System-prompt announcement.
118
169
  let disposeSection: (() => void) | undefined
119
170
 
120
- // Register (or drop) every surface to match the current source. Each group
171
+ // Register (or drop) every surface to match the resolved config. Each group
121
172
  // is kept under one disposer: re-registering first tears the old one down
122
173
  // so duplicate-name registrations never throw.
123
174
  const sync = (): void => {
@@ -162,29 +213,11 @@ function applyImpl(ctx: Context, config?: Config): void {
162
213
  )
163
214
  }
164
215
 
165
- ctx.inject(['settings'], (settingsCtx) => {
166
- try {
167
- if (typeof settingsCtx.settings?.installSection === 'function') {
168
- settingsCtx.settings.installSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, {
169
- setSource: (source) => {
170
- isSettingsBound = true
171
- current = source
172
- sync()
173
- },
174
- onChange: sync,
175
- })
176
- } else if (typeof settingsCtx.settings?.register === 'function') {
177
- const scope = settingsCtx.settings.register(SSH_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
178
- isSettingsBound = true
179
- current = () => scope?.get?.() ?? (config ?? {})
180
- scope?.watch?.(() => { sync() })
181
- }
182
- } catch {
183
- // Defensive fallback against settings registration differences
184
- }
185
- })
216
+ // A settings edit is committed into this instance's config references and
217
+ // announced on the owning fiber (the entry is not remounted), so the
218
+ // surfaces are re-derived from the new values here.
219
+ ctx.on('loader/volatile-update', () => { sync() })
186
220
 
187
- // Initial registration from the composition entry (covers deployments with
188
- // no settings service, whose installSection never fires its hooks).
221
+ // Initial registration from the config the Host activated this row with.
189
222
  sync()
190
223
  }