@banbolee/dsh-fish-shell 0.7.0 → 0.8.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +310 -13
  3. package/README.zh.md +99 -4
  4. package/THIRD-PARTY-NOTICES.md +91 -0
  5. package/cordis.patch.yml +56 -0
  6. package/node_modules/@deepseek-ai/cosmokit/LICENSE +21 -0
  7. package/node_modules/@deepseek-ai/cosmokit/README.md +30 -0
  8. package/node_modules/@deepseek-ai/cosmokit/lib/index.js +418 -0
  9. package/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts +15 -0
  10. package/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts.map +1 -0
  11. package/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts +13 -0
  12. package/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts.map +1 -0
  13. package/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts +41 -0
  14. package/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts.map +1 -0
  15. package/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts +89 -0
  16. package/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts.map +1 -0
  17. package/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts +19 -0
  18. package/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts.map +1 -0
  19. package/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts +42 -0
  20. package/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts.map +1 -0
  21. package/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts +40 -0
  22. package/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts.map +1 -0
  23. package/node_modules/@deepseek-ai/cosmokit/package.json +32 -0
  24. package/node_modules/@deepseek-ai/cosmokit/src/array.ts +42 -0
  25. package/node_modules/@deepseek-ai/cosmokit/src/index.ts +12 -0
  26. package/node_modules/@deepseek-ai/cosmokit/src/misc.ts +78 -0
  27. package/node_modules/@deepseek-ai/cosmokit/src/string.ts +113 -0
  28. package/node_modules/@deepseek-ai/cosmokit/src/time.ts +92 -0
  29. package/node_modules/@deepseek-ai/cosmokit/src/types.ts +168 -0
  30. package/node_modules/@deepseek-ai/cosmokit/src/volatile.ts +86 -0
  31. package/node_modules/@deepseek-ai/dsh-tool-terminal/LICENSE +21 -0
  32. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.i18n.yaml +100 -0
  33. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.md +186 -0
  34. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.zh.md +186 -0
  35. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/index.js +646 -0
  36. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/background.d.ts +20 -0
  37. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/index.d.ts +32 -0
  38. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/render.d.ts +81 -0
  39. package/node_modules/@deepseek-ai/dsh-tool-terminal/package.json +63 -0
  40. package/node_modules/@deepseek-ai/schemastery/LICENSE +21 -0
  41. package/node_modules/@deepseek-ai/schemastery/README.md +393 -0
  42. package/node_modules/@deepseek-ai/schemastery/lib/index.cjs +635 -0
  43. package/node_modules/@deepseek-ai/schemastery/lib/index.mjs +635 -0
  44. package/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts +211 -0
  45. package/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts.map +1 -0
  46. package/node_modules/@deepseek-ai/schemastery/package.json +39 -0
  47. package/node_modules/@deepseek-ai/schemastery/src/index.ts +962 -0
  48. package/node_modules/@standard-schema/spec/LICENSE +21 -0
  49. package/node_modules/@standard-schema/spec/README.md +198 -0
  50. package/node_modules/@standard-schema/spec/dist/index.cjs +18 -0
  51. package/node_modules/@standard-schema/spec/dist/index.d.cts +119 -0
  52. package/node_modules/@standard-schema/spec/dist/index.d.ts +119 -0
  53. package/node_modules/@standard-schema/spec/dist/index.js +0 -0
  54. package/node_modules/@standard-schema/spec/package.json +52 -0
  55. package/package.json +17 -3
  56. package/terminal-tools.js +259 -0
  57. package/terminal.js +297 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * `terminalTools` policy for the L2 interactive terminal surface of
