@banbolee/dsh-fish-shell 0.5.0 → 0.6.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/README.md +10 -3
- package/README.zh.md +63 -0
- package/index.js +60 -33
- package/job-kind.d.ts +24 -0
- package/local.js +13 -14
- package/package.json +45 -43
- package/persistent.js +238 -45
- package/policy.js +73 -14
- package/terminal-fish.js +146 -36
- package/tool.js +258 -60
package/policy.js
CHANGED
|
@@ -63,18 +63,43 @@ export const inject = ['tools', 'systemPrompt']
|
|
|
63
63
|
|
|
64
64
|
const PLUGIN_TAG = '@banbolee/dsh-fish-shell'
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* One agent as this policy reads it: the harness `Agent` viewed through the two
|
|
68
|
+
* members every registration below keys on — its session id (for warnings) and
|
|
69
|
+
* its agent-scoped context (where the restriction/prompt/tool registrations
|
|
70
|
+
* land). The full runtime `Agent` face is structurally assignable to this view.
|
|
71
|
+
* @typedef {{ id?: string, ctx: import('@deepseek-ai/cordis').Context }} PolicyAgent
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One agent's installed policy: which bash form was detected, the preset parent
|
|
76
|
+
* key it was installed under (the switch/re-entry fast path compares it), and
|
|
77
|
+
* the disposers that must move with `/preset`.
|
|
78
|
+
* @typedef {object} PolicyState
|
|
79
|
+
* @property {'persistent' | 'one-shot'} kind - the bash form this policy swapped out.
|
|
80
|
+
* @property {import('@deepseek-ai/dsh-scope').ScopeKey | undefined} parentKey - the preset parent scope key this state was installed under (`scopeParentOf(agent)`).
|
|
81
|
+
* @property {(() => Promise<void> | undefined) | undefined} persistentFish - the persistent fish tool's disposer, when this state installed one.
|
|
82
|
+
* @property {() => void} restriction - the `deny: ['bash']` restriction disposer.
|
|
83
|
+
* @property {() => void} prompt - the `tool:bash` prompt-shadow disposer.
|
|
84
|
+
*/
|
|
85
|
+
|
|
66
86
|
/** Agents currently being reconciled (guards the synchronous tools/change
|
|
67
|
-
* re-entry that restriction/prompt registration and disposal trigger).
|
|
87
|
+
* re-entry that restriction/prompt registration and disposal trigger).
|
|
88
|
+
* @type {WeakSet<PolicyAgent>} */
|
|
68
89
|
const installing = new WeakSet()
|
|
69
|
-
/** Agents already warned about an invisible fish tool (warn once per agent).
|
|
90
|
+
/** Agents already warned about an invisible fish tool (warn once per agent).
|
|
91
|
+
* @type {WeakSet<PolicyAgent>} */
|
|
70
92
|
const warned = new WeakSet()
|
|
71
93
|
/** Agents uninstalled via `agent/disposed`: a tools/change fired by the
|
|
72
|
-
* uninstall's own disposals must not re-install a dying agent.
|
|
94
|
+
* uninstall's own disposals must not re-install a dying agent.
|
|
95
|
+
* @type {WeakSet<PolicyAgent>} */
|
|
73
96
|
const removed = new WeakSet()
|
|
74
|
-
/** Per-agent policy state, including disposers that must move with /preset.
|
|
97
|
+
/** Per-agent policy state, including disposers that must move with /preset.
|
|
98
|
+
* @type {WeakMap<PolicyAgent, PolicyState>} */
|
|
75
99
|
const states = new WeakMap()
|
|
76
100
|
/** Live agents with policy installed; traversable so plugin teardown can
|
|
77
|
-
* strip every registration (a WeakMap would be unreachable on teardown).
|
|
101
|
+
* strip every registration (a WeakMap would be unreachable on teardown).
|
|
102
|
+
* @type {Set<PolicyAgent>} */
|
|
78
103
|
const liveAgents = new Set()
|
|
79
104
|
/** Global reconcile re-entrancy guard: a tools/change arriving while a
|
|
80
105
|
* reconcile round is in flight marks `reconcilePending` instead of starting a
|
|
@@ -83,17 +108,32 @@ const liveAgents = new Set()
|
|
|
83
108
|
let reconciling = false
|
|
84
109
|
let reconcilePending = false
|
|
85
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Narrow one unchecked value to a non-null, non-array object: the shape this
|
|
113
|
+
* policy reads duck-typed tool definitions and disposer results through.
|
|
114
|
+
* @param {unknown} value - the value to inspect.
|
|
115
|
+
* @returns {value is Record<string, unknown>} whether it is a plain object record.
|
|
116
|
+
*/
|
|
86
117
|
function isPlainObject(value) {
|
|
87
118
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
88
119
|
}
|
|
89
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Dispose one registration quietly: `undefined` means nothing was installed,
|
|
123
|
+
* a synchronous throw and a rejected async disposer are both warned instead of
|
|
124
|
+
* breaking the policy round that runs the disposal.
|
|
125
|
+
* @param {(() => unknown) | undefined} disposer - the registration's disposer, when there is one.
|
|
126
|
+
* @param {string} label - the registration's name in the warning.
|
|
127
|
+
* @param {PolicyAgent} agent - the agent the registration belongs to.
|
|
128
|
+
*/
|
|
90
129
|
function disposeQuietly(disposer, label, agent) {
|
|
91
130
|
if (disposer === undefined) return
|
|
92
131
|
try {
|
|
93
132
|
const result = disposer()
|
|
94
133
|
// Async disposers (persistent fish cleanup) are awaited out-of-band so a
|
|
95
|
-
// failing teardown is recorded instead of unhandled
|
|
96
|
-
|
|
134
|
+
// failing teardown is recorded instead of unhandled; a plain `void`
|
|
135
|
+
// disposer returns a non-thenable and needs no wait.
|
|
136
|
+
if (isPlainObject(result) && typeof result.then === 'function') {
|
|
97
137
|
void Promise.resolve(result).catch((error) => {
|
|
98
138
|
console.warn(`${PLUGIN_TAG}: could not dispose ${label} for agent ${agent.id ?? '(unnamed)'}:`, String(error))
|
|
99
139
|
})
|
|
@@ -103,12 +143,23 @@ function disposeQuietly(disposer, label, agent) {
|
|
|
103
143
|
}
|
|
104
144
|
}
|
|
105
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Drop every registration one policy state owns.
|
|
148
|
+
* @param {PolicyAgent} agent - the agent the state belongs to.
|
|
149
|
+
* @param {PolicyState} state - the installed state to tear down.
|
|
150
|
+
*/
|
|
106
151
|
function disposeState(agent, state) {
|
|
107
152
|
disposeQuietly(state.prompt, 'tool:bash prompt shadow', agent)
|
|
108
153
|
disposeQuietly(state.restriction, 'bash restriction', agent)
|
|
109
154
|
disposeQuietly(state.persistentFish, 'persistent fish tool', agent)
|
|
110
155
|
}
|
|
111
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Drop the registrations a preset switch must lift before it probes the target
|
|
159
|
+
* preset's surface (the prompt shadow and the bash restriction only).
|
|
160
|
+
* @param {PolicyAgent} agent - the agent whose transient policy is removed.
|
|
161
|
+
* @param {PolicyState | undefined} state - the previous state, when there is one.
|
|
162
|
+
*/
|
|
112
163
|
function removeTransientPolicy(agent, state) {
|
|
113
164
|
disposeQuietly(state?.prompt, 'tool:bash prompt shadow', agent)
|
|
114
165
|
disposeQuietly(state?.restriction, 'bash restriction', agent)
|
|
@@ -120,8 +171,8 @@ function removeTransientPolicy(agent, state) {
|
|
|
120
171
|
* output schema is a plain string. The one-shot bash tool (command +
|
|
121
172
|
* description + workdir + timeoutMs + run_in_background +
|
|
122
173
|
* sandbox_permissions…) and every other tool shape fall through.
|
|
123
|
-
* @param definition - the tool definition an agent resolves for `bash`.
|
|
124
|
-
* @returns true when the agent's bash is the persistent PTY form.
|
|
174
|
+
* @param {import('@deepseek-ai/dsh-tools').ToolDefinition} definition - the tool definition an agent resolves for `bash`.
|
|
175
|
+
* @returns {boolean} true when the agent's bash is the persistent PTY form.
|
|
125
176
|
*/
|
|
126
177
|
export function isPersistentBashForm(definition) {
|
|
127
178
|
if (!isPlainObject(definition)) return false
|
|
@@ -139,7 +190,10 @@ export function isPersistentBashForm(definition) {
|
|
|
139
190
|
return isPlainObject(properties) && Object.keys(properties).length === 1 && Object.hasOwn(properties, 'command')
|
|
140
191
|
}
|
|
141
192
|
|
|
142
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Remove every policy-owned registration for an agent.
|
|
195
|
+
* @param {PolicyAgent} agent - the agent whose policy is uninstalled.
|
|
196
|
+
*/
|
|
143
197
|
export function uninstallFishPolicy(agent) {
|
|
144
198
|
liveAgents.delete(agent)
|
|
145
199
|
removed.add(agent)
|
|
@@ -160,7 +214,7 @@ export function uninstallFishPolicy(agent) {
|
|
|
160
214
|
* disposers THIS call created.
|
|
161
215
|
*
|
|
162
216
|
* @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context.
|
|
163
|
-
* @param {
|
|
217
|
+
* @param {PolicyAgent} agent - the live agent (its `ctx` is the agent's scoped context).
|
|
164
218
|
*/
|
|
165
219
|
export function installFishPolicy(ctx, agent) {
|
|
166
220
|
if (installing.has(agent)) return
|
|
@@ -222,6 +276,7 @@ export function installFishPolicy(ctx, agent) {
|
|
|
222
276
|
// bash is hidden, so minimal sessions keep persistent semantics.
|
|
223
277
|
persistentFish = registerPersistentFish(ctx, agent.ctx)
|
|
224
278
|
}
|
|
279
|
+
/** @type {Array<() => void>} */
|
|
225
280
|
const created = []
|
|
226
281
|
try {
|
|
227
282
|
const restriction = agent.ctx.tools.restrict({ deny: ['bash'] })
|
|
@@ -244,7 +299,7 @@ export function installFishPolicy(ctx, agent) {
|
|
|
244
299
|
// persistent fish. The previous state's entries were already removed
|
|
245
300
|
// above, so `states` ends up consistent: no restriction, no shadow, no
|
|
246
301
|
// persistent fish — bash stays visible and the failure is surfaced.
|
|
247
|
-
for (let index = created.length - 1; index >= 0; index -= 1) created[index]()
|
|
302
|
+
for (let index = created.length - 1; index >= 0; index -= 1) created[index]?.()
|
|
248
303
|
disposeQuietly(persistentFish, 'persistent fish tool', agent)
|
|
249
304
|
throw error
|
|
250
305
|
}
|
|
@@ -256,11 +311,15 @@ export function installFishPolicy(ctx, agent) {
|
|
|
256
311
|
}
|
|
257
312
|
}
|
|
258
313
|
|
|
259
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Apply the policy to every live agent (no-op when the agents service is
|
|
260
316
|
* absent). Re-entrant calls (a registration/disposal mid-round fires
|
|
261
317
|
* `tools/change` synchronously) are absorbed into at most ONE coalesced
|
|
262
318
|
* catch-up round, so an external change dispatches a small constant number
|
|
263
|
-
* of times regardless of how many agents are live.
|
|
319
|
+
* of times regardless of how many agents are live.
|
|
320
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context (the `agents` service is resolved from it).
|
|
321
|
+
* @param {boolean} [catchUp] - whether this round is the coalesced catch-up (never re-triggers one).
|
|
322
|
+
*/
|
|
264
323
|
function reconcile(ctx, catchUp = false) {
|
|
265
324
|
if (reconciling) {
|
|
266
325
|
reconcilePending = true
|
package/terminal-fish.js
CHANGED
|
@@ -76,6 +76,56 @@ const DEFAULT_PTY_CONFIG = {
|
|
|
76
76
|
disposeGraceMs: 3e3,
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The terminal handle surface this backend reads: the cleanup verb a failed
|
|
81
|
+
* session factory must call (the real handle is `SubprocessTerminalHandle`).
|
|
82
|
+
* @typedef {{ terminate(): void | Promise<void> }} TerminalHandleLike
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The fully resolved backend configuration: the public `Config` surface with
|
|
87
|
+
* every default materialized, matching `@deepseek-ai/dsh-terminal-bash`'s
|
|
88
|
+
* `ResolvedConfig` (which that package does not export from its root).
|
|
89
|
+
* @typedef {object} ResolvedBackendConfig
|
|
90
|
+
* @property {string} backendType - backend registry type (`fish` by default).
|
|
91
|
+
* @property {'bash'} shellDialect - the readiness/prompt contract the fish prompt implements.
|
|
92
|
+
* @property {string} shellPath - the fish executable to spawn.
|
|
93
|
+
* @property {string[]} shellArgs - the resolved fish arguments.
|
|
94
|
+
* @property {number} rows - initial terminal rows.
|
|
95
|
+
* @property {number} cols - initial terminal columns.
|
|
96
|
+
* @property {number} scrollbackLines - retained logical lines.
|
|
97
|
+
* @property {number} scrollbackMaxBytes - retained UTF-8 bytes.
|
|
98
|
+
* @property {number} maxReadBytes - one read's byte cap.
|
|
99
|
+
* @property {number} pollIntervalMs - readiness polling interval.
|
|
100
|
+
* @property {number} exactProbeAfterMs - delay before exact syscall probes.
|
|
101
|
+
* @property {number} idleSilenceMs - silence that yields `inferred_idle`.
|
|
102
|
+
* @property {number} handoffGraceMs - extra wait for foreground handoff.
|
|
103
|
+
* @property {number} timeoutMs - readiness deadline for one startup sequence.
|
|
104
|
+
* @property {number} disposeGraceMs - SIGTERM→SIGKILL grace for the PTY session.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The backend session surface this module drives: `TerminalBackendSession`
|
|
109
|
+
* with the backend's own mutable `motd`. The interface declares that field
|
|
110
|
+
* read-only, while the concrete `LocalPtySession` (and the test doubles) assign
|
|
111
|
+
* the startup banner once readiness settles.
|
|
112
|
+
* @typedef {Omit<import('@deepseek-ai/dsh-terminal').TerminalBackendSession, 'motd'> & { motd: string }} PersistentPtySession
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The `BashTerminalBackend` internals this subclass must reach to override
|
|
117
|
+
* `spawn()`. The upstream declarations mark `ctx`, `config`, `spawnTerminal`,
|
|
118
|
+
* and `createSession` private — and are byte-identical in 0.1.5-rc.1 and
|
|
119
|
+
* 0.1.7-rc.2 — while the JS prototype keeps them as ordinary fields, so the
|
|
120
|
+
* subclass reads them through this one structural view instead of a
|
|
121
|
+
* suppression per access.
|
|
122
|
+
* @typedef {object} BashBackendInternals
|
|
123
|
+
* @property {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
|
|
124
|
+
* @property {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
|
|
125
|
+
* @property {(spec: object) => Promise<TerminalHandleLike>} spawnTerminal - the subprocess terminal primitive (`ctx.subprocess.spawnTerminal` by default).
|
|
126
|
+
* @property {(terminal: unknown, config: unknown) => any} createSession - the session factory (`new LocalPtySession(...)` by default).
|
|
127
|
+
*/
|
|
128
|
+
|
|
79
129
|
/**
|
|
80
130
|
* One-line fish prompt setup submitted before readiness: suppresses the
|
|
81
131
|
* greeting and right prompt, and defines a prompt that emits the shared OSC
|
|
@@ -89,24 +139,32 @@ export const FISH_PROMPT_SETUP = 'function fish_greeting; end; function fish_pro
|
|
|
89
139
|
* Resolve the effective fish shell specification: an unset or empty
|
|
90
140
|
* `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
|
|
91
141
|
* wins. Mirrors the official `resolveConfig` (minus dialect selection).
|
|
92
|
-
* @param config - the plugin configuration.
|
|
93
|
-
* @returns the fully resolved configuration.
|
|
142
|
+
* @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the plugin configuration.
|
|
143
|
+
* @returns {ResolvedBackendConfig} the fully resolved configuration.
|
|
94
144
|
*/
|
|
95
145
|
export function resolveConfig(config) {
|
|
96
146
|
return {
|
|
97
|
-
|
|
147
|
+
// `config.backendType?.length > 0` compared an optional property with `>`
|
|
148
|
+
// (a coercion, not a narrowing); the explicit guard is the same decision
|
|
149
|
+
// and lets the compiler see a `string` in the true branch.
|
|
150
|
+
backendType: config.backendType !== undefined && config.backendType.length > 0 ? config.backendType : 'fish',
|
|
151
|
+
// Inherited `ResolvedConfig` shape, not a dialect choice: this backend
|
|
152
|
+
// overrides `spawn()` with its own child environment and fish prompt, so
|
|
153
|
+
// the field is inert — `bash` names the readiness/prompt contract the
|
|
154
|
+
// fish prompt implements (`dsh-terminal-bash`'s bash branch).
|
|
155
|
+
shellDialect: /** @type {const} */ ('bash'),
|
|
98
156
|
shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
|
|
99
157
|
shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
|
|
100
158
|
...DEFAULT_PTY_CONFIG,
|
|
101
|
-
...Object.fromEntries(Object.entries(config).filter(([key]) => key in DEFAULT_PTY_CONFIG &&
|
|
159
|
+
...Object.fromEntries(Object.entries(config).filter(([key, value]) => key in DEFAULT_PTY_CONFIG && value !== undefined)),
|
|
102
160
|
}
|
|
103
161
|
}
|
|
104
162
|
|
|
105
163
|
/**
|
|
106
164
|
* Validate the effective configuration (mirrors the official
|
|
107
165
|
* `validateConfig`).
|
|
108
|
-
* @param config - the fully resolved configuration.
|
|
109
|
-
* @returns the
|
|
166
|
+
* @param {ResolvedBackendConfig} config - the fully resolved configuration.
|
|
167
|
+
* @returns {void} throws on a value the backend cannot run with.
|
|
110
168
|
*/
|
|
111
169
|
export function validateConfig(config) {
|
|
112
170
|
const resolved = config
|
|
@@ -125,8 +183,8 @@ export function validateConfig(config) {
|
|
|
125
183
|
* Fish child environment: the official bash `childEnvironment` common set
|
|
126
184
|
* (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
|
|
127
185
|
* harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
|
|
128
|
-
* @param spec - the spawn specification.
|
|
129
|
-
* @returns the child environment.
|
|
186
|
+
* @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the spawn specification.
|
|
187
|
+
* @returns {Record<string, string>} the child environment.
|
|
130
188
|
*/
|
|
131
189
|
export function childEnvironment(spec) {
|
|
132
190
|
return {
|
|
@@ -157,7 +215,17 @@ export function childEnvironment(spec) {
|
|
|
157
215
|
// ever contributed the fence falls back to the terminals registry probe
|
|
158
216
|
// (`ctx.terminals` may be absent — missing services resolve to `undefined`
|
|
159
217
|
// on a Cordis context, never a throw).
|
|
218
|
+
/** Per-owner fence state: the context whose dispatch is watched plus every
|
|
219
|
+
* manager's aggregated activity probe. @type {WeakMap<import('@deepseek-ai/dsh-agent').Agent, { ctx: import('@deepseek-ai/cordis').Context, checkers: Set<() => boolean> }>} */
|
|
160
220
|
const sandboxModeFences = new WeakMap()
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Install (or refresh) the per-owner sandbox-mode fence and register this
|
|
224
|
+
* manager's owner-activity probe with it.
|
|
225
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the context whose `internal/dispatch` the fence listens to.
|
|
226
|
+
* @param {import('@deepseek-ai/dsh-agent').Agent} owner - the live owner whose activity keeps the fence closed.
|
|
227
|
+
* @param {() => boolean} [hasActivity] - this manager's owner-activity probe, when it has one.
|
|
228
|
+
*/
|
|
161
229
|
export function ensureSandboxModeFence(ctx, owner, hasActivity) {
|
|
162
230
|
let state = sandboxModeFences.get(owner)
|
|
163
231
|
if (state === undefined) {
|
|
@@ -185,21 +253,25 @@ export function ensureSandboxModeFence(ctx, owner, hasActivity) {
|
|
|
185
253
|
/**
|
|
186
254
|
* Confine the fish argv through the shared sandbox policy, exactly like the
|
|
187
255
|
* official `spawnArgv` (which is private to `@deepseek-ai/dsh-terminal-bash`
|
|
188
|
-
* and therefore copied here).
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* @
|
|
256
|
+
* and therefore copied here). `SandboxProvider.confine` is ASYNC in
|
|
257
|
+
* 0.1.7-rc.2, so the confined branch MUST be awaited: reading `.argv` off the
|
|
258
|
+
* returned promise yields `undefined`, which made every confined persistent
|
|
259
|
+
* spawn fail (previously masked by a synchronous test double).
|
|
260
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context carrying the sandbox provider.
|
|
261
|
+
* @param {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
|
|
262
|
+
* @param {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} policy - the resolved execution policy for this session; the `danger-full-access` early return narrows `mode` to a confined mode for `confine()`.
|
|
263
|
+
* @param {AbortSignal} [signal] - cancellation of confinement preparation.
|
|
264
|
+
* @returns {Promise<string[]>} the (possibly confined) argv.
|
|
193
265
|
*/
|
|
194
|
-
export function spawnArgv(ctx, config, policy) {
|
|
266
|
+
export async function spawnArgv(ctx, config, policy, signal) {
|
|
195
267
|
const argv = [config.shellPath, ...config.shellArgs]
|
|
196
268
|
if (policy.mode === 'danger-full-access') return argv
|
|
197
269
|
const sandbox = ctx.get('sandbox')
|
|
198
270
|
if (sandbox === undefined) throw new Error(`terminal-fish: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
|
|
199
|
-
return sandbox.confine(argv, {
|
|
271
|
+
return (await sandbox.confine(argv, {
|
|
200
272
|
...policy,
|
|
201
273
|
mode: policy.mode,
|
|
202
|
-
}).argv
|
|
274
|
+
}, signal)).argv
|
|
203
275
|
}
|
|
204
276
|
|
|
205
277
|
/**
|
|
@@ -207,11 +279,12 @@ export function spawnArgv(ctx, config, policy) {
|
|
|
207
279
|
* poll with empty sends until the fish prompt satisfies the shared
|
|
208
280
|
* readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
|
|
209
281
|
* `startupSession` pwsh branch, with a startup deadline race.
|
|
210
|
-
* @param session - the backend session.
|
|
211
|
-
* @param timeoutMs - readiness deadline.
|
|
212
|
-
* @param signal - optional cancellation.
|
|
282
|
+
* @param {PersistentPtySession} session - the backend session to bring up.
|
|
283
|
+
* @param {number} timeoutMs - readiness deadline.
|
|
284
|
+
* @param {AbortSignal} [signal] - optional cancellation.
|
|
213
285
|
*/
|
|
214
286
|
export async function startupSession(session, timeoutMs, signal) {
|
|
287
|
+
/** @type {import('@deepseek-ai/dsh-terminal').TerminalSendOperation | undefined} */
|
|
215
288
|
let startupOperation
|
|
216
289
|
const start = async () => {
|
|
217
290
|
let viewport = ''
|
|
@@ -255,6 +328,25 @@ export async function startupSession(session, timeoutMs, signal) {
|
|
|
255
328
|
}
|
|
256
329
|
}
|
|
257
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Reject a failed startup only after its unpublished resources reach
|
|
333
|
+
* quiescence (copied from the official `@deepseek-ai/dsh-terminal-bash`,
|
|
334
|
+
* which does not export it): a cleanup failure is reported as
|
|
335
|
+
* `TerminalBackendCleanupError` so a leaked PTY is distinguishable from the
|
|
336
|
+
* startup failure that caused it.
|
|
337
|
+
* @param {unknown} error - the startup or session-factory failure.
|
|
338
|
+
* @param {() => unknown} cleanup - releases the resource the failure left behind.
|
|
339
|
+
* @returns {Promise<never>} never resolves; always rejects.
|
|
340
|
+
*/
|
|
341
|
+
async function rejectAfterStartupCleanup(error, cleanup) {
|
|
342
|
+
try {
|
|
343
|
+
await cleanup()
|
|
344
|
+
} catch (cleanupError) {
|
|
345
|
+
throw new TerminalBackendCleanupError(error, cleanupError)
|
|
346
|
+
}
|
|
347
|
+
throw error
|
|
348
|
+
}
|
|
349
|
+
|
|
258
350
|
/**
|
|
259
351
|
* Local fish PTY backend registered under the configured type (`fish`):
|
|
260
352
|
* spawns `fish --no-config -i` (config-overridable) with the fish child
|
|
@@ -264,37 +356,55 @@ export async function startupSession(session, timeoutMs, signal) {
|
|
|
264
356
|
* `BashTerminalBackend`/`LocalPtySession`.
|
|
265
357
|
*/
|
|
266
358
|
export class FishTerminalBackend extends BashTerminalBackend {
|
|
359
|
+
/**
|
|
360
|
+
* Spawn and bring up one fish PTY session for a spec the registry (or the
|
|
361
|
+
* self-managed persistent tool) resolved. Resolves with the ready session
|
|
362
|
+
* the configured session factory built (the base class declares the concrete
|
|
363
|
+
* `LocalPtySession` return, which this package cannot name).
|
|
364
|
+
* @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the identified spawn request.
|
|
365
|
+
*/
|
|
267
366
|
async spawn(spec) {
|
|
268
367
|
spec.signal?.throwIfAborted()
|
|
269
|
-
|
|
368
|
+
// The inherited fields (`ctx`, `config`, `spawnTerminal`, `createSession`)
|
|
369
|
+
// are TS-private upstream, so they are read through the one structural
|
|
370
|
+
// view declared above instead of repeating the same suppression per use.
|
|
371
|
+
const backend = /** @type {BashBackendInternals} */ (/** @type {unknown} */ (this))
|
|
372
|
+
ensureSandboxModeFence(backend.ctx, spec.owner)
|
|
270
373
|
// Resolve services through `ctx.get` (never property access): the
|
|
271
374
|
// backend may be constructed with a plugin context whose inject list
|
|
272
375
|
// does not declare these services (the policy plugin's ctx), and Cordis
|
|
273
376
|
// property access would throw "cannot get property … without inject".
|
|
274
377
|
// `ctx.get` resolves any registered service regardless of inject.
|
|
275
|
-
const policy =
|
|
276
|
-
const argv = spawnArgv(
|
|
378
|
+
const policy = backend.ctx.get('sandboxPolicy').resolve({ session: spec.owner.session })
|
|
379
|
+
const argv = await spawnArgv(backend.ctx, backend.config, policy, spec.signal)
|
|
380
|
+
// Confinement is async, so the caller may have cancelled while it ran.
|
|
381
|
+
spec.signal?.throwIfAborted()
|
|
277
382
|
if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
|
|
278
|
-
const terminal = await
|
|
383
|
+
const terminal = await backend.spawnTerminal({
|
|
279
384
|
argv,
|
|
280
385
|
cwd: spec.cwd ?? policy.workspaceRoot,
|
|
281
386
|
env: childEnvironment(spec),
|
|
282
|
-
rows:
|
|
283
|
-
cols:
|
|
284
|
-
|
|
387
|
+
rows: backend.config.rows,
|
|
388
|
+
cols: backend.config.cols,
|
|
389
|
+
// The provider advertises this as the child's `TERM` (it overwrites
|
|
390
|
+
// whatever `env` carried with it), so it must be the same `dumb` the
|
|
391
|
+
// fish prompt readiness contract is verified against.
|
|
392
|
+
terminalType: 'dumb',
|
|
393
|
+
graceMs: backend.config.disposeGraceMs,
|
|
285
394
|
signal: spec.signal,
|
|
286
395
|
})
|
|
287
|
-
|
|
396
|
+
let session
|
|
397
|
+
try {
|
|
398
|
+
session = backend.createSession(terminal, backend.config)
|
|
399
|
+
} catch (error) {
|
|
400
|
+
// A failed session factory must not leak the freshly allocated terminal.
|
|
401
|
+
return rejectAfterStartupCleanup(error, () => terminal.terminate())
|
|
402
|
+
}
|
|
288
403
|
try {
|
|
289
|
-
await startupSession(session,
|
|
404
|
+
await startupSession(session, backend.config.timeoutMs, spec.signal)
|
|
290
405
|
return session
|
|
291
406
|
} catch (error) {
|
|
292
|
-
|
|
293
|
-
await session.close('PTY startup failed')
|
|
294
|
-
} catch (closeError) {
|
|
295
|
-
throw new TerminalBackendCleanupError(error, closeError)
|
|
296
|
-
}
|
|
297
|
-
throw error
|
|
407
|
+
return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
|
|
298
408
|
}
|
|
299
409
|
}
|
|
300
410
|
}
|
|
@@ -307,8 +417,8 @@ export class FishTerminalBackend extends BashTerminalBackend {
|
|
|
307
417
|
* backend up explicitly. Plugin configuration mirrors the official
|
|
308
418
|
* `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
|
|
309
419
|
* defaults: backend type `fish`, `fish --no-config -i` argv.
|
|
310
|
-
* @param ctx - the harness context.
|
|
311
|
-
* @param config - optional backend configuration.
|
|
420
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
|
|
421
|
+
* @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
|
|
312
422
|
*/
|
|
313
423
|
export function apply(ctx, config = {}) {
|
|
314
424
|
const resolved = resolveConfig(config)
|