Files
deepseek-harness/packages/subprocess/subprocess/src/index.ts
T
Yichen Jiang 8470ddef1d refactor(http-proxy): converge the proxy API on four functions
The package exported six functions, four of them shaped by one SDK's
transport each: a dispatcher factory, a `node:http` agent factory, a
proxy-URL lookup, and a policy accessor. Review asked whether the call
sites could converge instead of the package growing an export per SDK.

They could, and each removal took a whole shape with it:

- The OTLP exporter moves to the SDK's `fetch` delegate, retiring
  `createNodeHttpAgent`. Its Node-version floor goes too: `proxyEnv` on
  an `http.Agent` needs 22.21 or 24.5, inside the engines range, so
  telemetry was direct on 22.19, 22.20, and 24.0-24.4. The cost is
  `compression`, a Node-transport option; the plugin now refuses it,
  `keepAlive`, and `httpAgentOptions` at load instead of ignoring them.
- `web-fetch-http` builds its own address-pinning agent under an
  annotated `proxy-exempt:` exemption, retiring `createDispatcher`.
  Pinning is per-request state a process-wide dispatcher cannot hold.
- E2B reads `route.proxy`, retiring `proxyUrlFor`.

What remains is `installProxyFromEnvironment`, `proxyRouteFor`,
`proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller
can need the policy. Installation absorbs resolution and diagnostic
reporting, which no caller needed apart.

`proxyRouteFor` also closes a defect the old accessor made expressible:
`web-fetch-http` read the policy to decide whether to pin, then read it
again to build a transport, so an unmount between the two returned a
direct, unpinned agent for a URL the first read had cleared as proxied.
A route carries the answer and the transport that answer assumed.

Every egress spec now installs through `installProxyFromEnvironment`, so
no test asserts a policy object a real launch could not produce.
2026-09-01 21:15:04 +08:00

155 lines
6.9 KiB
TypeScript

/**
* Service Definition for the subprocess capability seam (`ctx.subprocess`): execution-world executable lookup,
* fully specified managed process trees with raw or
* collected stdio, and one terminal-process primitive. Command defaulting,
* shell semantics, deadlines, protocol framing, terminal readiness, and
* presentation belong to consumers. The local implementation lives in
* `@deepseek-ai/dsh-subprocess-local`.
* @module @deepseek-ai/dsh-subprocess
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy'
import { DSH_ENV_PREFIX } from './types.ts'
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export type {
CollectedOutput,
DshEnvironment,
DshEnvironmentKey,
SubprocessCollect,
SubprocessCollectedOutputs,
SubprocessHandle,
SubprocessOutcome,
SubprocessOutputMode,
SubprocessOutputRead,
SubprocessOutputReader,
SubprocessSpawnSpec,
SubprocessStdinMode,
SubprocessStdio,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
SubprocessTerminalSignal,
SubprocessTerminalSpawnSpec,
} from './types.ts'
/**
* Credential-shaped environment names are NOT forwarded to children (the
* harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
* process implicitly). One heuristic for every in-repo spawner; a
* deliberately supplied entry survives because explicit env layers merge
* after the scrub.
*/
export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
/**
* The ambient parent environment minus credential-shaped names and minus all
* `DSH_*` names — the canonical base every harness child starts from. `PATH`,
* `HOME`, locale, and proxy variables survive, so child CLIs run normally;
* harness identity never leaks implicitly (a deliberately forwarded
* credential or current `DSH_*` fact goes through the spec's explicit `env`,
* which merges after this scrub). Both scrubs match case-insensitively:
* Windows environment names are case-insensitive, so a parent `dsh_*` entry
* would otherwise survive and read back as `$env:DSH_*` in the child;
* deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners
* that cannot route through the service (node-pty backends, SDK-managed
* transports) share the one scrub definition.
*
* When a proxy is active the result also carries the resolved proxy names and the flag a child Node
* needs to honor them, so a child inherits the same routing as its parent.
* @returns a fresh environment object safe to hand to a child spawn.
*/
export function scrubbedParentEnv(): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value
}
// A child Node ignores the inherited proxy variables unless the flag this adds is set, so an MCP
// stdio server or subagent CLI would connect directly while its parent proxies. The same overlay
// restores each proxy name to what the user exported, undoing this process's own normalization —
// `undefined` removes a name the user never set.
for (const [name, value] of Object.entries(proxyEnvironmentForChild())) {
if (value === undefined) Reflect.deleteProperty(env, name)
else env[name] = value
}
return env
}
declare module '@deepseek-ai/cordis' {
interface Context {
subprocess: SubprocessRuntime
}
}
/**
* Abstract subprocess service. Subclass, implement {@link spawn}, and load the
* subclass as a plugin — it registers as `ctx.subprocess` (one implementation
* per context; loading a second throws, which is cordis' standard
* duplicate-service behavior).
*
* Implementations must honor these semantics:
* - Executable paths belong to one execution world shared with the mounted
* filesystem provider.
* - {@link spawn} returns immediately with a live handle; `done` resolves at
* process close with exit facts and rejects only for spawn-level failures.
* - Collect-mode 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. Piped
* streams are handed to the caller raw and never buffered here.
* - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates
* SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every
* platform. {@link SubprocessHandle.waitForExit} observes whole-tree
* liveness, so a consumer-owned teardown ladder can hold each tier on real
* quiescence.
* - Disposal of the service terminates all still-running managed processes
* and awaits their exit.
* - {@link spawnTerminal} owns terminal allocation, text transport,
* foreground groups, signalling, and whole-session quiescence behind one
* awaited termination method; readiness and persistent-shell policy stay
* in the PTY consumer. Its output stream ends after queued terminal output
* when the top-level process exits.
*/
export abstract class SubprocessRuntime extends Service {
constructor(ctx: Context) {
super(ctx, 'subprocess')
}
/**
* Resolve one configured executable in this provider's execution world.
* Absolute paths are verified; bare names use the provider's scrubbed PATH
* plus explicit environment overrides. Relative paths containing separators
* are rejected: the resolution base is undefined, so providers fail loud
* instead of guessing.
* @param command - absolute executable path or bare PATH name.
* @param env - explicit environment entries used for lookup.
* @param signal - aborts remote or local lookup.
* @returns a canonical executable path.
*/
abstract resolveExecutable(
command: string,
env?: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<string>
/**
* Start one managed child process from a fully-specified spec; this seam
* applies no defaults.
* @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.
* @returns the live process handle (streams/readers, signalling, outcome promise).
*/
abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle
/**
* Allocate a real terminal and start one owned process session. This is the
* only non-pipe process primitive: implementations own terminal byte I/O,
* foreground groups, signals, and complete session-tree cleanup.
* @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
* @returns the live terminal handle after allocation succeeds.
*/
abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>
}
export default SubprocessRuntime