Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
398f608
feat(dsh-box): DeepSeek Harness provider backed by Upstash Box
alitariksahin Aug 25, 2026
929fa17
fix(dsh-box): review fixes from #228
alitariksahin Aug 25, 2026
f75f167
feat(dsh-box): terminal sessions, inherit output, and the 0.1.1-rc.2 …
alitariksahin Aug 25, 2026
41485cc
fix(dsh-box): terminal transport failures, teardown gating, and signa…
alitariksahin Aug 25, 2026
a97365c
fix(dsh-box): signal delivery, allocation cancellation, and timer bounds
alitariksahin Aug 25, 2026
4509178
fix(dsh-box): stdin backpressure, cancel ordering, and transport fail…
alitariksahin Aug 25, 2026
b31efb9
test(dsh-box): honour the stream error contract for failed stdin writes
alitariksahin Aug 25, 2026
a4ec844
fix(dsh-box): settle every teardown attempt before reporting a failure
alitariksahin Aug 25, 2026
c0610b6
fix(dsh-box): stop inferring signals, and close two teardown races
alitariksahin Aug 25, 2026
b2c4d8f
fix(dsh-box): map dsh-box in publish workflow, cancel stalled termina…
alitariksahin Aug 25, 2026
4e8917a
fix(dsh-box): cancel stalled process setups, drop AbortSignal.any for…
alitariksahin Aug 25, 2026
7c15e37
fix(dsh-box): release stdin on abandon, bound unread pipe and termina…
alitariksahin Aug 25, 2026
a5324ae
fix(dsh-box): keep settlement tied to session.wait(), bound inherit o…
alitariksahin Aug 25, 2026
7cf1dd8
test(dsh-box): observe the abandoned session's exit, drop a stale com…
alitariksahin Aug 25, 2026
d065397
feat(dsh-box): diagnose a session that fails on a host-side working d…
alitariksahin Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/dsh-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@upstash/dsh-box": minor
---

Add `@upstash/dsh-box`, a DeepSeek Harness provider that runs the harness's
subprocess seam inside a remote Upstash Box. The harness stays local; only the
process world moves, so `dsh-bash-local` and anything else built on
`ctx.subprocess` execute in the box without a fork.

The package ships two plugins behind one bundle: `@upstash/dsh-box` owns the box
lifecycle as `ctx.box`, and `@upstash/dsh-box/subprocess` implements the seam as
`ctx.subprocess`. `dsh plugin add @upstash/dsh-box` mounts both, disabling the
local provider first so a profile does not boot with two implementations of the
same service.

Covers `spawn`, `resolveExecutable`, bounded collect readers, tree-scoped
termination, `inherit` output, and terminal sessions on a real PTY sized at
creation. `inspectForeground()` resolves the foreground process group and
`signalForeground()` delivers to that group rather than the session leader, so
interrupting a running command leaves the shell alive. Spill files and a
filesystem adapter are not implemented yet.

Environment entries cross into the box only when a spawn asks for them
explicitly, so host ambient values never reach a remote process implicitly, and
a removal unsets the name in the child rather than blanking it.

Requires the DeepSeek Harness seam at `0.1.1-rc.2`.
2 changes: 2 additions & 0 deletions packages/dsh-box/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
examples
11 changes: 11 additions & 0 deletions packages/dsh-box/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}
82 changes: 82 additions & 0 deletions packages/dsh-box/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# @upstash/dsh-box

Run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) shell work inside a remote [Upstash Box](https://upstash.com/docs/box).

The harness keeps running locally: your agent, model calls, session state, and tools stay on your machine. Only the process world moves. Bash, and anything else built on the subprocess seam, executes in the box.

## Install

```bash
dsh plugin --profile <name> add @upstash/dsh-box
```

That installs the package and appends its bundle, which mounts two plugins:

| Plugin | `ctx` key | Role |
| ----------------------------- | ---------------- | --------------------------------------------------------------------------- |
| `@upstash/dsh-box` | `ctx.box` | Creates one box, prepares its working directory, and deletes it on disposal |
| `@upstash/dsh-box/subprocess` | `ctx.subprocess` | Implements the subprocess seam over Box live exec sessions |

Set `UPSTASH_BOX_API_KEY` in your environment, or pass `apiKey` in the profile.

Nothing above the seam is forked. `dsh-bash-local` delegates every execution-world operation to `ctx.subprocess`, so mounting the adapter is the whole integration.

## Configure

The bundle's defaults work as-is. To override, edit the rows in your profile:

```yaml
- id: box
name: "@upstash/dsh-box"
config:
cwd: /workspace/home
runtime: node

- id: subprocess-box
name: "@upstash/dsh-box/subprocess"
```

| Key | Default | Meaning |
| ------------------ | -------------------------------------------- | ------------------------------------------------------- |
| `apiKey` | `UPSTASH_BOX_API_KEY` | Account credential. It is never forwarded into the box. |
| `baseUrl` | `UPSTASH_BOX_BASE_URL`, then the SDK default | API endpoint. |
| `cwd` | `/workspace/home` | Shared remote working directory. |
| `runtime` | `node` | Box base image. |
| `requestTimeoutMs` | `600000` | Per-request HTTP timeout. Not a box lifetime. |

If you also set a sandbox policy, point its `workspaceRoot` at the same `cwd`, since that is bash's default working directory too.

## How it behaves

- **One box per profile.** The owner creates it at load and deletes it at disposal, so a box does not outlive the fiber that owns it.
- **The environment does not leak.** Only the environment entries a spawn explicitly asks for cross into the box. Your
machine's `PATH`, `HOME`, `USER`, `SSH_AUTH_SOCK`, and CI variables stay on your machine. Asking to remove a name
unsets it in the child rather than blanking it, and the box's own blocked names (`PATH`, `HOME`, `LD_PRELOAD`,
`LD_LIBRARY_PATH`, `NODE_OPTIONS`) stay under the server's control.
- **Termination is tree-scoped.** Stopping a command sends SIGTERM to the whole process tree, then SIGKILL after the grace period, so background children cannot outlive the command that started them.
- **The session owns the process.** Losing the connection stops the command rather than orphaning it in the box.
- **Terminals get a real PTY.** `spawnTerminal()` allocates one sized by `rows`/`cols` at creation, so a shell reports the
right size on its first read. Signals go to the foreground process group, so interrupting a running command leaves the
shell alive.

