From bfa3edd3f6b60c24243c70953c790a21d3050d24 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 21 Aug 2026 16:58:03 +0530 Subject: [PATCH] feat: add dsh createos integration --- .gitignore | 5 + CLAUDE.md | 19 +- README.md | 72 +- packages/dsh-createos/AGENTS.md | 10 + packages/dsh-createos/LICENSE | 21 + packages/dsh-createos/README.md | 97 +++ packages/dsh-createos/cordis.patch.yml | 55 ++ packages/dsh-createos/docs/how-it-works.md | 95 +++ packages/dsh-createos/package.json | 65 ++ packages/dsh-createos/scripts/link-dsh.mjs | 52 ++ packages/dsh-createos/src/createos/index.ts | 226 ++++++ .../dsh-createos/src/createos/processes.ts | 392 ++++++++++ packages/dsh-createos/src/fs/index.ts | 695 ++++++++++++++++++ packages/dsh-createos/src/index.ts | 6 + .../src/subprocess/environment.ts | 55 ++ packages/dsh-createos/src/subprocess/index.ts | 202 +++++ .../dsh-createos/src/subprocess/output.ts | 58 ++ .../dsh-createos/src/subprocess/process.ts | 279 +++++++ .../dsh-createos/src/subprocess/shared.ts | 123 ++++ .../dsh-createos/src/subprocess/terminal.ts | 220 ++++++ packages/dsh-createos/tests/createos.spec.ts | 127 ++++ .../dsh-createos/tests/filesystem.spec.ts | 91 +++ .../dsh-createos/tests/subprocess.spec.ts | 164 +++++ packages/dsh-createos/tests/terminal.spec.ts | 100 +++ packages/dsh-createos/tsconfig.json | 15 + packages/pi-extension/src/fanout.ts | 34 +- packages/pi-extension/src/tools.ts | 26 +- 27 files changed, 3268 insertions(+), 36 deletions(-) create mode 100644 packages/dsh-createos/AGENTS.md create mode 100644 packages/dsh-createos/LICENSE create mode 100644 packages/dsh-createos/README.md create mode 100644 packages/dsh-createos/cordis.patch.yml create mode 100644 packages/dsh-createos/docs/how-it-works.md create mode 100644 packages/dsh-createos/package.json create mode 100644 packages/dsh-createos/scripts/link-dsh.mjs create mode 100644 packages/dsh-createos/src/createos/index.ts create mode 100644 packages/dsh-createos/src/createos/processes.ts create mode 100644 packages/dsh-createos/src/fs/index.ts create mode 100644 packages/dsh-createos/src/index.ts create mode 100644 packages/dsh-createos/src/subprocess/environment.ts create mode 100644 packages/dsh-createos/src/subprocess/index.ts create mode 100644 packages/dsh-createos/src/subprocess/output.ts create mode 100644 packages/dsh-createos/src/subprocess/process.ts create mode 100644 packages/dsh-createos/src/subprocess/shared.ts create mode 100644 packages/dsh-createos/src/subprocess/terminal.ts create mode 100644 packages/dsh-createos/tests/createos.spec.ts create mode 100644 packages/dsh-createos/tests/filesystem.spec.ts create mode 100644 packages/dsh-createos/tests/subprocess.spec.ts create mode 100644 packages/dsh-createos/tests/terminal.spec.ts create mode 100644 packages/dsh-createos/tsconfig.json diff --git a/.gitignore b/.gitignore index 535cb13..0f160ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ +node_modules/ packages/opencode-plugin/node_modules/ +packages/dsh-createos/node_modules/ packages/opencode-plugin/bun.lock package.json +!packages/*/package.json package-lock.json .pi* .claude @@ -9,4 +12,6 @@ package-lock.json !.env.example lib/ docs +!docs/ +!packages/*/docs/ .mcp.json diff --git a/CLAUDE.md b/CLAUDE.md index 8ee1651..0cf1665 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,12 @@ Public plugin marketplace and integrations for CreateOS Sandbox. Three packages ship IDE plugins that drive the authed `createos` CLI to run ad-hoc / heavy / untrusted code in disposable CreateOS sandboxes: -| Package | IDE | Path | -| -------------------- | ----------- | ------------------------------ | -| `claude-code-plugin` | Claude Code | `packages/claude-code-plugin/` | -| `pi-extension` | Pi | `packages/pi-extension/` | -| `@createos/opencode` | OpenCode | `packages/opencode-plugin/` | +| Package | IDE / host | Path | +| -------------------------------- | ---------------- | ------------------------------ | +| `claude-code-plugin` | Claude Code | `packages/claude-code-plugin/` | +| `pi-extension` | Pi | `packages/pi-extension/` | +| `@createos/opencode` | OpenCode | `packages/opencode-plugin/` | +| `@nodeops-createos/dsh-createos` | DeepSeek Harness | `packages/dsh-createos/` | Marketplace index is the root `README.md`; each package has its own `README.md`. @@ -17,10 +18,10 @@ Marketplace index is the root `README.md`; each package has its own `README.md`. Architectural decisions live in `docs/adr/`. Read the relevant one before reworking the thing it covers. -| ADR | Decision | Status | -| ------------------------------------------ | ---------------------------------------------------------------------------------------- | -------------------------- | -| [0001](./docs/adr/0001-cos-bash-driver.md) | `cos`, a bash driver, as the plugin's execution engine — not the `createos` CLI directly | accepted, **under review** | -| [0003](./docs/adr/0003-pi-extension-prompt-surface.md) | pi-extension prompt surface: `promptGuidelines` only where they add signal | accepted | +| ADR | Decision | Status | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -------------------------- | +| [0001](./docs/adr/0001-cos-bash-driver.md) | `cos`, a bash driver, as the plugin's execution engine — not the `createos` CLI directly | accepted, **under review** | +| [0003](./docs/adr/0003-pi-extension-prompt-surface.md) | pi-extension prompt surface: `promptGuidelines` only where they add signal | accepted | ADR-0001 is load-bearing for anyone touching `scripts/cos`, the skill, or the slash commands. Two things it records that are easy to trip over: diff --git a/README.md b/README.md index 2dcc10c..1c8ce50 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ # CreateOS Integrations -**[Claude Code](https://docs.claude.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Pi](https://github.com/anthropics/pi) & [OpenCode](https://opencode.ai) plugins for disposable sandbox compute.** +**[Claude Code](https://docs.claude.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [Pi](https://github.com/anthropics/pi), [OpenCode](https://opencode.ai) & DeepSeek Harness plugins for disposable sandbox compute.** -Run code **off your machine** in disposable [CreateOS](https://createos.sh) Sandboxes — from Claude Code, Codex, Pi, or OpenCode. +Run code **off your machine** in disposable [CreateOS](https://createos.sh) Sandboxes — from Claude Code, Codex, Pi, OpenCode, or DeepSeek Harness. [![Claude Code](https://img.shields.io/badge/Claude%20Code-plugin-6E56CF)](https://docs.claude.com/en/docs/claude-code) [![Pi](https://img.shields.io/badge/Pi-extension-F97316)](https://github.com/anthropics/pi) [![Codex](https://img.shields.io/badge/Codex-plugin-10A37F)](https://github.com/openai/codex) [![OpenCode](https://img.shields.io/badge/OpenCode-plugin-0EA5E9)](https://opencode.ai) +[![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-111827)](./packages/dsh-createos) [![CreateOS](https://img.shields.io/badge/CreateOS-Sandboxes-0EA5E9)](https://createos.sh) [![Spawn](https://img.shields.io/badge/create%20to%20first%20command-~200ms-22C55E)](https://createos.sh) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](#contributing) @@ -83,16 +84,31 @@ opencode plugin @createos/opencode --global opencode ``` -The `createos` CLI **auto-installs** on first use. Sign in once with `createos login` (browser OAuth, run it in your own terminal) or `export CREATEOS_API_KEY=`; check with `cos auth`. Prefer a local checkout? See [Install](#install). +**DeepSeek Harness:** + +```bash +# 1. Install the bundle from this monorepo checkout +dsh plugin --profile web add /path/to/createos-claude-plugins/packages/dsh-createos + +# 2. Configure CreateOS sandbox credentials +export CREATEOS_SANDBOX_API_KEY='...' +export CREATEOS_SANDBOX_SHAPE='s-2vcpu-2gb' + +# 3. Start DSH Web from the workspace path the remote tools should use +dsh web +``` + +The Claude Code, Codex, Pi, and OpenCode integrations use the `createos` CLI, which **auto-installs** on first use. Sign in once with `createos login` (browser OAuth, run it in your own terminal) or `export CREATEOS_API_KEY=`; check with `cos auth`. The DeepSeek Harness integration uses `@nodeops-createos/sandbox` and `CREATEOS_SANDBOX_*` environment variables. Prefer a local checkout? See [Install](#install). ## Packages -| Package | What it does | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [**claude-code-plugin**](./packages/claude-code-plugin) | Hooks-based Claude Code plugin — offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public HTTPS expose, private-network clusters, BYO-S3 disk mounts, WireGuard VPN, and snapshot/fork — all driving the authed `createos` CLI. | -| [**pi-extension**](./packages/pi-extension) | Pi coding agent extension with all 33 `sandbox_*` tools for lifecycle, configuration, port tunnels, file sync, private networks, persistent disks, and device VPN. Built-in tools route remotely only with `--inside-createos-sandbox`. | -| [**@createos/codex**](./packages/codex-plugin) | Codex plugin — skill that teaches the `createos` CLI for sandbox lifecycle, networking, disks, and VPN. | -| [**@createos/opencode**](./packages/opencode-plugin) | OpenCode plugin with 33 sandbox tools (`sandbox_exec`, `sandbox_push`, `sandbox_pull`, networks, disks, VPN, sync) and system prompt injection for sandbox-first workflows. | +| Package | What it does | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [**claude-code-plugin**](./packages/claude-code-plugin) | Hooks-based Claude Code plugin — offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public HTTPS expose, private-network clusters, BYO-S3 disk mounts, WireGuard VPN, and snapshot/fork — all driving the authed `createos` CLI. | +| [**pi-extension**](./packages/pi-extension) | Pi coding agent extension with all 33 `sandbox_*` tools for lifecycle, configuration, port tunnels, file sync, private networks, persistent disks, and device VPN. Built-in tools route remotely only with `--inside-createos-sandbox`. | +| [**@createos/codex**](./packages/codex-plugin) | Codex plugin — skill that teaches the `createos` CLI for sandbox lifecycle, networking, disks, and VPN. | +| [**@createos/opencode**](./packages/opencode-plugin) | OpenCode plugin with 33 sandbox tools (`sandbox_exec`, `sandbox_push`, `sandbox_pull`, networks, disks, VPN, sync) and system prompt injection for sandbox-first workflows. | +| [**@nodeops-createos/dsh-createos**](./packages/dsh-createos) | DeepSeek Harness bundle that replaces `ctx.fs` and `ctx.subprocess` together, so Bash, file, LSP, and PTY consumers operate inside one CreateOS sandbox without provider-specific tool forks. | ## Claude Code — commands at a glance @@ -166,6 +182,18 @@ Full tool inventory lives in the [**Pi Extension README**](./packages/pi-extensi Full reference in [opencode-plugin/README.md](./packages/opencode-plugin/README.md). +## DeepSeek Harness — execution world + +The DSH bundle replaces the local filesystem and subprocess providers with CreateOS-backed providers over one shared sandbox. It uses the CreateOS SDK and managed-process API rather than the `createos` CLI. + +| Surface | What runs remotely | +| ---------------- | -------------------------------------------------- | +| `ctx.fs` | read, write, edit, glob, search, and atomic writes | +| `ctx.subprocess` | one-shot Bash commands and managed process waits | +| PTY terminals | persistent terminal sessions via managed PTYs | + +Full reference in [dsh-createos/README.md](./packages/dsh-createos/README.md). + ## Install **From GitHub (recommended):** @@ -183,6 +211,12 @@ git clone https://github.com/NodeOps-app/createos-claude-plugins /plugin install @createos/claude-code@createos ``` +**DeepSeek Harness from a local checkout:** + +```bash +dsh plugin --profile web add /path/to/createos-claude-plugins/packages/dsh-createos +``` + **Dev (instant, no install):** ```bash @@ -194,6 +228,7 @@ claude --plugin-dir /path/to/createos-claude-plugins/packages/claude-code-plugin - **[CreateOS](https://createos.sh) account** — the `createos` CLI auto-installs on first use. Opt out with `COS_NO_AUTOINSTALL=1`. - **Sign-in** — `createos login` in your own terminal (interactive browser OAuth; Claude can't drive a TTY prompt), or `export CREATEOS_API_KEY=` to skip the browser entirely. `cos auth` reports which is active. +- **DeepSeek Harness env:** `CREATEOS_SANDBOX_API_KEY` and `CREATEOS_SANDBOX_SHAPE`; optional `CREATEOS_SANDBOX_BASE_URL` and `CREATEOS_SANDBOX_ROOTFS`. - **Host tools:** `jq`, `tar`, `bash`, `base64`; `perl` for ANSI/path handling; `curl` for the one-time CLI install. ## Safety @@ -226,11 +261,17 @@ createos-claude-plugins/ # marketplace root │ │ ├─ scripts/cos, session-start.sh │ │ ├─ skills/using-createos-sandbox/ │ │ └─ README.md -│ └─ opencode-plugin/ # OpenCode plugin -│ ├─ index.ts # plugin entry (CreateOSPlugin) -│ ├─ src/cli.ts # createos CLI wrappers -│ ├─ src/tools.ts # 33 tool definitions -│ ├─ src/util.ts # shellQuote, shortId, joinPath +│ ├─ opencode-plugin/ # OpenCode plugin +│ │ ├─ index.ts # plugin entry (CreateOSPlugin) +│ │ ├─ src/cli.ts # createos CLI wrappers +│ │ ├─ src/tools.ts # 33 tool definitions +│ │ ├─ src/util.ts # shellQuote, shortId, joinPath +│ │ └─ README.md +│ └─ dsh-createos/ # DeepSeek Harness plugin +│ ├─ cordis.patch.yml # DSH bundle patch +│ ├─ src/createos/ # sandbox owner + managed-process client +│ ├─ src/fs/ # CreateOS-backed ctx.fs provider +│ ├─ src/subprocess/ # CreateOS-backed ctx.subprocess + PTY provider │ └─ README.md ├─ apps/ # (future starter templates) ├─ docs/ @@ -240,7 +281,7 @@ createos-claude-plugins/ # marketplace root ## Contributing -Issues and PRs welcome. All three plugins are thin surfaces over the [`createos`](https://createos.sh) CLI — keep the command surfaces aligned. +Issues and PRs welcome. The Claude Code, Codex, Pi, and OpenCode plugins are thin surfaces over the [`createos`](https://createos.sh) CLI; keep those command surfaces aligned. The DeepSeek Harness bundle uses the CreateOS SDK and managed-process API, so keep it aligned with the SDK and control-plane API. ## Links @@ -251,3 +292,4 @@ Issues and PRs welcome. All three plugins are thin surfaces over the [`createos` - [Pi extension README](./packages/pi-extension/README.md) - [Codex plugin README](./packages/codex-plugin/README.md) - [OpenCode plugin README](./packages/opencode-plugin/README.md) +- [DeepSeek Harness plugin README](./packages/dsh-createos/README.md) diff --git a/packages/dsh-createos/AGENTS.md b/packages/dsh-createos/AGENTS.md new file mode 100644 index 0000000..9a0f69e --- /dev/null +++ b/packages/dsh-createos/AGENTS.md @@ -0,0 +1,10 @@ +# AGENTS.md + +This repository provides one independently installable DeepSeek Harness execution-world bundle for CreateOS. + +- Keep `ctx.fs` and `ctx.subprocess` backed by the same `ctx.createos` sandbox owner. +- Treat managed-process ids as opaque branded values and validate wire responses. +- Disable retries for allocation, input, resize, signal, stdin-close, and other ambiguous writes. +- Preserve bounded reads, atomic sibling-file writes, complete process-tree termination, and teardown ownership. +- Update `README.md` and `cordis.patch.yml` when configuration or runtime requirements change. +- Run `DSH_REPO=/path/to/deepseek-harness npm run check` before committing. diff --git a/packages/dsh-createos/LICENSE b/packages/dsh-createos/LICENSE new file mode 100644 index 0000000..c1f7a78 --- /dev/null +++ b/packages/dsh-createos/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/dsh-createos/README.md b/packages/dsh-createos/README.md new file mode 100644 index 0000000..a6066f5 --- /dev/null +++ b/packages/dsh-createos/README.md @@ -0,0 +1,97 @@ +# dsh-createos + +Run the DeepSeek Harness execution world in one CreateOS sandbox. The plugin provides both `ctx.fs` and `ctx.subprocess`, so existing Bash, file, LSP, and PTY consumers operate remotely without provider-specific tool forks. + +## Requirements + +- Node.js `^22.19` or `>=24` +- A CreateOS API key and compute shape +- A CreateOS image with GNU `base64`, `find`, `realpath`, and `stat`, plus `/bin/sh` +- Managed-process endpoints enabled on the CreateOS control plane and guest agent + +Set the required environment variables before starting DSH: + +```sh +export CREATEOS_SANDBOX_API_KEY='...' +export CREATEOS_SANDBOX_SHAPE='...' +# Optional: +export CREATEOS_SANDBOX_BASE_URL='https://your-createos-control-plane' +export CREATEOS_SANDBOX_ROOTFS='...' +``` + +## Quick start: Web + +Install the bundle from this monorepo checkout into the Web profile: + +```sh +dsh plugin --profile web add /path/to/createos-claude-plugins/packages/dsh-createos +``` + +Configure a DSH model provider separately, set the CreateOS variables above, and start Web from the workspace path the remote tools should use: + +```sh +cd /path/to/workspace +dsh web +``` + +The plugin creates one sandbox automatically during Web startup and creates the launch directory at the same absolute path inside it. Open `http://127.0.0.1:3080`, create a session for that workspace, and ask the agent to use Bash to run `hostname` and `uname -a`. A successful smoke test reports a Linux sandbox hostname rather than the Host machine's hostname. + +The matching path starts empty: the plugin does not currently copy the Host workspace into the sandbox. Create or synchronize remote workspace contents before asking the agent to operate on an existing project. Selecting a different Web workspace also requires that absolute path to exist inside the sandbox. + +Stop Web with `Ctrl+C`. Plugin teardown destroys the shared sandbox. + +## Install + +Install directly from this monorepo while developing: + +```sh +dsh plugin --profile headless add /path/to/createos-claude-plugins/packages/dsh-createos +``` + +After publication, install the package by registry name: + +```sh +dsh plugin --profile headless add @nodeops-createos/dsh-createos +``` + +The package declares a `dsh.bundle` patch that replaces the local filesystem and subprocess providers together. The package can be published as `@nodeops-createos/dsh-createos` for registry installation. + +See [How it works](docs/how-it-works.md) for the Web-to-sandbox request flow, service replacement, lifecycle, and API mapping. + +## CreateOS API usage + +Sandbox creation, destruction, file transfer, and one-shot commands use `@nodeops-createos/sandbox`. Persistent processes and terminals use these managed-process routes: + +```text +POST /v1/sandboxes/:id/processes +GET /v1/sandboxes/:id/processes +GET /v1/sandboxes/:id/processes/:process_id +GET /v1/sandboxes/:id/processes/:process_id/connect +POST /v1/sandboxes/:id/processes/:process_id/input +POST /v1/sandboxes/:id/processes/:process_id/stdin/close +POST /v1/sandboxes/:id/processes/:process_id/resize +POST /v1/sandboxes/:id/processes/:process_id/signal +GET /v1/sandboxes/:id/processes/:process_id/wait +DELETE /v1/sandboxes/:id/processes/:process_id +``` + +## Development + +```sh +npm install +DSH_REPO=/path/to/deepseek-harness npm run check +``` + +The DSH packages are host peer dependencies. Until the complete DSH development dependency graph is published, `npm run check` links those peers from `DSH_REPO`; by default it looks for a sibling `deepseek-harness` checkout in both the historical standalone layout and this monorepo layout. The unit tests use fake CreateOS clients and do not require credentials. A live smoke test still requires a deployed control plane containing the managed-process API. + +## Limitations + +- The bundle disables host sandbox and permission-preset providers because they cannot constrain a remote kernel. The CreateOS sandbox is the isolation boundary. +- CreateOS retains a bounded managed-process output journal. Output that ages out before the harness consumes it cannot be reconstructed. +- Filesystem atomic writes require GNU userland behavior and a single filesystem for each destination and its temporary sibling. +- The provider does not expose CreateOS S3 mounts through a separate DSH filesystem namespace. +- Sandbox lifetime defaults to five minutes and destroys the shared execution world when it expires or the plugin unloads. + +## License + +MIT. This project includes code initially developed against DeepSeek Harness under its MIT license. diff --git a/packages/dsh-createos/cordis.patch.yml b/packages/dsh-createos/cordis.patch.yml new file mode 100644 index 0000000..17b9210 --- /dev/null +++ b/packages/dsh-createos/cordis.patch.yml @@ -0,0 +1,55 @@ +# Replace the local execution world with one short-lived CreateOS sandbox. +# The filesystem and subprocess seams move together so Bash, PTY terminals, +# LSP servers, and file tools all observe the same remote working directory. + +- id: fs-sandbox + disabled: true + +- id: subprocess + disabled: true + +# Host-kernel confinement cannot constrain processes running in CreateOS. +- id: sandbox + disabled: true + +- id: bash-sandbox + disabled: true + +- id: pwsh-sandbox + disabled: true + +- id: permission + disabled: true + +- id: sandbox-policy + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- insert: + - id: createos + name: "@nodeops-createos/dsh-createos/createos" + config: + baseUrl: !!js process.env.CREATEOS_SANDBOX_BASE_URL + shape: !!js process.env.CREATEOS_SANDBOX_SHAPE + rootfs: !!js process.env.CREATEOS_SANDBOX_ROOTFS + cwd: !!js process.cwd() + lifetimeMs: 300000 + + - id: fs-createos + name: "@nodeops-createos/dsh-createos/fs" + + - id: subprocess-createos + name: "@nodeops-createos/dsh-createos/subprocess" + + - id: bash-local + name: "@deepseek-ai/dsh-bash-local" + + - id: terminal + name: "@deepseek-ai/dsh-terminal" + + - id: terminal-bash + name: "@deepseek-ai/dsh-terminal-bash" + + - id: tool-terminal + name: "@deepseek-ai/dsh-tool-terminal" diff --git a/packages/dsh-createos/docs/how-it-works.md b/packages/dsh-createos/docs/how-it-works.md new file mode 100644 index 0000000..c455cd3 --- /dev/null +++ b/packages/dsh-createos/docs/how-it-works.md @@ -0,0 +1,95 @@ +# How dsh-createos works + +`dsh-createos` replaces the DeepSeek Harness filesystem and subprocess providers with implementations backed by one CreateOS sandbox. The browser never calls CreateOS directly and never receives the sandbox API key. + +See the README [Web quick start](../README.md#quick-start-web) for installation, launch, smoke-test, and shutdown commands. + +## Composition + +A DSH profile is an ordered Cordis plugin composition. Installing this package adds its [`cordis.patch.yml`](../cordis.patch.yml) as a bundle layer. That layer disables the local filesystem, subprocess, host-sandbox, and permission providers, then mounts: + +- `createos`, which owns one shared sandbox; +- `fs-createos`, which provides `ctx.fs`; +- `subprocess-createos`, which provides `ctx.subprocess`; +- the existing Bash and terminal consumers over those providers. + +Harness consumers depend on `ctx.fs` and `ctx.subprocess`, not concrete providers. Replacing those two services therefore moves Bash, file tools, LSP processes, and PTY terminals into the same remote execution environment without adding CreateOS-specific model tools. + +## Web request flow + +The browser communicates only with the DSH Web host: + +```text +Browser + -> Harness RPC and session events + -> agent loop + -> LLM + -> structured tool call + -> Harness tool consumer + -> ctx.fs or ctx.subprocess + -> dsh-createos provider + -> CreateOS control plane + -> sandbox guest agent +``` + +For example, a `hostname` request follows this path: + +1. The browser submits the user message to the Web host. +2. The agent loop records the message and sends the assembled prompt and tool schemas to the selected LLM. +3. The LLM returns a structured `bash` tool call. +4. The Bash consumer constructs a subprocess request and calls `ctx.subprocess.spawn()`. +5. `CreateOSSubprocessRuntime` allocates the process through `POST /v1/sandboxes/:id/processes`, consumes its output stream, waits for completion, and returns stdout and stderr. +6. Harness records the tool result in the session log, runs the next model step when needed, and projects the events back to the browser. + +The sandbox API key stays in the Host process environment throughout this flow. + +## Sandbox lifecycle + +`CreateOSRuntime` creates one sandbox when its Cordis entry activates. It initializes the configured working directory and a private `.dsh-createos` runtime directory, then shares the sandbox handle and managed-process client with the filesystem and subprocess providers. + +The runtime destroys the sandbox when the plugin unloads or `lifetimeMs` expires. One mounted runtime currently means one sandbox shared by every DSH session served by that Web process. + +## Filesystem operations + +The standard Harness read, write, edit, glob, and search tools call `ctx.fs`. `CreateOSFileSystem` implements that interface using the CreateOS file API and bounded sandbox commands. It preserves Harness behavior such as canonical path resolution, UTF-8 validation, bounded reads, version checks, and atomic sibling-file replacement. + +The filesystem and subprocess providers share the same `ctx.createos` owner, so a file written through a file tool is immediately visible to Bash and terminal processes. + +## Processes and terminals + +One-shot Bash execution calls `ctx.subprocess.spawn()`. Persistent terminal execution follows this path: + +```text +terminal_open + -> ctx.terminals.spawn() + -> dsh-terminal-bash + -> ctx.subprocess.spawnTerminal() + -> CreateOS managed PTY +``` + +Managed operations map to these routes: + +```text +create process or PTY POST /v1/sandboxes/:id/processes +list resources GET /v1/sandboxes/:id/processes +inspect resource GET /v1/sandboxes/:id/processes/:process_id +stream output GET /v1/sandboxes/:id/processes/:process_id/connect +send input POST /v1/sandboxes/:id/processes/:process_id/input +close stdin POST /v1/sandboxes/:id/processes/:process_id/stdin/close +resize PTY POST /v1/sandboxes/:id/processes/:process_id/resize +send signal POST /v1/sandboxes/:id/processes/:process_id/signal +wait for exit GET /v1/sandboxes/:id/processes/:process_id/wait +terminate process tree DELETE /v1/sandboxes/:id/processes/:process_id +``` + +Managed-process identifiers are treated as opaque values. Ambiguous mutating requests are not retried automatically. + +## Workspace provisioning + +The runtime currently creates only its configured `cwd`. A Web session can select a different absolute workspace path, but that path may not exist inside the sandbox. A subprocess started with a missing `cwd` then fails during process creation. + +Deployments must currently create or synchronize the selected workspace inside the sandbox before executing tools. Automatic per-session workspace provisioning and host-to-sandbox synchronization remain integration work; the plugin must not imply that a host directory is already present remotely merely because both paths use the same spelling. + +## Security model + +The bundle disables the Host kernel sandbox and permission-preset provider because they cannot constrain a remote kernel. CreateOS is the execution isolation boundary. The Browser has no direct control-plane credentials, and all sandbox operations pass through the trusted DSH Host and the plugin's validated service interfaces. diff --git a/packages/dsh-createos/package.json b/packages/dsh-createos/package.json new file mode 100644 index 0000000..4d7a9f3 --- /dev/null +++ b/packages/dsh-createos/package.json @@ -0,0 +1,65 @@ +{ + "name": "@nodeops-createos/dsh-createos", + "version": "0.1.0", + "description": "Run the DeepSeek Harness filesystem, subprocesses, and PTY terminals in a CreateOS sandbox", + "type": "module", + "license": "MIT", + "engines": { + "node": "^22.19.0 || >=24.0.0" + }, + "scripts": { + "link:dsh": "node scripts/link-dsh.mjs", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "check": "npm run link:dsh && npm run typecheck && npm test" + }, + "keywords": [ + "dsh-plugin", + "deepseek-harness", + "createos", + "sandbox", + "remote-execution", + "pty" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/NodeOps-app/createos-claude-plugins.git", + "directory": "packages/dsh-createos" + }, + "homepage": "https://github.com/NodeOps-app/createos-claude-plugins/tree/main/packages/dsh-createos#readme", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "exports": { + ".": "./src/index.ts", + "./createos": "./src/createos/index.ts", + "./fs": "./src/fs/index.ts", + "./subprocess": "./src/subprocess/index.ts", + "./package.json": "./package.json" + }, + "files": [ + "src", + "cordis.patch.yml", + "docs", + "README.md", + "LICENSE" + ], + "dependencies": { + "@deepseek-ai/schemastery": "3.18.1", + "@nodeops-createos/sandbox": "0.8.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-brand": "*", + "@deepseek-ai/dsh-fs": "*", + "@deepseek-ai/dsh-subprocess": "*", + "@deepseek-ai/dsh-timeout": "*" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + } +} diff --git a/packages/dsh-createos/scripts/link-dsh.mjs b/packages/dsh-createos/scripts/link-dsh.mjs new file mode 100644 index 0000000..44aec33 --- /dev/null +++ b/packages/dsh-createos/scripts/link-dsh.mjs @@ -0,0 +1,52 @@ +import { mkdir, realpath, symlink } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; + +const pluginRoot = resolve(import.meta.dirname, ".."); +const dshRoot = await findDshRoot(); +const packageNames = [ + "@deepseek-ai/cordis", + "@deepseek-ai/dsh-brand", + "@deepseek-ai/dsh-fs", + "@deepseek-ai/dsh-subprocess", + "@deepseek-ai/dsh-timeout", +]; + +for (const packageName of packageNames) { + const source = await firstRealPath([ + resolve(dshRoot, "node_modules", packageName), + resolve(dshRoot, "node_modules", ".pnpm", "node_modules", packageName), + ]); + if (source === undefined) { + throw new Error( + `link-dsh: ${packageName} is unavailable under ${dshRoot}; set DSH_REPO to a built Harness checkout`, + ); + } + const destination = resolve(pluginRoot, "node_modules", packageName); + await mkdir(dirname(destination), { recursive: true }); + await symlink(source, destination, "dir").catch((error) => { + if (error?.code !== "EEXIST") throw error; + }); + console.log(`link-dsh: ${basename(destination)} -> ${source}`); +} + +async function firstRealPath(candidates) { + for (const candidate of candidates) { + const path = await realpath(candidate).catch(() => undefined); + if (path !== undefined) return path; + } + return undefined; +} + +async function findDshRoot() { + if (process.env.DSH_REPO) return resolve(process.env.DSH_REPO); + + const root = await firstRealPath([ + // Historical standalone checkout: /src/dsh-createos next to /src/deepseek-harness. + resolve(pluginRoot, "..", "deepseek-harness"), + // Monorepo checkout: /src/createos-plugins/packages/dsh-createos. + resolve(pluginRoot, "..", "..", "..", "deepseek-harness"), + ]); + if (root !== undefined) return root; + + return resolve(pluginRoot, "..", "..", "..", "deepseek-harness"); +} diff --git a/packages/dsh-createos/src/createos/index.ts b/packages/dsh-createos/src/createos/index.ts new file mode 100644 index 0000000..8d86591 --- /dev/null +++ b/packages/dsh-createos/src/createos/index.ts @@ -0,0 +1,226 @@ +/** + * Shared ownership of one CreateOS sandbox and its managed-process client. + * Filesystem and subprocess providers await the same remote Linux execution world. + * @module @nodeops-createos/dsh-createos/createos + */ + +import { posix } from "node:path"; +import { Context, Service } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import { + createClient, + CreateosSandboxNotFoundError, + type CreateosSandboxClient, +} from "@nodeops-createos/sandbox"; +import type { Sandbox as CreateOSSandbox } from "@nodeops-createos/sandbox"; +import { CreateOSProcesses } from "./processes.ts"; + +export * from "./processes.ts"; +export { + CreateosSandboxError, + CreateosSandboxNotFoundError, + Sandbox as CreateOSSandbox, +} from "@nodeops-createos/sandbox"; +export type { CreateosSandboxClient } from "@nodeops-createos/sandbox"; + +/** Configuration for the shared CreateOS sandbox owner. */ +export interface Config { + /** API key; omission reads `CREATEOS_SANDBOX_API_KEY`. */ + apiKey?: string; + /** Control-plane URL; omission reads the SDK environment/default. */ + baseUrl?: string; + /** Required CreateOS compute shape. */ + shape: string; + /** Optional rootfs catalog or template name. */ + rootfs?: string; + /** Shared remote working directory. */ + cwd?: string; + /** Sandbox lifetime before owner-initiated destruction. */ + lifetimeMs?: number; +} + +interface ResolvedConfig { + apiKey: string; + baseUrl?: string; + shape: string; + rootfs?: string; + cwd: string; + lifetimeMs: number; +} + +interface SchemaResolvedConfig extends Config { + cwd: string; + lifetimeMs: number; +} + +const MAX_LIFETIME_MS = 2_147_483_647; + +declare module "@deepseek-ai/cordis" { + interface Context { + createos: CreateOSRuntime; + } +} + +/** Creates, shares, and destroys one CreateOS sandbox. */ +export class CreateOSRuntime extends Service { + static Config: z = z.object({ + apiKey: z.string(), + baseUrl: z.string(), + shape: z.string().required(), + rootfs: z.string(), + cwd: z.string().default("/root/workspace"), + lifetimeMs: z.number().default(300_000), + }); + + /** Validated working directory shared by both execution-world adapters. */ + readonly cwd: string; + /** Remote directory reserved for provider-owned state. */ + readonly runtimeRoot: string; + + private readonly config: ResolvedConfig; + private readonly client: CreateosSandboxClient; + private readonly ready: Promise; + private sandboxProcesses: CreateOSProcesses | undefined; + private disposed = false; + private lifetimeTimer: NodeJS.Timeout | undefined; + + constructor(ctx: Context, config: Config) { + super(ctx, "createos"); + const resolved = config as SchemaResolvedConfig; + this.config = { + apiKey: config.apiKey ?? process.env.CREATEOS_SANDBOX_API_KEY ?? "", + ...(config.baseUrl === undefined ? {} : { baseUrl: config.baseUrl }), + shape: config.shape, + ...(config.rootfs === undefined ? {} : { rootfs: config.rootfs }), + cwd: resolved.cwd, + lifetimeMs: resolved.lifetimeMs, + }; + this.validate(); + this.cwd = this.config.cwd; + this.runtimeRoot = posix.join(this.cwd, ".dsh-createos"); + this.client = createClient({ + apiKey: this.config.apiKey, + ...(this.config.baseUrl === undefined ? {} : { baseUrl: this.config.baseUrl }), + retry: false, + }); + this.ready = this.open(); + void this.ready.catch(() => {}); + + ctx.effect( + () => async () => { + this.disposed = true; + if (this.lifetimeTimer !== undefined) clearTimeout(this.lifetimeTimer); + let sandbox: CreateOSSandbox; + try { + sandbox = await this.ready; + } catch { + // open() owns its one acquired-sandbox rollback. + return; + } + await this.destroy(sandbox); + }, + "CreateOS sandbox teardown", + ); + } + + /** + * Return the shared live sandbox handle. + * @returns the sandbox after remote setup completes. + */ + async getSandbox(): Promise { + this.assertAvailable(); + const sandbox = await this.ready; + this.assertAvailable(); + return sandbox; + } + + /** + * Return managed-process access for the shared sandbox. + * @returns the identity-bound client after remote setup completes. + */ + async getProcesses(): Promise { + await this.getSandbox(); + return this.sandboxProcesses as CreateOSProcesses; + } + + /** + * Return the SDK client used to create the shared sandbox. + * @returns the configured CreateOS client. + */ + getClient(): CreateosSandboxClient { + return this.client; + } + + private assertAvailable(): void { + if (this.disposed) throw new Error("dsh-createos: sandbox service is disposing"); + } + + private validate(): void { + if (this.config.apiKey.length === 0) { + throw new Error("dsh-createos: configure apiKey or set CREATEOS_SANDBOX_API_KEY"); + } + if (this.config.shape.trim().length === 0) + throw new Error("dsh-createos: shape must be non-empty"); + if (!posix.isAbsolute(this.config.cwd)) { + throw new Error(`dsh-createos: cwd must be an absolute Linux path: ${this.config.cwd}`); + } + if ( + !Number.isFinite(this.config.lifetimeMs) || + this.config.lifetimeMs <= 0 || + this.config.lifetimeMs > MAX_LIFETIME_MS + ) { + throw new Error(`dsh-createos: lifetimeMs must be between 1 and ${MAX_LIFETIME_MS}`); + } + } + + private async open(): Promise { + const sandbox = await this.client.createSandbox( + { + shape: this.config.shape, + ...(this.config.rootfs === undefined ? {} : { rootfs: this.config.rootfs }), + }, + { retry: false }, + ); + this.sandboxProcesses = new CreateOSProcesses(this.client.http, sandbox.id); + try { + const setup = await sandbox.runCommand( + "/bin/mkdir", + ["-p", "--", this.cwd, this.runtimeRoot], + { retry: false }, + ); + if (setup.result.exit_code !== 0) throw new Error(setup.result.stderr || "mkdir failed"); + const inspect = await sandbox.runCommand( + "/usr/bin/stat", + ["--format=%F", "--", this.runtimeRoot], + { retry: false }, + ); + if (inspect.result.exit_code !== 0 || inspect.result.stdout.trim() !== "directory") { + throw new Error("dsh-createos: reserved runtime path must be a real directory"); + } + const chmod = await sandbox.runCommand("/bin/chmod", ["700", "--", this.runtimeRoot], { + retry: false, + }); + if (chmod.result.exit_code !== 0) throw new Error(chmod.result.stderr || "chmod failed"); + this.lifetimeTimer = setTimeout(() => { + this.disposed = true; + void this.destroy(sandbox).catch(() => {}); + }, this.config.lifetimeMs); + this.lifetimeTimer.unref(); + return sandbox; + } catch (error: unknown) { + await this.destroy(sandbox).catch(() => {}); + throw error; + } + } + + private async destroy(sandbox: CreateOSSandbox): Promise { + try { + await sandbox.destroy({ retry: false }); + if (sandbox.status !== "destroyed") await sandbox.waitUntilDestroyed({ timeoutMs: 120_000 }); + } catch (error: unknown) { + if (!(error instanceof CreateosSandboxNotFoundError)) throw error; + } + } +} + +export default CreateOSRuntime; diff --git a/packages/dsh-createos/src/createos/processes.ts b/packages/dsh-createos/src/createos/processes.ts new file mode 100644 index 0000000..3d60a12 --- /dev/null +++ b/packages/dsh-createos/src/createos/processes.ts @@ -0,0 +1,392 @@ +/** Typed access to CreateOS managed process and PTY resources. */ + +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { CreateosSandboxHttp } from "@nodeops-createos/sandbox"; + +const MAX_INPUT_BYTES = 256 * 1024; +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +/** Opaque managed-process resource id minted by the CreateOS guest agent. */ +export type CreateOSProcessId = Branded<"CreateOSProcessId">; + +/** + * Validate and brand one CreateOS managed-process id. + * @param value - untrusted resource id from the control plane. + * @returns the validated opaque id. + */ +export function CreateOSProcessId(value: string): CreateOSProcessId { + if (!/^proc_[A-Za-z0-9_-]{16,128}$/u.test(value)) { + throw new Error(`dsh-createos: invalid managed process id: ${JSON.stringify(value)}`); + } + return value as CreateOSProcessId; +} + +/** PTY dimensions supplied during managed-process creation. */ +export interface CreateOSPTYOptions { + /** Initial terminal column count. */ + cols: number; + /** Initial terminal row count. */ + rows: number; +} + +/** Exact process creation request accepted by CreateOS. */ +export interface CreateOSProcessSpec { + /** Executable path or name. */ + cmd: string; + /** Exact arguments, excluding argv[0]. */ + args?: readonly string[]; + /** Absolute working directory. */ + cwd?: string; + /** Process-local environment overrides accepted by the sandbox policy. */ + env?: Readonly>; + /** PTY allocation options; omission creates an ordinary pipe process. */ + pty?: CreateOSPTYOptions; +} + +/** Retained output bounds reported by the guest process manager. */ +export interface CreateOSOutputSummary { + /** Oldest retained output sequence. */ + oldest_seq: number; + /** Newest retained output sequence. */ + newest_seq: number; + /** Retained encoded payload bytes. */ + bytes: number; +} + +/** Immutable CreateOS view of one managed process. */ +export interface CreateOSProcessDetails { + /** Opaque resource id. */ + process_id: CreateOSProcessId; + /** Pipe process or PTY-backed process. */ + kind: "process" | "pty"; + /** Guest leader pid. */ + pid: number; + /** Guest lifecycle state. */ + state: "starting" | "running" | "terminating" | "exited" | "failed" | "unknown"; + /** Whether the top-level process exited. */ + leader_exited: boolean; + /** Whether the resource cgroup is empty. */ + tree_exited: boolean; + /** RFC 3339 creation timestamp. */ + created_at: string; + /** RFC 3339 leader completion timestamp. */ + finished_at: string | null; + /** Numeric exit code, or null for signal termination. */ + exit_code: number | null; + /** Signal reported by the guest, when signal-terminated. */ + signal?: string; + /** Retained output journal bounds. */ + output: CreateOSOutputSummary; +} + +/** One frame from a CreateOS process output connection. */ +export type CreateOSProcessEvent = + | { type: "data"; seq: number; stream: "stdout" | "stderr" | "pty"; data_base64: string } + | { type: "exit"; exit_code?: number; signal?: string } + | { type: "heartbeat" } + | { type: "error"; error: string; oldest_available_seq?: number }; + +interface InputResponse { + input_seq: number; +} + +/** Authenticated client for the managed-process endpoints of one sandbox. */ +export class CreateOSProcesses { + constructor( + private readonly http: CreateosSandboxHttp, + private readonly sandboxId: string, + ) {} + + /** + * Create one pipe or PTY-backed process without retrying an ambiguous POST. + * @param spec - exact managed-process allocation request. + * @param signal - optional allocation cancellation. + * @returns the validated allocated resource. + */ + async create(spec: CreateOSProcessSpec, signal?: AbortSignal): Promise { + const details = await this.http.request("POST", this.path(), { + body: spec, + retry: false, + signal, + }); + return validateDetails(details); + } + + /** + * List retained process resources. + * @param signal - optional request cancellation. + * @returns every validated resource retained by the sandbox agent. + */ + async list(signal?: AbortSignal): Promise { + const response = await this.http.request("GET", this.path(), { signal }); + if (!isRecord(response) || !Array.isArray(response.processes)) { + throw new Error("dsh-createos: managed process list carried invalid data"); + } + return response.processes.map(validateDetails); + } + + /** + * Inspect one retained process resource. + * @param id - opaque resource identity. + * @param signal - optional request cancellation. + * @returns the validated current resource state. + */ + async inspect(id: CreateOSProcessId, signal?: AbortSignal): Promise { + return validateDetails( + await this.http.request("GET", this.path(`/${id}`), { signal }), + ); + } + + /** + * Stream retained and live binary-safe output after one sequence. + * @param id - opaque resource identity. + * @param after - last consumed output sequence. + * @param signal - optional stream cancellation. + * @returns validated retained and live process events. + */ + async *connect( + id: CreateOSProcessId, + after: number, + signal?: AbortSignal, + ): AsyncGenerator { + const events = this.http.stream("GET", this.path(`/${id}/connect`), { + query: { after }, + signal, + timeoutMs: 0, + }); + for await (const event of events) yield validateEvent(event); + } + + /** + * Write ordered bytes to process stdin or the PTY master. + * @param id - opaque resource identity. + * @param data - bytes split into API-sized requests. + * @param signal - optional request cancellation. + * @returns the final accepted input sequence, or zero for empty input. + */ + async input( + id: CreateOSProcessId, + data: Uint8Array, + signal?: AbortSignal, + ): Promise { + let response: InputResponse = { input_seq: 0 }; + for (let offset = 0; offset < data.byteLength; offset += MAX_INPUT_BYTES) { + const value = await this.http.request("POST", this.path(`/${id}/input`), { + body: { + data_base64: Buffer.from(data.subarray(offset, offset + MAX_INPUT_BYTES)).toString( + "base64", + ), + }, + retry: false, + signal, + }); + if (!isRecord(value) || !isNonnegativeInteger(value.input_seq)) { + throw new Error("dsh-createos: managed process input carried an invalid sequence"); + } + response = { input_seq: value.input_seq }; + } + return response; + } + + /** + * Close an ordinary process stdin after accepted writes. + * @param id - opaque resource identity. + * @param signal - optional request cancellation. + */ + async closeStdin(id: CreateOSProcessId, signal?: AbortSignal): Promise { + await this.http.request("POST", this.path(`/${id}/stdin/close`), { + retry: false, + signal, + }); + } + + /** + * Resize a PTY-backed process. + * @param id - opaque resource identity. + * @param rows - positive terminal row count. + * @param cols - positive terminal column count. + * @param signal - optional request cancellation. + */ + async resize( + id: CreateOSProcessId, + rows: number, + cols: number, + signal?: AbortSignal, + ): Promise { + await this.http.request("POST", this.path(`/${id}/resize`), { + body: { rows, cols }, + retry: false, + signal, + }); + } + + /** + * Deliver a supported signal using the resource's mode-specific target. + * @param id - opaque resource identity. + * @param signalName - CreateOS-supported POSIX signal name. + * @param signal - optional request cancellation. + */ + async signal(id: CreateOSProcessId, signalName: string, signal?: AbortSignal): Promise { + await this.http.request("POST", this.path(`/${id}/signal`), { + body: { signal: signalName }, + retry: false, + signal, + }); + } + + /** + * Long-poll for leader or complete-tree exit. + * @param id - opaque resource identity. + * @param scope - top-level leader or complete resource cgroup. + * @param timeoutMs - server long-poll duration. + * @param signal - optional request cancellation. + * @returns the validated settled resource state. + */ + async wait( + id: CreateOSProcessId, + scope: "leader" | "tree", + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + return validateDetails( + await this.http.request("GET", this.path(`/${id}/wait`), { + query: { scope, timeout_ms: timeoutMs }, + signal, + timeoutMs: timeoutMs + 5_000, + }), + ); + } + + /** + * Idempotently terminate the resource cgroup and await quiescence. + * @param id - opaque resource identity. + * @param graceMs - TERM-to-cgroup-kill grace duration. + * @param signal - optional request cancellation. + * @returns the validated quiescent resource state. + */ + async terminate( + id: CreateOSProcessId, + graceMs: number, + signal?: AbortSignal, + ): Promise { + return validateDetails( + await this.http.request("DELETE", this.path(`/${id}`), { + query: { grace_ms: graceMs }, + signal, + timeoutMs: Math.max(10_000, graceMs + 5_000), + }), + ); + } + + private path(suffix = ""): string { + return `/v1/sandboxes/${encodeURIComponent(this.sandboxId)}/processes${suffix}`; + } +} + +function validateDetails(value: unknown): CreateOSProcessDetails { + if (!isRecord(value)) + throw new Error("dsh-createos: managed process response carried invalid data"); + const id = typeof value.process_id === "string" ? CreateOSProcessId(value.process_id) : undefined; + if (id === undefined || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0) { + throw new Error("dsh-createos: managed process response carried an invalid pid"); + } + if (value.kind !== "process" && value.kind !== "pty") + throw new Error("dsh-createos: managed process response carried an invalid kind"); + if (!PROCESS_STATES.has(String(value.state))) + throw new Error("dsh-createos: managed process response carried an invalid state"); + if (typeof value.leader_exited !== "boolean" || typeof value.tree_exited !== "boolean") { + throw new Error("dsh-createos: managed process response carried invalid exit state"); + } + if ( + typeof value.created_at !== "string" || + (value.finished_at !== null && typeof value.finished_at !== "string") + ) { + throw new Error("dsh-createos: managed process response carried invalid timestamps"); + } + if (value.exit_code !== null && !Number.isSafeInteger(value.exit_code)) { + throw new Error("dsh-createos: managed process response carried an invalid exit code"); + } + if (value.signal !== undefined && typeof value.signal !== "string") { + throw new Error("dsh-createos: managed process response carried an invalid signal"); + } + if ( + !isRecord(value.output) || + !isNonnegativeInteger(value.output.oldest_seq) || + !isNonnegativeInteger(value.output.newest_seq) || + !isNonnegativeInteger(value.output.bytes) + ) { + throw new Error("dsh-createos: managed process response carried invalid output bounds"); + } + return value as unknown as CreateOSProcessDetails; +} + +const PROCESS_STATES = new Set([ + "starting", + "running", + "terminating", + "exited", + "failed", + "unknown", +]); + +function validateEvent(value: unknown): CreateOSProcessEvent { + if (!isRecord(value) || typeof value.type !== "string") { + throw new Error("dsh-createos: managed process stream carried invalid data"); + } + if (value.type === "heartbeat") return { type: "heartbeat" }; + if (value.type === "exit") { + if ( + value.exit_code !== undefined && + value.exit_code !== null && + !Number.isSafeInteger(value.exit_code) + ) { + throw new Error("dsh-createos: managed process stream carried an invalid exit code"); + } + if (value.signal !== undefined && value.signal !== null && typeof value.signal !== "string") { + throw new Error("dsh-createos: managed process stream carried an invalid signal"); + } + return { + type: "exit", + ...(typeof value.exit_code === "number" ? { exit_code: value.exit_code } : {}), + ...(typeof value.signal === "string" ? { signal: value.signal } : {}), + }; + } + if (value.type === "error") { + if ( + typeof value.error !== "string" || + (value.oldest_available_seq !== undefined && + !isNonnegativeInteger(value.oldest_available_seq)) + ) { + throw new Error("dsh-createos: managed process stream carried an invalid error"); + } + return { + type: "error", + error: value.error, + ...(typeof value.oldest_available_seq === "number" + ? { oldest_available_seq: value.oldest_available_seq } + : {}), + }; + } + if (value.type === "data") { + if ( + !isNonnegativeInteger(value.seq) || + (value.stream !== "stdout" && value.stream !== "stderr" && value.stream !== "pty") || + typeof value.data_base64 !== "string" || + !BASE64.test(value.data_base64) + ) { + throw new Error("dsh-createos: managed process stream carried invalid output data"); + } + return value as unknown as CreateOSProcessEvent; + } + throw new Error( + `dsh-createos: managed process stream carried an unknown event type: ${value.type}`, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNonnegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/packages/dsh-createos/src/fs/index.ts b/packages/dsh-createos/src/fs/index.ts new file mode 100644 index 0000000..89f585c --- /dev/null +++ b/packages/dsh-createos/src/fs/index.ts @@ -0,0 +1,695 @@ +/** CreateOS implementation of the filesystem capability seam. */ + +import { Buffer } from "node:buffer"; +import { createHash, randomUUID } from "node:crypto"; +import { posix } from "node:path"; +import { + CreateosSandboxNotFoundError, + type CreateOSSandbox, +} from "@nodeops-createos/dsh-createos/createos"; +import { FileSystem, FsError, FsTargetKey, FsVersion } from "@deepseek-ai/dsh-fs"; +import type { + FsDirEntry, + FsEditOutcome, + FsEditRequest, + FsInfo, + FsPathInfo, + FsTarget, + FsWriteIntent, + FsWriteOutcome, +} from "@deepseek-ai/dsh-fs"; + +const BINARY_SAMPLE_BYTES = 8192; +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +interface RemoteEntry { + path: string; + type: "file" | "directory" | "symlink" | "other"; + size?: number; + mode: number; + modified: string; +} + +function signalOptions(signal: AbortSignal | undefined): { signal?: AbortSignal; retry: false } { + return { retry: false, ...(signal === undefined ? {} : { signal }) }; +} + +function assertNotAborted(signal: AbortSignal | undefined, operation: string): void { + if (signal?.aborted === true) throw new FsError(`${operation} aborted`, "FS_ABORTED"); +} + +function normalizeLineEndings(value: string): string { + return value.replaceAll("\r\n", "\n"); +} + +function detectsCrlf(value: string): boolean { + const sample = value.slice(0, 4096); + const crlf = sample.split("\r\n").length - 1; + const lf = sample.split("\n").length - 1 - crlf; + return crlf > lf; +} + +function restoreLineEndings(value: string, crlf: boolean): string { + return crlf ? normalizeLineEndings(value).replaceAll("\n", "\r\n") : value; +} + +function decodeText(bytes: Uint8Array, displayPath: string, sampleBytes: number): string { + if (bytes.subarray(0, sampleBytes).includes(0)) { + throw new FsError(`cannot read "${displayPath}": binary file`, "FS_NOT_TEXT"); + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error: unknown) { + throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { + cause: error, + }); + } +} + +function decodeBase64(value: string, operation: string): Buffer { + if (!BASE64.test(value)) throw new Error(`fs-createos: ${operation} returned invalid base64`); + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64") !== value) + throw new Error(`fs-createos: ${operation} returned non-canonical base64`); + return bytes; +} + +function decodeNulFields(value: string, operation: string): string[] { + const bytes = decodeBase64(value.trim(), operation); + if (bytes.length === 0 || bytes.at(-1) !== 0) + throw new Error(`fs-createos: ${operation} returned invalid framing`); + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error: unknown) { + throw new Error(`fs-createos: ${operation} returned invalid UTF-8`, { cause: error }); + } + return text.slice(0, -1).split("\0"); +} + +function entryVersion(entry: RemoteEntry): ReturnType { + return FsVersion(`createos:${createHash("sha256").update(JSON.stringify(entry)).digest("hex")}`); +} + +function mapError( + error: unknown, + operation: string, + displayPath: string, + signal?: AbortSignal, +): FsError { + if (error instanceof FsError) return error; + if (signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError")) { + return new FsError(`${operation} aborted`, "FS_ABORTED", { cause: error }); + } + if ( + error instanceof CreateosSandboxNotFoundError || + /not found|no such file/i.test(String(error)) + ) { + return new FsError(`cannot ${operation} "${displayPath}": not found`, "FS_NOT_FOUND", { + cause: error, + }); + } + if (/permission denied|operation not permitted/i.test(String(error))) { + return new FsError( + `cannot ${operation} "${displayPath}": permission denied`, + "FS_PERMISSION_DENIED", + { cause: error }, + ); + } + return new FsError(`cannot ${operation} "${displayPath}": ${String(error)}`, "FS_IO_ERROR", { + cause: error, + }); +} + +function literalEdit(content: string, request: FsEditRequest, displayPath: string): string { + const oldString = normalizeLineEndings(request.oldString); + const newString = normalizeLineEndings(request.newString); + if (oldString.length === 0) + throw new FsError( + `cannot edit "${displayPath}": old_string must be non-empty`, + "FS_EDIT_NOT_FOUND", + ); + let matches = 0; + let offset = 0; + for (;;) { + const found = content.indexOf(oldString, offset); + if (found < 0) break; + matches += 1; + offset = found + oldString.length; + } + if (matches === 0) + throw new FsError( + `cannot edit "${displayPath}": old_string was not found`, + "FS_EDIT_NOT_FOUND", + ); + if (!request.replaceAll && matches !== 1) { + throw new FsError( + `cannot edit "${displayPath}": old_string matched ${matches} times`, + "FS_AMBIGUOUS_EDIT", + ); + } + return request.replaceAll + ? content.split(oldString).join(newString) + : content.replace(oldString, newString); +} + +/** Remote filesystem backend sharing the sandbox owned by `ctx.createos`. */ +export class CreateOSFileSystem extends FileSystem { + static inject = ["createos"]; + + private readonly locks = new Map>(); + + /** @inheritdoc */ + override async resolve( + path: string, + opts?: { cwd?: string; signal?: AbortSignal }, + ): Promise { + assertNotAborted(opts?.signal, "resolve"); + if (path.trim().length === 0) + throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND"); + const displayPath = posix.resolve(opts?.cwd ?? this.ctx.createos.cwd, path); + try { + const sandbox = await this.ctx.createos.getSandbox(); + const result = await sandbox.runCommand( + "/bin/sh", + ["-c", 'realpath -mz -- "$1" | base64 -w0', "dsh-createos-realpath", displayPath], + signalOptions(opts?.signal), + ); + if (result.result.exit_code !== 0) throw new Error(result.result.stderr || "realpath failed"); + const framed = decodeBase64(result.result.stdout.trim(), "canonical path"); + if (framed.length < 2 || framed.at(-1) !== 0 || framed.subarray(0, -1).includes(0)) { + throw new Error("fs-createos: canonical path returned invalid framing"); + } + const canonical = new TextDecoder("utf-8", { fatal: true }).decode(framed.subarray(0, -1)); + if (!posix.isAbsolute(canonical)) + throw new Error("fs-createos: canonical path is not absolute"); + return { targetKey: FsTargetKey(canonical), displayPath }; + } catch (error: unknown) { + throw mapError(error, "resolve", displayPath, opts?.signal); + } + } + + /** @inheritdoc */ + override processPath(target: FsTarget): string { + return String(target.targetKey); + } + + /** @inheritdoc */ + override fileUrl(target: FsTarget): string { + const path = this.processPath(target); + if (!posix.isAbsolute(path)) + throw new Error(`fs-createos: expected absolute process path: ${path}`); + return `file://${path + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/")}`; + } + + /** @inheritdoc */ + override contains(parent: FsTarget, child: FsTarget): boolean { + const relative = posix.relative(this.processPath(parent), this.processPath(child)); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith("../") && !posix.isAbsolute(relative)) + ); + } + + /** @inheritdoc */ + override async stat(target: FsTarget, signal?: AbortSignal): Promise { + const entry = await this.probe(String(target.targetKey), true, target.displayPath, signal); + if (entry === undefined) return undefined; + return { + version: entryVersion(entry), + type: entry.type === "symlink" ? "other" : entry.type, + ...(entry.type === "file" ? { size: entry.size } : {}), + }; + } + + /** @inheritdoc */ + override async lstat( + path: string, + opts?: { cwd?: string }, + signal?: AbortSignal, + ): Promise { + if (path.trim().length === 0) + throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND"); + const displayPath = posix.resolve(opts?.cwd ?? this.ctx.createos.cwd, path); + const entry = await this.probe(displayPath, false, displayPath, signal); + if (entry === undefined) return undefined; + return { + version: entryVersion(entry), + type: entry.type, + ...(entry.type === "file" ? { size: entry.size } : {}), + }; + } + + /** @inheritdoc */ + override async readText(target: FsTarget, signal?: AbortSignal): Promise { + await this.requireRegular(target, signal); + try { + const sandbox = await this.ctx.createos.getSandbox(); + const bytes = new Uint8Array( + await sandbox.files.download(String(target.targetKey), signalOptions(signal)), + ); + assertNotAborted(signal, "read"); + return decodeText(bytes, target.displayPath, BINARY_SAMPLE_BYTES); + } catch (error: unknown) { + throw mapError(error, "read", target.displayPath, signal); + } + } + + /** @inheritdoc */ + override async readBytes( + target: FsTarget, + signal: AbortSignal | undefined, + maxBytes: number, + ): Promise { + const info = await this.requireRegular(target, signal); + if (info.size !== undefined && info.size > maxBytes) { + throw new FsError( + `cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, + "FS_TOO_LARGE", + ); + } + const stream = await this.openDownload(target, signal); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let complete = false; + try { + for (;;) { + assertNotAborted(signal, "read"); + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > maxBytes) { + throw new FsError( + `cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, + "FS_TOO_LARGE", + ); + } + chunks.push(next.value); + } + complete = true; + } catch (error: unknown) { + throw mapError(error, "read", target.displayPath, signal); + } finally { + if (!complete) await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + } + + /** @inheritdoc */ + override async streamText( + target: FsTarget, + signal?: AbortSignal, + ): Promise> { + await this.requireRegular(target, signal); + const stream = await this.openDownload(target, signal); + const displayPath = target.displayPath; + return { + async *[Symbol.asyncIterator](): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let sampled = 0; + let complete = false; + try { + for (;;) { + assertNotAborted(signal, "read"); + const next = await reader.read(); + if (next.done) break; + if (sampled < BINARY_SAMPLE_BYTES) { + const sample = next.value.subarray(0, BINARY_SAMPLE_BYTES - sampled); + if (sample.includes(0)) + throw new FsError(`cannot read "${displayPath}": binary file`, "FS_NOT_TEXT"); + sampled += sample.byteLength; + } + let text: string; + try { + text = decoder.decode(next.value, { stream: true }); + } catch (error: unknown) { + throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { + cause: error, + }); + } + if (text.length > 0) yield text; + } + decoder.decode(); + complete = true; + } catch (error: unknown) { + throw mapError(error, "read", displayPath, signal); + } finally { + if (!complete) await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + }, + }; + } + + /** @inheritdoc */ + override async listDir(target: FsTarget, signal?: AbortSignal): Promise { + const info = await this.stat(target, signal); + if (info === undefined) + throw new FsError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND"); + if (info.type !== "directory") + throw new FsError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY"); + try { + const sandbox = await this.ctx.createos.getSandbox(); + const result = await sandbox.runCommand( + "/bin/sh", + [ + "-c", + "find -P \"$1\" -mindepth 1 -maxdepth 1 -printf '%f\\0%p\\0%y\\0%s\\0%m\\0%T@\\0' | base64 -w0", + "dsh-createos-list", + String(target.targetKey), + ], + signalOptions(signal), + ); + if (result.result.exit_code !== 0) throw new Error(result.result.stderr || "find failed"); + if (result.result.stdout.length === 0) return []; + const fields = decodeNulFields(result.result.stdout, "directory listing"); + if (fields.length % 6 !== 0) + throw new Error("fs-createos: directory listing returned invalid fields"); + const entries: FsDirEntry[] = []; + for (let index = 0; index < fields.length; index += 6) { + const [name, childPath, typeCode, size, mode, modified] = fields.slice( + index, + index + 6, + ) as [string, string, string, string, string, string]; + const displayPath = posix.join(target.displayPath, name); + const resolved = await this.resolve(childPath, signal === undefined ? {} : { signal }); + const child = + typeCode === "l" + ? await this.probe(String(resolved.targetKey), true, displayPath, signal) + : parseEntry(childPath, typeCode, size, mode, modified); + entries.push({ + name, + type: child?.type === "symlink" ? "other" : (child?.type ?? "other"), + target: { targetKey: resolved.targetKey, displayPath }, + ...(child === undefined ? {} : { version: entryVersion(child) }), + ...(child?.type === "file" ? { size: child.size } : {}), + }); + } + return entries.sort((left, right) => left.name.localeCompare(right.name)); + } catch (error: unknown) { + throw mapError(error, "list", target.displayPath, signal); + } + } + + /** @inheritdoc */ + override async writeText( + target: FsTarget, + content: string, + expected?: FsWriteIntent, + signal?: AbortSignal, + ): Promise { + return this.withLock(String(target.targetKey), async () => { + const existing = await this.probe(String(target.targetKey), true, target.displayPath, signal); + if (existing !== undefined && existing.type !== "file") { + throw new FsError( + `cannot write "${target.displayPath}": not a regular file`, + "FS_NOT_REGULAR_FILE", + ); + } + this.checkWriteIntent(existing, expected, target); + const before = existing === undefined ? null : await this.readForDiff(target, signal); + const version = await this.writeAtomic( + target, + content, + existing, + expected?.kind === "createIfAbsent", + signal, + ); + return { + operation: existing === undefined ? "create" : "update", + version, + before, + after: normalizeLineEndings(content), + }; + }); + } + + /** @inheritdoc */ + override async editText( + target: FsTarget, + edit: FsEditRequest, + expected?: { version: ReturnType }, + signal?: AbortSignal, + ): Promise { + return this.withLock(String(target.targetKey), async () => { + const existing = await this.probe(String(target.targetKey), true, target.displayPath, signal); + if ( + existing === undefined || + (expected !== undefined && entryVersion(existing) !== expected.version) + ) { + throw new FsError( + `cannot edit "${target.displayPath}": file changed since it was read`, + "FS_STALE_VERSION", + ); + } + if (existing.type !== "file") { + throw new FsError( + `cannot edit "${target.displayPath}": not a regular file`, + "FS_NOT_REGULAR_FILE", + ); + } + const raw = await this.readText(target, signal); + const before = normalizeLineEndings(raw); + const after = literalEdit(before, edit, target.displayPath); + const version = await this.writeAtomic( + target, + restoreLineEndings(after, detectsCrlf(raw)), + existing, + false, + signal, + ); + return { version, before, after }; + }); + } + + private async withLock(key: string, operation: () => Promise): Promise { + const prior = this.locks.get(key) ?? Promise.resolve(); + const run = prior.then(operation, operation); + const tail = run.then( + () => undefined, + () => undefined, + ); + this.locks.set(key, tail); + try { + return await run; + } finally { + if (this.locks.get(key) === tail) this.locks.delete(key); + } + } + + private async probe( + path: string, + follow: boolean, + displayPath: string, + signal?: AbortSignal, + ): Promise { + assertNotAborted(signal, "stat"); + try { + const sandbox = await this.ctx.createos.getSandbox(); + const result = await sandbox.runCommand( + "/bin/sh", + [ + "-c", + `stat ${follow ? "-L " : ""}--printf='%F\\0%s\\0%a\\0%y\\0' -- "$1" | base64 -w0`, + "dsh-createos-stat", + path, + ], + signalOptions(signal), + ); + if (result.result.exit_code !== 0) { + if (/no such file|not found/i.test(result.result.stderr)) return undefined; + throw new Error(result.result.stderr || "stat failed"); + } + const [kind, size, mode, modified] = decodeNulFields(result.result.stdout, "stat") as [ + string, + string, + string, + string, + ]; + return { + path, + type: statType(kind), + ...(kind === "regular file" ? { size: parseNonnegative(size, "size") } : {}), + mode: Number.parseInt(mode, 8), + modified, + }; + } catch (error: unknown) { + throw mapError(error, "stat", displayPath, signal); + } + } + + private async openDownload( + target: FsTarget, + signal?: AbortSignal, + ): Promise> { + const sandbox = await this.ctx.createos.getSandbox(); + const path = `/v1/sandboxes/${encodeURIComponent(sandbox.id)}/files`; + const response = await this.ctx.createos.getClient().http.requestRaw("GET", path, { + query: { path: String(target.targetKey) }, + ...signalOptions(signal), + }); + if (!response.ok) + await this.ctx.createos.getClient().http.throwForResponse(response, "GET", path); + if (response.body === null) throw new Error("fs-createos: download returned no response body"); + return response.body; + } + + private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise { + const info = await this.stat(target, signal); + if (info === undefined) + throw new FsError(`cannot read "${target.displayPath}": not found`, "FS_NOT_FOUND"); + if (info.type !== "file") + throw new FsError( + `cannot read "${target.displayPath}": not a regular file`, + "FS_NOT_REGULAR_FILE", + ); + return info; + } + + private checkWriteIntent( + existing: RemoteEntry | undefined, + expected: FsWriteIntent | undefined, + target: FsTarget, + ): void { + if (expected?.kind === "createIfAbsent" && existing !== undefined) { + throw new FsError( + `cannot overwrite existing "${target.displayPath}" without reading it first`, + "FS_NOT_OBSERVED", + ); + } + if ( + expected?.kind === "replaceIfVersion" && + (existing === undefined || entryVersion(existing) !== expected.version) + ) { + throw new FsError( + `cannot write "${target.displayPath}": file changed since it was read`, + "FS_STALE_VERSION", + ); + } + } + + private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise { + try { + return normalizeLineEndings(await this.readText(target, signal)); + } catch (error: unknown) { + if (error instanceof FsError && error.code === "FS_NOT_TEXT") return null; + throw error; + } + } + + private async writeAtomic( + target: FsTarget, + content: string, + existing: RemoteEntry | undefined, + createIfAbsent: boolean, + signal?: AbortSignal, + ): Promise> { + assertNotAborted(signal, "write"); + const sandbox = await this.ctx.createos.getSandbox(); + const targetPath = String(target.targetKey); + const staging = posix.join(posix.dirname(targetPath), `.dsh-createos-${randomUUID()}.tmp`); + const temporary = posix.join(staging, "content"); + let created = false; + try { + await this.runChecked(sandbox, "/bin/mkdir", ["-p", "--", posix.dirname(targetPath)], signal); + await this.runChecked(sandbox, "/bin/mkdir", ["-m", "700", "--", staging], signal); + created = true; + await sandbox.files.upload(temporary, content, signalOptions(signal)); + await this.runChecked( + sandbox, + "/bin/chmod", + [(existing?.mode ?? 0o600).toString(8), "--", temporary], + signal, + ); + assertNotAborted(signal, "write"); + if (createIfAbsent) { + const publication = await sandbox.runCommand( + "/bin/ln", + ["-T", "--", temporary, targetPath], + signalOptions(undefined), + ); + if (publication.result.exit_code !== 0) { + const current = await this.probe(targetPath, false, target.displayPath); + if (current !== undefined) { + throw new FsError( + `cannot overwrite existing "${target.displayPath}" without reading it first`, + "FS_NOT_OBSERVED", + ); + } + throw new Error(publication.result.stderr || "guarded publication failed"); + } + } else { + await this.runChecked(sandbox, "/bin/mv", ["-f", "-T", "--", temporary, targetPath]); + } + const committed = await this.probe(targetPath, true, target.displayPath); + if (committed === undefined) throw new Error("fs-createos: committed file disappeared"); + await this.runChecked(sandbox, "/bin/rmdir", ["--", staging]).catch(() => {}); + return entryVersion(committed); + } catch (error: unknown) { + if (created) + await this.runChecked(sandbox, "/bin/rm", ["-rf", "--", staging]).catch(() => {}); + throw mapError(error, "write", target.displayPath, signal); + } + } + + private async runChecked( + sandbox: CreateOSSandbox, + command: string, + args: string[], + signal?: AbortSignal, + ): Promise { + const result = await sandbox.runCommand(command, args, signalOptions(signal)); + if (result.result.exit_code !== 0) throw new Error(result.result.stderr || `${command} failed`); + } +} + +function statType(kind: string): RemoteEntry["type"] { + if (kind === "regular file") return "file"; + if (kind === "directory") return "directory"; + if (kind === "symbolic link") return "symlink"; + return "other"; +} + +function parseEntry( + path: string, + typeCode: string, + size: string, + mode: string, + modified: string, +): RemoteEntry { + const type = + typeCode === "f" + ? "file" + : typeCode === "d" + ? "directory" + : typeCode === "l" + ? "symlink" + : "other"; + return { + path, + type, + ...(type === "file" ? { size: parseNonnegative(size, "size") } : {}), + mode: Number.parseInt(mode, 8), + modified, + }; +} + +function parseNonnegative(value: string, label: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`fs-createos: invalid ${label}`); + return parsed; +} + +export default CreateOSFileSystem; diff --git a/packages/dsh-createos/src/index.ts b/packages/dsh-createos/src/index.ts new file mode 100644 index 0000000..f5c7eb6 --- /dev/null +++ b/packages/dsh-createos/src/index.ts @@ -0,0 +1,6 @@ +/** Public entry points for the CreateOS execution-world plugin bundle. */ + +export { CreateOSRuntime } from "./createos/index.ts"; +export type { Config as CreateOSConfig } from "./createos/index.ts"; +export { CreateOSFileSystem } from "./fs/index.ts"; +export { CreateOSSubprocessRuntime } from "./subprocess/index.ts"; diff --git a/packages/dsh-createos/src/subprocess/environment.ts b/packages/dsh-createos/src/subprocess/environment.ts new file mode 100644 index 0000000..a5b86f7 --- /dev/null +++ b/packages/dsh-createos/src/subprocess/environment.ts @@ -0,0 +1,55 @@ +/** Remote CreateOS environment acquisition and credential scrubbing. */ + +import type { CreateOSSandbox } from "@nodeops-createos/dsh-createos/createos"; +import { SENSITIVE_ENV_PATTERN } from "@deepseek-ai/dsh-subprocess"; + +function entries(raw: string): Array { + const parsed: Array = []; + for (const entry of raw.split("\0")) { + if (entry.length === 0) continue; + const separator = entry.indexOf("="); + if (separator > 0) parsed.push([entry.slice(0, separator), entry.slice(separator + 1)]); + } + return parsed; +} + +/** Read the complete NUL-delimited environment of the CreateOS guest agent. */ +export async function readRemoteEnvironment( + sandbox: CreateOSSandbox, + signal?: AbortSignal, +): Promise { + const result = await sandbox.runCommand("/usr/bin/env", ["-0"], { + retry: false, + ...(signal === undefined ? {} : { signal }), + }); + if (result.result.exit_code !== 0) { + throw new Error(`subprocess-createos: cannot read remote environment: ${result.result.stderr}`); + } + return result.result.stdout; +} + +/** Produce explicit `env -i` assignments after scrubbing and caller overlay. */ +export function environmentArguments( + raw: string, + explicit: Readonly | undefined, +): string[] { + const environment = new Map(); + for (const [name, value] of entries(raw)) { + if (!name.toUpperCase().startsWith("DSH_") && !SENSITIVE_ENV_PATTERN.test(name)) { + environment.set(name, value); + } + } + for (const [name, value] of Object.entries(explicit ?? {})) { + if ( + name.length === 0 || + name.includes("=") || + name.includes("\0") || + value?.includes("\0") === true + ) { + throw new Error("subprocess-createos: environment entries require NUL-free names and values"); + } + if (value === undefined) environment.delete(name); + else environment.set(name, value); + } + return [...environment].map(([name, value]) => `${name}=${value}`); +} diff --git a/packages/dsh-createos/src/subprocess/index.ts b/packages/dsh-createos/src/subprocess/index.ts new file mode 100644 index 0000000..4e7b4bc --- /dev/null +++ b/packages/dsh-createos/src/subprocess/index.ts @@ -0,0 +1,202 @@ +/** CreateOS implementation of the subprocess capability seam. */ + +import { posix } from "node:path"; +import { Context } from "@deepseek-ai/cordis"; +import { SubprocessRuntime } from "@deepseek-ai/dsh-subprocess"; +import type { + SubprocessHandle, + SubprocessSpawnSpec, + SubprocessTerminalHandle, + SubprocessTerminalSpawnSpec, +} from "@deepseek-ai/dsh-subprocess"; +import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout"; +import { environmentArguments, readRemoteEnvironment } from "./environment.ts"; +import { CreateOSSubprocessHandle } from "./process.ts"; +import { CreateOSTerminalHandle } from "./terminal.ts"; + +/** Managed process and PTY provider sharing `ctx.createos`. */ +export class CreateOSSubprocessRuntime extends SubprocessRuntime { + static inject = ["createos"]; + + private readonly live = new Set(); + private readonly terminals = new Set(); + private readonly environment: Promise; + private disposing = false; + + constructor(ctx: Context) { + super(ctx); + this.environment = ctx.createos.getSandbox().then((sandbox) => readRemoteEnvironment(sandbox)); + void this.environment.catch(() => {}); + ctx.effect( + () => async () => { + this.disposing = true; + const processes = [...this.live]; + const terminals = [...this.terminals]; + for (const process of processes) process.terminate(); + const outcomes = await Promise.allSettled([ + ...processes.map(async (process) => { + await process.waitForExit(); + await process.done; + }), + ...terminals.map((terminal) => terminal.terminate()), + ]); + this.live.clear(); + this.terminals.clear(); + const failures: unknown[] = []; + for (const outcome of outcomes) { + if (outcome.status === "rejected") failures.push(outcome.reason as unknown); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "subprocess-createos: teardown failed"); + }, + "CreateOS subprocess teardown", + ); + } + + /** @inheritdoc */ + async resolveExecutable( + command: string, + env?: Readonly>, + signal?: AbortSignal, + ): Promise { + if (command.length === 0) + throw new Error("subprocess-createos: executable name must be non-empty"); + if (command.includes("/") && !posix.isAbsolute(command)) { + throw new Error(`subprocess-createos: relative executable paths are unsupported: ${command}`); + } + const sandbox = await this.ctx.createos.getSandbox(); + if (posix.isAbsolute(command)) { + const result = await sandbox.runCommand( + "/usr/bin/test", + ["-f", command, "-a", "-x", command], + { + retry: false, + ...(signal === undefined ? {} : { signal }), + }, + ); + if (result.result.exit_code !== 0) + throw new Error(`subprocess-createos: executable is unavailable: ${command}`); + return command; + } + const args = environmentArguments(await this.environment, env); + const result = await sandbox.runCommand( + "/usr/bin/env", + ["-i", ...args, "/bin/sh", "-c", 'command -v -- "$1"', "dsh-createos-resolve", command], + { retry: false, ...(signal === undefined ? {} : { signal }) }, + ); + if (result.result.exit_code !== 0) + throw new Error(`subprocess-createos: executable is unavailable: ${command}`); + const executable = result.result.stdout.trim(); + if (executable.includes("\n") || (!posix.isAbsolute(executable) && !executable.includes("/"))) { + throw new Error(`subprocess-createos: executable did not resolve to one path: ${command}`); + } + return posix.resolve(this.ctx.createos.cwd, executable); + } + + /** @inheritdoc */ + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + if (this.disposing) throw new Error("subprocess-createos: service is disposing"); + requireGrace(spec.graceMs); + if (spec.signal?.aborted === true) + throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`); + const processes = this.ctx.createos.getProcesses(); + const handle = new CreateOSSubprocessHandlePromise(processes, this.environment, spec); + this.live.add(handle); + void handle.done.finally(() => this.live.delete(handle)).catch(() => {}); + return handle; + } + + /** @inheritdoc */ + async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { + if (this.disposing) throw new Error("subprocess-createos: service is disposing"); + requireGrace(spec.graceMs); + spec.signal?.throwIfAborted(); + const [sandbox, processes, environment] = await Promise.all([ + this.ctx.createos.getSandbox(), + this.ctx.createos.getProcesses(), + this.environment, + ]); + const terminal = await CreateOSTerminalHandle.create( + sandbox, + processes, + environment, + spec, + environmentArguments, + ); + this.terminals.add(terminal); + void terminal.done.finally(() => this.terminals.delete(terminal)).catch(() => {}); + return terminal; + } +} + +class CreateOSSubprocessHandlePromise extends CreateOSSubprocessHandle { + constructor( + processes: ReturnType, + environment: Promise, + spec: SubprocessSpawnSpec, + ) { + const resolved = Promise.resolve(processes); + // The synchronous seam publishes before the remote owner promise settles. + super(new DeferredProcesses(resolved), environment, spec, environmentArguments); + } +} + +class DeferredProcesses { + constructor(private readonly ready: ReturnType) {} + create( + ...args: Parameters< + import("@nodeops-createos/dsh-createos/createos").CreateOSProcesses["create"] + > + ) { + return this.ready.then((processes) => processes.create(...args)); + } + connect( + ...args: Parameters< + import("@nodeops-createos/dsh-createos/createos").CreateOSProcesses["connect"] + > + ) { + const ready = this.ready; + return (async function* () { + const processes = await ready; + yield* processes.connect(...args); + })(); + } + input( + ...args: Parameters< + import("@nodeops-createos/dsh-createos/createos").CreateOSProcesses["input"] + > + ) { + return this.ready.then((processes) => processes.input(...args)); + } + closeStdin( + ...args: Parameters< + import("@nodeops-createos/dsh-createos/createos").CreateOSProcesses["closeStdin"] + > + ) { + return this.ready.then((processes) => processes.closeStdin(...args)); + } + wait( + ...args: Parameters + ) { + return this.ready.then((processes) => processes.wait(...args)); + } + terminate( + ...args: Parameters< + import("@nodeops-createos/dsh-createos/createos").CreateOSProcesses["terminate"] + > + ) { + return this.ready.then((processes) => processes.terminate(...args)); + } +} + +function requireGrace(graceMs: number): void { + const maximum = Math.min(MAX_TIMER_DELAY_MS, 60_000); + if (!Number.isFinite(graceMs) || graceMs <= 0 || graceMs > maximum) { + throw new Error( + `subprocess graceMs must be a positive finite number no greater than ${maximum}`, + ); + } +} + +export default CreateOSSubprocessRuntime; diff --git a/packages/dsh-createos/src/subprocess/output.ts b/packages/dsh-createos/src/subprocess/output.ts new file mode 100644 index 0000000..1ef53f3 --- /dev/null +++ b/packages/dsh-createos/src/subprocess/output.ts @@ -0,0 +1,58 @@ +/** Bounded collected-output projection for CreateOS streams. */ + +import { Buffer } from "node:buffer"; +import type { SubprocessOutputRead, SubprocessOutputReader } from "@deepseek-ai/dsh-subprocess"; + +/** Independent offset reader retaining one configured in-memory tail. */ +export class CreateOSOutputReader implements SubprocessOutputReader { + private chunks: Buffer[] = []; + private retainedBytes = 0; + private totalBytes = 0; + + constructor(private readonly maxBytes: number) {} + + /** Append byte-faithful output. */ + push(bytes: Uint8Array): void { + if (bytes.length === 0) return; + const chunk = Buffer.from(bytes); + this.totalBytes += chunk.length; + this.chunks.push(chunk); + this.retainedBytes += chunk.length; + while (this.retainedBytes > this.maxBytes) { + const head = this.chunks[0] as Buffer; + const excess = this.retainedBytes - this.maxBytes; + if (head.length <= excess) { + this.chunks.shift(); + this.retainedBytes -= head.length; + } else { + this.chunks[0] = head.subarray(excess); + this.retainedBytes -= excess; + } + } + } + + /** @inheritdoc */ + readFrom(fromByte: number): SubprocessOutputRead { + const retained = Buffer.concat(this.chunks, this.retainedBytes); + const firstRetained = this.totalBytes - this.retainedBytes; + const lossy = fromByte < firstRetained; + const start = lossy ? 0 : Math.min(retained.length, Math.max(0, fromByte - firstRetained)); + return { + text: retained.subarray(start).toString("utf8"), + nextOffset: this.totalBytes, + lossy, + }; + } +} + +/** Decode one canonical base64 payload without accepting malformed aliases. */ +export function decodeOutput(value: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) { + throw new Error("subprocess-createos: invalid base64 output frame"); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.toString("base64") !== value) { + throw new Error("subprocess-createos: non-canonical base64 output frame"); + } + return decoded; +} diff --git a/packages/dsh-createos/src/subprocess/process.ts b/packages/dsh-createos/src/subprocess/process.ts new file mode 100644 index 0000000..07a4c04 --- /dev/null +++ b/packages/dsh-createos/src/subprocess/process.ts @@ -0,0 +1,279 @@ +/** Ordinary managed-process handle over CreateOS output journals. */ + +import { Buffer } from "node:buffer"; +import { PassThrough, Writable } from "node:stream"; +import type { Readable } from "node:stream"; +import type { CreateOSProcessDetails } from "@nodeops-createos/dsh-createos/createos"; +import type { + SubprocessCollectedOutputs, + SubprocessHandle, + SubprocessOutputMode, + SubprocessOutcome, + SubprocessSpawnSpec, +} from "@deepseek-ai/dsh-subprocess"; +import { CreateOSOutputReader, decodeOutput } from "./output.ts"; +import { assertProcessEvent, processOutcome, startGate, waitScope } from "./shared.ts"; +import type { CreateOSProcessOperations } from "./shared.ts"; + +interface OutputSink { + stream?: PassThrough; + collected?: CreateOSOutputReader; + inherit?: NodeJS.WriteStream; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +/** One asynchronously allocated CreateOS process published through the synchronous subprocess seam. */ +export class CreateOSSubprocessHandle implements SubprocessHandle { + private process: CreateOSProcessDetails | undefined; + private readonly stdoutSink: OutputSink; + private readonly stderrSink: OutputSink; + private readonly completion = Promise.withResolvers(); + private readonly gateReady = Promise.withResolvers(); + private readonly startAbort = new AbortController(); + private termination: Promise | undefined; + private ended = false; + + /** @inheritdoc */ + get pid(): number { + return this.process?.pid ?? -1; + } + /** @inheritdoc */ + readonly stdin: Writable | undefined; + /** @inheritdoc */ + readonly stdout: Readable | undefined; + /** @inheritdoc */ + readonly stderr: Readable | undefined; + /** @inheritdoc */ + readonly collected: SubprocessCollectedOutputs; + /** @inheritdoc */ + readonly done = this.completion.promise; + + constructor( + private readonly processes: CreateOSProcessOperations, + private readonly remoteEnvironment: Promise, + private readonly spec: SubprocessSpawnSpec, + environmentArgs: (raw: string, explicit: Readonly | undefined) => string[], + ) { + this.stdoutSink = outputSink(spec.stdio.stdout, process.stdout); + this.stderrSink = outputSink(spec.stdio.stderr, process.stderr); + this.stdout = this.stdoutSink.stream; + this.stderr = this.stderrSink.stream; + this.collected = { + ...(this.stdoutSink.collected === undefined ? {} : { stdout: this.stdoutSink.collected }), + ...(this.stderrSink.collected === undefined ? {} : { stderr: this.stderrSink.collected }), + }; + this.stdin = + spec.stdio.stdin === "pipe" + ? new Writable({ + write: (chunk: Buffer | string, _encoding, callback) => { + void this.writeInput(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)).then( + () => { + callback(); + }, + (error: unknown) => { + callback(asError(error)); + }, + ); + }, + final: (callback) => { + void this.closeInput().then( + () => { + callback(); + }, + (error: unknown) => { + callback(asError(error)); + }, + ); + }, + }) + : undefined; + void this.start(environmentArgs); + const onAbort = (): void => { + this.terminate(); + }; + spec.signal?.addEventListener("abort", onAbort, { once: true }); + void this.done + .finally(() => spec.signal?.removeEventListener("abort", onAbort)) + .catch(() => {}); + } + + /** @inheritdoc */ + terminate(): void { + this.termination ??= this.terminateRemote(); + void this.termination.catch((error: unknown) => { + this.fail(error); + }); + } + + /** @inheritdoc */ + async waitForExit(signal?: AbortSignal): Promise { + let process = this.process; + if (process === undefined) { + await Promise.race([ + this.done.catch(() => undefined), + ...(signal === undefined + ? [] + : [ + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true }, + ); + }), + ]), + ]); + process = this.process; + } + if (process === undefined || signal?.aborted === true) return false; + return (await waitScope(this.processes, process.process_id, "tree", signal)) !== undefined; + } + + private async start( + environmentArgs: (raw: string, explicit: Readonly | undefined) => string[], + ): Promise { + try { + const program = this.spec.argv[0]; + if (program === undefined || program.length === 0) + throw new Error("invalid argv: expected a program"); + const rawEnvironment = await this.remoteEnvironment; + const gate = startGate(false); + const args = environmentArgs(rawEnvironment, this.spec.env); + this.process = await this.processes.create( + { + cmd: "/bin/sh", + args: [ + "-c", + gate.script, + "dsh-createos-runner", + gate.marker, + gate.token, + ...args, + program, + ...this.spec.argv.slice(1), + ], + cwd: this.spec.cwd, + }, + this.startAbort.signal, + ); + const pump = this.pumpOutput(gate.marker, gate.token); + await Promise.race([ + this.gateReady.promise, + pump.then(() => { + throw new Error("subprocess-createos: process exited before its start gate opened"); + }), + ]); + await this.initializeStdin(); + const outcome = await pump; + this.finish(outcome); + } catch (error: unknown) { + this.fail(error); + } + } + + private async pumpOutput(marker: string, token: string): Promise { + const process = this.process as CreateOSProcessDetails; + let markerPending = Buffer.from(marker); + let gateOpened = false; + for await (const event of this.processes.connect( + process.process_id, + 0, + this.startAbort.signal, + )) { + assertProcessEvent(event); + if (event.type === "exit") + return processOutcome({ exit_code: event.exit_code ?? null, signal: event.signal }); + if (event.type !== "data") continue; + let bytes = decodeOutput(event.data_base64); + if (!gateOpened) { + const matched = Math.min(markerPending.length, bytes.length); + if (!bytes.subarray(0, matched).equals(markerPending.subarray(0, matched))) { + throw new Error("subprocess-createos: process start marker was not first output"); + } + markerPending = markerPending.subarray(matched); + bytes = bytes.subarray(matched); + if (markerPending.length === 0) { + gateOpened = true; + await this.processes.input( + process.process_id, + Buffer.from(`${token}\n`), + this.startAbort.signal, + ); + this.gateReady.resolve(); + } + if (bytes.length === 0) continue; + } + this.push(event.stream, bytes); + } + throw new Error("subprocess-createos: output stream ended without an exit event"); + } + + private async initializeStdin(): Promise { + const mode = this.spec.stdio.stdin; + if (mode === "pipe") return; + if (typeof mode === "object") await this.writeInput(Buffer.from(mode.data)); + await this.closeInput(); + } + + private async writeInput(data: Uint8Array): Promise { + const process = await this.requireProcess(); + await this.processes.input(process.process_id, data, this.startAbort.signal); + } + + private async closeInput(): Promise { + const process = await this.requireProcess(); + await this.processes.closeStdin(process.process_id, this.startAbort.signal); + } + + private async requireProcess(): Promise { + while (this.process === undefined) { + if (this.ended) throw new Error("subprocess-createos: process did not start"); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return this.process; + } + + private push(stream: "stdout" | "stderr" | "pty", bytes: Buffer): void { + if (stream === "pty") + throw new Error("subprocess-createos: ordinary process emitted PTY output"); + const sink = stream === "stdout" ? this.stdoutSink : this.stderrSink; + sink.stream?.write(bytes); + sink.collected?.push(bytes); + sink.inherit?.write(bytes); + } + + private async terminateRemote(): Promise { + const process = await this.requireProcess(); + const ended = await this.processes.terminate(process.process_id, this.spec.graceMs); + this.startAbort.abort(new Error("subprocess-createos: process terminated")); + this.finish(processOutcome(ended)); + } + + private finish(outcome: SubprocessOutcome): void { + if (this.ended) return; + this.ended = true; + this.stdoutSink.stream?.end(); + this.stderrSink.stream?.end(); + this.completion.resolve(outcome); + } + + private fail(error: unknown): void { + if (this.ended) return; + this.ended = true; + const failure = error instanceof Error ? error : new Error(String(error)); + this.stdoutSink.stream?.destroy(failure); + this.stderrSink.stream?.destroy(failure); + this.completion.reject(failure); + } +} + +function outputSink(mode: SubprocessOutputMode, inherit: NodeJS.WriteStream): OutputSink { + if (mode === "pipe") return { stream: new PassThrough() }; + if (mode === "inherit") return { inherit }; + return { collected: new CreateOSOutputReader(mode.maxBytes) }; +} diff --git a/packages/dsh-createos/src/subprocess/shared.ts b/packages/dsh-createos/src/subprocess/shared.ts new file mode 100644 index 0000000..fce0d5b --- /dev/null +++ b/packages/dsh-createos/src/subprocess/shared.ts @@ -0,0 +1,123 @@ +/** Shared CreateOS managed-process helpers. */ + +import { randomUUID } from "node:crypto"; +import type { + CreateOSProcessDetails, + CreateOSProcessEvent, + CreateOSProcesses, +} from "@nodeops-createos/dsh-createos/createos"; +import type { SubprocessOutcome } from "@deepseek-ai/dsh-subprocess"; + +/** Private marker and gate used to attach output before the requested executable starts. */ +export function startGate(pty: boolean): { marker: string; token: string; script: string } { + const marker = `__DSH_CREATEOS_READY_${randomUUID()}__`; + const token = `dsh-${randomUUID()}`; + const terminalPrefix = pty ? "stty -echo; " : ""; + const terminalSuffix = pty ? "stty echo; " : ""; + return { + marker, + token, + script: `${terminalPrefix}printf '%s' "$1"; dsh_gate="$2"; shift 2; IFS= read -r dsh_seen; test "$dsh_seen" = "$dsh_gate" || exit 125; ${terminalSuffix}exec /usr/bin/env -i "$@"`, + }; +} + +/** Map CreateOS/Go signal vocabulary into the Node subprocess seam. */ +export function processOutcome(details: { + exit_code: number | null; + signal?: string | undefined; +}): SubprocessOutcome { + return { + exitCode: details.exit_code, + signal: normalizeSignal(details.signal), + }; +} + +function normalizeSignal(value: string | undefined): NodeJS.Signals | null { + if (value === undefined || value.length === 0) return null; + const canonical = value.startsWith("SIG") ? value : SIGNAL_NAMES[value.toLowerCase()]; + if (canonical === undefined || !SIGNAL_SET.has(canonical)) { + throw new Error(`subprocess-createos: unknown exit signal ${JSON.stringify(value)}`); + } + return canonical as NodeJS.Signals; +} + +const SIGNAL_NAMES: Readonly> = { + aborted: "SIGABRT", + alarm: "SIGALRM", + "bus error": "SIGBUS", + "broken pipe": "SIGPIPE", + child: "SIGCHLD", + continued: "SIGCONT", + "floating point exception": "SIGFPE", + hangup: "SIGHUP", + "illegal instruction": "SIGILL", + interrupt: "SIGINT", + killed: "SIGKILL", + quit: "SIGQUIT", + "segmentation fault": "SIGSEGV", + stopped: "SIGSTOP", + "stopped (tty input)": "SIGTTIN", + "stopped (tty output)": "SIGTTOU", + "stopped (signal)": "SIGTSTP", + terminated: "SIGTERM", + "trace/breakpoint trap": "SIGTRAP", + "urgent i/o condition": "SIGURG", + "user defined signal 1": "SIGUSR1", + "user defined signal 2": "SIGUSR2", + "window changed": "SIGWINCH", + "cpu time limit exceeded": "SIGXCPU", + "file size limit exceeded": "SIGXFSZ", +}; + +const SIGNAL_SET = new Set([ + ...Object.values(SIGNAL_NAMES), + "SIGIO", + "SIGPOLL", + "SIGPROF", + "SIGSYS", + "SIGVTALRM", +]); + +/** Managed-process operations consumed by ordinary remote handles. */ +export type CreateOSProcessOperations = Pick< + CreateOSProcesses, + "create" | "connect" | "input" | "closeStdin" | "wait" | "terminate" +>; + +/** Long-poll until one requested process scope exits or cancellation wins. */ +export async function waitScope( + processes: Pick, + id: CreateOSProcessDetails["process_id"], + scope: "leader" | "tree", + signal?: AbortSignal, +): Promise { + for (;;) { + if (signal?.aborted === true) return undefined; + try { + return await processes.wait(id, scope, 30_000, signal); + } catch (error: unknown) { + if (isAborted(signal)) return undefined; + if (isWaitTimeout(error)) continue; + throw error; + } + } +} + +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true; +} + +function isWaitTimeout(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (("statusCode" in error && error.statusCode === 408) || + ("name" in error && error.name === "CreateosSandboxTimeoutError")) + ); +} + +/** Require the terminal event kinds used by the provider. */ +export function assertProcessEvent(event: CreateOSProcessEvent): void { + if (event.type === "error") + throw new Error(`subprocess-createos: output stream failed: ${event.error}`); +} diff --git a/packages/dsh-createos/src/subprocess/terminal.ts b/packages/dsh-createos/src/subprocess/terminal.ts new file mode 100644 index 0000000..99061c0 --- /dev/null +++ b/packages/dsh-createos/src/subprocess/terminal.ts @@ -0,0 +1,220 @@ +/** PTY-backed terminal handle over CreateOS managed processes. */ + +import { Buffer } from "node:buffer"; +import { PassThrough } from "node:stream"; +import type { Readable } from "node:stream"; +import type { + CreateOSProcessDetails, + CreateOSProcesses, + CreateOSSandbox, +} from "@nodeops-createos/dsh-createos/createos"; +import type { + SubprocessOutcome, + SubprocessTerminalForeground, + SubprocessTerminalHandle, + SubprocessTerminalSignal, + SubprocessTerminalSpawnSpec, +} from "@deepseek-ai/dsh-subprocess"; +import { decodeOutput } from "./output.ts"; +import { assertProcessEvent, processOutcome, startGate } from "./shared.ts"; + +/** One CreateOS PTY resource with serialized control operations. */ +export class CreateOSTerminalHandle implements SubprocessTerminalHandle { + /** @inheritdoc */ + readonly pid: number; + /** @inheritdoc */ + readonly output: Readable; + /** @inheritdoc */ + readonly done: Promise; + + private readonly stream = new PassThrough(); + private readonly completion = Promise.withResolvers(); + private readonly operations = new AbortController(); + private writeTail: Promise = Promise.resolve(); + private termination: Promise | undefined; + + private constructor( + private readonly sandbox: CreateOSSandbox, + private readonly processes: CreateOSProcesses, + private readonly process: CreateOSProcessDetails, + private readonly graceMs: number, + ) { + this.pid = process.pid; + this.output = this.stream; + this.done = this.completion.promise; + } + + /** Allocate, attach, and release the private start gate before publication. */ + static async create( + sandbox: CreateOSSandbox, + processes: CreateOSProcesses, + remoteEnvironment: string, + spec: SubprocessTerminalSpawnSpec, + environmentArgs: (raw: string, explicit: Readonly | undefined) => string[], + ): Promise { + const program = spec.argv[0]; + if (program === undefined || program.length === 0) + throw new Error("subprocess-createos: terminal argv requires a program"); + const gate = startGate(true); + const process = await processes.create( + { + cmd: "/bin/sh", + args: [ + "-c", + gate.script, + "dsh-createos-terminal", + gate.marker, + gate.token, + ...environmentArgs(remoteEnvironment, spec.env), + program, + ...spec.argv.slice(1), + ], + cwd: spec.cwd, + pty: { rows: spec.rows, cols: spec.cols }, + }, + spec.signal, + ); + const handle = new CreateOSTerminalHandle(sandbox, processes, process, spec.graceMs); + try { + await handle.attach(gate.marker, gate.token, spec.signal); + return handle; + } catch (error: unknown) { + await processes.terminate(process.process_id, spec.graceMs).catch(() => {}); + throw error; + } + } + + /** @inheritdoc */ + write(data: string): Promise { + if (this.termination !== undefined) + return Promise.reject(new Error("subprocess-createos: terminal is terminating")); + const write = this.writeTail.then(async () => { + await this.processes.input( + this.process.process_id, + Buffer.from(data), + this.operations.signal, + ); + }); + this.writeTail = write.catch(() => {}); + return write; + } + + /** @inheritdoc */ + async inspectForeground(): Promise { + if (this.termination !== undefined) return undefined; + const result = await this.sandbox.runCommand( + "/bin/ps", + ["-o", "tpgid=", "-p", String(this.pid)], + { + retry: false, + signal: this.operations.signal, + }, + ); + if (result.result.exit_code !== 0) return undefined; + const processGroupId = Number(result.result.stdout.trim()); + if (!Number.isSafeInteger(processGroupId) || processGroupId <= 0) return undefined; + return { processGroupId, inputWaiting: false }; + } + + /** @inheritdoc */ + async signalForeground(signal: SubprocessTerminalSignal): Promise { + const foreground = await this.inspectForeground(); + if (foreground === undefined) + throw new Error(`subprocess-createos: cannot resolve foreground group for ${this.pid}`); + if (signal === "SIGKILL" && foreground.processGroupId === this.pid) { + throw new Error( + "refusing to SIGKILL the terminal shell; terminate the terminal session instead", + ); + } + if (signal !== "SIGTSTP") { + await this.processes.signal(this.process.process_id, signal, this.operations.signal); + return foreground.processGroupId; + } + const result = await this.sandbox.runCommand( + "/bin/kill", + [`-${signal.slice(3)}`, "--", `-${foreground.processGroupId}`], + { retry: false, signal: this.operations.signal }, + ); + if (result.result.exit_code !== 0) { + throw new Error(`subprocess-createos: foreground signal failed: ${result.result.stderr}`); + } + return foreground.processGroupId; + } + + /** @inheritdoc */ + terminate(): Promise { + this.termination ??= this.terminateRemote(); + return this.termination; + } + + private async attach(marker: string, token: string, signal?: AbortSignal): Promise { + const setupSignal = + signal === undefined + ? this.operations.signal + : AbortSignal.any([signal, this.operations.signal]); + const iterator = this.processes.connect(this.process.process_id, 0, setupSignal); + let markerPending = Buffer.from(marker); + for (;;) { + signal?.throwIfAborted(); + const next = await iterator.next(); + if (next.done) throw new Error("subprocess-createos: terminal output ended during setup"); + const event = next.value; + assertProcessEvent(event); + if (event.type === "exit") + throw new Error("subprocess-createos: terminal exited during setup"); + if (event.type !== "data") continue; + const bytes = decodeOutput(event.data_base64); + const matched = Math.min(markerPending.length, bytes.length); + if (!bytes.subarray(0, matched).equals(markerPending.subarray(0, matched))) { + throw new Error("subprocess-createos: terminal start marker was not first output"); + } + markerPending = markerPending.subarray(matched); + const remaining = bytes.subarray(matched); + if (markerPending.length > 0) continue; + await this.processes.input(this.process.process_id, Buffer.from(`${token}\n`), setupSignal); + if (remaining.length > 0) this.stream.write(remaining); + void this.pump(iterator); + return; + } + } + + private async pump( + iterator: AsyncGenerator< + import("@nodeops-createos/dsh-createos/createos").CreateOSProcessEvent + >, + ): Promise { + try { + for (;;) { + const next = await iterator.next(); + if (next.done) throw new Error("subprocess-createos: terminal output ended without exit"); + const event = next.value; + assertProcessEvent(event); + if (event.type === "exit") { + this.stream.end(); + this.completion.resolve( + processOutcome({ exit_code: event.exit_code ?? null, signal: event.signal }), + ); + return; + } + if (event.type === "data") { + if (event.stream !== "pty") + throw new Error("subprocess-createos: PTY emitted a pipe stream"); + this.stream.write(decodeOutput(event.data_base64)); + } + } + } catch (error: unknown) { + if (this.termination !== undefined) return; + const failure = error instanceof Error ? error : new Error(String(error)); + this.stream.destroy(failure); + this.completion.reject(failure); + } + } + + private async terminateRemote(): Promise { + this.operations.abort(new Error("subprocess-createos: terminal is terminating")); + await this.writeTail; + const details = await this.processes.terminate(this.process.process_id, this.graceMs); + this.stream.end(); + this.completion.resolve(processOutcome(details)); + } +} diff --git a/packages/dsh-createos/tests/createos.spec.ts b/packages/dsh-createos/tests/createos.spec.ts new file mode 100644 index 0000000..d57ec3d --- /dev/null +++ b/packages/dsh-createos/tests/createos.spec.ts @@ -0,0 +1,127 @@ +import { Context } from "@deepseek-ai/cordis"; +import CreateOSRuntime, { + CreateOSProcessId, + CreateOSProcesses, +} from "@nodeops-createos/dsh-createos/createos"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sdk = vi.hoisted(() => ({ client: undefined as unknown })); + +vi.mock("@nodeops-createos/sandbox", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createClient: () => sdk.client }; +}); + +function details(id = "proc_abcdefghijklmnopqrstuv") { + return { + process_id: id, + kind: "process" as const, + pid: 42, + state: "running" as const, + leader_exited: false, + tree_exited: false, + created_at: "2026-08-19T00:00:00Z", + finished_at: null, + exit_code: null, + output: { oldest_seq: 0, newest_seq: 0, bytes: 0 }, + }; +} + +describe("CreateOSProcesses", () => { + it("uses opaque ids, disables create retries, and preserves reconnect offsets", async () => { + const request = vi.fn().mockResolvedValue(details()); + const stream = vi.fn(() => + (async function* () { + yield { type: "heartbeat" as const }; + })(), + ); + const processes = new CreateOSProcesses({ request, stream } as never, "sb_test"); + + const created = await processes.create({ cmd: "/bin/true", args: [] }); + expect(created.process_id).toBe("proc_abcdefghijklmnopqrstuv"); + expect(request).toHaveBeenCalledWith("POST", "/v1/sandboxes/sb_test/processes", { + body: { cmd: "/bin/true", args: [] }, + retry: false, + signal: undefined, + }); + + const iterator = processes.connect(created.process_id, 7); + await iterator.next(); + expect(stream).toHaveBeenCalledWith( + "GET", + `/v1/sandboxes/sb_test/processes/${created.process_id}/connect`, + { + query: { after: 7 }, + signal: undefined, + timeoutMs: 0, + }, + ); + }); + + it("rejects malformed cross-boundary ids", () => { + expect(() => CreateOSProcessId("../process")).toThrow("invalid managed process id"); + }); + + it("splits input at the API limit and rejects malformed stream events", async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ input_seq: 1 }) + .mockResolvedValueOnce({ input_seq: 2 }); + const stream = vi.fn(() => + (async function* () { + yield { type: "data", seq: -1 }; + })(), + ); + const processes = new CreateOSProcesses({ request, stream } as never, "sb_test"); + const id = CreateOSProcessId("proc_abcdefghijklmnopqrstuv"); + + await expect(processes.input(id, new Uint8Array(256 * 1024 + 1))).resolves.toEqual({ + input_seq: 2, + }); + expect(request).toHaveBeenCalledTimes(2); + await expect(processes.connect(id, 0).next()).rejects.toThrow("invalid output data"); + }); +}); + +describe("CreateOSRuntime", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("creates one shared sandbox, protects its runtime directory, and awaits destruction", async () => { + const runCommand = vi.fn(async (command: string) => ({ + result: { + exit_code: 0, + stdout: command === "/usr/bin/stat" ? "directory\n" : "", + stderr: "", + }, + })); + const destroy = vi.fn().mockResolvedValue({ id: "sb_test", status: "destroying" }); + const waitUntilDestroyed = vi + .fn() + .mockImplementation(async function (this: { status: string }) { + this.status = "destroyed"; + return this; + }); + const sandbox = { id: "sb_test", status: "running", runCommand, destroy, waitUntilDestroyed }; + const createSandbox = vi.fn().mockResolvedValue(sandbox); + sdk.client = { createSandbox, http: {} }; + + const ctx = new Context(); + const fiber = await ctx.plugin(CreateOSRuntime, { apiKey: "key", shape: "s-2vcpu-2gb" }); + const service = ctx.createos; + await expect(service.getSandbox()).resolves.toBe(sandbox); + expect(createSandbox).toHaveBeenCalledWith({ shape: "s-2vcpu-2gb" }, { retry: false }); + expect(runCommand).toHaveBeenNthCalledWith( + 1, + "/bin/mkdir", + ["-p", "--", "/root/workspace", "/root/workspace/.dsh-createos"], + { retry: false }, + ); + + await fiber.dispose(); + expect(destroy).toHaveBeenCalledWith({ retry: false }); + expect(waitUntilDestroyed).toHaveBeenCalledOnce(); + await expect(service.getSandbox()).rejects.toThrow("disposing"); + }); +}); diff --git a/packages/dsh-createos/tests/filesystem.spec.ts b/packages/dsh-createos/tests/filesystem.spec.ts new file mode 100644 index 0000000..5865832 --- /dev/null +++ b/packages/dsh-createos/tests/filesystem.spec.ts @@ -0,0 +1,91 @@ +import { Buffer } from "node:buffer"; +import { Context, Service } from "@deepseek-ai/cordis"; +import type { CreateOSSandbox } from "@nodeops-createos/dsh-createos/createos"; +import CreateOSFileSystem from "@nodeops-createos/dsh-createos/fs"; +import { describe, expect, it } from "vitest"; + +function encodedNul(...fields: string[]): string { + return Buffer.from(`${fields.join("\0")}\0`).toString("base64"); +} + +class FakeCreateOS extends Service { + readonly cwd = "/root/workspace"; + readonly files = new Map([ + ["/root/workspace/file.txt", new TextEncoder().encode("hello\n")], + ]); + readonly sandbox = { + id: "sb_test", + files: { + download: async (path: string) => (this.files.get(path) as Uint8Array).slice().buffer, + upload: async (path: string, body: BodyInit) => { + if (typeof body !== "string") throw new Error("test client expects string uploads"); + this.files.set(path, new TextEncoder().encode(body)); + }, + }, + runCommand: async (command: string, args: string[]) => { + if (command === "/bin/sh" && args[1]?.includes("realpath")) { + return result(Buffer.from(`${args[3]}\0`).toString("base64")); + } + if (command === "/bin/sh" && args[1]?.includes("stat")) { + const path = args[3] as string; + const data = this.files.get(path); + return data === undefined + ? result("", "stat: No such file", 1) + : result( + encodedNul( + "regular file", + String(data.byteLength), + "600", + "2026-08-19 00:00:00.000000000 +0000", + ), + ); + } + return result(""); + }, + } as unknown as CreateOSSandbox; + + constructor(ctx: Context) { + super(ctx, "createos"); + } + getSandbox(): Promise { + return Promise.resolve(this.sandbox); + } + getClient() { + return { + http: { + requestRaw: async (_method: string, _path: string, options: { query: { path: string } }) => + new Response((this.files.get(options.query.path) ?? new Uint8Array()).slice().buffer), + throwForResponse: async () => { + throw new Error("request failed"); + }, + }, + }; + } +} + +function result(stdout: string, stderr = "", exitCode = 0) { + return { result: { stdout, stderr, exit_code: exitCode } }; +} + +describe("CreateOSFileSystem", () => { + it("resolves canonical remote identity and reads bounded text and bytes", async () => { + const ctx = new Context(); + const owner = await ctx.plugin(FakeCreateOS); + const fsFiber = await ctx.plugin(CreateOSFileSystem); + const fs = ctx.fs; + + const target = await fs.resolve("file.txt"); + expect(String(target.targetKey)).toBe("/root/workspace/file.txt"); + await expect(fs.stat(target)).resolves.toMatchObject({ type: "file", size: 6 }); + await expect(fs.readText(target)).resolves.toBe("hello\n"); + await expect(fs.readBytes(target, undefined, 5)).rejects.toMatchObject({ + code: "FS_TOO_LARGE", + }); + await expect(fs.readBytes(target, undefined, 6)).resolves.toEqual( + new TextEncoder().encode("hello\n"), + ); + + await fsFiber.dispose(); + await owner.dispose(); + }); +}); diff --git a/packages/dsh-createos/tests/subprocess.spec.ts b/packages/dsh-createos/tests/subprocess.spec.ts new file mode 100644 index 0000000..46ea4f9 --- /dev/null +++ b/packages/dsh-createos/tests/subprocess.spec.ts @@ -0,0 +1,164 @@ +import { Buffer } from "node:buffer"; +import { once } from "node:events"; +import type { + CreateOSProcessDetails, + CreateOSProcessEvent, + CreateOSProcessSpec, +} from "@nodeops-createos/dsh-createos/createos"; +import type { SubprocessSpawnSpec } from "@deepseek-ai/dsh-subprocess"; +import { describe, expect, it } from "vitest"; +import { environmentArguments } from "../src/subprocess/environment.ts"; +import { CreateOSSubprocessHandle } from "../src/subprocess/process.ts"; +import type { CreateOSProcessOperations } from "../src/subprocess/shared.ts"; + +function processDetails(overrides: Partial = {}): CreateOSProcessDetails { + return { + process_id: "proc_abcdefghijklmnopqrstuv" as CreateOSProcessDetails["process_id"], + kind: "process", + pid: 4242, + state: "running", + leader_exited: false, + tree_exited: false, + created_at: "2026-08-19T00:00:00Z", + finished_at: null, + exit_code: null, + output: { oldest_seq: 0, newest_seq: 0, bytes: 0 }, + ...overrides, + }; +} + +class FakeProcesses implements CreateOSProcessOperations { + createdSpec: CreateOSProcessSpec | undefined; + readonly inputs: string[] = []; + closes = 0; + + async create(spec: CreateOSProcessSpec): Promise { + this.createdSpec = spec; + return processDetails(); + } + + async *connect(): AsyncGenerator { + const args = this.createdSpec?.args as string[]; + const marker = args[3] as string; + yield { + type: "data", + seq: 1, + stream: "stdout", + data_base64: Buffer.from(marker).toString("base64"), + }; + while (this.inputs.length === 0) await new Promise((resolve) => setTimeout(resolve, 0)); + yield { + type: "data", + seq: 2, + stream: "stdout", + data_base64: Buffer.from("hello\n").toString("base64"), + }; + yield { + type: "data", + seq: 3, + stream: "stderr", + data_base64: Buffer.from("warning\n").toString("base64"), + }; + yield { type: "exit", exit_code: 0 }; + } + + async input( + _id: CreateOSProcessDetails["process_id"], + data: Uint8Array, + ): Promise<{ input_seq: number }> { + this.inputs.push(Buffer.from(data).toString()); + return { input_seq: this.inputs.length }; + } + + async closeStdin(): Promise { + this.closes += 1; + } + async wait(): Promise { + return processDetails({ leader_exited: true, tree_exited: true }); + } + async terminate(): Promise { + return processDetails({ + state: "exited", + leader_exited: true, + tree_exited: true, + signal: "terminated", + }); + } +} + +const baseSpec: SubprocessSpawnSpec = { + argv: ["/bin/echo", "hello"], + cwd: "/root/workspace", + stdio: { + stdin: { data: "payload" }, + stdout: { maxBytes: 64 }, + stderr: "pipe", + }, + graceMs: 100, + env: { EXPLICIT: "yes" }, +}; + +describe("CreateOSSubprocessHandle", () => { + it("opens the private start gate, streams both pipes, and closes batch stdin", async () => { + const processes = new FakeProcesses(); + const handle = new CreateOSSubprocessHandle( + processes, + Promise.resolve("PATH=/usr/bin\0NPM_TOKEN=secret\0"), + baseSpec, + environmentArguments, + ); + const stderr = [] as Buffer[]; + handle.stderr?.on("data", (chunk) => stderr.push(chunk as Buffer)); + + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }); + expect(handle.pid).toBe(4242); + expect(processes.inputs).toHaveLength(2); + expect(processes.inputs[0]).toMatch(/^dsh-[0-9a-f-]+\n$/u); + expect(processes.inputs[1]).toBe("payload"); + expect(processes.closes).toBe(1); + expect(handle.collected.stdout?.readFrom(0)).toMatchObject({ text: "hello\n", lossy: false }); + expect(Buffer.concat(stderr).toString()).toBe("warning\n"); + expect(processes.createdSpec?.env).toBeUndefined(); + expect(processes.createdSpec?.args).toContain("EXPLICIT=yes"); + expect(processes.createdSpec?.args).not.toContain("NPM_TOKEN=secret"); + }); + + it("terminates through the cgroup-backed resource endpoint", async () => { + const processes = new FakeProcesses(); + processes.connect = async function* () { + const args = processes.createdSpec?.args as string[]; + yield { + type: "data", + seq: 1, + stream: "stdout", + data_base64: Buffer.from(args[3] as string).toString("base64"), + }; + await new Promise(() => {}); + }; + const handle = new CreateOSSubprocessHandle( + processes, + Promise.resolve("PATH=/usr/bin\0"), + { ...baseSpec, stdio: { stdin: "pipe", stdout: "pipe", stderr: "pipe" } }, + environmentArguments, + ); + while (handle.pid < 0) await new Promise((resolve) => setTimeout(resolve, 0)); + handle.terminate(); + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: "SIGTERM" }); + await expect(handle.waitForExit()).resolves.toBe(true); + }); + + it("keeps raw pipe streams readable through completion", async () => { + const processes = new FakeProcesses(); + const handle = new CreateOSSubprocessHandle( + processes, + Promise.resolve("PATH=/usr/bin\0"), + { ...baseSpec, stdio: { stdin: "ignore", stdout: "pipe", stderr: "pipe" } }, + environmentArguments, + ); + const output: Buffer[] = []; + handle.stdout?.on("data", (chunk) => output.push(chunk as Buffer)); + await handle.done; + if (handle.stdout !== undefined) await once(handle.stdout, "close").catch(() => {}); + expect(Buffer.concat(output).toString()).toBe("hello\n"); + }); +}); diff --git a/packages/dsh-createos/tests/terminal.spec.ts b/packages/dsh-createos/tests/terminal.spec.ts new file mode 100644 index 0000000..8f37100 --- /dev/null +++ b/packages/dsh-createos/tests/terminal.spec.ts @@ -0,0 +1,100 @@ +import { Buffer } from "node:buffer"; +import type { + CreateOSProcessDetails, + CreateOSProcessEvent, + CreateOSProcessSpec, + CreateOSSandbox, +} from "@nodeops-createos/dsh-createos/createos"; +import { describe, expect, it, vi } from "vitest"; +import { environmentArguments } from "../src/subprocess/environment.ts"; +import { CreateOSTerminalHandle } from "../src/subprocess/terminal.ts"; + +function details(): CreateOSProcessDetails { + return { + process_id: "proc_abcdefghijklmnopqrstuv" as CreateOSProcessDetails["process_id"], + kind: "pty", + pid: 88, + state: "running", + leader_exited: false, + tree_exited: false, + created_at: "2026-08-19T00:00:00Z", + finished_at: null, + exit_code: null, + output: { oldest_seq: 0, newest_seq: 0, bytes: 0 }, + }; +} + +describe("CreateOSTerminalHandle", () => { + it("allocates an arbitrary PTY command and reports the group it signals", async () => { + let spec: CreateOSProcessSpec | undefined; + const inputs: string[] = []; + const processes = { + create: vi.fn(async (value: CreateOSProcessSpec) => { + spec = value; + return details(); + }), + connect: () => + (async function* (): AsyncGenerator { + const marker = spec?.args?.[3] ?? ""; + yield { + type: "data", + seq: 1, + stream: "pty", + data_base64: Buffer.from(marker).toString("base64"), + }; + while (inputs.length === 0) await new Promise((resolve) => setTimeout(resolve, 0)); + yield { + type: "data", + seq: 2, + stream: "pty", + data_base64: Buffer.from("prompt$ ").toString("base64"), + }; + yield { type: "exit", exit_code: 0 }; + })(), + input: vi.fn(async (_id, data: Uint8Array) => { + inputs.push(Buffer.from(data).toString()); + return { input_seq: inputs.length }; + }), + signal: vi.fn(async () => {}), + terminate: vi.fn(async () => ({ + ...details(), + state: "exited" as const, + leader_exited: true, + tree_exited: true, + signal: "terminated", + })), + }; + const runCommand = vi.fn(async (command: string) => + command === "/bin/ps" + ? { result: { exit_code: 0, stdout: "99\n", stderr: "" } } + : { result: { exit_code: 0, stdout: "", stderr: "" } }, + ); + const sandbox = { runCommand } as unknown as CreateOSSandbox; + + const terminal = await CreateOSTerminalHandle.create( + sandbox, + processes as never, + "PATH=/usr/bin\0", + { argv: ["/bin/bash", "-il"], cwd: "/root/workspace", rows: 24, cols: 80, graceMs: 100 }, + environmentArguments, + ); + expect(spec?.pty).toEqual({ rows: 24, cols: 80 }); + await expect(terminal.inspectForeground()).resolves.toEqual({ + processGroupId: 99, + inputWaiting: false, + }); + await expect(terminal.signalForeground("SIGTSTP")).resolves.toBe(99); + expect(runCommand).toHaveBeenLastCalledWith( + "/bin/kill", + ["-TSTP", "--", "-99"], + expect.any(Object), + ); + await expect(terminal.signalForeground("SIGINT")).resolves.toBe(99); + expect(processes.signal).toHaveBeenCalledWith( + details().process_id, + "SIGINT", + expect.any(AbortSignal), + ); + await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null }); + }); +}); diff --git a/packages/dsh-createos/tsconfig.json b/packages/dsh-createos/tsconfig.json new file mode 100644 index 0000000..a9c7f38 --- /dev/null +++ b/packages/dsh-createos/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/pi-extension/src/fanout.ts b/packages/pi-extension/src/fanout.ts index 55a4579..79882c7 100644 --- a/packages/pi-extension/src/fanout.ts +++ b/packages/pi-extension/src/fanout.ts @@ -34,7 +34,9 @@ export interface FanoutResult { } export function createScenarioCommand(scenario: FanoutScenario): string { - const environment = scenario.environment.map(({ name, value }) => `${name}=${shellQuote(value)}`).join(" "); + const environment = scenario.environment + .map(({ name, value }) => `${name}=${shellQuote(value)}`) + .join(" "); return `cd ${shellQuote(REMOTE_DIR)} && ${environment ? `${environment} ` : ""}${scenario.command}`; } @@ -54,7 +56,9 @@ export async function fanoutScenarios( } } - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, options.scenarios.length) }, worker)); + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, options.scenarios.length) }, worker), + ); if (signal?.aborted) throw new Error("aborted"); return results; } @@ -130,19 +134,31 @@ async function runForeground( }; } if (result.exitCode !== 0) { - return { name, sandboxId, verified: false, output: result.stdout, error: "scenario command failed" }; + return { + name, + sandboxId, + verified: false, + output: result.stdout, + error: "scenario command failed", + }; } return { name, sandboxId, verified: true, output: result.stdout }; } export function createHealthCheckUrl(url: string, path = "/"): URL { - if (!path.startsWith("/") || path.startsWith("//")) throw new Error("health check path must be relative"); + if (!path.startsWith("/") || path.startsWith("//")) + throw new Error("health check path must be relative"); const endpoint = new URL(path, url); - if (endpoint.origin !== new URL(url).origin) throw new Error("health check must use the sandbox ingress URL"); + if (endpoint.origin !== new URL(url).origin) + throw new Error("health check must use the sandbox ingress URL"); return endpoint; } -async function verifyScenario(url: string, scenario: FanoutScenario, signal?: AbortSignal): Promise { +async function verifyScenario( + url: string, + scenario: FanoutScenario, + signal?: AbortSignal, +): Promise { const endpoint = createHealthCheckUrl(url, scenario.healthCheckPath); for (let attempt = 0; attempt < 15; attempt += 1) { if (signal?.aborted) throw new Error("aborted"); @@ -151,7 +167,11 @@ async function verifyScenario(url: string, scenario: FanoutScenario, signal?: Ab const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; const response = await fetch(endpoint, { signal: requestSignal, redirect: "error" }); const body = await response.text(); - if (response.ok && (!scenario.healthCheckContains || body.includes(scenario.healthCheckContains))) return true; + if ( + response.ok && + (!scenario.healthCheckContains || body.includes(scenario.healthCheckContains)) + ) + return true; } catch { // The process may still be starting. } diff --git a/packages/pi-extension/src/tools.ts b/packages/pi-extension/src/tools.ts index 7985570..c492818 100644 --- a/packages/pi-extension/src/tools.ts +++ b/packages/pi-extension/src/tools.ts @@ -143,14 +143,30 @@ export function registerTools(pi: ExtensionAPI, getActive: () => ToolSandbox | n }), ), ), - port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535, description: "Server port; omit for a foreground test" })), - health_check_path: Type.Optional(Type.String({ description: "HTTP path to check (default: /)" })), - health_check_contains: Type.Optional(Type.String({ description: "Expected text in a successful health response" })), + port: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 65535, + description: "Server port; omit for a foreground test", + }), + ), + health_check_path: Type.Optional( + Type.String({ description: "HTTP path to check (default: /)" }), + ), + health_check_contains: Type.Optional( + Type.String({ description: "Expected text in a successful health response" }), + ), }), { minItems: 1, maxItems: 25, description: "Independent scenarios to run" }, ), - source_dir: Type.Optional(Type.String({ description: "Absolute local project directory (default: current directory)" })), - name_prefix: Type.Optional(Type.String({ description: "Sandbox name prefix (default: scenario)" })), + source_dir: Type.Optional( + Type.String({ + description: "Absolute local project directory (default: current directory)", + }), + ), + name_prefix: Type.Optional( + Type.String({ description: "Sandbox name prefix (default: scenario)" }), + ), shape: Type.Optional(Type.String({ description: "Sandbox size (default: s-2vcpu-2gb)" })), rootfs: Type.Optional(Type.String({ description: "Base image or template" })), }),