refactor(process): split the process manager out of the bash executor
New process/ capability family: @deepseek-ai/dsh-process owns ctx.processes — abstract ProcessManager.spawn(spec) over a fully-explicit ProcessSpawnSpec — plus the shared DSH_* managed-environment and CollectedOutput vocabulary; @deepseek-ai/dsh-process-local carries the former bash-local run.ts plumbing (detached groups, tail-keep spill-backed output, credential scrub, kill escalation, kill-and-join disposal) with no config of its own. dsh-bash-local becomes a consumer: it keeps command defaulting, the fused deadline timedOut/aborted classification, the model-friendly terminal env (now merged through the ordinary env channel), and the [stderr]-marked background read merge, and spawns through ctx.processes. Background-process lifetime moves to the manager, so an executor reload no longer kills live background work; a background spawn failure is injected once into the read path instead of being buffered as fake stderr. dsh-bash re-exports the moved vocabulary so bash consumers keep one import root; dsh-bash-sandbox only redeclares the inherited inject. Every composition loading a bash executor now loads dsh-process-local (CLI, examples, python bundled runtime, create-sdk bash feature, inline test configs).
This commit is contained in:
@@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md)
|
||||
|
||||
Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden.
|
||||
|
||||
- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently.
|
||||
- **bash** ([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently.
|
||||
- **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`.
|
||||
- **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。
|
||||
|
||||
- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。
|
||||
- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。
|
||||
- **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。
|
||||
- **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。)
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: The process manager is its own seam under the bash executors (`dsh-process` / `dsh-process-local`)
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-process-manager-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers.
|
||||
|
||||
## Decision
|
||||
|
||||
A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it:
|
||||
|
||||
- **`@deepseek-ai/dsh-process` (interface)** — the abstract `ProcessManager` owning `ctx.processes` with one method, `spawn(spec): ProcessHandle`, and the shared vocabulary: the fully-explicit `ProcessSpawnSpec` (argv, cwd, per-stream caps, spill cap, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `ProcessHandle` with non-consuming offset-based readers, `ProcessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted.
|
||||
- **`@deepseek-ai/dsh-process-local` (implementation)** — `LocalProcessManager` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` merge, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel.
|
||||
- **`dsh-bash-local` (consumer)** — `inject: ['processes']`; maps each resolved `BashExecSpec` onto a `ProcessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.
|
||||
- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-process`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
|
||||
|
||||
Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-process-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
|
||||
|
||||
Background-process lifetime moved from the executor to the manager: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the manager's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the manager rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split.
|
||||
|
||||
**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.processes` in the same change.** Rejected as scope creep with real design risk: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README.
|
||||
|
||||
**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry.
|
||||
|
||||
**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the manager.** Rejected: a generic process manager must not impose terminal presentation policy on non-terminal consumers; the scrub and `DSH_*` channel rules are security/identity invariants and stay, but terminal friendliness is the bash tool's choice, expressed through the ordinary env channel where an explicit caller entry still wins.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-process-local` (argv-based, plus argv-validation and manager lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, manager-owned lifetime) against the real manager.
|
||||
|
||||
Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the manager leaves `ctx.bash` pending on `ctx.processes` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the process seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists.
|
||||
@@ -88,6 +88,10 @@
|
||||
- id: workspace
|
||||
name: '@deepseek-ai/dsh-workspace'
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash-local
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
|
||||
@@ -82,10 +82,13 @@ flowchart LR
|
||||
pkg_agent_spine_demo["agent-spine-demo"]
|
||||
pkg_goal["goal"]
|
||||
svc_goals["ctx.goals<br/>Same-session goal domain"]
|
||||
pkg_bash["bash"]
|
||||
svc_bash["ctx.bash<br/>Bash executor seam"]
|
||||
pkg_process["process"]
|
||||
svc_processes["ctx.processes<br/>Process manager seam"]
|
||||
pkg_process_local["process-local"]
|
||||
pkg_bash_local["bash-local"]
|
||||
pkg_bash_sandbox["bash-sandbox"]
|
||||
pkg_bash["bash"]
|
||||
svc_bash["ctx.bash<br/>Bash executor seam"]
|
||||
svc_bashEnv["ctx.bashEnv<br/>Managed bash environment registry"]
|
||||
pkg_pty["pty"]
|
||||
svc_pty["ctx.pty<br/>Persistent PTY session registry"]
|
||||
@@ -164,6 +167,8 @@ flowchart LR
|
||||
pkg_modules --> svc_clientModuleHost
|
||||
pkg_permission --> svc_permission
|
||||
pkg_plan_mode --> svc_planMode
|
||||
pkg_process --> svc_processes
|
||||
pkg_process_local --> svc_processes
|
||||
pkg_pty --> svc_pty
|
||||
pkg_pty_local --> svc_pty
|
||||
pkg_sandbox --> svc_sandbox
|
||||
@@ -234,6 +239,8 @@ flowchart LR
|
||||
svc_invariants --> pkg_session
|
||||
svc_llm --> pkg_agent_loop
|
||||
svc_llm --> pkg_compact_basic
|
||||
svc_processes --> pkg_bash_local
|
||||
svc_processes --> pkg_bash_sandbox
|
||||
svc_pty --> pkg_tool_pty
|
||||
svc_sandbox --> pkg_bash_sandbox
|
||||
svc_sandbox --> pkg_pty_local
|
||||
@@ -317,6 +324,7 @@ flowchart LR
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
|
||||
| `ctx.processes` | `seam` | [`process`](../packages/process/process) | [`process-local`](../packages/process/process-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation. |
|
||||
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
|
||||
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
|
||||
| `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. |
|
||||
|
||||
@@ -192,6 +192,8 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp
|
||||
|
||||
## `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
Requires: `processes`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
@@ -210,11 +212,11 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts)
|
||||
Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-sandbox`
|
||||
|
||||
Requires: `sandbox` · `sandboxPolicy`
|
||||
Requires: `processes` · `sandbox` · `sandboxPolicy`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
@@ -2053,6 +2055,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
|
||||
- `@deepseek-ai/dsh-process-local` ([`packages/process/process-local/src/index.ts`](../packages/process/process-local/src/index.ts))
|
||||
- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
|
||||
@@ -2073,6 +2076,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
|
||||
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
|
||||
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
|
||||
- `@deepseek-ai/dsh-process` — abstract `ProcessManager` ([`packages/process/process/src/index.ts`](../packages/process/process/src/index.ts))
|
||||
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
|
||||
|
||||
@@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment
|
||||
list(): BashEnvVariableInfo[]
|
||||
```
|
||||
|
||||
Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md)
|
||||
Types: [DshEnvironment](../core-data-structures/process.md) · [ToolExecution](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
@@ -829,6 +829,31 @@ Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts)
|
||||
|
||||
## `ctx.processes` — `ProcessManager` (abstract seam)
|
||||
|
||||
Abstract process manager. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.processes` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
|
||||
|
||||
Implementations must honor these semantics:
|
||||
|
||||
- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
|
||||
- Output readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists.
|
||||
- ProcessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group.
|
||||
- Disposal kills all still-running managed processes and awaits their exit.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Start one managed child process from a fully-specified spec; this seam
|
||||
* applies no defaults.
|
||||
* @param spec - argv, directory, limits, grace, cancellation, and environment.
|
||||
* @returns the live process handle (readers, kill, outcome promise).
|
||||
*/
|
||||
abstract spawn(spec: ProcessSpawnSpec): ProcessHandle
|
||||
```
|
||||
|
||||
Types: [ProcessHandle](../core-data-structures/process.md) · [ProcessSpawnSpec](../core-data-structures/process.md)
|
||||
|
||||
Source: [`packages/process/process/src/index.ts:48`](../../packages/process/process/src/index.ts)
|
||||
|
||||
## `ctx.pty` — `PtyService`
|
||||
|
||||
In-process registry for replaceable PTY backends and exact-Agent sessions.
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
|
||||
English | [中文](bash.zh.md)
|
||||
|
||||
The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle.
|
||||
The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [process-manager seam](process.md).
|
||||
|
||||
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
|
||||
|
||||
## Managed shell environment namespace
|
||||
|
||||
`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot.
|
||||
|
||||
```ts type-equiv
|
||||
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
|
||||
type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Trusted DeepSeek Harness variables for one bash execution. */
|
||||
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
```
|
||||
`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the process manager removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`.
|
||||
|
||||
## Request vs. spec: the `resolve()` split
|
||||
|
||||
@@ -145,19 +135,7 @@ interface BashRunResult {
|
||||
}
|
||||
```
|
||||
|
||||
Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file:
|
||||
|
||||
```ts type-equiv
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
interface CollectedOutput {
|
||||
/** Collected text — the TAIL of the stream when truncated. */
|
||||
text: string
|
||||
/** True when bytes were dropped from `text`. */
|
||||
truncated: boolean
|
||||
/** Path to a file holding the COMPLETE stream, when truncated and available. */
|
||||
spillPath?: string
|
||||
}
|
||||
```
|
||||
Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`.
|
||||
|
||||
## File sandbox: `BashSandboxInfo`
|
||||
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
|
||||
[English](bash.md) | 中文
|
||||
|
||||
bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。
|
||||
bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](process.md)之后。
|
||||
|
||||
源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
|
||||
|
||||
## 受管 shell 环境命名空间
|
||||
|
||||
`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。
|
||||
|
||||
```ts type-equiv
|
||||
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
|
||||
type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Trusted DeepSeek Harness variables for one bash execution. */
|
||||
type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
```
|
||||
`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。
|
||||
|
||||
## 请求与规格:`resolve()` 拆分
|
||||
|
||||
@@ -145,19 +135,7 @@ interface BashRunResult {
|
||||
}
|
||||
```
|
||||
|
||||
每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件:
|
||||
|
||||
```ts type-equiv
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
interface CollectedOutput {
|
||||
/** Collected text — the TAIL of the stream when truncated. */
|
||||
text: string
|
||||
/** True when bytes were dropped from `text`. */
|
||||
truncated: boolean
|
||||
/** Path to a file holding the COMPLETE stream, when truncated and available. */
|
||||
spillPath?: string
|
||||
}
|
||||
```
|
||||
每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。
|
||||
|
||||
## 文件沙箱:`BashSandboxInfo`
|
||||
|
||||
|
||||
133
docs/core-data-structures/process.md
Normal file
133
docs/core-data-structures/process.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Process Manager
|
||||
|
||||
The child-process manager seam is split across interface ([dsh-process](../../packages/process/process), `ctx.processes`) and implementation ([dsh-process-local](../../packages/process/process-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root.
|
||||
|
||||
Source: [`packages/process/process/src/types.ts`](../../packages/process/process/src/types.ts)
|
||||
|
||||
## The fully-explicit spawn spec
|
||||
|
||||
The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden process-manager default — decides them. `argv` is never shell-interpreted.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A fully-specified spawn request. This seam applies no defaults: every limit
|
||||
* and directory is explicit, so the caller's own config — not a hidden
|
||||
* process-manager default — decides them (the `dsh-bash` request/spec split
|
||||
* is the owning template).
|
||||
*/
|
||||
interface ProcessSpawnSpec {
|
||||
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
|
||||
argv: readonly string[]
|
||||
/** Working directory for the child. */
|
||||
cwd: string
|
||||
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stdoutMaxBytes: number
|
||||
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stderrMaxBytes: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes: number
|
||||
/** Grace period for kill escalation and for inherited pipes after process exit. */
|
||||
graceMs: number
|
||||
/**
|
||||
* Abort signal — kills the process group when it fires. The caller owns
|
||||
* deadlines and cause classification; this seam only reacts to the abort.
|
||||
*/
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the child's stdin, then close it. Absent (or empty)
|
||||
* leaves stdin closed/empty.
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Ordinary environment entries merged after the implementation's credential
|
||||
* scrub. `DSH_*` names are rejected and belong in {@link dshEnv}.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Harness-owned `DSH_*` variables for this execution. Implementations
|
||||
* discard ambient `DSH_*` entries before merging this snapshot, so an
|
||||
* unavailable current fact cannot inherit a stale value from the harness
|
||||
* process, and reject non-`DSH_*` names supplied through this channel.
|
||||
*/
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
}
|
||||
```
|
||||
|
||||
## Handles and offset-based reads
|
||||
|
||||
A spawn returns a live handle immediately. Output readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; the consuming-cursor model the bash tool presents is consumer-owned state over these readers.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
|
||||
* escalation; buffered output remains readable after exit.
|
||||
*/
|
||||
interface ProcessHandle {
|
||||
/** Process id (group leader); -1 when the spawn itself failed. */
|
||||
readonly pid: number
|
||||
/** Live stdout reader (also readable after exit). */
|
||||
readonly stdout: ProcessOutputReader
|
||||
/** Live stderr reader (also readable after exit). */
|
||||
readonly stderr: ProcessOutputReader
|
||||
/** Resolves when the process closes; rejects only for spawn-level failures. */
|
||||
readonly done: Promise<ProcessOutcome>
|
||||
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
|
||||
kill(): void
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Cursor-free incremental access to one live output stream. Offsets are
|
||||
* whole-stream byte coordinates owned by the caller, so independent readers
|
||||
* cannot consume one another's output.
|
||||
*/
|
||||
interface ProcessOutputReader {
|
||||
/**
|
||||
* Read everything captured since `fromByte`. When that offset has slid out
|
||||
* of the in-memory tail window the read is `lossy` — it returns the whole
|
||||
* retained tail and the gap is only recoverable from the spill file.
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
||||
* @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists.
|
||||
*/
|
||||
readFrom(fromByte: number): ProcessOutputRead
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One incremental {@link ProcessOutputReader.readFrom} read. */
|
||||
interface ProcessOutputRead {
|
||||
/** Stream text from the requested offset (the whole retained tail when lossy). */
|
||||
text: string
|
||||
/** Whole-stream offset to resume from on the next read. */
|
||||
nextOffset: number
|
||||
/** True when the requested offset slid out of the in-memory tail window. */
|
||||
lossy: boolean
|
||||
/** Path to the full-stream spill file, when one was created and remains intact. */
|
||||
spillPath?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Outcomes carry no cause classification
|
||||
|
||||
`done` reports raw exit facts. The manager kills on abort but never decides why — the caller reads the deadline signal it owns to classify timeout versus cancellation (the bash executor's `timedOut`/`aborted` split).
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Raw outcome of one closed process. Deliberately carries NO timeout or
|
||||
* cancellation classification: the manager kills on abort but does not decide
|
||||
* why — the caller reads the signal it owns to classify causes.
|
||||
*/
|
||||
interface ProcessOutcome {
|
||||
/** Exit code; null when the process died from a signal. */
|
||||
exitCode: number | null
|
||||
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
```
|
||||
|
||||
## Service behavior
|
||||
|
||||
The abstract [`ProcessManager`](../../packages/process/process/src/index.ts) seam defines `spawn` only; [`LocalProcessManager`](../../packages/process/process-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-process`](../../packages/process/process/README.md) for the seam contract and [`dsh-process-local`](../../packages/process/process-local/README.md) for the mechanics.
|
||||
@@ -184,6 +184,10 @@ flowchart TD
|
||||
subgraph group_mcp["packages/mcp"]
|
||||
pkg_mcp_client["mcp-client"]
|
||||
end
|
||||
subgraph group_process["packages/process"]
|
||||
pkg_process["process"]
|
||||
pkg_process_local["process-local"]
|
||||
end
|
||||
subgraph group_pty["packages/pty"]
|
||||
pkg_pty["pty"]
|
||||
pkg_pty_local["pty-local"]
|
||||
@@ -243,6 +247,7 @@ flowchart TD
|
||||
pkg_jsonrpc_demo --> pkg_invariants
|
||||
pkg_host_apiproxy --> pkg_invariants
|
||||
pkg_host_webserver --> pkg_invariants
|
||||
pkg_process --> pkg_invariants
|
||||
pkg_storage --> pkg_invariants
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_llm --> pkg_invariants
|
||||
@@ -266,6 +271,8 @@ flowchart TD
|
||||
pkg_client_ui_workspace --> pkg_client_ui_primitives
|
||||
pkg_client_ui_workspace --> pkg_client_ui_slots
|
||||
pkg_client_ui_workspace --> pkg_invariants
|
||||
pkg_process_local --> pkg_invariants
|
||||
pkg_process_local --> pkg_process
|
||||
pkg_helper --> pkg_brand
|
||||
pkg_helper --> pkg_invariants
|
||||
pkg_telemetry --> pkg_brand
|
||||
@@ -307,6 +314,7 @@ flowchart TD
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_bash --> pkg_invariants
|
||||
pkg_bash --> pkg_process
|
||||
pkg_bash --> pkg_sandbox
|
||||
pkg_fs --> pkg_brand
|
||||
pkg_fs --> pkg_invariants
|
||||
@@ -371,6 +379,7 @@ flowchart TD
|
||||
pkg_goal --> pkg_session
|
||||
pkg_bash_local --> pkg_bash
|
||||
pkg_bash_local --> pkg_invariants
|
||||
pkg_bash_local --> pkg_process
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_fs_local --> pkg_fs
|
||||
pkg_fs_local --> pkg_invariants
|
||||
@@ -828,6 +837,7 @@ flowchart TD
|
||||
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
|
||||
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`process`](../packages/process/process) | `process` | [`invariants`](../packages/support/invariants) |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
@@ -836,6 +846,7 @@ flowchart TD
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`process-local`](../packages/process/process-local) | `process` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
@@ -850,7 +861,7 @@ flowchart TD
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
@@ -869,7 +880,7 @@ flowchart TD
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`timeout`](../packages/util/timeout) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
|
||||
|
||||
@@ -14,6 +14,8 @@ flowchart LR
|
||||
cfg --> plugin_acp_sandbox
|
||||
plugin_acp_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
|
||||
cfg --> plugin_acp_sandbox_policy
|
||||
plugin_acp_processes["processes<br/>@deepseek-ai/dsh-process-local"]
|
||||
cfg --> plugin_acp_processes
|
||||
plugin_acp_bash["bash<br/>@deepseek-ai/dsh-bash-sandbox"]
|
||||
cfg --> plugin_acp_bash
|
||||
plugin_acp_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
|
||||
@@ -68,6 +70,7 @@ flowchart LR
|
||||
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
|
||||
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
|
||||
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
|
||||
| `processes` | `@deepseek-ai/dsh-process-local` |
|
||||
| `bash` | `@deepseek-ai/dsh-bash-sandbox` |
|
||||
| `approval` | `@deepseek-ai/dsh-user-approval` |
|
||||
| `acp-agent` | `@deepseek-ai/dsh-acp-demo` |
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')"
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
config:
|
||||
|
||||
@@ -12,6 +12,8 @@ flowchart LR
|
||||
cfg --> plugin_cordis_hmr
|
||||
plugin_cordis_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
|
||||
cfg --> plugin_cordis_llm_deepseek
|
||||
plugin_cordis_processes["processes<br/>@deepseek-ai/dsh-process-local"]
|
||||
cfg --> plugin_cordis_processes
|
||||
plugin_cordis_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
|
||||
cfg --> plugin_cordis_bash
|
||||
plugin_cordis_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
|
||||
@@ -39,6 +41,7 @@ flowchart LR
|
||||
| --- | --- |
|
||||
| `hmr` | `@cordisjs/plugin-hmr` |
|
||||
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
|
||||
| `processes` | `@deepseek-ai/dsh-process-local` |
|
||||
| `bash` | `@deepseek-ai/dsh-bash-local` |
|
||||
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
|
||||
| `web` | `@deepseek-ai/dsh-web` |
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||
# Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an
|
||||
# ordinary tool whose calls make the mounted listeners observably fire.
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -10,6 +10,8 @@ flowchart LR
|
||||
cfg["examples/headless-agent<br/>cordis.yml"]
|
||||
plugin_headless_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
|
||||
cfg --> plugin_headless_llm_deepseek
|
||||
plugin_headless_processes["processes<br/>@deepseek-ai/dsh-process-local"]
|
||||
cfg --> plugin_headless_processes
|
||||
plugin_headless_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
|
||||
cfg --> plugin_headless_bash
|
||||
plugin_headless_cli_agent["cli-agent<br/>@deepseek-ai/dsh-cli-demo"]
|
||||
@@ -54,6 +56,7 @@ flowchart LR
|
||||
| Plugin id | Package / module |
|
||||
| --- | --- |
|
||||
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
|
||||
| `processes` | `@deepseek-ai/dsh-process-local` |
|
||||
| `bash` | `@deepseek-ai/dsh-bash-local` |
|
||||
| `cli-agent` | `@deepseek-ai/dsh-cli-demo` |
|
||||
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
- id: deepseek-v4-flash
|
||||
contextWindow: 128000
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
file: !!js process.env.DSH_SNAPSHOT_FILE
|
||||
overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
@@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise<Context> {
|
||||
await harness.plugin(AgentRegistry)
|
||||
await harness.plugin(AgentLoop, { agents: [] })
|
||||
await harness.plugin(LlmDeepSeek)
|
||||
await harness.plugin(LocalProcessManager)
|
||||
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await harness.plugin(ToolBash)
|
||||
await harness.plugin(WorkerCodeRuntime, {})
|
||||
@@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise<Context> {
|
||||
const harness = await typedCodeModeHarness()
|
||||
await harness.plugin(LocalTaskService)
|
||||
await harness.plugin(ToolTasks, {})
|
||||
await harness.plugin(LocalProcessManager)
|
||||
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await harness.plugin(ToolBash)
|
||||
return harness
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
- id: cli-mock-llm
|
||||
name: '../cli-mock-llm.ts'
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
- id: time-context-mock-llm
|
||||
name: './time-context-mock-llm.ts'
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
|
||||
await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : {
|
||||
models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }],
|
||||
})
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(ToolTodo)
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
thinking: enabled
|
||||
reasoningEffort: max
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@deepseek-ai/dsh-lsp": "workspace:*",
|
||||
"@deepseek-ai/dsh-lsp-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:*",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-permission": "workspace:*",
|
||||
"@deepseek-ai/dsh-pty": "workspace:*",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:*",
|
||||
|
||||
@@ -12,6 +12,8 @@ flowchart LR
|
||||
cfg --> plugin_tui_hmr
|
||||
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
|
||||
cfg --> plugin_tui_llm_deepseek
|
||||
plugin_tui_processes["processes<br/>@deepseek-ai/dsh-process-local"]
|
||||
cfg --> plugin_tui_processes
|
||||
plugin_tui_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
|
||||
cfg --> plugin_tui_bash
|
||||
plugin_tui_tui_agent["tui-agent<br/>@deepseek-ai/dsh-tui-demo"]
|
||||
@@ -69,6 +71,7 @@ flowchart LR
|
||||
| --- | --- |
|
||||
| `hmr` | `@cordisjs/plugin-hmr` |
|
||||
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
|
||||
| `processes` | `@deepseek-ai/dsh-process-local` |
|
||||
| `bash` | `@deepseek-ai/dsh-bash-local` |
|
||||
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
|
||||
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
reasoningEffort: max
|
||||
|
||||
# Local executor for the app bundle's bash tool.
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
- id: scripted-llm
|
||||
name: './tui-scripted-llm.ts'
|
||||
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -204,6 +205,7 @@ async function mountScenarioContext(
|
||||
skills: { local: { agentsHome: join(cwd, '.agents') } },
|
||||
})
|
||||
await ctx.plugin(TokenMeterService)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(FsPolicy)
|
||||
|
||||
@@ -11,6 +11,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
|
||||
@@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` |
|
||||
| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) |
|
||||
| `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) |
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
|
||||
Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c <command>` per call as a managed process group through `ctx.processes`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the process manager's.
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -22,11 +22,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
|
||||
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file.
|
||||
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -40,8 +40,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`.
|
||||
- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them.
|
||||
- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
|
||||
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
|
||||
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
|
||||
- **POSIX-only** — the `bash` binary is hardcoded, and the underlying manager's group semantics are POSIX; Windows is unsupported.
|
||||
- **A background spawn-failure note is single-delivery** — the manager buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it.
|
||||
|
||||
The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring.
|
||||
Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-process": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -38,6 +39,8 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Local-subprocess implementation of the bash executor seam. Each command runs
|
||||
* as `bash -c` in its own process group; disposal kills and joins live groups.
|
||||
* Execution policy belongs in `tools/pre-execute` or a sandboxing executor.
|
||||
* Local implementation of the bash executor seam over the process-manager
|
||||
* seam. Each command runs as `bash -c` in a managed process group spawned
|
||||
* through `ctx.processes`; this executor owns command defaulting, deadlines
|
||||
* and cause classification, the model-friendly terminal environment, and the
|
||||
* model-facing stdout/stderr merge for background reads. Execution policy
|
||||
* belongs in `tools/pre-execute` or a sandboxing executor.
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
@@ -9,9 +12,28 @@ import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts'
|
||||
import type { RunInternals, RunningBash } from './run.ts'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
* interactive terminal features that would garble tool output (the same set
|
||||
* Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy —
|
||||
* merged into the ordinary env channel, so a trusted caller's own entry still
|
||||
* wins; the process manager applies its credential scrub independently.
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
|
||||
const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
@@ -39,10 +61,15 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Local bash executor with bounded output, spill files, and process-group
|
||||
* `SIGTERM` to `SIGKILL` escalation.
|
||||
* Local bash executor over `ctx.processes`. Bounded output, spill files, and
|
||||
* process-group SIGTERM→SIGKILL escalation are the process manager's
|
||||
* mechanics; this executor supplies their configured budgets per spawn, so a
|
||||
* still-running background process stays managed (killed and joined at
|
||||
* composition teardown) even across an executor reload.
|
||||
*/
|
||||
export class LocalBashExecutor extends BashExecutor {
|
||||
static inject = ['processes']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
@@ -52,11 +79,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
/** Live processes retained only so disposal can kill and join them. */
|
||||
private live = new Map<BashProcess, RunningBash>()
|
||||
/** Test seam: spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
@@ -69,17 +91,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
ctx.effect(() => async () => {
|
||||
// Await closure so even a TERM-trapping child cannot outlive the fiber.
|
||||
const pending: Promise<void>[] = []
|
||||
for (const [proc, running] of this.live) {
|
||||
proc.status = 'killed'
|
||||
running.kill()
|
||||
pending.push(proc.done)
|
||||
}
|
||||
this.live.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local bash teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
stdoutMaxBytes,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
|
||||
// no config default. run.ts owns the scrub and merge order.
|
||||
// no config default. The process manager owns the scrub and merge order.
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
@@ -116,21 +127,27 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const outcome = await runBash({
|
||||
command: spec.command,
|
||||
/** Map one resolved bash spec onto a fully-specified process spawn. */
|
||||
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec {
|
||||
return {
|
||||
argv: ['bash', '-c', spec.command],
|
||||
cwd: spec.workdir,
|
||||
stdoutMaxBytes: spec.stdoutMaxBytes,
|
||||
stdoutMaxBytes,
|
||||
stderrMaxBytes: this.config.maxOutputBytes,
|
||||
maxSpillBytes: this.config.maxSpillBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: d.signal,
|
||||
signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
env: { ...ENV_OVERRIDES, ...spec.env },
|
||||
dshEnv: spec.dshEnv,
|
||||
}, this.internals).done
|
||||
}
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
|
||||
const aborted = d.signal.aborted && !timedOut
|
||||
@@ -139,18 +156,16 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
stdoutMaxBytes: this.config.maxOutputBytes,
|
||||
stderrMaxBytes: this.config.maxOutputBytes,
|
||||
maxSpillBytes: this.config.maxSpillBytes,
|
||||
graceMs: this.config.graceMs,
|
||||
signal: spec.signal,
|
||||
stdin: spec.stdin,
|
||||
env: spec.env,
|
||||
dshEnv: spec.dshEnv,
|
||||
}, this.internals)
|
||||
const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
|
||||
// A spawn failure produces no process output, so the manager has nothing
|
||||
// to buffer; the note is delivered exactly once through the read path.
|
||||
let spawnFailureNote: string | undefined
|
||||
const consumeSpawnFailure = (): string => {
|
||||
const note = spawnFailureNote ?? ''
|
||||
spawnFailureNote = undefined
|
||||
return note
|
||||
}
|
||||
|
||||
let stdoutOffset = 0
|
||||
let stderrOffset = 0
|
||||
@@ -166,13 +181,11 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, running.stderr.readFrom(0).text)
|
||||
this.live.delete(proc)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.onProcessDone(proc, running.stderr.readFrom(0).text)
|
||||
this.live.delete(proc)
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = running.stdout.readFrom(stdoutOffset)
|
||||
@@ -180,11 +193,14 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
stdoutOffset = out.nextOffset
|
||||
stderrOffset = err.nextOffset
|
||||
|
||||
// A failed spawn never produced process output, so the note and real
|
||||
// stderr text are mutually exclusive.
|
||||
const errText = err.text.length > 0 ? err.text : consumeSpawnFailure()
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
+ (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '')
|
||||
return {
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
@@ -199,7 +215,6 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.live.set(proc, running)
|
||||
return proc
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,18 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
|
||||
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
// A short kill grace via the REAL config path, so escalation tests stay fast.
|
||||
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
@@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalBashExecutor disposal', () => {
|
||||
it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => {
|
||||
describe('process lifecycle ownership (the manager, not the executor)', () => {
|
||||
it('a background process survives executor-fiber disposal and dies with the process manager', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const managerFiber = await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
// The child prints its own pid ($$ = the detached bash group leader) so
|
||||
// the test can probe liveness through the public read surface alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' }))
|
||||
const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
await fiber.dispose()
|
||||
// Disposal itself waited: the pid must already be gone, no grace left —
|
||||
// even for a TERM-trapping child held until the SIGKILL escalation landed.
|
||||
// Reloading/disposing the executor no longer kills backend work — the
|
||||
// handle stays live and readable, mirroring the task runtime's
|
||||
// registrations-outlive-producer-fibers contract.
|
||||
await executorFiber.dispose()
|
||||
expect(proc.status).toBe('running')
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
|
||||
// Manager disposal kills the group and AWAITS its exit (no orphans).
|
||||
await managerFiber.dispose()
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
expect(proc.status).toBe('killed')
|
||||
await proc.done
|
||||
expect(proc.status).toBe('killed')
|
||||
})
|
||||
|
||||
it('settled processes already left the live map: dispose does not touch them', async () => {
|
||||
it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const managerFiber = await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
|
||||
const finished = bash.start(bash.resolve({ command: 'echo done' }))
|
||||
await finished.done
|
||||
expect(finished.status).toBe('completed')
|
||||
const running = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
|
||||
await readUntil(trapping, 'armed')
|
||||
|
||||
await fiber.dispose()
|
||||
// The teardown marks every LIVE entry killed; a settled process had
|
||||
// already left the map, so its status stays completed.
|
||||
await managerFiber.dispose()
|
||||
// A settled process was untouched; the live one died by escalation.
|
||||
expect(finished.status).toBe('completed')
|
||||
expect(running.status).toBe('killed')
|
||||
await running.done
|
||||
expect(running.signal).toBe('SIGTERM')
|
||||
await trapping.done
|
||||
expect(trapping.status).toBe('killed')
|
||||
expect(trapping.signal).toBe('SIGKILL')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../process/process"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
|
||||
@@ -34,7 +34,7 @@ export type Config = LocalConfig
|
||||
* mode; `result.sandbox` reports the mode and enforcement actually used.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox', 'sandboxPolicy']
|
||||
static override inject = ['processes', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
@@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` that `runBash` spawns
|
||||
* the inherited spawn path runs (the outer `bash -c` the process manager spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -58,9 +59,10 @@ async function setup(
|
||||
...mode !== undefined ? { mode } : {},
|
||||
...workspaceRoot !== undefined ? { workspaceRoot } : {},
|
||||
})
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-process": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Execution types for the bash executor seam. Background task semantics belong
|
||||
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles.
|
||||
* to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The
|
||||
* managed-environment and captured-output vocabulary is owned by the
|
||||
* process-manager seam and re-exported here so bash consumers keep one import
|
||||
* root.
|
||||
* @module dsh-bash/types
|
||||
*/
|
||||
|
||||
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process'
|
||||
|
||||
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
|
||||
export const DSH_ENV_PREFIX = 'DSH_' as const
|
||||
|
||||
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
|
||||
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
|
||||
/** Trusted DeepSeek Harness variables for one bash execution. */
|
||||
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
|
||||
export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process'
|
||||
|
||||
/**
|
||||
* Sandbox facts for one run, present iff a sandboxing executor handled it.
|
||||
@@ -110,16 +108,6 @@ export interface BashExecSpec {
|
||||
sandboxPolicy: SandboxExecutionPolicy | undefined
|
||||
}
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
export interface CollectedOutput {
|
||||
/** Collected text — the TAIL of the stream when truncated. */
|
||||
text: string
|
||||
/** True when bytes were dropped from `text`. */
|
||||
truncated: boolean
|
||||
/** Path to a file holding the COMPLETE stream, when truncated and available. */
|
||||
spillPath?: string
|
||||
}
|
||||
|
||||
/** The outcome of one completed (or killed) foreground run. */
|
||||
export interface BashRunResult {
|
||||
/** Exit code; null when the process died from a signal. */
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../process/process"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
@@ -32,8 +33,9 @@ async function setup() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
@@ -46,8 +48,9 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
}
|
||||
@@ -275,8 +278,9 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
;(ctx.processes as LocalProcessManager).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
await ctx.plugin(ToolBash)
|
||||
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
||||
expect(text(result)).toContain('[output truncated; full output: ')
|
||||
@@ -383,6 +387,7 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -400,6 +405,7 @@ describe('bash tool', () => {
|
||||
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -411,6 +417,7 @@ describe('bash tool', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
ToolBash.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
@@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
|
||||
@@ -426,6 +426,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'processes',
|
||||
summary: 'Abstract process manager.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle',
|
||||
jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pty',
|
||||
summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.',
|
||||
@@ -1760,6 +1770,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PresetSpec',
|
||||
declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessHandle',
|
||||
declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise<ProcessOutcome>;\n kill(): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessOutcome',
|
||||
declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessOutputRead',
|
||||
declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessOutputReader',
|
||||
declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProcessSpawnSpec',
|
||||
declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptAssembly',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
|
||||
@@ -94,6 +94,8 @@ async function makeConsumer(): Promise<string> {
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
' name: \'./mock-llm.mjs\'',
|
||||
'- id: processes',
|
||||
' name: \'@deepseek-ai/dsh-process-local\'',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: acp-agent',
|
||||
|
||||
@@ -35,6 +35,8 @@ const CORDIS_YML = `
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: acp-agent
|
||||
|
||||
@@ -80,6 +80,8 @@ async function makeConsumer(): Promise<string> {
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
" name: './mock-llm.ts'",
|
||||
'- id: processes',
|
||||
" name: '@deepseek-ai/dsh-process-local'",
|
||||
'- id: bash',
|
||||
" name: '@deepseek-ai/dsh-bash-local'",
|
||||
'- id: cli-agent',
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -61,6 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
@@ -52,6 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -353,6 +355,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -374,6 +377,7 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
|
||||
await fiber.dispose()
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
@@ -41,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksClaude, { configPath, ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -359,6 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
@@ -657,6 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the session cwd).
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -686,6 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -41,6 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -164,6 +166,7 @@ describe('hooks-codex bridge', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
await fiber.dispose()
|
||||
@@ -186,6 +189,7 @@ describe('hooks-codex bridge', () => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -31,6 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -310,6 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||
@@ -619,6 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
10
packages/process/README.md
Normal file
10
packages/process/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# process/ — child-process manager capability family
|
||||
|
||||
The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
|
||||
|
||||
| Package | ctx key | Role |
|
||||
|---|---|---|
|
||||
| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary |
|
||||
| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal |
|
||||
|
||||
The manager owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.
|
||||
27
packages/process/process-local/README.md
Normal file
27
packages/process/process-local/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @deepseek-ai/dsh-process-local
|
||||
|
||||
Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today).
|
||||
|
||||
## Behavior (and where it came from)
|
||||
|
||||
- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
|
||||
- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
|
||||
- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Offset-based reads** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist.
|
||||
- **Kill-and-join disposal** — the manager retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported.
|
||||
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
|
||||
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.
|
||||
|
||||
The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring.
|
||||
39
packages/process/process-local/package.json
Normal file
39
packages/process/process-local/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-process-local",
|
||||
"description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-process": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
53
packages/process/process-local/src/index.ts
Normal file
53
packages/process/process-local/src/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Local-subprocess implementation of the process-manager seam. Each spawn is
|
||||
* a detached process group with bounded, spill-backed output; disposal kills
|
||||
* and joins live groups. It has no config: every limit arrives on the spec,
|
||||
* so the deployment-varying choices stay with the calling seam's config (the
|
||||
* bash executor's, today).
|
||||
* @module @deepseek-ai/dsh-process-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { ProcessManager } from '@deepseek-ai/dsh-process'
|
||||
import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
import { spawnProcess } from './spawn.ts'
|
||||
import type { SpawnInternals } from './spawn.ts'
|
||||
|
||||
/**
|
||||
* Local process manager: detached process groups, tail-keep truncation with
|
||||
* bounded spill files, credential-scrubbed environment, and group
|
||||
* SIGTERM→grace→SIGKILL escalation.
|
||||
*/
|
||||
export class LocalProcessManager extends ProcessManager {
|
||||
/** Live handles retained only so disposal can kill and join them. */
|
||||
private live = new Set<ProcessHandle>()
|
||||
/** Test seam: spill knobs forwarded to spawnProcess. */
|
||||
internals: SpawnInternals = {}
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx)
|
||||
ctx.effect(() => async () => {
|
||||
// Await closure so even a TERM-trapping child cannot outlive the fiber.
|
||||
const pending: Promise<unknown>[] = []
|
||||
for (const handle of this.live) {
|
||||
handle.kill()
|
||||
// Spawn-failure rejections already settled and left the live set.
|
||||
pending.push(handle.done.catch(() => {}))
|
||||
}
|
||||
this.live.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local process-manager teardown')
|
||||
}
|
||||
|
||||
spawn(spec: ProcessSpawnSpec): ProcessHandle {
|
||||
const handle = spawnProcess(spec, this.internals)
|
||||
this.live.add(handle)
|
||||
handle.done.then(
|
||||
() => { this.live.delete(handle) },
|
||||
() => { this.live.delete(handle) },
|
||||
)
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalProcessManager
|
||||
30
packages/process/process-local/src/invariant.ts
Normal file
30
packages/process/process-local/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-process-local`.
|
||||
* @module @deepseek-ai/dsh-process-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-process-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'process-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Process plumbing for the local bash executor: detached process-group spawn,
|
||||
* tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer
|
||||
* reacts to an abort signal; the executor owns deadlines and classifies causes.
|
||||
* @module dsh-bash-local/run
|
||||
* Process plumbing for the local process manager: detached process-group
|
||||
* spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation.
|
||||
* This layer reacts to an abort signal; callers own deadlines and classify
|
||||
* causes.
|
||||
* @module dsh-process-local/spawn
|
||||
*/
|
||||
|
||||
import { type ChildProcessByStdio, spawn } from 'node:child_process'
|
||||
@@ -11,23 +12,11 @@ import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process'
|
||||
import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
* interactive terminal features that would garble tool output (the same set
|
||||
* Codex hardcodes; Claude Code achieves it via TERM=dumb).
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Credential-shaped env vars are NOT forwarded to commands (the harness's
|
||||
* Credential-shaped env vars are NOT forwarded to children (the harness's
|
||||
* own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
|
||||
* spill files). Same default pattern as Codex's env policy; a future config
|
||||
* can whitelist specific vars when a workflow genuinely needs one.
|
||||
@@ -35,10 +24,10 @@ export const ENV_OVERRIDES = {
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/**
|
||||
* Build a child environment from scrubbed ambient values, terminal overrides,
|
||||
* ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed
|
||||
* names are removed; ordinary and managed entries reject the other channel's
|
||||
* namespace before `dshEnv` merges last.
|
||||
* Build a child environment from scrubbed ambient values, ordinary caller
|
||||
* entries, and a managed `DSH_*` snapshot. Ambient managed names are removed;
|
||||
* ordinary and managed entries reject the other channel's namespace before
|
||||
* `dshEnv` merges last.
|
||||
* @param extra - caller entries; `DSH_*` names are rejected.
|
||||
* @param dshEnv - managed entries; non-`DSH_*` names are rejected.
|
||||
* @returns the environment to hand to `spawn` for the child process.
|
||||
@@ -53,77 +42,23 @@ export function childEnv(
|
||||
}
|
||||
for (const key of Object.keys(extra ?? {})) {
|
||||
if (key.startsWith(DSH_ENV_PREFIX)) {
|
||||
throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`)
|
||||
throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`)
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(dshEnv ?? {})) {
|
||||
if (!key.startsWith(DSH_ENV_PREFIX)) {
|
||||
throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`)
|
||||
throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`)
|
||||
}
|
||||
}
|
||||
return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv }
|
||||
}
|
||||
|
||||
/** What to run and under which limits (resolved — no defaults in here). */
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
cwd: string
|
||||
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stdoutMaxBytes: number
|
||||
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stderrMaxBytes: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
graceMs: number
|
||||
/**
|
||||
* Abort signal — kills the process group when it fires. The executor owns
|
||||
* timing: `run()` passes a fused timeout/cancel deadline signal (see
|
||||
* `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
|
||||
* runBash only listens and kills; it does NOT classify why (the executor
|
||||
* reads the signal's reason afterward).
|
||||
*/
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the child's stdin, then close it. Absent (or empty)
|
||||
* leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
|
||||
* the model-facing `dsh-tool-bash` tool does not thread model input here.
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Ordinary environment entries merged after the credential scrub and
|
||||
* terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw outcome of one closed process (before result shaping). Deliberately
|
||||
* carries NO timeout/cancel classification: runBash kills on abort but does not
|
||||
* decide why — the executor's `run()`/`start()` reads the deadline signal it
|
||||
* owns to classify `timedOut`/`aborted` (see the package README).
|
||||
*/
|
||||
export interface SpawnOutcome {
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
return { ...env, ...extra, ...dshEnv }
|
||||
}
|
||||
|
||||
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
|
||||
export interface RunInternals {
|
||||
export interface SpawnInternals {
|
||||
/** Directory for spill files (defaults to the OS temp dir). */
|
||||
spillDir?: string
|
||||
}
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
|
||||
export const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
/** Default per-stream spill cap (the `maxSpillBytes` config). */
|
||||
export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
|
||||
|
||||
let spillCounter = 0
|
||||
let defaultSpillDir: string | undefined
|
||||
|
||||
@@ -133,7 +68,7 @@ let defaultSpillDir: string | undefined
|
||||
* other local users read command output or pre-create symlinks.
|
||||
*/
|
||||
function privateSpillDir(): string {
|
||||
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-'))
|
||||
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-'))
|
||||
return defaultSpillDir
|
||||
}
|
||||
|
||||
@@ -205,7 +140,7 @@ export class OutputCollector {
|
||||
// prediction and symlink planting in shared tmp dirs.
|
||||
this.spillFile = join(
|
||||
this.spillDir,
|
||||
`dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
||||
`dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
||||
)
|
||||
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
|
||||
for (const prior of this.chunks) writeSync(this.spillFd, prior)
|
||||
@@ -300,41 +235,28 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* A live bash child process: the promise resolves when the process closes;
|
||||
* `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group.
|
||||
*/
|
||||
export interface RunningBash {
|
||||
/** Process id (group leader); -1 when the spawn itself failed. */
|
||||
readonly pid: number
|
||||
/** stdout/stderr collectors (live — background polling reads incrementally). */
|
||||
readonly stdout: OutputCollector
|
||||
readonly stderr: OutputCollector
|
||||
/** Resolves when the process closes; rejects only for spawn-level failures. */
|
||||
readonly done: Promise<SpawnOutcome>
|
||||
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
|
||||
kill(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn one isolated `bash -c` process group and collect its output.
|
||||
* Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject.
|
||||
* @param spec - fully resolved command, cwd, limits, and cancellation.
|
||||
* @param internals - test-only process and spill-directory overrides.
|
||||
* Spawn one isolated detached process group and collect its output.
|
||||
* Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject.
|
||||
* @param spec - fully resolved argv, cwd, limits, and cancellation.
|
||||
* @param internals - test-only spill-directory override.
|
||||
* @returns live process handle and outcome promise.
|
||||
*/
|
||||
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
||||
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
|
||||
export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle {
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
|
||||
if (spec.signal?.aborted) {
|
||||
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
||||
}
|
||||
const [program, ...args] = spec.argv
|
||||
if (program === undefined || program.length === 0) {
|
||||
throw new Error('invalid argv: expected a non-empty program name at argv[0]')
|
||||
}
|
||||
|
||||
// Keep absent stdin as /dev/null; literal tuples preserve non-null output types.
|
||||
const env = childEnv(spec.env, spec.dshEnv)
|
||||
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
|
||||
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
|
||||
: spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
||||
|
||||
const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir)
|
||||
const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir)
|
||||
@@ -352,7 +274,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
|
||||
}
|
||||
|
||||
// The executor owns timeout classification; this layer only reacts to abort.
|
||||
// The caller owns timeout classification; this layer only reacts to abort.
|
||||
const onAbort = (): void => { kill() }
|
||||
spec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
@@ -362,7 +284,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
|
||||
child.stdin.end(spec.stdin)
|
||||
}
|
||||
|
||||
const done = new Promise<SpawnOutcome>((resolve, reject) => {
|
||||
const done = new Promise<ProcessOutcome>((resolve, reject) => {
|
||||
let settled = false
|
||||
let pipeDrainTimer: NodeJS.Timeout | undefined
|
||||
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
||||
70
packages/process/process-local/tests/manager.spec.ts
Normal file
70
packages/process/process-local/tests/manager.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
|
||||
function spec(command: string, overrides: Partial<ProcessSpawnSpec> = {}): ProcessSpawnSpec {
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
cwd: process.cwd(),
|
||||
stdoutMaxBytes: 64_000,
|
||||
stderrMaxBytes: 64_000,
|
||||
maxSpillBytes: 64 * 1024 * 1024,
|
||||
graceMs: 200,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LocalProcessManager', () => {
|
||||
it('registers as ctx.processes and spawns managed handles', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const result = await ctx.processes.spawn(spec('echo managed')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('managed\n')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposal kills still-running processes and awaits their exit', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const handle = ctx.processes.spawn(spec('sleep 60'))
|
||||
await fiber.dispose()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
})
|
||||
|
||||
it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const handle = ctx.processes.spawn(spec('true'))
|
||||
const outcome = await handle.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposal tolerates a handle whose spawn already failed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' }))
|
||||
await expect(handle.done).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposal contains a spawn-failure rejection that races teardown', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalProcessManager)
|
||||
// Dispose before the rejection continuation removes the handle from the
|
||||
// live set, so teardown itself must swallow the rejected done.
|
||||
const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' }))
|
||||
await fiber.dispose()
|
||||
await expect(handle.done).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
class SecondManager extends LocalProcessManager {}
|
||||
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
|
||||
import type { RunningBash } from '../src/run.ts'
|
||||
import type { DshEnvironment } from '@deepseek-ai/dsh-process'
|
||||
import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts'
|
||||
import type { ProcessHandle } from '@deepseek-ai/dsh-process'
|
||||
|
||||
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
||||
failNextClose: { value: false },
|
||||
@@ -31,11 +31,11 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-'))
|
||||
|
||||
function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
|
||||
function spec(command: string, overrides: Partial<Parameters<typeof spawnProcess>[0]> = {}) {
|
||||
return {
|
||||
command,
|
||||
argv: ['bash', '-c', command],
|
||||
cwd: process.cwd(),
|
||||
stdoutMaxBytes: 64_000,
|
||||
stderrMaxBytes: 64_000,
|
||||
@@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
||||
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (running.stdout.readFrom(0).text.includes(expected)) return
|
||||
@@ -82,9 +82,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number>
|
||||
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
describe('runBash', () => {
|
||||
describe('spawnProcess', () => {
|
||||
it('captures stdout on success', async () => {
|
||||
const result = await runBash(spec('echo hello')).done
|
||||
const result = await spawnProcess(spec('echo hello')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.stdout.text).toBe('hello\n')
|
||||
@@ -93,41 +93,43 @@ describe('runBash', () => {
|
||||
})
|
||||
|
||||
it('captures stderr separately', async () => {
|
||||
const result = await runBash(spec('echo oops >&2')).done
|
||||
const result = await spawnProcess(spec('echo oops >&2')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('')
|
||||
expect(result.stderr.text).toBe('oops\n')
|
||||
})
|
||||
|
||||
it('captures both streams', async () => {
|
||||
const result = await runBash(spec('echo out; echo err >&2')).done
|
||||
const result = await spawnProcess(spec('echo out; echo err >&2')).done
|
||||
expect(result.stdout.text).toBe('out\n')
|
||||
expect(result.stderr.text).toBe('err\n')
|
||||
})
|
||||
|
||||
it('reports non-zero exit codes', async () => {
|
||||
const result = await runBash(spec('exit 42')).done
|
||||
const result = await spawnProcess(spec('exit 42')).done
|
||||
expect(result.exitCode).toBe(42)
|
||||
expect(result.signal).toBeNull()
|
||||
})
|
||||
|
||||
it('applies model-friendly env overrides', async () => {
|
||||
const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done
|
||||
expect(result.stdout.text).toBe('1/dumb/cat\n')
|
||||
it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
|
||||
const result = await spawnProcess(spec('echo "${TERM:-unset}"', {
|
||||
env: { TERM: 'callers-choice' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('callers-choice\n')
|
||||
})
|
||||
|
||||
it('runs in the requested cwd', async () => {
|
||||
const result = await runBash(spec('pwd', { cwd: '/tmp' })).done
|
||||
const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done
|
||||
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
|
||||
it('kills the process group with SIGTERM when the signal fires', async () => {
|
||||
// runBash owns no timer: it kills on abort. The executor drives the timeout
|
||||
// spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout
|
||||
// by firing this signal via a deadline (see executor.spec.ts); here we
|
||||
// assert the kill itself lands as SIGTERM.
|
||||
const controller = new AbortController()
|
||||
const start = Date.now()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('deadline') }, 100)
|
||||
const result = await running.done
|
||||
expect(Date.now() - start).toBeLessThan(5_000)
|
||||
@@ -136,7 +138,7 @@ describe('runBash', () => {
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
||||
const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
||||
await waitForStdout(running, 'ready\n')
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
@@ -147,7 +149,7 @@ describe('runBash', () => {
|
||||
// The subshell writes the sleep's pid then waits on it; killing the
|
||||
// group must take the sleep down with bash.
|
||||
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
||||
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
||||
const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
||||
const grandchild = await waitForPidFile(pidFile)
|
||||
expect(grandchild).toBeGreaterThan(0)
|
||||
|
||||
@@ -159,7 +161,7 @@ describe('runBash', () => {
|
||||
|
||||
it('aborts via AbortSignal mid-run', async () => {
|
||||
const controller = new AbortController()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
@@ -168,17 +170,17 @@ describe('runBash', () => {
|
||||
it('throws when the signal is already aborted before spawn', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('too late')
|
||||
expect(() => runBash(spec('echo hi', { signal: controller.signal })))
|
||||
expect(() => spawnProcess(spec('echo hi', { signal: controller.signal })))
|
||||
.toThrow(/aborted before spawn: too late/)
|
||||
})
|
||||
|
||||
it('rejects with a spawn error for a nonexistent cwd', async () => {
|
||||
await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
||||
await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
||||
.rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('kill() is idempotent (second call does not restart escalation)', async () => {
|
||||
const running = runBash(spec('sleep 60'))
|
||||
const running = spawnProcess(spec('sleep 60'))
|
||||
running.kill()
|
||||
running.kill()
|
||||
const result = await running.done
|
||||
@@ -188,7 +190,7 @@ describe('runBash', () => {
|
||||
it('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
||||
const started = Date.now()
|
||||
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
||||
const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
||||
const descendant = await waitForPidFile(pidFile)
|
||||
try {
|
||||
const result = await running.done
|
||||
@@ -204,7 +206,7 @@ describe('runBash', () => {
|
||||
|
||||
describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
it('writes stdin to the command and closes it', async () => {
|
||||
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
|
||||
const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('hello from stdin\n')
|
||||
})
|
||||
@@ -212,7 +214,7 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
it('a command that reads stdin sees EOF when none is supplied', async () => {
|
||||
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
|
||||
// output (it does NOT block).
|
||||
const result = await runBash(spec('cat')).done
|
||||
const result = await spawnProcess(spec('cat')).done
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.text).toBe('')
|
||||
})
|
||||
@@ -220,41 +222,40 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
|
||||
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
|
||||
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
|
||||
const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done
|
||||
expect(none.stdout.text).toBe('char\n')
|
||||
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
|
||||
const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
|
||||
expect(piped.stdout.text).toBe('socket\n')
|
||||
})
|
||||
|
||||
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
|
||||
const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
||||
const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
||||
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('alpha/beta\n')
|
||||
})
|
||||
|
||||
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
|
||||
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
|
||||
it('an explicit extra env entry overrides the credential scrub', async () => {
|
||||
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
||||
// entry is still honored — the scrub only drops AMBIENT process.env creds.
|
||||
const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', {
|
||||
env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
||||
const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
|
||||
env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
||||
})).done
|
||||
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
|
||||
expect(result.stdout.text).toBe('explicit-wins\n')
|
||||
})
|
||||
|
||||
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
|
||||
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
|
||||
// The handler swallows that write error and `done` reports the child's real exit.
|
||||
const big = 'x'.repeat(1024 * 1024)
|
||||
const result = await runBash(spec('exit 7', { stdin: big })).done
|
||||
const result = await spawnProcess(spec('exit 7', { stdin: big })).done
|
||||
expect(result.exitCode).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('output truncation and spill', () => {
|
||||
it('applies stdout and stderr caps independently', async () => {
|
||||
const result = await runBash(
|
||||
const result = await spawnProcess(
|
||||
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
|
||||
stdoutMaxBytes: 500,
|
||||
stderrMaxBytes: 100,
|
||||
@@ -269,7 +270,7 @@ describe('output truncation and spill', () => {
|
||||
|
||||
it('keeps the tail and spills the full stream to disk', async () => {
|
||||
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
|
||||
const result = await runBash(
|
||||
const result = await spawnProcess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
@@ -284,7 +285,7 @@ describe('output truncation and spill', () => {
|
||||
})
|
||||
|
||||
it('does not truncate output exactly at the cap', async () => {
|
||||
const result = await runBash(
|
||||
const result = await spawnProcess(
|
||||
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
@@ -295,7 +296,7 @@ describe('output truncation and spill', () => {
|
||||
|
||||
it('settles with the tail and no spill path when final spill close fails', async () => {
|
||||
failNextClose.value = true
|
||||
const result = await runBash(
|
||||
const result = await spawnProcess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
@@ -402,12 +403,27 @@ describe('killGroup', () => {
|
||||
})
|
||||
|
||||
it('swallows ESRCH for vanished groups', async () => {
|
||||
const running = runBash(spec('true'))
|
||||
const running = spawnProcess(spec('true'))
|
||||
await running.done
|
||||
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('argv validation', () => {
|
||||
it('rejects an empty argv before spawning', () => {
|
||||
expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
|
||||
})
|
||||
|
||||
it('rejects an empty program name before spawning', () => {
|
||||
expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
|
||||
})
|
||||
|
||||
it('spawns argv verbatim without shell interpretation', async () => {
|
||||
const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done
|
||||
expect(result.stdout.text).toBe('$HOME')
|
||||
})
|
||||
})
|
||||
|
||||
describe('abort edge cases', () => {
|
||||
it('reports a fallback reason for reason-less pre-aborted signals', () => {
|
||||
// Real AbortControllers always set a DOMException reason; signal-like
|
||||
@@ -418,14 +434,14 @@ describe('abort edge cases', () => {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
expect(() => runBash(spec('echo hi', { signal: bare })))
|
||||
expect(() => spawnProcess(spec('echo hi', { signal: bare })))
|
||||
.toThrow(/aborted before spawn: aborted/)
|
||||
})
|
||||
|
||||
it('reports the terminating signal of an externally self-killed command', async () => {
|
||||
// runBash reports the raw signal; whether it counts as timeout/cancel is the
|
||||
// spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
|
||||
// executor's classification (a self-kill is neither) — see executor.spec.ts.
|
||||
const result = await runBash(spec('kill -TERM $$')).done
|
||||
const result = await spawnProcess(spec('kill -TERM $$')).done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
})
|
||||
})
|
||||
@@ -436,7 +452,7 @@ describe('environment and spill-file hardening', () => {
|
||||
process.env.DSH_TEST_TOKEN = 'also-secret'
|
||||
process.env.DSH_TEST_PLAIN = 'visible'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
||||
const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
|
||||
} finally {
|
||||
delete process.env.DSH_TEST_API_KEY
|
||||
@@ -448,7 +464,7 @@ describe('environment and spill-file hardening', () => {
|
||||
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
|
||||
process.env.DSH_STALE = 'old-value'
|
||||
try {
|
||||
const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
||||
const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
||||
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
|
||||
})).done
|
||||
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
|
||||
@@ -458,33 +474,33 @@ describe('environment and spill-file hardening', () => {
|
||||
})
|
||||
|
||||
it('rejects DSH variables on the ordinary env channel', () => {
|
||||
expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
||||
expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
||||
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
|
||||
})
|
||||
|
||||
it('rejects ordinary variables on the managed env channel', () => {
|
||||
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
|
||||
expect(() => runBash(spec('true', { dshEnv: invalid })))
|
||||
.toThrow(/managed bash env.*PATH.*use env/)
|
||||
expect(() => spawnProcess(spec('true', { dshEnv: invalid })))
|
||||
.toThrow(/managed child env.*PATH.*use env/)
|
||||
})
|
||||
|
||||
it('creates spill files with owner-only permissions and random names', async () => {
|
||||
const result = await runBash(
|
||||
const result = await spawnProcess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
).done
|
||||
const path = result.stdout.spillPath!
|
||||
expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
||||
expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
||||
const mode = statSync(path).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
it('defaults spills into a private per-process directory', async () => {
|
||||
const result = await runBash(
|
||||
const result = await spawnProcess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
).done
|
||||
const dir = dirname(result.stdout.spillPath!)
|
||||
expect(dir).toMatch(/dsh-bash-/)
|
||||
expect(dir).toMatch(/dsh-proc-/)
|
||||
const mode = statSync(dir).mode & 0o777
|
||||
expect(mode).toBe(0o700)
|
||||
})
|
||||
@@ -502,7 +518,7 @@ describe('environment and spill-file hardening', () => {
|
||||
|
||||
it('honors AbortSignal on background-style runs (no timeout)', async () => {
|
||||
const controller = new AbortController()
|
||||
const running = runBash(spec('sleep 60', { signal: controller.signal }))
|
||||
const running = spawnProcess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
24
packages/process/process-local/tsconfig.json
Normal file
24
packages/process/process-local/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../process"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
packages/process/process/README.md
Normal file
26
packages/process/process/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-process
|
||||
|
||||
The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md).
|
||||
|
||||
## Contract
|
||||
|
||||
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures.
|
||||
- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden process-manager default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself.
|
||||
- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists.
|
||||
- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification).
|
||||
- Disposal kills all still-running managed processes and awaits their exit.
|
||||
|
||||
See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract.
|
||||
- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story.
|
||||
37
packages/process/process/package.json
Normal file
37
packages/process/process/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-process",
|
||||
"description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
62
packages/process/process/src/index.ts
Normal file
62
packages/process/process/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The child-process manager seam (`ctx.processes`): spawn fully-specified
|
||||
* commands into managed process groups with bounded, spill-backed output and
|
||||
* escalated kills. Command defaulting, shell semantics, deadlines, and
|
||||
* presentation belong to consumers — the bash executor seam is the owning
|
||||
* template. The local implementation lives in
|
||||
* `@deepseek-ai/dsh-process-local`.
|
||||
* @module @deepseek-ai/dsh-process
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ProcessHandle, ProcessSpawnSpec } from './types.ts'
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
export type {
|
||||
CollectedOutput,
|
||||
DshEnvironment,
|
||||
DshEnvironmentKey,
|
||||
ProcessHandle,
|
||||
ProcessOutcome,
|
||||
ProcessOutputRead,
|
||||
ProcessOutputReader,
|
||||
ProcessSpawnSpec,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
processes: ProcessManager
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract process manager. Subclass, implement {@link spawn}, and load the
|
||||
* subclass as a plugin — it registers as `ctx.processes` (one implementation
|
||||
* per context; loading a second throws, which is cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Implementations must honor these semantics:
|
||||
* - {@link spawn} returns immediately with a live handle; `done` resolves at
|
||||
* process close and rejects only for spawn-level failures.
|
||||
* - Output readers are offset-based and non-consuming, so independent readers
|
||||
* never consume one another's output; lossy reads report truncation and the
|
||||
* spill file holding the complete stream when one exists.
|
||||
* - {@link ProcessHandle.kill} and the spec's abort signal escalate
|
||||
* SIGTERM→grace→SIGKILL across the whole process group.
|
||||
* - Disposal kills all still-running managed processes and awaits their exit.
|
||||
*/
|
||||
export abstract class ProcessManager extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'processes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one managed child process from a fully-specified spec; this seam
|
||||
* applies no defaults.
|
||||
* @param spec - argv, directory, limits, grace, cancellation, and environment.
|
||||
* @returns the live process handle (readers, kill, outcome promise).
|
||||
*/
|
||||
abstract spawn(spec: ProcessSpawnSpec): ProcessHandle
|
||||
}
|
||||
|
||||
export default ProcessManager
|
||||
22
packages/process/process/src/invariant.ts
Normal file
22
packages/process/process/src/invariant.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-process'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'process-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this stateless seam owns spawn-spec/handle types, while implementations own observations. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register the process-manager invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
128
packages/process/process/src/types.ts
Normal file
128
packages/process/process/src/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Vocabulary for the process-manager seam: fully-specified spawn requests,
|
||||
* bounded output with spill recovery, and live process handles. Command
|
||||
* defaulting, shell semantics, and presentation belong to consumers such as
|
||||
* the bash executor seam.
|
||||
* @module dsh-process/types
|
||||
*/
|
||||
|
||||
/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */
|
||||
export const DSH_ENV_PREFIX = 'DSH_' as const
|
||||
|
||||
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
|
||||
export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`
|
||||
|
||||
/** Trusted DeepSeek Harness variables for one child-process execution. */
|
||||
export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>
|
||||
|
||||
/** One captured stream: the (possibly truncated) text plus recovery info. */
|
||||
export interface CollectedOutput {
|
||||
/** Collected text — the TAIL of the stream when truncated. */
|
||||
text: string
|
||||
/** True when bytes were dropped from `text`. */
|
||||
truncated: boolean
|
||||
/** Path to a file holding the COMPLETE stream, when truncated and available. */
|
||||
spillPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-specified spawn request. This seam applies no defaults: every limit
|
||||
* and directory is explicit, so the caller's own config — not a hidden
|
||||
* process-manager default — decides them (the `dsh-bash` request/spec split
|
||||
* is the owning template).
|
||||
*/
|
||||
export interface ProcessSpawnSpec {
|
||||
/** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */
|
||||
argv: readonly string[]
|
||||
/** Working directory for the child. */
|
||||
cwd: string
|
||||
/** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stdoutMaxBytes: number
|
||||
/** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
stderrMaxBytes: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes: number
|
||||
/** Grace period for kill escalation and for inherited pipes after process exit. */
|
||||
graceMs: number
|
||||
/**
|
||||
* Abort signal — kills the process group when it fires. The caller owns
|
||||
* deadlines and cause classification; this seam only reacts to the abort.
|
||||
*/
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the child's stdin, then close it. Absent (or empty)
|
||||
* leaves stdin closed/empty.
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
* Ordinary environment entries merged after the implementation's credential
|
||||
* scrub. `DSH_*` names are rejected and belong in {@link dshEnv}.
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Harness-owned `DSH_*` variables for this execution. Implementations
|
||||
* discard ambient `DSH_*` entries before merging this snapshot, so an
|
||||
* unavailable current fact cannot inherit a stale value from the harness
|
||||
* process, and reject non-`DSH_*` names supplied through this channel.
|
||||
*/
|
||||
dshEnv?: DshEnvironment | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw outcome of one closed process. Deliberately carries NO timeout or
|
||||
* cancellation classification: the manager kills on abort but does not decide
|
||||
* why — the caller reads the signal it owns to classify causes.
|
||||
*/
|
||||
export interface ProcessOutcome {
|
||||
/** Exit code; null when the process died from a signal. */
|
||||
exitCode: number | null
|
||||
/** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/** One incremental {@link ProcessOutputReader.readFrom} read. */
|
||||
export interface ProcessOutputRead {
|
||||
/** Stream text from the requested offset (the whole retained tail when lossy). */
|
||||
text: string
|
||||
/** Whole-stream offset to resume from on the next read. */
|
||||
nextOffset: number
|
||||
/** True when the requested offset slid out of the in-memory tail window. */
|
||||
lossy: boolean
|
||||
/** Path to the full-stream spill file, when one was created and remains intact. */
|
||||
spillPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-free incremental access to one live output stream. Offsets are
|
||||
* whole-stream byte coordinates owned by the caller, so independent readers
|
||||
* cannot consume one another's output.
|
||||
*/
|
||||
export interface ProcessOutputReader {
|
||||
/**
|
||||
* Read everything captured since `fromByte`. When that offset has slid out
|
||||
* of the in-memory tail window the read is `lossy` — it returns the whole
|
||||
* retained tail and the gap is only recoverable from the spill file.
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
||||
* @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists.
|
||||
*/
|
||||
readFrom(fromByte: number): ProcessOutputRead
|
||||
}
|
||||
|
||||
/**
|
||||
* A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL
|
||||
* escalation; buffered output remains readable after exit.
|
||||
*/
|
||||
export interface ProcessHandle {
|
||||
/** Process id (group leader); -1 when the spawn itself failed. */
|
||||
readonly pid: number
|
||||
/** Live stdout reader (also readable after exit). */
|
||||
readonly stdout: ProcessOutputReader
|
||||
/** Live stderr reader (also readable after exit). */
|
||||
readonly stderr: ProcessOutputReader
|
||||
/** Resolves when the process closes; rejects only for spawn-level failures. */
|
||||
readonly done: Promise<ProcessOutcome>
|
||||
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
|
||||
kill(): void
|
||||
}
|
||||
55
packages/process/process/tests/service.spec.ts
Normal file
55
packages/process/process/tests/service.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { ProcessManager } from '@deepseek-ai/dsh-process'
|
||||
import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process'
|
||||
|
||||
/**
|
||||
* Minimal concrete manager: a hand-built handle. The seam is spawn-only —
|
||||
* defaulting, shell semantics, and deadlines belong to callers — so this stub
|
||||
* is all an implementation owes the abstract class.
|
||||
*/
|
||||
class StubProcessManager extends ProcessManager {
|
||||
spawn(spec: ProcessSpawnSpec): ProcessHandle {
|
||||
const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false }
|
||||
let killed = false
|
||||
return {
|
||||
pid: spec.argv.length,
|
||||
stdout: { readFrom: () => read },
|
||||
stderr: { readFrom: () => read },
|
||||
done: Promise.resolve({
|
||||
exitCode: killed ? null : 0,
|
||||
signal: null,
|
||||
stdout: { text: 'ok', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}),
|
||||
kill: () => { killed = true },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProcessManager seam', () => {
|
||||
it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubProcessManager)
|
||||
const handle = ctx.processes.spawn({
|
||||
argv: ['true'],
|
||||
cwd: '/stub',
|
||||
stdoutMaxBytes: 1,
|
||||
stderrMaxBytes: 1,
|
||||
maxSpillBytes: 1,
|
||||
graceMs: 1,
|
||||
})
|
||||
expect(handle.pid).toBe(1)
|
||||
expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
|
||||
handle.kill()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.stdout.text).toBe('ok')
|
||||
})
|
||||
|
||||
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubProcessManager)
|
||||
class SecondManager extends StubProcessManager {}
|
||||
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/)
|
||||
})
|
||||
})
|
||||
21
packages/process/process/tsconfig.json
Normal file
21
packages/process/process/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
|
||||
summary: 'Command execution',
|
||||
mode: 'exclusive',
|
||||
required: true,
|
||||
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }],
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'local',
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
@@ -27,6 +28,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -182,6 +182,9 @@ importers:
|
||||
'@deepseek-ai/dsh-paths':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/util/paths
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/process/process-local
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/core/session
|
||||
@@ -424,6 +427,9 @@ importers:
|
||||
'@deepseek-ai/dsh-plan-mode':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plan/plan-mode
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/process/process-local
|
||||
'@deepseek-ai/dsh-pty':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/pty/pty
|
||||
@@ -585,6 +591,9 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-process':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
@@ -604,6 +613,12 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-process':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
@@ -622,6 +637,9 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
@@ -668,6 +686,9 @@ importers:
|
||||
'@deepseek-ai/dsh-paths':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/paths
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
@@ -1980,6 +2001,9 @@ importers:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-retention':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/retention
|
||||
@@ -2200,6 +2224,9 @@ importers:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
@@ -2249,6 +2276,9 @@ importers:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
@@ -2613,6 +2643,27 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/process/process:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/process/process-local:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-process':
|
||||
specifier: workspace:^
|
||||
version: link:../process
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/pty/pty:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
@@ -3569,6 +3620,9 @@ importers:
|
||||
'@deepseek-ai/dsh-llm-deepseek':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm-deepseek
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../process/process-local
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
@@ -4582,6 +4636,12 @@ importers:
|
||||
'@deepseek-ai/dsh-plan-mode':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/plan/plan-mode
|
||||
'@deepseek-ai/dsh-process':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/process/process
|
||||
'@deepseek-ai/dsh-process-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/process/process-local
|
||||
'@deepseek-ai/dsh-repeat-tool-guard':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/guard/repeat-tool-guard
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-process": "workspace:^",
|
||||
"@deepseek-ai/dsh-process-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
# Local bash executor; $DSH_CWD wins over the process cwd.
|
||||
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -30,6 +30,8 @@ _CORDIS_YML = """\
|
||||
root: './sessions'
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
- id: processes
|
||||
name: '@deepseek-ai/dsh-process-local'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"docs/testing.md": 1100,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 660,
|
||||
"packages/README.md": 790
|
||||
"packages/README.md": 810
|
||||
}
|
||||
|
||||
@@ -64,7 +64,12 @@ export const LINK_MAP: Record<string, string> = {
|
||||
BashExecSpec: 'bash.md',
|
||||
BashProcess: 'bash.md',
|
||||
BashRunResult: 'bash.md',
|
||||
DshEnvironment: 'bash.md',
|
||||
DshEnvironment: 'process.md',
|
||||
ProcessHandle: 'process.md',
|
||||
ProcessOutcome: 'process.md',
|
||||
ProcessOutputRead: 'process.md',
|
||||
ProcessOutputReader: 'process.md',
|
||||
ProcessSpawnSpec: 'process.md',
|
||||
CodeRunRequest: 'code-runtime.md',
|
||||
CodeRunResult: 'code-runtime.md',
|
||||
CompactionResult: 'compaction.md',
|
||||
|
||||
@@ -59,6 +59,7 @@ const GROUP_ORDER = [
|
||||
'llm',
|
||||
'core',
|
||||
'goal',
|
||||
'process',
|
||||
'bash',
|
||||
'pty',
|
||||
'sandbox',
|
||||
@@ -264,6 +265,15 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
|
||||
},
|
||||
{
|
||||
key: 'processes',
|
||||
pkg: 'process',
|
||||
title: 'Process manager seam',
|
||||
mode: 'seam',
|
||||
implementations: ['process-local'],
|
||||
consumers: ['bash-local', 'bash-sandbox'],
|
||||
note: 'The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation.',
|
||||
},
|
||||
{
|
||||
key: 'bash',
|
||||
pkg: 'bash',
|
||||
|
||||
@@ -19,6 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalProcessManager from '@deepseek-ai/dsh-process-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
|
||||
@@ -197,6 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
|
||||
writes: ['tool/call', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalProcessManager)
|
||||
await ctx.plugin(LocalBashExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
},
|
||||
|
||||
@@ -729,16 +729,6 @@
|
||||
"symbol": "ApprovalRequest",
|
||||
"source": "packages/ui/user-approval/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "DshEnvironmentKey",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "DshEnvironment",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashExecRequest",
|
||||
@@ -759,11 +749,6 @@
|
||||
"symbol": "BashSandboxInfo",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "CollectedOutput",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.md",
|
||||
"symbol": "BashProcess",
|
||||
@@ -1867,16 +1852,6 @@
|
||||
"symbol": "ApprovalRequest",
|
||||
"source": "packages/ui/user-approval/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "DshEnvironmentKey",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "DshEnvironment",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashExecRequest",
|
||||
@@ -1897,11 +1872,6 @@
|
||||
"symbol": "BashSandboxInfo",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "CollectedOutput",
|
||||
"source": "packages/bash/bash/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/bash.zh.md",
|
||||
"symbol": "BashProcess",
|
||||
@@ -2191,6 +2161,31 @@
|
||||
"doc": "docs/core-data-structures/workflow.zh.md",
|
||||
"symbol": "WorkflowRun",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/process.md",
|
||||
"symbol": "ProcessSpawnSpec",
|
||||
"source": "packages/process/process/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/process.md",
|
||||
"symbol": "ProcessHandle",
|
||||
"source": "packages/process/process/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/process.md",
|
||||
"symbol": "ProcessOutputReader",
|
||||
"source": "packages/process/process/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/process.md",
|
||||
"symbol": "ProcessOutputRead",
|
||||
"source": "packages/process/process/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/process.md",
|
||||
"symbol": "ProcessOutcome",
|
||||
"source": "packages/process/process/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
|
||||
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
|
||||
'packages/process/process': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/process/process-local': { kind: 'indirect', reason: 'The manager backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"./packages/prompt/*/src/invariant.ts",
|
||||
"./packages/llm/*/src/invariant.ts",
|
||||
"./packages/bash/*/src/invariant.ts",
|
||||
"./packages/process/*/src/invariant.ts",
|
||||
"./packages/code-runtime/*/src/invariant.ts",
|
||||
"./packages/fs/*/src/invariant.ts",
|
||||
"./packages/skill/*/src/invariant.ts",
|
||||
@@ -123,6 +124,7 @@
|
||||
"./packages/llm/*/src",
|
||||
"./packages/bash/*/src",
|
||||
"./packages/pty/*/src",
|
||||
"./packages/process/*/src",
|
||||
"./packages/code-runtime/*/src",
|
||||
"./packages/fs/*/src",
|
||||
"./packages/lsp/*/src",
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
{ "path": "./packages/llm/llm-retry" },
|
||||
{ "path": "./packages/examples/agent-spine-demo" },
|
||||
{ "path": "./packages/examples/cli-demo" },
|
||||
{ "path": "./packages/process/process" },
|
||||
{ "path": "./packages/process/process-local" },
|
||||
{ "path": "./packages/bash/bash" },
|
||||
{ "path": "./packages/pty/pty" },
|
||||
{ "path": "./packages/pty/pty-local" },
|
||||
|
||||
@@ -40,7 +40,7 @@ const testIncludes = [
|
||||
// that worker threads cannot isolate reliably under aggregate gate contention.
|
||||
// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes.
|
||||
const processBoundTests = [
|
||||
'packages/bash/bash-local/tests/run.spec.ts',
|
||||
'packages/process/process-local/tests/spawn.spec.ts',
|
||||
'packages/context/time-context/tests/time-context.spec.ts',
|
||||
'packages/llm/llm-pi-ai/tests/adapter.spec.ts',
|
||||
'packages/ui/app-boot/tests/app-boot.spec.ts',
|
||||
|
||||
Reference in New Issue
Block a user