3
+ * `@banbolee/dsh-fish-shell` — the D6 switch, `'allow' | 'deny'`.
4
+ *
5
+ * The bundle's patch appends a `fish-terminal-group` insert row that mounts
6
+ * the official `@deepseek-ai/dsh-terminal` registry, the official
7
+ * `@deepseek-ai/dsh-terminal-bash` backend driven with fish argv
8
+ * (`--no-config -i -C <prompt setup>`), and the official
9
+ * `@deepseek-ai/dsh-tool-terminal`, whose six `terminal_*` tools register
10
+ * into the HOST tool registry (the group isolates `terminals`, not `tools`).
11
+ * This module is the only entry the plugin itself adds for L2: a per-agent
12
+ * switch that can take those six tools away again.
13
+ *
14
+ * - `terminalTools: 'allow'` (the default, matching every other tool in the
15
+ * deployment) does nothing at all — `apply` returns before registering a
16
+ * single listener, so an allowed mount has zero runtime footprint.
17
+ * - `terminalTools: 'deny'` restricts the six tools for every live agent at
18
+ * the `agent/created` publication edge and on every `tools/change`. The
19
+ * official `tools.restrict` is the only enforcement point, and it REJECTS
20
+ * names that are not registered at that moment, so an attempt made before
21
+ * the terminal tools exist fails and is retried on the next
22
+ * `tools/change` (the registry announces every registration and scoped
23
+ * restriction through that event).
24
+ *
25
+ * Every `apply()` call owns its ENTIRE state — its own live-agent set, its own
26
+ * per-agent disposers, its own reconcile flags, and its own
27
+ * installing/warned/removed bookkeeping. Two mounts sharing one context (two
28
+ * bundle rows) or two profiles living in one process therefore cannot dedupe
29
+ * into, lift, or orphan each other's restrictions: each mount installs its own
30
+ * restriction and that mount alone lifts it on teardown, so no restriction
31
+ * outlives the mount that owns it.
32
+ *
33
+ * The governed names are the EXPLICIT six-name enumeration measured on a real
34
+ * profile (`TERMINAL_TOOL_NAMES`); a `terminal_*` wildcard is deliberately not
35
+ * used, so an upstream seventh tool cannot silently pass the policy — the
36
+ * contract test compares this constant with the bundled
37
+ * `@deepseek-ai/dsh-tool-terminal` surface instead.
38
+ *
39
+ * Boundaries: this policy registers no tool, wraps/replaces no host object,
40
+ * monkey-patches nothing, and offers no privilege-escalation entry point. It
41
+ * only narrows an agent's tool surface through the public
42
+ * `agent.ctx.tools.restrict({ deny })` API. The interactive sessions
43
+ * themselves inherit the host's confinement (a `workspace-write` profile is
44
+ * fenced by the host sandbox).
45
+ *
46
+ * @module @banbolee/dsh-fish-shell/terminal-tools
47
+ */
48
+
49
+ /** Cordis plugin name (the patch row id is `fish-terminal-tools`). */
50
+ export const name = 'fish-terminal-tools-policy'
51
+ /** Required service: the tool registry the six names are restricted in. */
52
+ export const inject = ['tools']
53
+
54
+ /**
55
+ * The six `terminal_*` tools mounted by the bundle's `fish-terminal-group`,
56
+ * enumerated explicitly (never a `terminal_*` pattern): `terminal_open`,
57
+ * `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`,
58
+ * `terminal_list`.
59
+ */
60
+ export const TERMINAL_TOOL_NAMES = Object.freeze([
61
+ 'terminal_open',
62
+ 'terminal_send',
63
+ 'terminal_read',
64
+ 'terminal_signal',
65
+ 'terminal_close',
66
+ 'terminal_list',
67
+ ])
68
+
69
+ /**
70
+ * Plugin configuration schema: one switch. A plain object implementing the
71
+ * standard-schema interface (no external validator): an absent
72
+ * `terminalTools` defaults to `allow`; any value other than `allow`/`deny` is
73
+ * rejected at mount time.
74
+ */
75
+ export const Config = {
76
+ '~standard': {
77
+ version: /** @type {1} */ (1),
78
+ vendor: '@banbolee/dsh-fish-shell/terminal-tools',
79
+ /**
80
+ * Normalize one mount's raw configuration.
81
+ * @param {unknown} value
82
+ */
83
+ validate(value) {
84
+ const mode = /** @type {{ terminalTools?: unknown }} */ (value ?? {}).terminalTools ?? 'allow'
85
+ if (mode !== 'allow' && mode !== 'deny') {
86
+ throw new TypeError("banbo-fish-terminal-tools config.terminalTools must be 'allow' or 'deny'")
87
+ }
88
+ return { value: { terminalTools: mode } }
89
+ },
90
+ },
91
+ }
92
+
93
+ const PLUGIN_TAG = '@banbolee/dsh-fish-shell'
94
+
95
+ /**
96
+ * One agent as this policy reads it: the harness `Agent` viewed through the
97
+ * two members it keys on — its session id (for warnings) and its agent-scoped
98
+ * context (where the restriction lands). The full runtime `Agent` face is
99
+ * structurally assignable to this view.
100
+ * @typedef {{ id?: string, ctx: import('@deepseek-ai/cordis').Context }} PolicyAgent
101
+ */
102
+
103
+ /**
104
+ * Mount one `deny` policy on `ctx`, owning every piece of state it needs.
105
+ * Nothing in here is shared with another mount of this module.
106
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context this mount lives on.
107
+ */
108
+ function mountDenyPolicy(ctx) {
109
+ /** Agents this mount is currently restricting (guards the synchronous
110
+ * `tools/change` re-entry that `tools.restrict` itself fires).
111
+ * @type {WeakSet<PolicyAgent>} */
112
+ const installing = new WeakSet()
113
+ /** Agents this mount already warned about a failed restriction (warn once
114
+ * per agent per mount).
115
+ * @type {WeakSet<PolicyAgent>} */
116
+ const warned = new WeakSet()
117
+ /** Agents this mount saw disposed: a `tools/change` fired by the uninstall's
118
+ * own disposer must not re-restrict a dying agent.
119
+ * @type {WeakSet<PolicyAgent>} */
120
+ const removed = new WeakSet()
121
+ /** The exact restriction disposer THIS mount installed per agent.
122
+ * @type {WeakMap<PolicyAgent, () => void>} */
123
+ const restrictions = new WeakMap()
124
+ /** This mount's agents with a restriction installed; traversable so its own
125
+ * teardown lifts exactly its own restrictions (a WeakMap would be
126
+ * unreachable).
127
+ * @type {Set<PolicyAgent>} */
128
+ const liveAgents = new Set()
129
+ /** This mount's reconcile re-entrancy guard: a `tools/change` arriving while
130
+ * a reconcile round is in flight marks `reconcilePending` instead of starting
131
+ * a nested round; the round in flight runs exactly one coalesced catch-up
132
+ * round afterwards. */
133
+ let reconciling = false
134
+ let reconcilePending = false
135
+ /** Set while THIS mount's teardown runs so its own listeners (still
136
+ * registered — they were created before the teardown effect, which cordis
137
+ * disposes first) cannot re-install a restriction being lifted. */
138
+ let disposed = false
139
+
140
+ /**
141
+ * Deny the six terminal tools for one agent, once. A failure (the tools are
142
+ * not registered in this profile yet, so `tools.restrict` rejects the unknown
143
+ * names) is warned once per agent and retried on the next `tools/change`.
144
+ * @param {PolicyAgent} agent - the live agent (its `ctx` is the agent's scoped context).
145
+ */
146
+ const install = (agent) => {
147
+ if (installing.has(agent) || removed.has(agent)) return
148
+ if (restrictions.has(agent)) return
149
+ installing.add(agent)
150
+ try {
151
+ // A fresh array: the frozen constant stays the policy's single source of
152
+ // names, and the toolbox never receives the shared instance.
153
+ const restriction = agent.ctx.tools.restrict({ deny: [...TERMINAL_TOOL_NAMES] })
154
+ restrictions.set(agent, restriction)
155
+ } catch (error) {
156
+ if (!warned.has(agent)) {
157
+ warned.add(agent)
158
+ console.warn(
159
+ `${PLUGIN_TAG}: could not deny the terminal tools for agent ${agent.id ?? '(unnamed)'} yet `
160
+ + '(they are not registered in this profile so far); retrying on the next tools/change:',
161
+ String(error),
162
+ )
163
+ }
164
+ } finally {
165
+ installing.delete(agent)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Lift one agent's restriction, if THIS mount installed one. The exact
171
+ * disposer is removed from the state BEFORE it runs: lifting a restriction
172
+ * synchronously fires `tools/change` as well.
173
+ * @param {PolicyAgent} agent - the agent whose restriction is lifted.
174
+ * @param {string} label - the operation name in the warning.
175
+ */
176
+ const lift = (agent, label) => {
177
+ const restriction = restrictions.get(agent)
178
+ if (restriction === undefined) return
179
+ restrictions.delete(agent)
180
+ try {
181
+ restriction()
182
+ } catch (error) {
183
+ console.warn(`${PLUGIN_TAG}: could not ${label} terminal tools for agent ${agent.id ?? '(unnamed)'}:`, String(error))
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Ensure every live agent carries this mount's restriction (no-op when the
189
+ * `agents` service is absent). Nested calls (the restriction's own
190
+ * `tools/change`) are absorbed into at most ONE coalesced catch-up round, so
191
+ * the dispatch count stays bounded regardless of how many agents are live.
192
+ * @param {boolean} [catchUp] - whether this round is the coalesced catch-up (never re-triggers one).
193
+ */
194
+ const reconcile = (catchUp = false) => {
195
+ if (reconciling) {
196
+ reconcilePending = true
197
+ return
198
+ }
199
+ reconciling = true
200
+ try {
201
+ for (const agent of ctx.get('agents')?.list() ?? []) {
202
+ liveAgents.add(agent)
203
+ install(agent)
204
+ }
205
+ } finally {
206
+ reconciling = false
207
+ }
208
+ if (reconcilePending && !catchUp) {
209
+ reconcilePending = false
210
+ reconcile(true)
211
+ } else {
212
+ reconcilePending = false
213
+ }
214
+ }
215
+
216
+ // `agent/created` fires synchronously at the agent publication boundary
217
+ // (before the agent's first turn); a synchronous listener error would
218
+ // propagate into the publish path, but `install` contains its own failures.
219
+ ctx.on('agent/created', ({ agent }) => {
220
+ if (disposed) return
221
+ liveAgents.add(agent)
222
+ install(agent)
223
+ })
224
+ ctx.on('agent/disposed', ({ agent }) => {
225
+ if (disposed) return
226
+ removed.add(agent)
227
+ liveAgents.delete(agent)
228
+ lift(agent, 'lift the restriction on the')
229
+ })
230
+ ctx.on('tools/change', () => {
231
+ if (disposed) return
232
+ reconcile()
233
+ })
234
+ // Plugin-owned teardown (unload/disable/HMR): registered LAST so cordis
235
+ // disposes it FIRST (effects run in reverse registration order) — while this
236
+ // loop lifts THIS mount's restrictions, `disposed` above makes its listeners
237
+ // no-ops, so lifting cannot re-install.
238
+ ctx.effect(() => () => {
239
+ disposed = true
240
+ for (const agent of [...liveAgents]) lift(agent, 'lift the restriction on the')
241
+ liveAgents.clear()
242
+ }, 'fish terminal-tools policy teardown')
243
+ reconcile()
244
+ }
245
+
246
+ /**
247
+ * Cordis plugin entry. With `terminalTools: 'deny'` the six `terminal_*` tools
248
+ * are taken away from every live agent — at the `agent/created` publication
249
+ * edge, on every `tools/change`, and for agents that already exist (plugin
250
+ * hot-reload) — and every restriction this mount installed is lifted on its
251
+ * teardown. With the default `allow` nothing is registered and the function
252
+ * returns.
253
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
254
+ * @param {{ terminalTools?: 'allow' | 'deny' }} [config] - validated plugin config.
255
+ */
256
+ export function apply(ctx, config) {
257
+ if (config?.terminalTools !== 'deny') return
258
+ mountDenyPolicy(ctx)
259
+ }
package/terminal.js ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * L2 interactive PTY backend: the official `@deepseek-ai/dsh-terminal-bash`
3
+ * `BashTerminalBackend` with ONE addition — the harness home contract
4
+ * (`DSH_HOME`, and `DSH_PROFILE` / `DSH_PROFILE_DIR` when the context carries
5
+ * them) in the PTY child environment.
6
+ *
7
+ * Why it exists: an interactive session's child environment is the backend's
8
+ * own overlay (`TERM=dumb`, `PAGER=cat`, `GIT_PAGER=cat`, `DSH_SHELL`,
9
+ * `DSH_SESSION_ID`, `DSH_PTY_SESSION_ID`) layered on `scrubbedParentEnv()`,
10
+ * which drops EVERY inherited `DSH_*` name. The one-shot `fish` tool instead
11
+ * runs with the trusted snapshot of the `ctx.shellEnv` registry, merged after
12
+ * that same scrub. The two surfaces therefore disagreed about the harness
13
+ * home: a `dsh` nested inside an interactive session resolved `~/.dsh` (an
14
+ * older home with older plugin state) while the one-shot tool resolved the
15
+ * real harness home. This module puts both surfaces on the same home facts.
16
+ *
17
+ * How the injection stays on public seams: the base class builds the overlay
18
+ * with its module-private `childEnvironment()` and hands the finished terminal
19
+ * spec to its `spawnTerminal` CONSTRUCTOR parameter. This subclass passes a
20
+ * wrapper for that parameter (the official default is
21
+ * `(spec) => ctx.subprocess.spawnTerminal(spec)`), and the spec's `env` field
22
+ * is documented as "explicit environment layered after the provider's ambient
23
+ * scrub" — exactly where a deliberately forwarded `DSH_*` fact belongs. No
24
+ * official module internal, no private field and no monkey-patch is involved:
25
+ * argv confinement, the sandbox-mode fence, `session.initialize()` readiness,
26
+ * the `-C` fish prompt setup, the session factory and the config surface
27
+ * (`Config` is re-exported verbatim, so `backendType` keeps its `shell`
28
+ * default) are all the base class's own code.
29
+ *
30
+ * Where the values come from: the SAME registry the one-shot shell tools use —
31
+ * `ctx.shellEnv.collect()`, whose snapshot `dsh-bash-local` merges into a
32
+ * one-shot execution — so the two cannot drift. The three home facts are
33
+ * execution-independent by construction: `collect` reads `execution.agent`
34
+ * only to add `DSH_SESSION_ID`, and the profile facts come from the context.
35
+ * The snapshot is therefore taken at spawn time with an empty execution, and
36
+ * the session facts stay the PTY overlay's own official values: this module
37
+ * never rewrites `DSH_SHELL` / `DSH_SESSION_ID` / `DSH_PTY_SESSION_ID`.
38
+ *
39
+ * `DSH_SESSION_ID` / `DSH_PTY_SESSION_ID` are deliberately KEPT on a newly
40
+ * opened shell. Evidence: they are the official PTY facts, produced by
41
+ * `dsh-terminal-bash` (`spec.owner.id` / `spec.sessionId`) and by
42
+ * `dsh-api-terminal-controller` (`agent.id`) for the GUI terminal panel, and
43
+ * grep over the installed harness finds no reader of either name (only those
44
+ * two producers and the registry's reserved-key list). Removing them would be
45
+ * a deviation with no evidence behind it. What would change that: an in-tree
46
+ * consumer that reads the CURRENT session's identity out of these variables
47
+ * (e.g. a plugin resolving its caller from `$DSH_SESSION_ID`), or a nested-boot
48
+ * failure traced to the inherited value.
49
+ *
50
+ * Fallback, and its exact boundary: the overlay always comes from exactly ONE
51
+ * source. When the registry is NOT mounted at all (`ctx.get('shellEnv')` is
52
+ * `undefined` — a custom composition outside `dsh-base`), the three facts are
53
+ * read from their public owners: `resolveDshHome()` and
54
+ * `ctx.get('profileContext')`, which are the registry's own sources, so the
55
+ * values are byte-identical unless a profile configures `dshHome` on its
56
+ * `shell-env` row — that row is visible to the registry path only. When the
57
+ * service IS mounted (even if its snapshot declares fewer than three keys) the
58
+ * registry is the only source: nothing is merged in per key, and a throwing
59
+ * `collect()` PROPAGATES, because the one-shot shell path collects fail-loud
60
+ * too and swallowing that error would silently resolve a DIFFERENT home (the
61
+ * ambient `DSH_HOME`) than the one the registry recorded — the exact failure
62
+ * this module exists to remove.
63
+ *
64
+ * The config surface is the official one in behaviour, not only in shape: the
65
+ * `apply` below runs the official `resolveConfig`/`validateConfig` semantics
66
+ * (replicated as {@link resolveTerminalConfig} / {@link validateTerminalConfig},
67
+ * because the official package does not export them), so an empty
68
+ * `shellPath`/`shellArgs` gets the dialect defaults and an invalid duration is
69
+ * rejected rather than accepted. A parity test drives the official `apply` and
70
+ * this one with the same inputs and compares the effective config and the
71
+ * validation outcome.
72
+ *
73
+ * Upstream note: this subclass is only here because the official row exposes
74
+ * no `env` config field. If `@deepseek-ai/dsh-terminal-bash` ever grows one
75
+ * (or accepts an environment overlay from the registry itself), the patch row
76
+ * can go back to the official package and this module can be deleted — together
77
+ * with the `NESTED_DSH_ENTRY` contract in
78
+ * `tests/docs-shape-fish-shell.spec.ts`, which names this module.
79
+ *
80
+ * @module @banbolee/dsh-fish-shell/terminal
81
+ */
82
+
83
+ import { BashTerminalBackend, Config } from '@deepseek-ai/dsh-terminal-bash'
84
+ import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
85
+ import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
86
+
87
+ /**
88
+ * The official backend's config schema, re-exported verbatim so cordis
89
+ * resolves this row with exactly the official defaults (`backendType: 'shell'`
90
+ * included) and the row keeps the official config surface. Also imported
91
+ * above, where `apply` uses it to materialize those defaults for a direct,
92
+ * loader-free call.
93
+ */
94
+ export { Config }
95
+
96
+ /** Cordis plugin name (the bundle patch decides the row id and mount point). */
97
+ export const name = 'terminal-home'
98
+ /**
99
+ * Required services: verbatim the official backend's list. The inherited
100
+ * `spawn()` needs all four; declaring the same list keeps activation identical
101
+ * to the official row.
102
+ */
103
+ export const inject = [
104
+ 'terminals',
105
+ 'sandboxPolicy',
106
+ 'sessionProjections',
107
+ 'subprocess',
108
+ ]
109
+
110
+ /** Environment variable holding the profile facts the registry snapshots. */
111
+ const DSH_PROFILE_KEY = 'DSH_PROFILE'
112
+ /** Environment variable holding the profile directory the registry snapshots. */
113
+ const DSH_PROFILE_DIR_KEY = 'DSH_PROFILE_DIR'
114
+ /** The home contract this module forwards, in the order it is documented. */
115
+ const HOME_CONTRACT_KEYS = /** @type {const} */ ([DSH_HOME_ENV, DSH_PROFILE_KEY, DSH_PROFILE_DIR_KEY])
116
+
117
+ /** Bash dialect default executable — the official `DEFAULT_BASH_SHELL`. */
118
+ const DEFAULT_BASH_SHELL = '/bin/bash'
119
+ /** Bash dialect default arguments (interactive, profile-free) — the official `DEFAULT_BASH_ARGS`. */
120
+ const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i']
121
+ /** Pwsh dialect default arguments — the official `DEFAULT_PWSH_ARGS`. */
122
+ const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile']
123
+
124
+ /**
125
+ * The official `resolveConfig`, replicated from
126
+ * `@deepseek-ai/dsh-terminal-bash@0.2.0-rc.1` (`lib/index.js:27-35`; the
127
+ * official package does not export it). An unset or EMPTY
128
+ * `shellPath`/`shellArgs` selects the dialect's defaults — schemastery
129
+ * materializes an absent optional array as `[]` — while a non-empty explicit
130
+ * value always wins. This module re-exports the official schema, so both
131
+ * `apply`s must materialize the same effective config; the parity test drives
132
+ * the official `apply` and this one side by side. Keep in sync with the
133
+ * official source: a change there changes this row's config contract.
134
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the schemastery-resolved plugin configuration.
135
+ * @returns {ConstructorParameters<typeof BashTerminalBackend>[1]} the fully resolved configuration.
136
+ */
137
+ export function resolveTerminalConfig(config) {
138
+ const shellDialect = config.shellDialect ?? 'bash'
139
+ return /** @type {ConstructorParameters<typeof BashTerminalBackend>[1]} */ ({
140
+ ...config,
141
+ shellDialect,
142
+ shellPath: config.shellPath !== undefined && config.shellPath.length > 0
143
+ ? config.shellPath
144
+ : shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL,
145
+ shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0
146
+ ? config.shellArgs
147
+ : shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS,
148
+ })
149
+ }
150
+
151
+ /**
152
+ * The official `validateConfig`, replicated from
153
+ * `@deepseek-ai/dsh-terminal-bash@0.2.0-rc.1` (`lib/index.js:62-74`; not
154
+ * exported) with the official error text verbatim — including the
155
+ * `terminal-bash:` prefix, which is deliberate: the row's config surface IS the
156
+ * official schema, and parity with the official validator (messages compared
157
+ * byte for byte) is a tested contract. Every numeric field is a positive safe
158
+ * integer except `promptTailGraceMs`, whose zero is the documented "no
159
+ * extension" value and which is therefore checked on its own. Keep in sync with
160
+ * the official source.
161
+ * @param {ConstructorParameters<typeof BashTerminalBackend>[1]} config - the resolved configuration.
162
+ * @returns {void} throws on a value the backend cannot run with.
163
+ */
164
+ export function validateTerminalConfig(config) {
165
+ if (config.backendType.length === 0) throw new Error('terminal-bash: backendType must be non-empty')
166
+ if (config.shellPath.length === 0) throw new Error('terminal-bash: shellPath must be non-empty')
167
+ for (const [name, value] of Object.entries(config)) {
168
+ if (name === 'promptTailGraceMs') continue
169
+ if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
170
+ throw new Error(`terminal-bash: ${name} must be a positive safe integer`)
171
+ }
172
+ }
173
+ if (typeof config.promptTailGraceMs === 'number' && (!Number.isSafeInteger(config.promptTailGraceMs) || config.promptTailGraceMs < 0)) {
174
+ throw new Error('terminal-bash: promptTailGraceMs must be a non-negative safe integer')
175
+ }
176
+ if (config.maxReadBytes > config.scrollbackMaxBytes) throw new Error('terminal-bash: maxReadBytes must not exceed scrollbackMaxBytes')
177
+ if (config.handoffGraceMs < config.pollIntervalMs) throw new Error('terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
178
+ if (config.promptTailGraceMs !== 0 && config.promptTailGraceMs < config.pollIntervalMs) throw new Error('terminal-bash: promptTailGraceMs must be zero or at least pollIntervalMs so a nonzero tolerance contains one readiness poll')
179
+ }
180
+
181
+ /**
182
+ * The public profile facts `ctx.get('profileContext')` carries. Deliberately
183
+ * structural: the service belongs to the launcher, so this module reads the
184
+ * two fields it forwards instead of importing its owner's types.
185
+ * @typedef {{ name?: string, dir?: string } | undefined} ProfileFacts
186
+ */
187
+
188
+ /**
189
+ * The registry's snapshot of the home contract, or `undefined` when the
190
+ * registry is NOT mounted (`ctx.get('shellEnv') === undefined`, i.e. a custom
191
+ * composition outside `dsh-base`). The distinction is load-bearing: a mounted
192
+ * registry is the ONLY source, so a key its snapshot omits stays omitted
193
+ * instead of being filled in from another source.
194
+ *
195
+ * `collect` is called with an empty execution on purpose: of the three keys
196
+ * this module forwards, none depends on the execution (only `DSH_SESSION_ID`
197
+ * does, and that one stays the PTY overlay's own value). A throwing `collect`
198
+ * is NOT swallowed either: once the registry is mounted it is the only source
199
+ * that can see a configured `dshHome`, and quietly falling back would resolve a
200
+ * different home — exactly the drift this module removes. It fails loud here
201
+ * for the same reason the one-shot path (`dsh-tool-bash`) does.
202
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
203
+ * @returns {Record<string, string> | undefined} the home facts the registry declares, or `undefined` when it is not mounted.
204
+ */
205
+ function registryHomeContract(ctx) {
206
+ /** @type {import('@deepseek-ai/dsh-shell-env').ShellEnvRegistry | undefined} */
207
+ const registry = ctx.get('shellEnv')
208
+ if (registry === undefined) return undefined
209
+ const snapshot = registry.collect(
210
+ /** @type {import('@deepseek-ai/dsh-tools').ToolExecution} */ ({}),
211
+ )
212
+ /** @type {Record<string, string>} */
213
+ const facts = {}
214
+ for (const key of HOME_CONTRACT_KEYS) {
215
+ const value = snapshot[key]
216
+ if (value !== undefined) facts[key] = value
217
+ }
218
+ return facts
219
+ }
220
+
221
+ /**
222
+ * The harness home contract for one PTY child, from exactly ONE source:
223
+ *
224
+ * - the mounted `ctx.shellEnv` registry (the one-shot shell tools' own
225
+ * source) — its declared facts verbatim, including the case where it
226
+ * declares fewer than three (the official registry always declares
227
+ * `DSH_HOME`, and declares the profile pair whenever a `profileContext`
228
+ * exists, so an incomplete snapshot means a foreign implementation: the
229
+ * honest reading is then "this environment does not carry that fact", not
230
+ * "borrow it from somewhere else");
231
+ * - only when that service is NOT mounted, the public owners
232
+ * `dsh-home-paths` / `profileContext`, which are the registry's own sources.
233
+ *
234
+ * A `collect` error propagates rather than switching sources — see the module
235
+ * doc for both rules.
236
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
237
+ * @returns {Record<string, string>} the environment overlay to merge into the child environment.
238
+ */
239
+ export function harnessHomeEnvironment(ctx) {
240
+ const fromRegistry = registryHomeContract(ctx)
241
+ if (fromRegistry !== undefined) return fromRegistry
242
+ /** @type {ProfileFacts} */
243
+ const profile = ctx.get('profileContext')
244
+ /** @type {Record<string, string>} */
245
+ const environment = { [DSH_HOME_ENV]: resolveDshHome() }
246
+ if (profile?.name !== undefined) environment[DSH_PROFILE_KEY] = profile.name
247
+ if (profile?.dir !== undefined) environment[DSH_PROFILE_DIR_KEY] = profile.dir
248
+ return environment
249
+ }
250
+
251
+ /**
252
+ * One terminal spawn spec with the home contract merged into its environment:
253
+ * the same request object, one layer further down than the base class's own
254
+ * overlay (the provider applies `spec.env` after its ambient scrub).
255
+ * @param {import('@deepseek-ai/dsh-subprocess').SubprocessTerminalSpawnSpec} spec - the terminal request the base class built.
256
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
257
+ * @returns {import('@deepseek-ai/dsh-subprocess').SubprocessTerminalSpawnSpec} the same request with the home facts added.
258
+ */
259
+ export function withHarnessHome(spec, ctx) {
260
+ return { ...spec, env: { ...spec.env, ...harnessHomeEnvironment(ctx) } }
261
+ }
262
+
263
+ /**
264
+ * The official local PTY backend with the harness home contract in its child
265
+ * environment. `spawn()` is NOT overridden: argv confinement, the sandbox-mode
266
+ * fence, readiness through `session.initialize()` and the `-C` prompt setup
267
+ * are the base class's own.
268
+ */
269
+ export class HarnessHomeTerminalBackend extends BashTerminalBackend {
270
+ /**
271
+ * Wrap the base class's `spawnTerminal` seam so every terminal request it
272
+ * builds carries the home contract. The seam is deliberately not a
273
+ * constructor parameter: the row must not be able to lose the contract.
274
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
275
+ * @param {ConstructorParameters<typeof BashTerminalBackend>[1]} config - the resolved official backend configuration.
276
+ * @param {ConstructorParameters<typeof BashTerminalBackend>[3]} [createSession] - the base class's session-factory seam, for tests; omitted in production, where the base default builds the `LocalPtySession`.
277
+ */
278
+ constructor(ctx, config, createSession) {
279
+ super(ctx, config, (spec) => ctx.subprocess.spawnTerminal(withHarnessHome(spec, ctx)), createSession)
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Register the home-carrying local PTY backend under the configured type
285
+ * (`shell` unless the row overrides it).
286
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
287
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
288
+ */
289
+ export function apply(ctx, config = {}) {
290
+ // The exported `Config` is the official schema, so cordis has already
291
+ // resolved this object; resolving again keeps a direct `apply()` call (tests,
292
+ // direct assembly) on the same defaults. The two explicit steps after it are
293
+ // the official `apply`'s own: dialect defaulting, then validation.
294
+ const resolved = resolveTerminalConfig(Config(config))
295
+ validateTerminalConfig(resolved)
296
+ ctx.terminals.registerBackend(new HarnessHomeTerminalBackend(ctx, resolved))
297
+ }