@banbolee/dsh-fish-shell 0.5.0 → 0.7.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 +164 -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
|
@@ -72,10 +72,66 @@ const DEFAULT_PTY_CONFIG = {
|
|
|
72
72
|
exactProbeAfterMs: 150,
|
|
73
73
|
idleSilenceMs: 3e3,
|
|
74
74
|
handoffGraceMs: 500,
|
|
75
|
+
// The official default is `0` (tail grace DISABLED, i.e. the pre-`promptTailGraceMs`
|
|
76
|
+
// behaviour). This backend keeps that default rather than inventing a positive
|
|
77
|
+
// one: the fish prompt is verified to arrive whole under `TERM=dumb`, so the
|
|
78
|
+
// tolerance is only there for a caller that asks for it.
|
|
79
|
+
promptTailGraceMs: 0,
|
|
75
80
|
timeoutMs: 3e4,
|
|
76
81
|
disposeGraceMs: 3e3,
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The terminal handle surface this backend reads: the cleanup verb a failed
|
|
86
|
+
* session factory must call (the real handle is `SubprocessTerminalHandle`).
|
|
87
|
+
* @typedef {{ terminate(): void | Promise<void> }} TerminalHandleLike
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The fully resolved backend configuration: the public `Config` surface with
|
|
92
|
+
* every default materialized, matching `@deepseek-ai/dsh-terminal-bash`'s
|
|
93
|
+
* `ResolvedConfig` (which that package does not export from its root).
|
|
94
|
+
* @typedef {object} ResolvedBackendConfig
|
|
95
|
+
* @property {string} backendType - backend registry type (`fish` by default).
|
|
96
|
+
* @property {'bash'} shellDialect - the readiness/prompt contract the fish prompt implements.
|
|
97
|
+
* @property {string} shellPath - the fish executable to spawn.
|
|
98
|
+
* @property {string[]} shellArgs - the resolved fish arguments.
|
|
99
|
+
* @property {number} rows - initial terminal rows.
|
|
100
|
+
* @property {number} cols - initial terminal columns.
|
|
101
|
+
* @property {number} scrollbackLines - retained logical lines.
|
|
102
|
+
* @property {number} scrollbackMaxBytes - retained UTF-8 bytes.
|
|
103
|
+
* @property {number} maxReadBytes - one read's byte cap.
|
|
104
|
+
* @property {number} pollIntervalMs - readiness polling interval.
|
|
105
|
+
* @property {number} exactProbeAfterMs - delay before exact syscall probes.
|
|
106
|
+
* @property {number} idleSilenceMs - silence that yields `inferred_idle`.
|
|
107
|
+
* @property {number} handoffGraceMs - extra wait for foreground handoff.
|
|
108
|
+
* @property {number} promptTailGraceMs - extra wait for a controlled prompt prefix to complete; `0` disables the tail grace.
|
|
109
|
+
* @property {number} timeoutMs - readiness deadline for one startup sequence.
|
|
110
|
+
* @property {number} disposeGraceMs - SIGTERM→SIGKILL grace for the PTY session.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The backend session surface this module drives: `TerminalBackendSession`
|
|
115
|
+
* with the backend's own mutable `motd`. The interface declares that field
|
|
116
|
+
* read-only, while the concrete `LocalPtySession` (and the test doubles) assign
|
|
117
|
+
* the startup banner once readiness settles.
|
|
118
|
+
* @typedef {Omit<import('@deepseek-ai/dsh-terminal').TerminalBackendSession, 'motd'> & { motd: string }} PersistentPtySession
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `BashTerminalBackend` internals this subclass must reach to override
|
|
123
|
+
* `spawn()`. The upstream declarations mark `ctx`, `config`, `spawnTerminal`,
|
|
124
|
+
* and `createSession` private — and are byte-identical in 0.1.5-rc.1 and
|
|
125
|
+
* 0.1.7-rc.2 — while the JS prototype keeps them as ordinary fields, so the
|
|
126
|
+
* subclass reads them through this one structural view instead of a
|
|
127
|
+
* suppression per access.
|
|
128
|
+
* @typedef {object} BashBackendInternals
|
|
129
|
+
* @property {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
|
|
130
|
+
* @property {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
|
|
131
|
+
* @property {(spec: object) => Promise<TerminalHandleLike>} spawnTerminal - the subprocess terminal primitive (`ctx.subprocess.spawnTerminal` by default).
|
|
132
|
+
* @property {(terminal: unknown, config: unknown) => any} createSession - the session factory (`new LocalPtySession(...)` by default).
|
|
133
|
+
*/
|
|
134
|
+
|
|
79
135
|
/**
|
|
80
136
|
* One-line fish prompt setup submitted before readiness: suppresses the
|
|
81
137
|
* greeting and right prompt, and defines a prompt that emits the shared OSC
|
|
@@ -89,44 +145,64 @@ export const FISH_PROMPT_SETUP = 'function fish_greeting; end; function fish_pro
|
|
|
89
145
|
* Resolve the effective fish shell specification: an unset or empty
|
|
90
146
|
* `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
|
|
91
147
|
* wins. Mirrors the official `resolveConfig` (minus dialect selection).
|
|
92
|
-
* @param config - the plugin configuration.
|
|
93
|
-
* @returns the fully resolved configuration.
|
|
148
|
+
* @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the plugin configuration.
|
|
149
|
+
* @returns {ResolvedBackendConfig} the fully resolved configuration.
|
|
94
150
|
*/
|
|
95
151
|
export function resolveConfig(config) {
|
|
96
152
|
return {
|
|
97
|
-
|
|
153
|
+
// `config.backendType?.length > 0` compared an optional property with `>`
|
|
154
|
+
// (a coercion, not a narrowing); the explicit guard is the same decision
|
|
155
|
+
// and lets the compiler see a `string` in the true branch.
|
|
156
|
+
backendType: config.backendType !== undefined && config.backendType.length > 0 ? config.backendType : 'fish',
|
|
157
|
+
// Inherited `ResolvedConfig` shape, not a dialect choice: this backend
|
|
158
|
+
// overrides `spawn()` with its own child environment and fish prompt, so
|
|
159
|
+
// the field is inert — `bash` names the readiness/prompt contract the
|
|
160
|
+
// fish prompt implements (`dsh-terminal-bash`'s bash branch).
|
|
161
|
+
shellDialect: /** @type {const} */ ('bash'),
|
|
98
162
|
shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
|
|
99
163
|
shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
|
|
100
164
|
...DEFAULT_PTY_CONFIG,
|
|
101
|
-
...Object.fromEntries(Object.entries(config).filter(([key]) => key in DEFAULT_PTY_CONFIG &&
|
|
165
|
+
...Object.fromEntries(Object.entries(config).filter(([key, value]) => key in DEFAULT_PTY_CONFIG && value !== undefined)),
|
|
102
166
|
}
|
|
103
167
|
}
|
|
104
168
|
|
|
105
169
|
/**
|
|
106
170
|
* Validate the effective configuration (mirrors the official
|
|
107
171
|
* `validateConfig`).
|
|
108
|
-
* @param config - the fully resolved configuration.
|
|
109
|
-
* @returns the
|
|
172
|
+
* @param {ResolvedBackendConfig} config - the fully resolved configuration.
|
|
173
|
+
* @returns {void} throws on a value the backend cannot run with.
|
|
110
174
|
*/
|
|
111
175
|
export function validateConfig(config) {
|
|
112
176
|
const resolved = config
|
|
113
177
|
if (resolved.backendType.length === 0) throw new Error('terminal-fish: backendType must be non-empty')
|
|
114
178
|
if (resolved.shellPath.length === 0) throw new Error('terminal-fish: shellPath must be non-empty')
|
|
115
179
|
for (const [field, value] of Object.entries(resolved)) {
|
|
180
|
+
// `promptTailGraceMs` is the one duration that legally reads `0` (it means
|
|
181
|
+
// "no tail grace", which is the official default), so it is checked on its
|
|
182
|
+
// own below instead of by the positive-integer rule.
|
|
183
|
+
if (field === 'promptTailGraceMs') continue
|
|
116
184
|
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
117
185
|
throw new Error(`terminal-fish: ${field} must be a positive safe integer`)
|
|
118
186
|
}
|
|
119
187
|
}
|
|
188
|
+
if (!Number.isSafeInteger(resolved.promptTailGraceMs) || resolved.promptTailGraceMs < 0) {
|
|
189
|
+
throw new Error('terminal-fish: promptTailGraceMs must be a non-negative safe integer (0 disables the prompt tail grace)')
|
|
190
|
+
}
|
|
120
191
|
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) throw new Error('terminal-fish: maxReadBytes must not exceed scrollbackMaxBytes')
|
|
121
192
|
if (resolved.handoffGraceMs < resolved.pollIntervalMs) throw new Error('terminal-fish: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
|
|
193
|
+
// A nonzero tolerance shorter than one poll cannot observe the rest of the
|
|
194
|
+
// prompt, so it would be a silent no-op; the official schema rejects it too.
|
|
195
|
+
if (resolved.promptTailGraceMs !== 0 && resolved.promptTailGraceMs < resolved.pollIntervalMs) {
|
|
196
|
+
throw new Error('terminal-fish: promptTailGraceMs must be zero or at least pollIntervalMs so a nonzero tail grace covers one readiness poll')
|
|
197
|
+
}
|
|
122
198
|
}
|
|
123
199
|
|
|
124
200
|
/**
|
|
125
201
|
* Fish child environment: the official bash `childEnvironment` common set
|
|
126
202
|
* (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
|
|
127
203
|
* harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
|
|
128
|
-
* @param spec - the spawn specification.
|
|
129
|
-
* @returns the child environment.
|
|
204
|
+
* @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the spawn specification.
|
|
205
|
+
* @returns {Record<string, string>} the child environment.
|
|
130
206
|
*/
|
|
131
207
|
export function childEnvironment(spec) {
|
|
132
208
|
return {
|
|
@@ -157,7 +233,17 @@ export function childEnvironment(spec) {
|
|
|
157
233
|
// ever contributed the fence falls back to the terminals registry probe
|
|
158
234
|
// (`ctx.terminals` may be absent — missing services resolve to `undefined`
|
|
159
235
|
// on a Cordis context, never a throw).
|
|
236
|
+
/** Per-owner fence state: the context whose dispatch is watched plus every
|
|
237
|
+
* manager's aggregated activity probe. @type {WeakMap<import('@deepseek-ai/dsh-agent').Agent, { ctx: import('@deepseek-ai/cordis').Context, checkers: Set<() => boolean> }>} */
|
|
160
238
|
const sandboxModeFences = new WeakMap()
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Install (or refresh) the per-owner sandbox-mode fence and register this
|
|
242
|
+
* manager's owner-activity probe with it.
|
|
243
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the context whose `internal/dispatch` the fence listens to.
|
|
244
|
+
* @param {import('@deepseek-ai/dsh-agent').Agent} owner - the live owner whose activity keeps the fence closed.
|
|
245
|
+
* @param {() => boolean} [hasActivity] - this manager's owner-activity probe, when it has one.
|
|
246
|
+
*/
|
|
161
247
|
export function ensureSandboxModeFence(ctx, owner, hasActivity) {
|
|
162
248
|
let state = sandboxModeFences.get(owner)
|
|
163
249
|
if (state === undefined) {
|
|
@@ -185,21 +271,25 @@ export function ensureSandboxModeFence(ctx, owner, hasActivity) {
|
|
|
185
271
|
/**
|
|
186
272
|
* Confine the fish argv through the shared sandbox policy, exactly like the
|
|
187
273
|
* official `spawnArgv` (which is private to `@deepseek-ai/dsh-terminal-bash`
|
|
188
|
-
* and therefore copied here).
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* @
|
|
274
|
+
* and therefore copied here). `SandboxProvider.confine` is ASYNC in
|
|
275
|
+
* 0.1.7-rc.2, so the confined branch MUST be awaited: reading `.argv` off the
|
|
276
|
+
* returned promise yields `undefined`, which made every confined persistent
|
|
277
|
+
* spawn fail (previously masked by a synchronous test double).
|
|
278
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context carrying the sandbox provider.
|
|
279
|
+
* @param {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
|
|
280
|
+
* @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()`.
|
|
281
|
+
* @param {AbortSignal} [signal] - cancellation of confinement preparation.
|
|
282
|
+
* @returns {Promise<string[]>} the (possibly confined) argv.
|
|
193
283
|
*/
|
|
194
|
-
export function spawnArgv(ctx, config, policy) {
|
|
284
|
+
export async function spawnArgv(ctx, config, policy, signal) {
|
|
195
285
|
const argv = [config.shellPath, ...config.shellArgs]
|
|
196
286
|
if (policy.mode === 'danger-full-access') return argv
|
|
197
287
|
const sandbox = ctx.get('sandbox')
|
|
198
288
|
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, {
|
|
289
|
+
return (await sandbox.confine(argv, {
|
|
200
290
|
...policy,
|
|
201
291
|
mode: policy.mode,
|
|
202
|
-
}).argv
|
|
292
|
+
}, signal)).argv
|
|
203
293
|
}
|
|
204
294
|
|
|
205
295
|
/**
|
|
@@ -207,11 +297,12 @@ export function spawnArgv(ctx, config, policy) {
|
|
|
207
297
|
* poll with empty sends until the fish prompt satisfies the shared
|
|
208
298
|
* readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
|
|
209
299
|
* `startupSession` pwsh branch, with a startup deadline race.
|
|
210
|
-
* @param session - the backend session.
|
|
211
|
-
* @param timeoutMs - readiness deadline.
|
|
212
|
-
* @param signal - optional cancellation.
|
|
300
|
+
* @param {PersistentPtySession} session - the backend session to bring up.
|
|
301
|
+
* @param {number} timeoutMs - readiness deadline.
|
|
302
|
+
* @param {AbortSignal} [signal] - optional cancellation.
|
|
213
303
|
*/
|
|
214
304
|
export async function startupSession(session, timeoutMs, signal) {
|
|
305
|
+
/** @type {import('@deepseek-ai/dsh-terminal').TerminalSendOperation | undefined} */
|
|
215
306
|
let startupOperation
|
|
216
307
|
const start = async () => {
|
|
217
308
|
let viewport = ''
|
|
@@ -255,6 +346,25 @@ export async function startupSession(session, timeoutMs, signal) {
|
|
|
255
346
|
}
|
|
256
347
|
}
|
|
257
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Reject a failed startup only after its unpublished resources reach
|
|
351
|
+
* quiescence (copied from the official `@deepseek-ai/dsh-terminal-bash`,
|
|
352
|
+
* which does not export it): a cleanup failure is reported as
|
|
353
|
+
* `TerminalBackendCleanupError` so a leaked PTY is distinguishable from the
|
|
354
|
+
* startup failure that caused it.
|
|
355
|
+
* @param {unknown} error - the startup or session-factory failure.
|
|
356
|
+
* @param {() => unknown} cleanup - releases the resource the failure left behind.
|
|
357
|
+
* @returns {Promise<never>} never resolves; always rejects.
|
|
358
|
+
*/
|
|
359
|
+
async function rejectAfterStartupCleanup(error, cleanup) {
|
|
360
|
+
try {
|
|
361
|
+
await cleanup()
|
|
362
|
+
} catch (cleanupError) {
|
|
363
|
+
throw new TerminalBackendCleanupError(error, cleanupError)
|
|
364
|
+
}
|
|
365
|
+
throw error
|
|
366
|
+
}
|
|
367
|
+
|
|
258
368
|
/**
|
|
259
369
|
* Local fish PTY backend registered under the configured type (`fish`):
|
|
260
370
|
* spawns `fish --no-config -i` (config-overridable) with the fish child
|
|
@@ -264,37 +374,55 @@ export async function startupSession(session, timeoutMs, signal) {
|
|
|
264
374
|
* `BashTerminalBackend`/`LocalPtySession`.
|
|
265
375
|
*/
|
|
266
376
|
export class FishTerminalBackend extends BashTerminalBackend {
|
|
377
|
+
/**
|
|
378
|
+
* Spawn and bring up one fish PTY session for a spec the registry (or the
|
|
379
|
+
* self-managed persistent tool) resolved. Resolves with the ready session
|
|
380
|
+
* the configured session factory built (the base class declares the concrete
|
|
381
|
+
* `LocalPtySession` return, which this package cannot name).
|
|
382
|
+
* @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the identified spawn request.
|
|
383
|
+
*/
|
|
267
384
|
async spawn(spec) {
|
|
268
385
|
spec.signal?.throwIfAborted()
|
|
269
|
-
|
|
386
|
+
// The inherited fields (`ctx`, `config`, `spawnTerminal`, `createSession`)
|
|
387
|
+
// are TS-private upstream, so they are read through the one structural
|
|
388
|
+
// view declared above instead of repeating the same suppression per use.
|
|
389
|
+
const backend = /** @type {BashBackendInternals} */ (/** @type {unknown} */ (this))
|
|
390
|
+
ensureSandboxModeFence(backend.ctx, spec.owner)
|
|
270
391
|
// Resolve services through `ctx.get` (never property access): the
|
|
271
392
|
// backend may be constructed with a plugin context whose inject list
|
|
272
393
|
// does not declare these services (the policy plugin's ctx), and Cordis
|
|
273
394
|
// property access would throw "cannot get property … without inject".
|
|
274
395
|
// `ctx.get` resolves any registered service regardless of inject.
|
|
275
|
-
const policy =
|
|
276
|
-
const argv = spawnArgv(
|
|
396
|
+
const policy = backend.ctx.get('sandboxPolicy').resolve({ session: spec.owner.session })
|
|
397
|
+
const argv = await spawnArgv(backend.ctx, backend.config, policy, spec.signal)
|
|
398
|
+
// Confinement is async, so the caller may have cancelled while it ran.
|
|
399
|
+
spec.signal?.throwIfAborted()
|
|
277
400
|
if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
|
|
278
|
-
const terminal = await
|
|
401
|
+
const terminal = await backend.spawnTerminal({
|
|
279
402
|
argv,
|
|
280
403
|
cwd: spec.cwd ?? policy.workspaceRoot,
|
|
281
404
|
env: childEnvironment(spec),
|
|
282
|
-
rows:
|
|
283
|
-
cols:
|
|
284
|
-
|
|
405
|
+
rows: backend.config.rows,
|
|
406
|
+
cols: backend.config.cols,
|
|
407
|
+
// The provider advertises this as the child's `TERM` (it overwrites
|
|
408
|
+
// whatever `env` carried with it), so it must be the same `dumb` the
|
|
409
|
+
// fish prompt readiness contract is verified against.
|
|
410
|
+
terminalType: 'dumb',
|
|
411
|
+
graceMs: backend.config.disposeGraceMs,
|
|
285
412
|
signal: spec.signal,
|
|
286
413
|
})
|
|
287
|
-
|
|
414
|
+
let session
|
|
415
|
+
try {
|
|
416
|
+
session = backend.createSession(terminal, backend.config)
|
|
417
|
+
} catch (error) {
|
|
418
|
+
// A failed session factory must not leak the freshly allocated terminal.
|
|
419
|
+
return rejectAfterStartupCleanup(error, () => terminal.terminate())
|
|
420
|
+
}
|
|
288
421
|
try {
|
|
289
|
-
await startupSession(session,
|
|
422
|
+
await startupSession(session, backend.config.timeoutMs, spec.signal)
|
|
290
423
|
return session
|
|
291
424
|
} catch (error) {
|
|
292
|
-
|
|
293
|
-
await session.close('PTY startup failed')
|
|
294
|
-
} catch (closeError) {
|
|
295
|
-
throw new TerminalBackendCleanupError(error, closeError)
|
|
296
|
-
}
|
|
297
|
-
throw error
|
|
425
|
+
return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
|
|
298
426
|
}
|
|
299
427
|
}
|
|
300
428
|
}
|
|
@@ -307,8 +435,8 @@ export class FishTerminalBackend extends BashTerminalBackend {
|
|
|
307
435
|
* backend up explicitly. Plugin configuration mirrors the official
|
|
308
436
|
* `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
|
|
309
437
|
* defaults: backend type `fish`, `fish --no-config -i` argv.
|
|
310
|
-
* @param ctx - the harness context.
|
|
311
|
-
* @param config - optional backend configuration.
|
|
438
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
|
|
439
|
+
* @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
|
|
312
440
|
*/
|
|
313
441
|
export function apply(ctx, config = {}) {
|
|
314
442
|
const resolved = resolveConfig(config)
|