## Requirements

DeepSeek Harness supplies `@deepseek-ai/cordis`, `@deepseek-ai/dsh-subprocess`, and `@deepseek-ai/dsh-timeout` as peer dependencies. A dsh profile already has them.

## Limitations

- **No filesystem adapter.** Only the process world moves, so `ctx.fs` still resolves against your local machine. Use bash for anything that touches the workspace.
- **`inherit` copies rather than inherits.** A remote process has no descriptor to hand over, so its bytes are written to
the harness's own stdout/stderr. Output lands where a local `inherit` would put it, but the child cannot detect a TTY
through it.
- **Terminal stdin-wait is best effort.** `inspectForeground()` reads the foreground group from `/proc`, and proves an
input wait only when the kernel parks a member in a tty read. `inputWaiting` is therefore `false` when the substrate
offers no evidence, not only when the group is busy.
- **Collect output has no spill file.** Overflowing output keeps a bounded tail and reports truncation without a recovery path.
- **Sessions cannot be reattached.** A dropped connection ends the command, so this is the wrong tool for work that must outlive the harness.
- **`pid` is `-1` briefly.** `spawn()` returns before the session handshake finishes, so a consumer needing a positive pid immediately cannot use this provider unchanged.
- **The seam is pre-1.0.** It is pinned to `0.1.1-rc.2`; expect breaking changes as DeepSeek Harness evolves.

## License

MIT
26 changes: 26 additions & 0 deletions packages/dsh-box/cordis.patch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# The @upstash/dsh-box bundle: relocate the execution world into one Upstash Box.
#
# Applied over a profile that already carries dsh-base. The local subprocess
# provider is disabled by id first: cordis allows one implementation per service,
# so leaving it enabled would make two providers race to register ctx.subprocess
# and the profile would fail to boot.
#
# Bash and every other consumer is untouched. They resolve execution-world
# operations through ctx.subprocess, so swapping the provider is the whole
# integration.
#
# One-world note: box.cwd and any sandbox-policy workspaceRoot must name the
# same remote directory, which is also bash-local's default workdir.

- id: subprocess
name: "@deepseek-ai/dsh-subprocess-local"
disabled: true

- insert:
- id: box
name: "@upstash/dsh-box"
config:
cwd: /workspace/home

- id: subprocess-box
name: "@upstash/dsh-box/subprocess"
79 changes: 79 additions & 0 deletions packages/dsh-box/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"name": "@upstash/dsh-box",
Comment thread
alitariksahin marked this conversation as resolved.
"version": "0.0.0",
"description": "DeepSeek Harness provider that runs the subprocess seam inside a remote Upstash Box",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/upstash/box.git",
"directory": "packages/dsh-box"
},
"author": "Upstash, Inc.",
"license": "MIT",
"bugs": {
"url": "https://github.com/upstash/box/issues"
},
"homepage": "https://github.com/upstash/box/tree/main/packages/dsh-box#readme",
"keywords": [
"deepseek-harness",
"dsh",
"dsh-plugin",
"cordis",
"upstash",
"box",
"sandbox"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"publishConfig": {
"access": "public"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./subprocess": {
"types": "./dist/subprocess.d.ts",
"import": "./dist/subprocess.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"cordis.patch.yml",
"README.md"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"format": "prettier --write .",
"ci:lint": "prettier --check .",
"test:integration": "vitest run -c vitest.integration.config.ts"
},
"dependencies": {
"@upstash/box": "workspace:*",
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
"@deepseek-ai/dsh-timeout": "^0.1.1-rc.2"
Comment thread
alitariksahin marked this conversation as resolved.
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
"@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
"@types/node": "^20.10.0",
"prettier": "^3.8.1",
"typescript": "^5.3.0",
"vitest": "^4.1.2"
}
}
26 changes: 26 additions & 0 deletions packages/dsh-box/src/deferred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* A promise with its settlement functions exposed.
*
* `Promise.withResolvers` would say this in one line, but it landed in Node 22
* and this package supports Node 18, so the handle would throw on construction
* for two supported major versions.
*/
export interface Deferred<T> {
readonly promise: Promise<T>;
resolve(value: T): void;
reject(reason: unknown): void;
}

/**
* Create a deferred promise.
* @returns the promise and its settlement functions.
*/
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
Loading
Loading