Merge studio WSL transport and cover remote host boundaries

Problem: Review requested WSL boundary evidence and removal of the unrelated
design-qa.md diff after studio introduced non-SSH machines.

Root cause: The new WSL transport supersedes a blanket rejection at shared
remote endpoints. SSH construction must still reject WSL entries, while
generic probe, browse and open must select their configured transport.
The earlier QA cleanup also deleted a document already owned by studio.

Fix: Integrate studio through 0363720bd and cover the real Hub/remotehost/pool
route with disposable WSL and SSH factories. Assert WSL failures survive the
HTTP boundary and never construct an SSH client. Cover Windows, distribution,
empty and fallback default paths. Restore design-qa.md byte-for-byte from
studio so it no longer appears in the PR diff. Retain the upstream SSH form's
refusal to overwrite non-SSH entries.

Verification: Frontend 5529 tests and production build; full serve,
remotehost, remote, attach, wslconn, config, builtin and boot suites; gofmt,
root vet, make lint and make check. No live Windows/WSL qualification claimed.
This commit is contained in:
SivanCola committed 2026-10-18 18:21:13 +08:00
commit 17f816d11c
33 files changed
+2401 -23

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
---
status: active
owner: @esengine
backup: @SivanCola
reviewed: 2026-10-13
---
# Foundation palette verification
final result: passed
Clear Blue and Tea Green implement the two approved colour directions for
[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909).
The existing default remains the baseline. Layout, typography, copy and brand
assets follow the product rather than generated mockup variations.
## Screenshots
Captured from the actual Studio components with synthetic performance-harness
data, one conversation turn and an empty composer. Fixture workspace and host
names are not real user data. Screenshots use 1:1 density.
| Palette | Light, 1280 × 720 | Dark, 1280 × 720 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) |
| Palette | Light, 390 × 844 | Dark, 390 × 844 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) |
## Visual comparison
The approved light mockups were 1619 × 971 pixels, including a caption outside
the application. They were viewed together with the implementation screenshots.
Comparison excluded that caption and assessed the palette direction, rather
than treating generated layout drift as a product specification.
- Typography: existing families, size, line height and tracking retained. Plain
light palettes now share the default prose weight; user overrides still win.
- Spacing: existing grid, controls, corners and panel proportions retained.
- Colours: cool white/blue and warm white/tea-green surfaces match the approved
directions. Text, syntax and button contrast checks pass in both schemes.
- Assets: existing logos and icons retained; no added image or sky layers.
- Copy: existing workbench text retained; new palette names and descriptions
have Chinese and English labels.
Full views made the navigation, transcript and composer readable without
additional crops. Dark palettes extend the approved hues; the source mockups
did not specify a dark layout. Phone captures confirm no horizontal overflow.
## Findings and iteration history
1. Default preview inherited the selected pack, and plain packs changed reading
surfaces and weight. Shared canonical default variables and plain-surface
rules now keep the preview independent and typography consistent.
2. Initial comparison found neutral selected navigation and a dark send button
overridden by body colour. Shared pack selectors now use accent ink/wash and
the accent/foreground pair; disabled controls remain neutral.
3. Revised screenshots were compared with both approved directions. No
actionable P0/P1/P2 differences remain. Sky themes now join image themes in
the expandable collection; the active backdrop remains in the first group.
## Behavioural evidence
- Immediate selection, return to default, light/dark and system-following modes
verified in the browser; each preview follows its own scheme tokens.
- Default comparison: original and revised Studio CSS applied to the same DOM.
Fourteen style properties matched for 735 visible elements in each scheme
under a light system and 734 in each scheme under a dark system.
- Compared colour, background, border, shadow, font, size, weight, line height,
tracking, width, height, padding, margin and radius.
- Go integration tests cover selection, config persistence, server recreation
and reset to the default for both new built-in IDs.
- Frontend neighbourhood: 22 test files, 209 tests passed; production build and
typecheck passed. Browser warning/error logs were empty.
- Theme and HTTP server package tests, repository checks and whitespace checks
passed. Lint budgets were not increased.
## Remaining coverage
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
tokens. Real Git checks cover branches, creation, retry and cancellation; visual
QA passed. Captures remain in
[PR #13598](https://github.com/esengine/DeepSeek-Reasonix/pull/13598).
工作目录面板与准备状态复用现有字号、图标和表面样式;真实 Git 验证了分支搜索、
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
+125
View File
@@ -0,0 +1,125 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-17
---
# Using Reasonix from DeepSeek Harness
<a href="./ACP.md">ACP</a>
&nbsp;·&nbsp;
<a href="https://github.com/deepseek-ai/deepseek-harness/tree/main/packages/subagent/subagent-acp">DSH subagent-acp</a>
&nbsp;·&nbsp;
<a href="https://reasonix.io">Reasonix website</a>
## Purpose
DeepSeek Harness (`dsh`) can delegate a task to any Agent Client Protocol agent through its `subagent-acp` provider. Reasonix speaks ACP with `reasonix acp`, so a Harness agent can hand work to Reasonix as a subagent with no code on either side, only configuration.
Verified against `dsh` 0.2.1-rc.2 and `@deepseek-ai/dsh-subagent-acp` 0.2.1-rc.2, with a real DeepSeek model in the Reasonix child.
## Preconditions
1. Reasonix MUST be installed on the machine that runs `dsh`, and its model provider MUST be set up there: run `reasonix setup` once.
2. `reasonix` MUST be reachable by the command you configure (an absolute path is the safest).
3. The provider key MUST live in Reasonix's own home (`<Reasonix home>/.env`, see [CONFIG_PATHS](./CONFIG_PATHS.md)). Reasonix does not read provider keys from environment variables, so the `env` block of the Harness row cannot supply one.
4. The provider package version MUST equal the `dsh` version (`dsh --version`). Name it exactly: the `latest` tag points at an old `0.0.1-rc.1` and `next` may resolve to an older pre-release, and `dsh` refuses both as incompatible.
## Steps
1. Install the provider into the Harness profile you use:
```sh
dsh plugin --profile <profile> add @deepseek-ai/dsh-subagent-acp@<dsh version>
```
2. Add two rows to that profile's `cordis.patch.yml` (or `$DSH_HOME/cordis.patch.yml`):
```yaml
- insert:
- id: subagent-acp
name: '@deepseek-ai/dsh-subagent-acp'
config:
providerName: reasonix
command: reasonix
args: ['acp']
permission: reject
- id: tool-subagent-reasonix
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: reasonix
toolName: reasonix
maxDepth: provider-managed
```
3. Start `dsh`. The model now has a `reasonix` tool that takes a `prompt`, a short `description` and an optional `cwd`.
`maxDepth: provider-managed` is required: the provider cannot enforce a depth cap in the child, and `dsh` refuses to load the tool row without it.
## Ready-made package
The same two rows ship as a Harness bundle in this repository, `integrations/dsh-plugin`, so steps 1 and 2 can be one command. It is not on npm; `pnpm` installs the subdirectory through its `path:` selector (see [pnpm package sources](https://pnpm.io/package-sources)):
```sh
dsh plugin --profile <profile> add github:esengine/DeepSeek-Reasonix#path:/integrations/dsh-plugin
```
The bundle pins `@deepseek-ai/dsh-subagent-acp` and `dsh` to the same version (`0.2.1-rc.2` today) and uses `permission: reject`. Change the policy in your own `cordis.patch.yml`, never in the package.
## Finding it in Discover
DSH ships an optional "Custom plugin markets" feature with no default index; a user adds index URLs themselves. This repository publishes one:
1. In the DSH Web UI open Plugins, select More beside Experimental plugins and switch on Custom plugin markets.
2. In the Discover tab add this index URL:
```text
https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json
```
3. Select "Reasonix subagent" and install. The entry page shows the author, the website, the source repository and the exact pinned commit.
The index entry pins a commit, so it is re-pinned whenever the package changes (see the Studio release runbook).
## Configuration reference
| Key | Where | Meaning |
| --- | --- | --- |
| `command`, `args` | `subagent-acp` | What `dsh` spawns for every run. `['acp', '--model', '<name>']` selects the child model; `['acp', '--preset', 'delivery']` selects the execution setting. |
| `cwd` | `subagent-acp` | Child workspace. Omitted: the delegating session's working directory. |
| `permission` | `subagent-acp` | `reject` (default) or `allow`. See the next section. |
| `env` | `subagent-acp` | Extra child environment, layered over a credential-scrubbed copy of the parent's. Use it for `REASONIX_HOME` when the child should use a different home. |
| `maxDepth` | `tool-subagent` | MUST be `provider-managed`. |
## Permission policy
`dsh` never shows the child's permission cards to a person. The `permission` key answers every `session/request_permission` automatically:
| Policy | Effect on Reasonix |
| --- | --- |
| `reject` | `dsh` answers `cancelled`. Reasonix treats it as a refusal: the tool call fails with "blocked by permission policy", the model is told the call was declined, and the turn goes on. Writes and shell commands that need approval do not run. |
| `allow` | `dsh` selects the first `allow_once` or `allow_always` option of every request. Every approval card Reasonix raises is accepted without a person looking at it. |
Choosing `allow` MUST be a deliberate decision to let the Harness agent, and whoever steers it, run whatever Reasonix's tools can do in that workspace. It answers approval cards only; Reasonix's sandbox is configured separately in Reasonix.
1. Use `reject` for read-only research and review tasks.
2. Use `allow` only for a task you would approve wholesale, in a workspace you can discard or restore, and set `cwd` to that workspace.
## What the parent sees
| Event | Parent receives |
| --- | --- |
| Child answers | The child's final message text, as the closing message of a finished background subagent. |
| Child turn pauses as unverified (files written, no verification command ran) | The same final text, ending in a host-written `[paused] Not verified, …` line. The turn itself ends normally. |
| Child turn fails (model endpoint down, provider refusal) | A failed subagent with category `transport`. `dsh` does not put the child's error message into the parent model's view. |
| Parent cancels | The child is sent `session/cancel`, the turn ends as `cancelled`, and the process is reaped. |
## Limits
1. One fresh `reasonix acp` process per delegation; nothing from the parent conversation crosses, only the prompt text and the workspace path.
2. The child accepts no follow-up messages.
3. Only message text crosses back. The child's reasoning, tool traffic and plans stay in its own session.
4. `dsh` advertises no filesystem or terminal client capability, so Reasonix reads and writes the workspace directly.
5. The child pays for its own context; its tokens do not enter the parent conversation.
6. An install without credentials fails `session/new` with the ACP "Authentication required" error (`-32000`), which `dsh` reports as a failed start.
+12 -2
View File
@@ -476,8 +476,18 @@ A WSL distribution on a Windows machine is a host with `kind = "wsl"` and a `dis
- It has no address, so it takes only the folder and install fields. `host`, `port`, `user`, keys, passwords,
`proxy_jump`, `use_ssh_config` and forwards are rejected on it.
- Connecting to one is not available yet. An SSH dial of such an entry fails with `remote: host is not reached over
ssh` rather than guessing an address.
- Studio opens it like any other machine in its list. Connecting needs WSL 2 and a distribution that has `ps`
and `nohup`; a minimal image without them is refused with the missing name.
- The kernel runs inside a shell session Studio keeps open, because WSL ends everything a session started when it ends.
- When Studio closes its link, the kernel goes away with the distribution (WSL stops an idle one after about 15
seconds).
- Conversations are kept on disk and resume on the next connect.
- Nothing keeps a distribution running behind your back.
- Model credentials come from the distribution's own configuration (`provider = "remote"`): under WSL's default
networking the distribution cannot reach this machine's loopback, so this machine's provider is not shared.
- A folder chosen as a Windows path (`C:\work\app` or `\\wsl.localhost\<distro>\home\me\app`) is translated by
the distribution; a folder it cannot see is refused. The command-line `reasonix remote connect` still dials
SSH only: such an entry fails with `remote: host is not reached over ssh`.
CLI:
+1
View File
@@ -25,6 +25,7 @@ Workflow: `.github/workflows/release-studio.yml`. Trigger: push of a `studio-vX.
| P3 | `release-notes/studio/X.Y.Z.md` is in that commit and passes `make check`. | `git cat-file -e <sha>:release-notes/studio/X.Y.Z.md` |
| P4 | The version is valid semver and above the latest tag. | `git tag -l 'studio-v*' --sort=-v:refname \| head -1` |
| P5 | The Windows signing mode is known. `true` signs with Certum and blocks the release if signing fails; anything else ships unsigned and the body says so. | `gh variable list \| grep STUDIO_SIGNING` |
| P6 | The DeepSeek Harness market index pins the current `integrations/dsh-plugin`. On failure run `node scripts/dsh-market-index.mjs --pin` and commit the index with the release notes. | `node scripts/dsh-market-index.mjs --check` |
## 3. Steps
+77
View File
@@ -0,0 +1,77 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-17
---
# dsh-plugin-reasonix
English | [中文](#中文)
[Website](https://reasonix.io) · [Documentation](../../docs/DSH.md) · [Source](../../README.md)
Use [Reasonix](../../README.md) as a subagent in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`). The package is configuration only: one patch layer that points Harness's `subagent-acp` provider at `reasonix acp` and exposes it to the model as a `reasonix` tool. It contains no code.
## Before you install
1. Install Reasonix and run `reasonix setup` once on the same machine. Reasonix reads its provider key from its own home, not from environment variables, so Harness cannot pass one.
2. Make sure `reasonix` is on the `PATH` of the process that runs `dsh`.
## Install
```sh
dsh plugin --profile <profile> add github:esengine/DeepSeek-Reasonix#path:/integrations/dsh-plugin
```
The package is not on npm; `pnpm` installs the `integrations/dsh-plugin` subdirectory of this repository (the `path:` selector, see [pnpm package sources](https://pnpm.io/package-sources)).
## What it changes
| Row | Effect |
| --- | --- |
| `subagent-acp-reasonix` | Spawns `reasonix acp` for each delegation; `permission: reject`. |
| `tool-subagent-reasonix` | Adds the `reasonix` tool (`prompt`, `description`, optional `cwd`); `maxDepth: provider-managed`, which the provider requires. |
## Permissions
- `permission: reject` is the default. Every approval card Reasonix raises is declined, so the child can read and research but cannot write files or run commands that need approval.
- `permission: allow` makes Harness accept every card automatically. The delegated task can then do anything Reasonix's tools can do in that workspace.
- Change the policy only in your own `cordis.patch.yml`, for a task and workspace you would approve wholesale.
Details, what the parent receives, and limits: [Using Reasonix from DeepSeek Harness](../../docs/DSH.md).
## Versions
The provider version must equal your `dsh` version. This release pins `dsh` 0.2.1-rc.2.
---
## 中文
[官网](https://reasonix.io) · [文档](../../docs/DSH.md) · [源码](../../README.md)
把 [Reasonix](https://github.com/esengine/DeepSeek-Reasonix) 作为子智能体接入 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)。本包只有配置,没有代码:一层补丁,让 Harness 的 `subagent-acp` 提供方去启动 `reasonix acp`,并以 `reasonix` 工具的形式暴露给模型。
**安装前**
1. 在同一台机器上安装 Reasonix 并运行一次 `reasonix setup`。Reasonix 只从自己的主目录读取模型密钥,不读环境变量,所以 Harness 无法替它传密钥。
2. 保证运行 `dsh` 的进程的 `PATH` 里有 `reasonix`。
**安装**
```sh
dsh plugin --profile <profile> add github:esengine/DeepSeek-Reasonix#path:/integrations/dsh-plugin
```
包不在 npm 上:`pnpm` 通过 `path:` 选择器安装本仓库的 `integrations/dsh-plugin` 子目录(见 [pnpm package sources](https://pnpm.io/package-sources))。
**权限**
- 默认 `permission: reject`:Reasonix 弹出的每个审批卡都会被拒绝,子智能体只能读和调研,不能写文件或执行需要批准的命令。
- `permission: allow` 会让 Harness 自动同意所有审批卡,被委派的任务可以在该工作区里做 Reasonix 工具能做的任何事。
- 只在你自己的 `cordis.patch.yml` 里,为你愿意整体放行的任务和工作区才改成 `allow`。
详细说明见 [Using Reasonix from DeepSeek Harness](../../docs/DSH.md)。
**版本**:提供方版本必须与你的 `dsh` 版本一致,本版本固定为 `dsh` 0.2.1-rc.2。
+14
View File
@@ -0,0 +1,14 @@
- insert:
- id: subagent-acp-reasonix
name: '@deepseek-ai/dsh-subagent-acp'
config:
providerName: reasonix
command: reasonix
args: ['acp']
permission: reject
- id: tool-subagent-reasonix
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: reasonix
toolName: reasonix
maxDepth: provider-managed
+83
View File
@@ -0,0 +1,83 @@
{
"format": 1,
"title": {
"en": "Reasonix",
"zh": "Reasonix"
},
"description": {
"en": "Run Reasonix as a subagent from DeepSeek Harness.",
"zh": "在 DeepSeek Harness 中把 Reasonix 作为子智能体运行。"
},
"plugins": [
{
"name": "dsh-plugin-reasonix",
"title": {
"en": "Reasonix subagent",
"zh": "Reasonix 子智能体"
},
"description": {
"en": "Delegate tasks to Reasonix over the Agent Client Protocol. Configuration only, no code.",
"zh": "通过 Agent Client Protocol 把任务委派给 Reasonix。只有配置,没有代码。"
},
"author": "Reasonix",
"homepage": "https://reasonix.io",
"repository": {
"url": "https://github.com/esengine/DeepSeek-Reasonix",
"directory": "integrations/dsh-plugin"
},
"metadata": {
"category": "agents",
"labels": [
"reasonix",
"deepseek"
],
"keywords": [
"reasonix",
"acp",
"subagent",
"deepseek",
"delegation"
]
},
"versions": [
{
"version": "0.1.0",
"publishTimestamp": 1792315829,
"source": {
"type": "git",
"url": "https://github.com/esengine/DeepSeek-Reasonix",
"commit": "79430cf8cf978505a2a8f433e4dc29a6def5e3cf",
"path": "integrations/dsh-plugin"
},
"engines": {
"dsh": "0.2.1-rc.2"
}
}
]
}
],
"metadata": {
"categories": {
"agents": {
"title": {
"en": "Agents and orchestration",
"zh": "智能体与编排"
}
}
},
"labels": {
"reasonix": {
"title": {
"en": "Reasonix",
"zh": "Reasonix"
}
},
"deepseek": {
"title": {
"en": "DeepSeek",
"zh": "DeepSeek"
}
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "dsh-plugin-reasonix",
"version": "0.1.0",
"description": "Use Reasonix as a subagent in DeepSeek Harness (dsh) through its ACP provider. Configuration only, no code.",
"homepage": "https://reasonix.io",
"author": "Reasonix",
"keywords": [
"dsh-plugin",
"deepseek-harness",
"reasonix",
"acp",
"subagent"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/esengine/DeepSeek-Reasonix.git",
"directory": "integrations/dsh-plugin"
},
"files": [
"cordis.patch.yml",
"README.md"
],
"engines": {
"dsh": "0.2.1-rc.2"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"dependencies": {
"@deepseek-ai/dsh-subagent-acp": "0.2.1-rc.2"
},
"private": true
}
@@ -0,0 +1,24 @@
package config
import "testing"
func TestWSLDefaultWorkspacePreservesTheDeclaredPath(t *testing.T) {
for _, tc := range []struct {
name string
workspace string
workspaces []string
want string
}{
{"empty", "", nil, ""},
{"windows", ` C:\work\project `, []string{`D:\other`}, `C:\work\project`},
{"distribution", " /home/dev/project ", []string{"/other"}, "/home/dev/project"},
{"fallback", " ", []string{"", ` C:\work\fallback `, "/other"}, `C:\work\fallback`},
} {
t.Run(tc.name, func(t *testing.T) {
entry := RemoteHostEntry{Name: "dev", Kind: RemoteKindWSL, Distro: "Ubuntu", Workspace: tc.workspace, Workspaces: tc.workspaces}
if got := entry.DefaultWorkspace(); got != tc.want {
t.Fatalf("DefaultWorkspace() = %q, want %q", got, tc.want)
}
})
}
}
@@ -0,0 +1,74 @@
package remotehost
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/attach"
)
func TestWSLRequestsNeverReachTheSSHTransport(t *testing.T) {
home := testenv.TempDir(t)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
entry := config.RemoteHostEntry{Name: "dev", Kind: config.RemoteKindWSL, Distro: "Ubuntu", Workspace: `C:\work\project`}
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
return nil, c.UpsertRemoteHost(entry)
}); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if client, err := attach.Dial(cfg, entry.Name, attach.Prompts{}); !errors.Is(err, remote.ErrNotSSHHost) || client != nil {
t.Fatalf("SSH construction = %v, %v; want no client and ErrNotSSHHost", client, err)
}
for _, route := range []string{"probe", "dirs", "open", "tree", "sessions/pin", "sessions/archive", "sessions/remove"} {
t.Run(route, func(t *testing.T) {
sshCalls, wslCalls := 0, 0
link := &Link{pool: attach.NewPool(t.Context(), attach.Options{
Dial: func(string, attach.Prompts) (*remote.Client, error) {
sshCalls++
return nil, fmt.Errorf("unexpected SSH construction")
},
DialWSL: func(got config.RemoteHostEntry) (attach.Link, error) {
wslCalls++
if got.Name != entry.Name || got.Distro != entry.Distro || got.Host != "" {
t.Errorf("WSL entry = %+v", got)
}
return nil, fmt.Errorf("WSL fixture unavailable")
},
})}
hub := serve.NewHub(serve.HubOptions{Remote: link})
method, path, body := http.MethodGet, "/remotes/dev/"+route, ""
if route == "open" {
method, path, body = http.MethodPost, "/remotes/open", `{"host":"dev"}`
} else if strings.HasPrefix(route, "sessions/") {
method, body = http.MethodPost, `{"path":"/session.jsonl","pinned":true,"archived":true}`
}
req := httptest.NewRequest(method, "http://localhost"+path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+hub.AuthToken())
req.Header.Set("Content-Type", "application/json")
out := httptest.NewRecorder()
hub.Handler().ServeHTTP(out, req)
var reason serve.Reason
if err := json.Unmarshal(out.Body.Bytes(), &reason); err != nil {
t.Fatal(err)
}
if out.Code != http.StatusBadGateway || reason.Code != "remote.attach_failed" || !strings.Contains(out.Body.String(), "WSL fixture unavailable") || sshCalls != 0 || wslCalls != 1 {
t.Fatalf("status=%d body=%s SSH=%d WSL=%d", out.Code, out.Body.String(), sshCalls, wslCalls)
}
})
}
}
+45 -8
View File
@@ -21,6 +21,7 @@ import (
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/bootstrap"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/wsl"
)
// Broker is this machine's provider broker: the loopback address it listens on
@@ -51,6 +52,9 @@ type Options struct {
// which is the only part of an attach that needs a configured machine, so
// substituting it is what lets the rest be exercised against a real one.
Dial func(host string, prompts Prompts) (*remote.Client, error)
// DialWSL builds a WSL machine's connection. Nil binds the distribution the
// entry names through the system wsl.exe.
DialWSL func(entry config.RemoteHostEntry) (Link, error)
}
// Call is what one attach decides for itself: where the progress of a first
@@ -120,7 +124,7 @@ func (g *gate) wait(ctx context.Context) error {
type link struct {
gate
host string
client *remote.Client
client Link
install string // the host entry's own strategy, read once at dial
// provider is where this host's model credentials come from, read at dial
// beside install. A host set to resolve its own gets no broker forward.
@@ -170,7 +174,7 @@ func (p *Pool) broker(l *link) (string, error) {
}
// boundBroker is the remote address the broker forward is listening on.
func boundBroker(client *remote.Client) (string, bool) {
func boundBroker(client Link) (string, bool) {
for _, e := range client.Forwards().List() {
if e.Spec.Name == brokerForwardName && e.Up && e.BoundAddr != "" {
return e.BoundAddr, true
@@ -181,7 +185,7 @@ func boundBroker(client *remote.Client) (string, bool) {
// followBroker points every kernel on l at the broker's forward after the link
// comes back. The kernels outlive the link; the port they were told may not.
func (p *Pool) followBroker(l *link, client *remote.Client) {
func (p *Pool) followBroker(l *link, client Link) {
if !p.brokers(l) {
return
}
@@ -349,15 +353,20 @@ func (p *Pool) dial(l *link, call Call) (unsubscribe func()) {
fail(err)
return nil
}
if entry, ok := cfg.RemoteHost(l.host); ok {
entry, configured := cfg.RemoteHost(l.host)
if configured {
l.install = entry.ServeInstallMode()
l.provider = entry.ProviderMode()
}
build := p.opts.Dial
if build == nil {
build = func(host string, prompts Prompts) (*remote.Client, error) { return Dial(cfg, host, prompts) }
var client Link
if configured && entry.HostKind() == config.RemoteKindWSL {
// A distribution has no tunnel to publish this machine's broker through,
// so it resolves providers from its own configuration.
l.provider = config.RemoteProviderRemote
client, err = p.openWSL(entry)
} else {
client, err = p.openSSH(cfg, l.host)
}
client, err := build(l.host, p.opts.Prompts)
if err != nil {
fail(err)
return nil
@@ -387,6 +396,25 @@ func (p *Pool) dial(l *link, call Call) (unsubscribe func()) {
return unsubscribe
}
func (p *Pool) openSSH(cfg *config.Config, host string) (Link, error) {
build := p.opts.Dial
if build == nil {
build = func(host string, prompts Prompts) (*remote.Client, error) { return Dial(cfg, host, prompts) }
}
c, err := build(host, p.opts.Prompts)
if err != nil {
return nil, err
}
return sshLink{c}, nil
}
func (p *Pool) openWSL(entry config.RemoteHostEntry) (Link, error) {
if p.opts.DialWSL != nil {
return p.opts.DialWSL(entry)
}
return newWSLLink(p.ctx, entry, wsl.System(), wsl.SystemCommand())
}
func (p *Pool) forgetLink(l *link) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -430,6 +458,15 @@ func (p *Pool) holdSpace(l *link, workspace string) (*space, bool) {
// that reaches it.
func (p *Pool) serve(ctx context.Context, l *link, s *space, workspace string, call Call) {
defer close(s.ready)
if m, ok := l.client.(workspaceMapper); ok {
mapped, err := m.Workspace(ctx, workspace)
if err != nil {
s.err = err
p.forgetSpace(l, s)
return
}
workspace = mapped
}
install := p.opts.Install
if install == "" {
install = l.install
+1 -12
View File
@@ -59,21 +59,10 @@ func (p *Pool) Browse(ctx context.Context, host, dir string) (Listing, error) {
// Released on a timer, not at return — see browseLinger.
defer func() { time.AfterFunc(browseLinger, func() { p.dropLink(l) }) }()
fsys, err := l.client.SFTP()
if err != nil {
return Listing{}, err
}
if strings.TrimSpace(dir) == "" {
dir = "~"
}
// The far machine resolves it: ~ belongs to the account that logged in, and
// a symlinked project must be listed under the path it really has, or
// stepping up from it would leave the tree it was reached through.
at, err := fsys.RealPath(ctx, dir)
if err != nil {
return Listing{}, err
}
entries, err := fsys.List(ctx, at)
at, entries, err := l.client.Dirs(ctx, dir)
if err != nil {
return Listing{}, err
}
+59
View File
@@ -0,0 +1,59 @@
package attach
import (
"context"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/bootstrap"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/remote/sftpfs"
)
// Link is one machine's connection as the pool uses it: commands and files for
// the bootstrap, a way to reach a port that machine listens on, and a state to
// report. An SSH client is one, and a WSL distribution is the other.
type Link interface {
bootstrap.Conn
Start(ctx context.Context) error
Close() error
Subscribe(fn func(remote.StatusEvent)) (cancel func())
Forwards() Forwards
// Dirs resolves dir (empty or ~ meaning the login home) on that machine and
// lists what is inside it, in that machine's own spelling.
Dirs(ctx context.Context, dir string) (at string, entries []sftpfs.Entry, err error)
}
// workspaceMapper is a Link whose machine spells folders its own way, so a path
// chosen on this one has to be translated before the kernel there is asked to
// open it.
type workspaceMapper interface {
Workspace(ctx context.Context, p string) (string, error)
}
// Forwards is the part of a forward registry the pool drives.
type Forwards interface {
Add(spec forward.Spec) (string, error)
List() []forward.Entry
Remove(name string) error
}
// sshLink adapts a supervised SSH client to Link.
type sshLink struct{ *remote.Client }
func (l sshLink) Forwards() Forwards { return l.Client.Forwards() }
func (l sshLink) Dirs(ctx context.Context, dir string) (string, []sftpfs.Entry, error) {
fsys, err := l.SFTP()
if err != nil {
return "", nil, err
}
// The far machine resolves it: ~ belongs to the account that logged in, and
// a symlinked project must be listed under the path it really has, or
// stepping up from it would leave the tree it was reached through.
at, err := fsys.RealPath(ctx, dir)
if err != nil {
return "", nil, err
}
entries, err := fsys.List(ctx, at)
return at, entries, err
}
+315
View File
@@ -0,0 +1,315 @@
package attach
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/remote/wslconn"
"reasonix/internal/platform/wsl"
)
// ErrNoRemoteForward: a WSL distribution has no channel to publish a port of
// this machine into, so the one thing that would ask for it must not be sent.
var ErrNoRemoteForward = errors.New("attach: a WSL machine cannot be given a port of this one")
// wslLink is a WSL distribution as a Link. The distribution's loopback is
// reachable from here, so a "forward" is the address itself: nothing listens
// on this side and nothing has to be torn down.
type wslLink struct {
*wslconn.Conn
host string
anchorStop context.CancelFunc
anchorDone chan struct{}
anchorIn io.Writer // guarded by mu; nil while no session is up
anchorUp chan struct{}
upOnce sync.Once
mu sync.Mutex
subs map[int]func(remote.StatusEvent)
next int
last remote.StatusEvent
have bool
}
func newWSLLink(ctx context.Context, entry config.RemoteHostEntry, list wsl.Runner, run wsl.Command) (Link, error) {
conn, err := wslconn.New(ctx, list, run, entry.Distro)
if err != nil {
return nil, err
}
return &wslLink{Conn: conn, host: entry.Name, subs: map[int]func(remote.StatusEvent){}, anchorUp: make(chan struct{})}, nil
}
func (l *wslLink) publish(ev remote.StatusEvent) {
ev.Host, ev.At = l.host, time.Now()
l.mu.Lock()
l.last, l.have = ev, true
fns := make([]func(remote.StatusEvent), 0, len(l.subs))
for _, fn := range l.subs {
fns = append(fns, fn)
}
l.mu.Unlock()
for _, fn := range fns {
fn(ev)
}
}
// requiredTools prints the first program a kernel launch depends on that the
// distribution lacks and stops with exitMissingTool. Minimal images omit ps,
// which is how the launched process is identified as the kernel.
const (
requiredTools = `for t in ps nohup; do command -v "$t" >/dev/null 2>&1 || { echo "$t"; exit 46; }; done`
exitMissingTool = 46
)
// Workspace spells a folder the way the distribution sees it: a Windows path
// is translated by the distribution itself, anything else is already its own.
func (l *wslLink) Workspace(ctx context.Context, p string) (string, error) {
if !wsl.IsWindowsPath(p) {
return p, nil
}
return l.ToLinux(ctx, p)
}
// Start wakes the distribution: the first command is what boots it, and a
// distribution that cannot answer one is reported as the connect failing.
func (l *wslLink) Start(ctx context.Context) error {
l.publish(remote.StatusEvent{Status: remote.StatusConnecting})
res, err := l.Exec(ctx, requiredTools)
switch {
case err != nil:
case res.ExitCode == exitMissingTool:
err = fmt.Errorf("%w: %q has no %s, which starting a kernel there needs", wsl.ErrUnsupported, l.Distro(), strings.TrimSpace(string(res.Stdout)))
case res.ExitCode != 0:
err = fmt.Errorf("%w: %q answered with status %d", wsl.ErrUnsupported, l.Distro(), res.ExitCode)
}
if err != nil {
l.publish(remote.StatusEvent{Status: remote.StatusStopped, Err: err})
return err
}
l.publish(remote.StatusEvent{Status: remote.StatusConnected})
anchorCtx, stop := context.WithCancel(context.WithoutCancel(ctx))
l.anchorStop, l.anchorDone = stop, make(chan struct{})
go l.holdAnchor(anchorCtx)
select {
case <-l.anchorUp:
case <-ctx.Done():
stop()
<-l.anchorDone
return ctx.Err()
}
return nil
}
// anchorRetry is how long a lost anchor waits before the distribution is asked
// for another.
var anchorRetry = 2 * time.Second
// holdAnchor keeps one shell session open and feeds it the commands that must
// outlive the call that sent them: WSL ends everything a wsl.exe session
// started when it ends, setsid and nohup included. A lost session is asked for
// again, and the kernel it held is gone.
func (l *wslLink) holdAnchor(ctx context.Context) {
defer close(l.anchorDone)
for attempt := 1; ctx.Err() == nil; attempt++ {
pr, pw := io.Pipe()
l.mu.Lock()
l.anchorIn = pw
l.mu.Unlock()
l.upOnce.Do(func() { close(l.anchorUp) })
sessionDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = pw.Close()
case <-sessionDone:
}
}()
_, err := l.Run(ctx, pr, "/bin/sh")
close(sessionDone)
l.mu.Lock()
l.anchorIn = nil
l.mu.Unlock()
_ = pw.Close()
if ctx.Err() != nil {
return
}
if err == nil {
err = fmt.Errorf("%w: the session holding %q open ended", wsl.ErrUnsupported, l.Distro())
}
l.publish(remote.StatusEvent{Status: remote.StatusReconnecting, Attempt: attempt, Err: err})
select {
case <-ctx.Done():
return
case <-time.After(anchorRetry):
}
l.publish(remote.StatusEvent{Status: remote.StatusConnected, Attempt: attempt})
}
}
// ErrNoSession: the session that launches long-lived processes is not up, or
// ended before the command it was given finished.
var ErrNoSession = errors.New("attach: the WSL session that keeps a kernel alive is not open")
// ErrUnsafeScratch: the directory launch commands are staged in is not the
// current user's private directory, so what is written there could be read or
// replaced by someone else.
var ErrUnsafeScratch = errors.New("attach: the WSL launch directory is not private to this user")
// detachedPoll is how often a launched command is asked whether it finished.
const detachedPoll = 100 * time.Millisecond
const (
exitUnsafeScratch = 47
exitNotYet = 44
// scratch is a directory under the account's home that must be a real
// directory it owns and nobody else can enter. A path under /tmp would be
// accepted by mkdir -p whoever had made it.
scratch = `d="$HOME/.reasonix/link"`
)
// ExecDetached runs cmd inside the held session, so what it starts is not
// ended when this call returns. The command is written to a file and only its
// name is typed into the session, which keeps the script's text out of the
// session's input. The wait ends when the command does or when the session it
// was given to does, whichever is first.
func (l *wslLink) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
var raw [8]byte
_, _ = rand.Read(raw[:])
id := hex.EncodeToString(raw[:])
put, err := l.Script(ctx, strings.NewReader(cmd), scratch+`
mkdir -p -m 700 "$d" || exit 1
[ -d "$d" ] && [ ! -L "$d" ] && [ "$(stat -c %u "$d")" = "$(id -u)" ] && [ "$(stat -c %a "$d")" = 700 ] || exit 47
cat > "$d/$1.sh"`, id)
if err != nil {
return remote.ExecResult{}, err
}
switch put.ExitCode {
case 0:
case exitUnsafeScratch:
return remote.ExecResult{}, ErrUnsafeScratch
default:
return remote.ExecResult{}, fmt.Errorf("attach: stage the launch command: exit %d: %s", put.ExitCode, strings.TrimSpace(string(put.Stderr)))
}
in := l.session()
if in == nil {
return remote.ExecResult{}, ErrNoSession
}
line := fmt.Sprintf(scratch+`; /bin/sh "$d/%[1]s.sh" </dev/null >"$d/%[1]s.out" 2>"$d/%[1]s.err"; echo $? >"$d/%[1]s.rc"`+"\n", id)
wrote := make(chan error, 1)
go func() { _, err := io.WriteString(in, line); wrote <- err }()
select {
case err := <-wrote:
if err != nil {
return remote.ExecResult{}, fmt.Errorf("%w: %w", ErrNoSession, err)
}
case <-ctx.Done():
return remote.ExecResult{}, ctx.Err()
}
for {
res, err := l.Script(ctx, nil, scratch+`; [ -s "$d/$1.rc" ] || exit 44`, id)
if err != nil {
return remote.ExecResult{}, err
}
if res.ExitCode != exitNotYet {
break
}
if l.session() != in {
return remote.ExecResult{}, ErrNoSession
}
select {
case <-ctx.Done():
return remote.ExecResult{}, ctx.Err()
case <-time.After(detachedPoll):
}
}
return l.collect(ctx, id)
}
// session is the input of the shell session currently held, nil when none is.
func (l *wslLink) session() io.Writer {
l.mu.Lock()
defer l.mu.Unlock()
return l.anchorIn
}
// collect reads what a finished launch printed and how it ended, then removes
// its files. The status is read from its own file, so no exit code of the
// command can be mistaken for the poll's "not yet".
func (l *wslLink) collect(ctx context.Context, id string) (remote.ExecResult, error) {
read := func(name string) ([]byte, error) {
res, err := l.Script(ctx, nil, scratch+`; cat "$d/$1.`+name+`"`, id)
return res.Stdout, err
}
out, err := read("out")
if err != nil {
return remote.ExecResult{}, err
}
errb, err := read("err")
if err != nil {
return remote.ExecResult{}, err
}
rc, err := read("rc")
if err != nil {
return remote.ExecResult{}, err
}
_, _ = l.Script(ctx, nil, scratch+`; rm -f "$d/$1.sh" "$d/$1.out" "$d/$1.err" "$d/$1.rc"`, id)
code, err := strconv.Atoi(strings.TrimSpace(string(rc)))
if err != nil {
return remote.ExecResult{}, fmt.Errorf("attach: the launch left no readable status: %w", err)
}
return remote.ExecResult{Stdout: out, Stderr: errb, ExitCode: code}, nil
}
func (l *wslLink) Close() error {
if l.anchorStop != nil {
l.anchorStop()
<-l.anchorDone
}
l.publish(remote.StatusEvent{Status: remote.StatusStopped})
return nil
}
func (l *wslLink) Subscribe(fn func(remote.StatusEvent)) func() {
l.mu.Lock()
id := l.next
l.next++
l.subs[id] = fn
last, have := l.last, l.have
l.mu.Unlock()
if have {
fn(last)
}
return func() {
l.mu.Lock()
delete(l.subs, id)
l.mu.Unlock()
}
}
func (l *wslLink) Forwards() Forwards { return loopbackForwards{} }
// loopbackForwards reaches a port on the distribution's loopback directly.
type loopbackForwards struct{}
func (loopbackForwards) Add(spec forward.Spec) (string, error) {
if spec.Direction != forward.Local {
return "", ErrNoRemoteForward
}
return spec.TargetAddr, nil
}
func (loopbackForwards) List() []forward.Entry { return nil }
func (loopbackForwards) Remove(string) error { return nil }
@@ -0,0 +1,73 @@
//go:build windows
package attach
import (
"net/http"
"os"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/platform/wsl"
)
// REASONIX_LIVE_WSL names an installed WSL2 distribution that already has a
// reasonix 2.x on its PATH (install "never"); the test brings up a kernel in it
// through the pool and calls it from Windows over loopback.
func TestLiveAttachBringsUpAKernelInADistribution(t *testing.T) {
name := os.Getenv("REASONIX_LIVE_WSL")
if name == "" {
t.Skip("set REASONIX_LIVE_WSL to a WSL2 distribution with reasonix installed")
}
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
return nil, c.UpsertRemoteHost(config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: name, ServeInstall: "never"})
}); err != nil {
t.Fatal(err)
}
if _, err := wsl.SystemCommand()(t.Context(), nil, wsl.ExecArgs(name, "/bin/mkdir", "-p", "/tmp/reasonix-live-workspace")...); err != nil {
t.Fatal(err)
}
p := NewPool(t.Context(), Options{Install: "never"})
defer p.Close()
ep, err := p.Attach(t.Context(), "dev", "/tmp/reasonix-live-workspace", Call{Progress: func(step, detail string) { t.Logf("step %s %s", step, detail) }})
if err != nil {
t.Fatalf("Attach: %v", err)
}
t.Logf("kernel at %s workspace %s", ep.Addr, ep.Workspace)
req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+ep.Addr+"/", nil)
req.Header.Set("Authorization", "Bearer "+ep.Token)
var resp *http.Response
for range 20 {
if resp, err = http.DefaultClient.Do(req); err == nil {
break
}
time.Sleep(500 * time.Millisecond)
}
if err != nil {
t.Fatalf("the kernel did not answer over loopback: %v", err)
}
resp.Body.Close()
t.Logf("GET / = %d", resp.StatusCode)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode >= 500 {
t.Fatalf("the kernel refused the token it was started with: %d", resp.StatusCode)
}
// A second pane on the same workspace shares the one kernel.
again, err := p.Attach(t.Context(), "dev", "/tmp/reasonix-live-workspace", Call{})
if err != nil || again.Addr != ep.Addr {
t.Fatalf("second attach = %+v, %v; want the same address %s", again, err, ep.Addr)
}
again.Release()
// The same folder named the Windows way reaches the same kernel.
unc, err := p.Attach(t.Context(), "dev", `\\wsl.localhost\`+name+`\tmp\reasonix-live-workspace`, Call{})
if err != nil || unc.Addr != ep.Addr || unc.Workspace != ep.Workspace {
t.Fatalf("UNC attach = %+v, %v; want the kernel at %s for %s", unc, err, ep.Addr, ep.Workspace)
}
unc.Release()
ep.Release()
}
@@ -0,0 +1,126 @@
//go:build linux
package attach
import (
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/wsl"
)
// localSession answers wsl.exe's --exec by running the program here with HOME
// pointing at a directory the test owns, so the staging scripts and the session
// shell meet a real GNU userland.
func localSession(home string) wsl.Command {
return func(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
for i, a := range args {
if a == "--exec" {
args = args[i+1:]
break
}
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
cmd.Env = append(os.Environ(), "HOME="+home)
var out, errb bytes.Buffer
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, &out, &errb
err := cmd.Run()
res := wsl.Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode, err = ee.ExitCode(), nil
}
return res, err
}
}
func localLink(t *testing.T, home string) Link {
t.Helper()
if err := exec.Command("stat", "-c", "%a", "/").Run(); err != nil {
t.Skip("needs GNU stat")
}
m := &wslMachine{t: t}
l, err := newWSLLink(t.Context(), config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: "Ubuntu"}, m.list, localSession(home))
if err != nil {
t.Fatal(err)
}
return l
}
func detach(l Link) func(context.Context, string) (remote.ExecResult, error) {
return l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
}).ExecDetached
}
func TestALaunchRunsInARealSessionAndKeepsItsOwnStatus(t *testing.T) {
l := localLink(t, t.TempDir())
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
res, err := detach(l)(t.Context(), "echo out; echo err >&2; exit 44")
if err != nil || string(res.Stdout) != "out\n" || string(res.Stderr) != "err\n" || res.ExitCode != 44 {
t.Fatalf("ExecDetached = %+v, %v", res, err)
}
}
// mkdir -p succeeds on a directory someone else prepared, so the staging
// directory is checked after it is made and never trusted for existing.
func TestAScratchDirectoryOthersCouldUseIsRefused(t *testing.T) {
for name, prepare := range map[string]func(t *testing.T, dir string){
"group and world accessible": func(t *testing.T, dir string) {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
},
"world writable": func(t *testing.T, dir string) {
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
},
"a symlink to elsewhere": func(t *testing.T, dir string) {
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
t.Fatal(err)
}
elsewhere := t.TempDir()
if err := os.Chmod(elsewhere, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, dir); err != nil {
t.Fatal(err)
}
},
} {
t.Run(name, func(t *testing.T) {
home := t.TempDir()
prepare(t, filepath.Join(home, ".reasonix", "link"))
l := localLink(t, home)
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
if _, err := detach(l)(t.Context(), "echo hi"); !errors.Is(err, ErrUnsafeScratch) {
t.Fatalf("err = %v, want ErrUnsafeScratch", err)
}
entries, _ := os.ReadDir(filepath.Join(home, ".reasonix", "link"))
for _, e := range entries {
t.Errorf("a command was staged in the unsafe directory: %s", e.Name())
}
})
}
}
+342
View File
@@ -0,0 +1,342 @@
package attach
import (
"context"
"errors"
"io"
"slices"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/wsl"
)
// wslMachine is a fake wsl.exe: it lists one distribution and records the
// commands it is asked to run.
type wslMachine struct {
t *testing.T
version int
mu sync.Mutex
calls [][]string
// anchor decides how the long-lived session behaves; nil blocks until cancelled.
anchor func(ctx context.Context, stdin io.Reader) (wsl.Result, error)
// script answers every other command; nil answers success with no output.
script func(args []string, stdin io.Reader) (wsl.Result, error)
// typed collects what the session was sent.
typed strings.Builder
typedMu sync.Mutex
// started gets one value each time the long-lived session is launched.
started chan struct{}
}
func (m *wslMachine) list(context.Context, ...string) ([]byte, error) {
v := m.version
if v == 0 {
v = 2
}
return []byte(" NAME STATE VERSION\n* Ubuntu Running " + string(rune('0'+v)) + "\n"), nil
}
func (m *wslMachine) run(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
m.mu.Lock()
m.calls = append(m.calls, slices.Clone(args))
m.mu.Unlock()
if slices.Equal(args, []string{"-d", "Ubuntu", "--exec", "/bin/sh"}) {
if m.started != nil {
m.started <- struct{}{}
}
if m.anchor != nil {
return m.anchor(ctx, stdin)
}
return m.session(ctx, stdin)
}
if m.script != nil {
return m.script(args, stdin)
}
return wsl.Result{}, nil
}
// session is a shell that reads its commands until it is cancelled or its
// input closes, the way /bin/sh does.
func (m *wslMachine) session(ctx context.Context, stdin io.Reader) (wsl.Result, error) {
done := make(chan struct{})
go func() {
buf := make([]byte, 4096)
for {
n, err := stdin.Read(buf)
m.typedMu.Lock()
m.typed.Write(buf[:n])
m.typedMu.Unlock()
if err != nil {
close(done)
return
}
}
}()
select {
case <-ctx.Done():
return wsl.Result{}, ctx.Err()
case <-done:
return wsl.Result{}, nil
}
}
func (m *wslMachine) link(entry config.RemoteHostEntry) (Link, error) {
return newWSLLink(m.t.Context(), entry, m.list, m.run)
}
func wslEntry() config.RemoteHostEntry {
return config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: "Ubuntu"}
}
func TestAWSLLinkIsBoundToAnInstalledWSL2Distribution(t *testing.T) {
m := &wslMachine{t: t}
if _, err := m.link(config.RemoteHostEntry{Name: "x", Kind: "wsl", Distro: "Nope"}); !errors.Is(err, wsl.ErrNoDistro) {
t.Fatalf("unknown distribution = %v", err)
}
m.version = 1
if _, err := m.link(wslEntry()); !errors.Is(err, wsl.ErrUnsupported) {
t.Fatalf("WSL1 = %v, want ErrUnsupported", err)
}
m.version = 2
if _, err := m.link(wslEntry()); err != nil {
t.Fatal(err)
}
}
func TestStartingAWSLLinkHoldsOneSessionOpenUntilClose(t *testing.T) {
m := &wslMachine{t: t, started: make(chan struct{}, 4)}
l, err := m.link(wslEntry())
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var seen []remote.Status
l.Subscribe(func(ev remote.StatusEvent) { mu.Lock(); seen = append(seen, ev.Status); mu.Unlock() })
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
<-m.started
if err := l.Close(); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !slices.Equal(seen, []remote.Status{remote.StatusConnecting, remote.StatusConnected, remote.StatusStopped}) {
t.Fatalf("states = %v", seen)
}
var anchors int
for _, c := range m.calls {
if slices.Equal(c, []string{"-d", "Ubuntu", "--exec", "/bin/sh"}) {
anchors++
}
}
if anchors != 1 {
t.Fatalf("%d sessions were held open, want 1", anchors)
}
}
func TestALostSessionIsReportedAndAskedForAgain(t *testing.T) {
defer func(d time.Duration) { anchorRetry = d }(anchorRetry)
anchorRetry = time.Millisecond
var starts int
var mu sync.Mutex
m := &wslMachine{t: t, started: make(chan struct{}, 4), anchor: func(ctx context.Context, _ io.Reader) (wsl.Result, error) {
mu.Lock()
starts++
n := starts
mu.Unlock()
if n == 1 {
return wsl.Result{}, nil
}
<-ctx.Done()
return wsl.Result{}, ctx.Err()
}}
l, _ := m.link(wslEntry())
events := make(chan remote.StatusEvent, 16)
l.Subscribe(func(ev remote.StatusEvent) { events <- ev })
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
deadline := time.After(5 * time.Second)
var sawLost bool
for !sawLost {
select {
case ev := <-events:
sawLost = ev.Status == remote.StatusReconnecting && ev.Err != nil && ev.Attempt == 1
case <-deadline:
t.Fatal("a session that ended was not reported")
}
}
<-m.started
<-m.started
_ = l.Close()
mu.Lock()
defer mu.Unlock()
if starts != 2 {
t.Fatalf("the session was started %d times, want 2", starts)
}
}
func TestAWSLLinkReachesTheKernelDirectlyAndRefusesAnInboundPort(t *testing.T) {
f := loopbackForwards{}
addr, err := f.Add(forward.Spec{Direction: forward.Local, BindAddr: "127.0.0.1:0", TargetAddr: "127.0.0.1:4242"})
if err != nil || addr != "127.0.0.1:4242" {
t.Fatalf("Add local = %q, %v", addr, err)
}
if _, err := f.Add(forward.Spec{Direction: forward.Remote, BindAddr: "127.0.0.1:0", TargetAddr: "127.0.0.1:1"}); !errors.Is(err, ErrNoRemoteForward) {
t.Fatalf("Add remote = %v, want ErrNoRemoteForward", err)
}
if len(f.List()) != 0 || f.Remove("x") != nil {
t.Fatal("a direct route has nothing to list or remove")
}
}
// A WSL entry resolves providers on the distribution: a broker configured on
// this pool must not be published to it.
func TestAPoolOpensAWSLEntryThroughTheWSLLinkAndSendsNoBroker(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
return nil, c.UpsertRemoteHost(wslEntry())
}); err != nil {
t.Fatal(err)
}
m := &wslMachine{t: t}
var opened int
p := NewPool(t.Context(), Options{
Broker: Broker{Addr: "127.0.0.1:9", Token: "t"},
Dial: func(string, Prompts) (*remote.Client, error) {
t.Error("an SSH dial was made for a WSL entry")
return nil, errors.New("no")
},
DialWSL: func(e config.RemoteHostEntry) (Link, error) { opened++; return m.link(e) },
})
defer p.Close()
l, dialer := p.holdLink("dev")
if !dialer {
t.Fatal("first hold must dial")
}
p.dial(l, Call{})
if err := l.wait(t.Context()); err != nil {
t.Fatal(err)
}
if opened != 1 || p.brokers(l) {
t.Fatalf("opened = %d, brokers = %v", opened, p.brokers(l))
}
}
// What a launch starts must belong to the held session, not to the call that
// sent it: the command is staged in a file, only its name is typed into the
// session, and the call returns what the command printed and its own status.
func TestADetachedCommandRunsInTheHeldSessionAndReturnsItsOutput(t *testing.T) {
var staged string
var polls int
m := &wslMachine{t: t}
m.script = func(args []string, stdin io.Reader) (wsl.Result, error) {
body := args[5]
switch {
case strings.Contains(body, `cat > "$d/$1.sh"`):
b, _ := io.ReadAll(stdin)
staged = string(b)
case strings.Contains(body, `exit 44`):
polls++
if polls < 3 {
return wsl.Result{ExitCode: 44}, nil
}
case strings.HasSuffix(body, `.out"`):
return wsl.Result{Stdout: []byte("12345\n")}, nil
case strings.HasSuffix(body, `.err"`):
return wsl.Result{Stdout: []byte("warned\n")}, nil
case strings.HasSuffix(body, `.rc"`):
return wsl.Result{Stdout: []byte("44\n")}, nil
}
return wsl.Result{}, nil
}
l, _ := m.link(wslEntry())
d := l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
})
if _, err := d.ExecDetached(t.Context(), "x"); !errors.Is(err, ErrNoSession) {
t.Fatalf("before the session is up: %v, want ErrNoSession", err)
}
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
res, err := d.ExecDetached(t.Context(), "nohup serve & echo $!")
if err != nil || string(res.Stdout) != "12345\n" || string(res.Stderr) != "warned\n" || res.ExitCode != 44 {
t.Fatalf("ExecDetached = %+v, %v; a command's own status 44 must not read as 'not yet'", res, err)
}
if staged != "nohup serve & echo $!" || polls != 3 {
t.Fatalf("staged = %q, polls = %d", staged, polls)
}
deadline := time.Now().Add(5 * time.Second)
for {
m.typedMu.Lock()
typed := m.typed.String()
m.typedMu.Unlock()
if strings.Contains(typed, `$HOME/.reasonix/link`) && strings.HasSuffix(typed, "\n") && !strings.Contains(typed, "nohup") && !strings.Contains(typed, "/tmp") {
return
}
if time.Now().After(deadline) {
t.Fatalf("the session was sent %q, want a line naming the private staged file and none of its text", typed)
}
time.Sleep(10 * time.Millisecond)
}
}
// The wait for a launched command is bounded by the session it was given to:
// one that dies after the write leaves nothing that will ever finish.
func TestALaunchWhoseSessionDiesAfterTheWriteStopsWaiting(t *testing.T) {
m := &wslMachine{t: t}
m.anchor = func(ctx context.Context, stdin io.Reader) (wsl.Result, error) {
buf := make([]byte, 4096)
_, _ = stdin.Read(buf)
return wsl.Result{}, nil
}
m.script = func(args []string, _ io.Reader) (wsl.Result, error) {
if strings.Contains(args[5], `exit 44`) {
return wsl.Result{ExitCode: 44}, nil
}
return wsl.Result{}, nil
}
l, _ := m.link(wslEntry())
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, err := l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
}).ExecDetached(ctx, "serve")
if !errors.Is(err, ErrNoSession) {
t.Fatalf("ExecDetached = %v, want ErrNoSession (not a wait until the deadline)", err)
}
}
func TestAScratchDirectoryThatIsNotPrivateIsRefusedWithATypedError(t *testing.T) {
m := &wslMachine{t: t, script: func(args []string, _ io.Reader) (wsl.Result, error) {
if strings.Contains(args[5], `cat > "$d/$1.sh"`) {
return wsl.Result{ExitCode: exitUnsafeScratch}, nil
}
return wsl.Result{}, nil
}}
l, _ := m.link(wslEntry())
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
_, err := l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
}).ExecDetached(t.Context(), "serve")
if !errors.Is(err, ErrUnsafeScratch) {
t.Fatalf("ExecDetached = %v, want ErrUnsafeScratch", err)
}
}
@@ -32,6 +32,14 @@ type Conn interface {
Files() (sftpfs.Files, error)
}
// Detacher is a connection whose commands end every process they started when
// they return. Such a machine launches the kernel through ExecDetached, which
// starts it under something that outlives the call, so the kernel lives as long
// as that does.
type Detacher interface {
ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error)
}
// Install strategies.
const (
InstallAuto = "auto"
@@ -208,7 +216,11 @@ func launchServe(ctx context.Context, conn Conn, target remoteOS, fs sftpfs.File
_ = fs.Remove(cleanup, scriptPath, false)
}()
}
launchRes, err := conn.Exec(ctx, target.Launch(spec, paths))
launch := conn.Exec
if d, ok := conn.(Detacher); ok {
launch = d.ExecDetached
}
launchRes, err := launch(ctx, target.Launch(spec, paths))
if err != nil {
cleanupFailedLaunch(conn, target, fs, paths, 0)
return Result{}, fmt.Errorf("bootstrap: launch: %w", err)
@@ -742,3 +742,52 @@ func TestRepointBrokerRewritesOnlyAServeThatFollowsItsFile(t *testing.T) {
}
}
}
// detachConn is a machine that ends what a command started when the command
// returns: the launch must go through ExecDetached, never plain Exec.
type detachConn struct {
*fakeConn
t *testing.T
detached []string
}
func (d *detachConn) Exec(ctx context.Context, cmd string) (remote.ExecResult, error) {
if strings.Contains(cmd, "nohup") {
d.t.Errorf("the launch went through Exec: %s", cmd)
}
return d.fakeConn.Exec(ctx, cmd)
}
func (d *detachConn) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
d.detached = append(d.detached, cmd)
return d.fakeConn.Exec(ctx, cmd)
}
func TestEnsureServeLaunchesThroughTheDetachedPathWhenTheMachineAsksForIt(t *testing.T) {
skipOnWindows(t)
root := testenv.TempDir(t)
var portFile string
inner := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "uname"):
return ok("Linux x86_64\n")
case strings.Contains(cmd, "command -v reasonix"):
return ok("bin /usr/bin/reasonix\nver reasonix v9.9.0\n" + allFlagsYes())
case strings.Contains(cmd, "nohup"):
_ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600)
return ok("54321\n")
case strings.Contains(cmd, "ps -p 54321"):
return ok("1\n")
}
return ok("")
})
portFile = pathsFor(root, root).PortFile
conn := &detachConn{fakeConn: inner, t: t}
res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0", Clock: time.Now})
if err != nil {
t.Fatalf("EnsureServe: %v", err)
}
if res.State.PID != 54321 || len(conn.detached) != 1 || !strings.Contains(conn.detached[0], "nohup") {
t.Fatalf("pid = %d, detached = %v", res.State.PID, conn.detached)
}
}
@@ -131,6 +131,17 @@ func (c *envConn) Exec(ctx context.Context, cmd string) (remote.ExecResult, erro
return c.Conn.Exec(ctx, "export PATH="+shellQuote(strings.Join(c.env.Path, ":"))+`:"$PATH"; `+cmd)
}
// ExecDetached keeps the captured PATH on a launch and leaves it to the wrapped
// connection how the launched process is kept alive; one with nothing special
// to do runs it as any other command.
func (c *envConn) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
cmd = "export PATH=" + shellQuote(strings.Join(c.env.Path, ":")) + `:"$PATH"; ` + cmd
if d, ok := c.Conn.(Detacher); ok {
return d.ExecDetached(ctx, cmd)
}
return c.Conn.Exec(ctx, cmd)
}
// withLoginEnv wraps conn only when the capture produced a PATH.
func withLoginEnv(env loginEnv, conn Conn) Conn {
if env.Outcome != EnvCaptured {
@@ -341,3 +341,39 @@ func TestEachEntryPointCapturesOnceOnTheRawConnection(t *testing.T) {
})
}
}
type detachingConn struct {
scriptConn
detached []string
}
func (c *detachingConn) ExecDetached(_ context.Context, cmd string) (remote.ExecResult, error) {
c.detached = append(c.detached, cmd)
return remote.ExecResult{}, nil
}
// A machine that needs launches kept alive by something outside the call must
// still get them with the login PATH in front, and one that does not runs a
// launch as a plain command.
func TestAWrappedConnectionStillLaunchesThroughTheDetachedPath(t *testing.T) {
env := loginEnv{Outcome: EnvCaptured, Path: []string{"/opt/bin"}}
inner := &detachingConn{scriptConn: scriptConn{fn: func(string) (remote.ExecResult, error) { return remote.ExecResult{}, nil }}}
d, ok := withLoginEnv(env, inner).(Detacher)
if !ok {
t.Fatal("the wrapper hides the detached path")
}
if _, err := d.ExecDetached(context.Background(), "serve"); err != nil {
t.Fatal(err)
}
if len(inner.detached) != 1 || !strings.HasPrefix(inner.detached[0], "export PATH='/opt/bin':") || !strings.HasSuffix(inner.detached[0], "serve") || len(inner.execs) != 0 {
t.Fatalf("detached = %q, execs = %q", inner.detached, inner.execs)
}
plain := &scriptConn{fn: func(string) (remote.ExecResult, error) { return remote.ExecResult{}, nil }}
if _, err := withLoginEnv(env, plain).(Detacher).ExecDetached(context.Background(), "serve"); err != nil {
t.Fatal(err)
}
if len(plain.execs) != 1 || !strings.HasSuffix(plain.execs[0], "serve") {
t.Fatalf("a plain machine did not get the launch as a command: %q", plain.execs)
}
}
+86
View File
@@ -0,0 +1,86 @@
package wslconn
import (
"context"
"fmt"
"io"
"io/fs"
"strings"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/sftpfs"
"reasonix/internal/platform/wsl"
)
// Conn is one distribution as a bootstrap connection.
type Conn struct {
distro string
run wsl.Command
}
// New binds a connection to an installed distribution. The name must be one
// wsl.exe lists, which keeps anything else out of its argument list.
func New(ctx context.Context, list wsl.Runner, run wsl.Command, distro string) (*Conn, error) {
all, err := wsl.List(ctx, list)
if err != nil {
return nil, err
}
for _, d := range all {
if d.Name == distro {
if d.Version != 2 {
return nil, fmt.Errorf("%w: %q is WSL%d", wsl.ErrUnsupported, distro, d.Version)
}
return &Conn{distro: distro, run: run}, nil
}
}
return nil, fmt.Errorf("%w: %q", wsl.ErrNoDistro, distro)
}
// Distro is the distribution's name.
func (c *Conn) Distro() string { return c.distro }
// Exec runs cmd under /bin/sh in the distribution, as an SSH exec would run it
// under the account's shell.
func (c *Conn) Exec(ctx context.Context, cmd string) (remote.ExecResult, error) {
res, err := c.run(ctx, nil, wsl.ExecArgs(c.distro, "/bin/sh", "-c", cmd)...)
return remote.ExecResult{Stdout: res.Stdout, Stderr: res.Stderr, ExitCode: res.ExitCode}, err
}
// Files is the distribution's file layer.
func (c *Conn) Files() (sftpfs.Files, error) { return &files{c: c}, nil }
// Script runs body under /bin/sh with args as $1... and stdin.
func (c *Conn) Script(ctx context.Context, stdin io.Reader, body string, args ...string) (wsl.Result, error) {
return c.script(ctx, stdin, body, args...)
}
// Run starts a wsl.exe session that lives until ctx ends, feeding stdin to the
// program argv names, and returns when it ends.
func (c *Conn) Run(ctx context.Context, stdin io.Reader, argv ...string) (wsl.Result, error) {
return c.run(ctx, stdin, wsl.ExecArgs(c.distro, argv...)...)
}
// script runs body with args as $1... and stdin, returning its result.
func (c *Conn) script(ctx context.Context, stdin io.Reader, body string, args ...string) (wsl.Result, error) {
argv := append([]string{"/bin/sh", "-c", body, "sh"}, args...)
return c.run(ctx, stdin, wsl.ExecArgs(c.distro, argv...)...)
}
// Exit statuses of the file scripts. Anything else non-zero is a failure
// whose text is the tool's, kept for the log and never matched.
const (
exitMissing = 44
exitIsDir = 45
)
func failure(op string, res wsl.Result) error {
switch res.ExitCode {
case 0:
return nil
case exitMissing:
return fmt.Errorf("%s: %w", op, fs.ErrNotExist)
case exitIsDir:
return fmt.Errorf("%s: is a directory", op)
}
return fmt.Errorf("%s: exit %d: %s", op, res.ExitCode, strings.TrimSpace(string(res.Stderr)))
}
@@ -0,0 +1,20 @@
package wslconn
import (
"io/fs"
"testing"
)
func TestFileModeReadsTheRawBits(t *testing.T) {
for raw, want := range map[uint32]fs.FileMode{
0o100644: 0o644,
0o040755: fs.ModeDir | 0o755,
0o120777: fs.ModeSymlink | 0o777,
0o104755: fs.ModeSetuid | 0o755,
0o041777: fs.ModeDir | fs.ModeSticky | 0o777,
} {
if got := fileMode(raw); got != want {
t.Errorf("fileMode(%o) = %v, want %v", raw, got, want)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// Package wslconn lets the kernel bootstrap treat a WSL distribution as a
// machine it can run commands on and keep files in, the way it does an SSH host.
// Commands go through `wsl.exe --exec /bin/sh -c`, and file operations are
// small fixed POSIX scripts that take every path as a positional argument, so
// no path is ever spliced into script text. A missing path is reported by an
// exit status this package defines, never by reading a tool's message.
package wslconn
+195
View File
@@ -0,0 +1,195 @@
package wslconn
import (
"bytes"
"context"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"reasonix/internal/platform/remote/sftpfs"
)
type files struct{ c *Conn }
var _ sftpfs.Files = (*files)(nil)
const (
statScript = `[ -e "$1" ] || exit 44
exec stat -L -c '%s %f %Y' -- "$1"`
readScript = `[ -e "$1" ] || exit 44
[ -d "$1" ] && exit 45
exec head -c "$2" -- "$1"`
writeScript = `t="$(dirname -- "$1")/.$(basename -- "$1").reasonix-tmp-$$"
{ cat > "$t" && chmod "$2" "$t" && mv -f -- "$t" "$1"; } || { rm -f -- "$t"; exit 1; }`
mkdirAllScript = `exec mkdir -p -- "$1"`
mkdirScript = `exec mkdir -- "$1"`
chmodScript = `[ -e "$1" ] || exit 44
exec chmod "$2" -- "$1"`
removeScript = `[ -e "$1" ] || [ -L "$1" ] || exit 44
if [ -d "$1" ] && [ ! -L "$1" ]; then
if [ "$2" = 1 ]; then exec rm -rf -- "$1"; else exec rmdir -- "$1"; fi
fi
exec rm -f -- "$1"`
realPathScript = `case "$1" in
"~") p=$HOME ;;
"~/"*) p=$HOME/${1#"~/"} ;;
*) p=$1 ;;
esac
exec readlink -f -- "$p"`
)
func (f *files) Stat(ctx context.Context, p string) (sftpfs.Entry, error) {
res, err := f.c.script(ctx, nil, statScript, p)
if err != nil {
return sftpfs.Entry{}, err
}
if err := failure("stat "+p, res); err != nil {
return sftpfs.Entry{}, err
}
var size, mtime int64
var rawMode uint64
if _, err := fmt.Sscanf(strings.TrimSpace(string(res.Stdout)), "%d %x %d", &size, &rawMode, &mtime); err != nil {
return sftpfs.Entry{}, fmt.Errorf("stat %s: unreadable answer: %w", p, err)
}
mode := fileMode(uint32(rawMode))
return sftpfs.Entry{Name: path.Base(p), Path: p, Size: size, Mode: mode, ModTime: mtime, IsDir: mode.IsDir()}, nil
}
// fileMode decodes a raw st_mode by its bit fields: the format bits say what
// the file is, the low twelve are the permissions.
func fileMode(raw uint32) fs.FileMode {
mode := fs.FileMode(raw & 0o777)
if raw&0o4000 != 0 {
mode |= fs.ModeSetuid
}
if raw&0o2000 != 0 {
mode |= fs.ModeSetgid
}
if raw&0o1000 != 0 {
mode |= fs.ModeSticky
}
switch raw & 0o170000 {
case 0o040000:
mode |= fs.ModeDir
case 0o120000:
mode |= fs.ModeSymlink
case 0o010000:
mode |= fs.ModeNamedPipe
case 0o140000:
mode |= fs.ModeSocket
case 0o020000:
mode |= fs.ModeDevice | fs.ModeCharDevice
case 0o060000:
mode |= fs.ModeDevice
}
return mode
}
func (f *files) ReadFile(ctx context.Context, p string, maxSize int64) ([]byte, bool, sftpfs.Kind, error) {
if maxSize <= 0 {
maxSize = sftpfs.DefaultReadCap
}
res, err := f.c.script(ctx, nil, readScript, p, strconv.FormatInt(maxSize+1, 10))
if err != nil {
return nil, false, sftpfs.KindBinary, err
}
if err := failure("read "+p, res); err != nil {
return nil, false, sftpfs.KindBinary, err
}
data, truncated := res.Stdout, false
if int64(len(data)) > maxSize {
data, truncated = data[:maxSize], true
}
return data, truncated, sftpfs.DetectKind(data), nil
}
func (f *files) WriteFileAtomic(ctx context.Context, p string, data []byte, perm fs.FileMode) error {
res, err := f.c.script(ctx, bytes.NewReader(data), writeScript, p, strconv.FormatUint(uint64(perm.Perm()), 8))
if err != nil {
return err
}
return failure("write "+p, res)
}
func (f *files) MkdirAll(ctx context.Context, p string) error {
return f.simple(ctx, "mkdir "+p, mkdirAllScript, p)
}
func (f *files) MkdirExclusive(ctx context.Context, p string) error {
return f.simple(ctx, "mkdir "+p, mkdirScript, p)
}
func (f *files) Chmod(ctx context.Context, p string, perm fs.FileMode) error {
return f.simple(ctx, "chmod "+p, chmodScript, p, strconv.FormatUint(uint64(perm.Perm()), 8))
}
func (f *files) Remove(ctx context.Context, p string, recursive bool) error {
flag := "0"
if recursive {
flag = "1"
}
return f.simple(ctx, "remove "+p, removeScript, p, flag)
}
func (f *files) RealPath(ctx context.Context, p string) (string, error) {
res, err := f.c.script(ctx, nil, realPathScript, p)
if err != nil {
return "", err
}
if err := failure("realpath "+p, res); err != nil {
return "", err
}
return strings.TrimRight(string(res.Stdout), "\n"), nil
}
func (f *files) simple(ctx context.Context, op, body string, args ...string) error {
res, err := f.c.script(ctx, nil, body, args...)
if err != nil {
return err
}
return failure(op, res)
}
// dirsScript prints the directories under $1 (symlinks to directories
// included), each name closed by a NUL so no file name can end it early.
const dirsScript = `cd -- "$1" 2>/dev/null || exit 44
for f in ./* ./.[!.]* ./..?*; do
[ -d "$f" ] && printf '%s\0' "${f#./}"
done
exit 0`
// Dirs resolves dir (empty or ~ meaning the home directory) and lists the
// directories inside it.
func (c *Conn) Dirs(ctx context.Context, dir string) (string, []sftpfs.Entry, error) {
if strings.TrimSpace(dir) == "" {
dir = "~"
}
fsys := &files{c: c}
at, err := fsys.RealPath(ctx, dir)
if err != nil {
return "", nil, err
}
res, err := c.script(ctx, nil, dirsScript, at)
if err != nil {
return "", nil, err
}
if err := failure("list "+at, res); err != nil {
return "", nil, err
}
var out []sftpfs.Entry
for name := range strings.SplitSeq(string(res.Stdout), "\x00") {
if name == "" {
continue
}
out = append(out, sftpfs.Entry{Name: name, Path: path.Join(at, name), IsDir: true, Mode: fs.ModeDir})
}
return at, out, nil
}
@@ -0,0 +1,67 @@
//go:build windows
package wslconn
import (
"context"
"errors"
"os"
"testing"
"reasonix/internal/platform/wsl"
)
// REASONIX_LIVE_WSL names an installed WSL2 distribution to run the file layer in.
func TestFileLayerInARealDistribution(t *testing.T) {
name := os.Getenv("REASONIX_LIVE_WSL")
if name == "" {
t.Skip("set REASONIX_LIVE_WSL to an installed WSL2 distribution")
}
ctx := context.Background()
c, err := New(ctx, wsl.System(), wsl.SystemCommand(), name)
if err != nil {
t.Fatal(err)
}
res, err := c.Exec(ctx, "mktemp -d")
if err != nil || res.ExitCode != 0 {
t.Fatalf("mktemp = %+v, %v", res, err)
}
dir := string(res.Stdout[:len(res.Stdout)-1])
defer c.Exec(ctx, "rm -rf '"+dir+"'")
runFileScenario(t, c, dir)
runPathScenario(t, c)
}
// runPathScenario holds the translation to what a real distribution answers.
func runPathScenario(t *testing.T, c *Conn) {
t.Helper()
ctx := context.Background()
for in, want := range map[string]string{
`C:\Users\someone\a b`: "/mnt/c/Users/someone/a b",
`c:/Users/x`: "/mnt/c/Users/x",
`C:\a b\it's "q"\-dash\back`: "/mnt/c/a b/it's \"q\"/-dash/back",
`C:\a b\`: "/mnt/c/a b/",
`C:\dollar $HOME\` + "`z`" + `\ünï`: "/mnt/c/dollar $HOME/`z`/ünï",
`\\wsl.localhost\` + c.Distro() + `\home\dev`: "/home/dev",
} {
if got, err := c.ToLinux(ctx, in); err != nil || got != want {
t.Errorf("ToLinux(%q) = %q, %v; want %q", in, got, err, want)
}
}
for _, in := range []string{`\\wsl.localhost\Another\x`, `\\server\share\x`, `Z:\nodrive`, `relative\dir`, `/home/dev`, `C:`, ``} {
if _, err := c.ToLinux(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToLinux(%q) = %v, want ErrUntranslatable", in, err)
}
}
if got, err := c.ToWindows(ctx, "/mnt/c/Users"); err != nil || got != `C:\Users` {
t.Errorf("ToWindows(/mnt/c/Users) = %q, %v", got, err)
}
if got, err := c.ToWindows(ctx, "/home/dev"); err != nil || got != `\\wsl.localhost\`+c.Distro()+`\home\dev` {
t.Errorf("ToWindows(/home/dev) = %q, %v", got, err)
}
for _, in := range []string{"relative", "", `C:\x`} {
if _, err := c.ToWindows(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToWindows(%q) = %v, want ErrUntranslatable", in, err)
}
}
}
@@ -0,0 +1,46 @@
//go:build linux
package wslconn
import (
"bytes"
"context"
"errors"
"io"
"os/exec"
"testing"
"reasonix/internal/platform/wsl"
)
// localCommand answers wsl.exe's --exec by running the program here, so the
// scripts meet a real GNU userland without a distribution.
func localCommand(t *testing.T) wsl.Command {
t.Helper()
return func(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
for i, a := range args {
if a == "--exec" {
args = args[i+1:]
break
}
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var out, errb bytes.Buffer
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, &out, &errb
err := cmd.Run()
res := wsl.Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode, err = ee.ExitCode(), nil
}
return res, err
}
}
func TestFileLayerAgainstALocalPOSIXShell(t *testing.T) {
if err := exec.Command("stat", "-c", "%s", "/").Run(); err != nil {
t.Skip("needs GNU stat")
}
c := &Conn{distro: "local", run: localCommand(t)}
runFileScenario(t, c, t.TempDir())
}
+58
View File
@@ -0,0 +1,58 @@
package wslconn
import (
"context"
"errors"
"fmt"
"strings"
"reasonix/internal/platform/wsl"
)
// ErrUntranslatable: the path has no spelling on the other side, such as a
// drive that is not mounted or another distribution's share.
var ErrUntranslatable = errors.New("wslconn: path cannot be translated")
// ToLinux turns an absolute Windows path, a drive path or this distribution's
// own \\wsl.localhost share, into the path the distribution sees. The
// distribution's wslpath does it, because only it knows where drives are mounted.
func (c *Conn) ToLinux(ctx context.Context, p string) (string, error) {
if !wsl.IsWindowsPath(p) {
return "", fmt.Errorf("%w: %q is not an absolute Windows path", ErrUntranslatable, p)
}
out, err := c.wslpath(ctx, "-u", p)
if err != nil {
return "", err
}
if !strings.HasPrefix(out, "/") {
return "", fmt.Errorf("%w: %q has no absolute form in %q", ErrUntranslatable, p, c.distro)
}
return out, nil
}
// ToWindows is the reverse: an absolute Linux path as Windows spells it, a
// drive path where it is under a mount and this distribution's share otherwise.
func (c *Conn) ToWindows(ctx context.Context, p string) (string, error) {
if !strings.HasPrefix(p, "/") {
return "", fmt.Errorf("%w: %q is not an absolute Linux path", ErrUntranslatable, p)
}
out, err := c.wslpath(ctx, "-w", p)
if err != nil {
return "", err
}
if !wsl.IsWindowsPath(out) {
return "", fmt.Errorf("%w: %q has no Windows form", ErrUntranslatable, p)
}
return out, nil
}
func (c *Conn) wslpath(ctx context.Context, flag, p string) (string, error) {
res, err := c.Run(ctx, nil, "wslpath", flag, p)
if err != nil {
return "", err
}
if res.ExitCode != 0 {
return "", fmt.Errorf("%w: %q", ErrUntranslatable, p)
}
return strings.TrimRight(string(res.Stdout), "\r\n"), nil
}
@@ -0,0 +1,49 @@
package wslconn
import (
"context"
"errors"
"io"
"testing"
"reasonix/internal/platform/wsl"
)
// answers is a distribution whose wslpath says what the table says.
func answers(table map[string]wsl.Result) *Conn {
return &Conn{distro: "Ubuntu", run: func(_ context.Context, _ io.Reader, args ...string) (wsl.Result, error) {
// -d Ubuntu --exec wslpath <flag> <path>
return table[args[5]], nil
}}
}
func TestATranslationIsTheDistributionsAnswerOrARefusal(t *testing.T) {
c := answers(map[string]wsl.Result{
`C:\work\app`: {Stdout: []byte("/mnt/c/work/app\n")},
`Z:\none`: {ExitCode: 1},
`C:\odd`: {Stdout: []byte("C:\\odd\n")},
"/mnt/c/work": {Stdout: []byte("C:\\work\r\n")},
"/home/dev": {Stdout: []byte("\\\\wsl.localhost\\Ubuntu\\home\\dev\n")},
"/odd": {Stdout: []byte("/odd\n")},
})
ctx := context.Background()
if got, err := c.ToLinux(ctx, `C:\work\app`); err != nil || got != "/mnt/c/work/app" {
t.Fatalf("ToLinux = %q, %v", got, err)
}
for _, in := range []string{`Z:\none`, `C:\odd`, `/home/dev`, `rel`, ``} {
if _, err := c.ToLinux(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToLinux(%q) = %v, want ErrUntranslatable", in, err)
}
}
if got, err := c.ToWindows(ctx, "/mnt/c/work"); err != nil || got != `C:\work` {
t.Fatalf("ToWindows = %q, %v", got, err)
}
if got, err := c.ToWindows(ctx, "/home/dev"); err != nil || got != `\\wsl.localhost\Ubuntu\home\dev` {
t.Fatalf("ToWindows share = %q, %v", got, err)
}
for _, in := range []string{"/odd", "rel", `C:\x`} {
if _, err := c.ToWindows(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToWindows(%q) = %v, want ErrUntranslatable", in, err)
}
}
}
@@ -0,0 +1,107 @@
//go:build linux || windows
package wslconn
import (
"bytes"
"context"
"errors"
"io/fs"
"path"
"strings"
"testing"
"reasonix/internal/platform/remote/sftpfs"
)
// runFileScenario drives the file layer against a real POSIX shell: the local
// one in CI, a distribution in the live test. dir is a scratch directory there.
func runFileScenario(t *testing.T, c *Conn, dir string) {
t.Helper()
ctx := context.Background()
fsys, _ := c.Files()
awkward := path.Join(dir, `a b "q" 'x' $HOME `+"`z`"+` ünï back\slash trailing\ -dash`)
if err := fsys.MkdirAll(ctx, awkward+"/deep/er"); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := fsys.MkdirExclusive(ctx, awkward+"/deep"); err == nil {
t.Fatal("MkdirExclusive on an existing directory succeeded")
}
if err := fsys.MkdirExclusive(ctx, awkward+"/lock"); err != nil {
t.Fatalf("MkdirExclusive: %v", err)
}
if err := fsys.MkdirAll(ctx, awkward+"/-dash"); err != nil {
t.Fatalf("MkdirAll of a component that starts with a dash: %v", err)
}
file := awkward + "/-dash/state.json"
want := []byte("{\"port\": 4242}\n")
if err := fsys.WriteFileAtomic(ctx, file, want, 0o600); err != nil {
t.Fatalf("WriteFileAtomic: %v", err)
}
if err := fsys.WriteFileAtomic(ctx, file, append(want, 'x'), 0o600); err != nil {
t.Fatalf("overwrite: %v", err)
}
got, truncated, kind, err := fsys.ReadFile(ctx, file, 0)
if err != nil || truncated || kind != sftpfs.KindText || !bytes.Equal(got, append(want, 'x')) {
t.Fatalf("ReadFile = %q, %v, %v, %v", got, truncated, kind, err)
}
if head, truncated, _, err := fsys.ReadFile(ctx, file, 4); err != nil || !truncated || string(head) != "{\"po" {
t.Fatalf("a capped read = %q, %v, %v", head, truncated, err)
}
en, err := fsys.Stat(ctx, file)
if err != nil || en.IsDir || en.Size != int64(len(want)+1) || en.Mode.Perm() != 0o600 || en.ModTime == 0 {
t.Fatalf("Stat file = %+v, %v", en, err)
}
if en, err := fsys.Stat(ctx, awkward); err != nil || !en.IsDir {
t.Fatalf("Stat dir = %+v, %v", en, err)
}
if _, _, _, err := fsys.ReadFile(ctx, awkward+"/deep", 0); err == nil || errors.Is(err, fs.ErrNotExist) {
t.Fatalf("reading a directory = %v, want a failure that is not 'missing'", err)
}
if err := fsys.Chmod(ctx, file, 0o640); err != nil {
t.Fatalf("Chmod: %v", err)
}
if en, _ := fsys.Stat(ctx, file); en.Mode.Perm() != 0o640 {
t.Fatalf("mode after chmod = %v", en.Mode)
}
for name, call := range map[string]func() error{
"stat": func() error { _, err := fsys.Stat(ctx, awkward+"/none"); return err },
"read": func() error { _, _, _, err := fsys.ReadFile(ctx, awkward+"/none", 0); return err },
"chmod": func() error { return fsys.Chmod(ctx, awkward+"/none", 0o600) },
"remove": func() error { return fsys.Remove(ctx, awkward+"/none", false) },
} {
if err := call(); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("%s of a missing path = %v, want fs.ErrNotExist", name, err)
}
}
real, err := fsys.RealPath(ctx, awkward+"/deep/../-dash/state.json")
if err != nil || !strings.HasSuffix(real, "/-dash/state.json") || strings.Contains(real, "..") {
t.Fatalf("RealPath = %q, %v", real, err)
}
if home, err := fsys.RealPath(ctx, "~"); err != nil || !strings.HasPrefix(home, "/") {
t.Fatalf("RealPath(~) = %q, %v", home, err)
}
if err := fsys.Remove(ctx, awkward+"/deep", false); err == nil {
t.Fatal("a non-recursive remove of a non-empty directory succeeded")
}
if err := fsys.Remove(ctx, file, false); err != nil {
t.Fatalf("Remove file: %v", err)
}
if err := fsys.Remove(ctx, awkward, true); err != nil {
t.Fatalf("Remove tree: %v", err)
}
if _, err := fsys.Stat(ctx, awkward); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("the removed tree is still there: %v", err)
}
res, err := c.Exec(ctx, "printf '%s' \"$((6*7))\"; echo err >&2; exit 3")
if err != nil || string(res.Stdout) != "42" || strings.TrimSpace(string(res.Stderr)) != "err" || res.ExitCode != 3 {
t.Fatalf("Exec = %+v, %v", res, err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package wsl
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/base/proc"
)
// maxCapture bounds what one command may hand back on each stream. A file the
// caller wants whole is read in pieces by the layer above, not by raising it.
const maxCapture = 8 << 20
// Result is what a command in a distribution wrote and how it ended. A command
// that ran and failed is a Result with a non-zero ExitCode, not an error: the
// error return is for a command that could not be run at all.
type Result struct {
Stdout, Stderr []byte
ExitCode int
}
// Command runs wsl.exe with args, feeding stdin when it is not nil.
type Command func(ctx context.Context, stdin io.Reader, args ...string) (Result, error)
// SystemCommand runs the wsl.exe in System32, so a PATH entry cannot stand in for it.
func SystemCommand() Command {
return func(ctx context.Context, stdin io.Reader, args ...string) (Result, error) {
dir := systemDirectory()
if runtime.GOOS != "windows" || dir == "" {
return Result{}, ErrUnavailable
}
exe := filepath.Join(dir, "wsl.exe")
if _, err := os.Stat(exe); err != nil {
return Result{}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
cmd := exec.CommandContext(ctx, exe, args...)
proc.HideWindow(cmd)
var out, errb bytes.Buffer
cmd.Stdin = stdin
cmd.Stdout = &limited{w: &out, left: maxCapture}
cmd.Stderr = &limited{w: &errb, left: maxCapture}
err := cmd.Run()
res := Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode = ee.ExitCode()
return res, nil
}
return res, err
}
}
// ExecArgs is the wsl.exe argument list that runs argv inside a distribution
// with no shell between wsl.exe and the program. The distribution name is its
// own argument, so it cannot become an option.
func ExecArgs(distro string, argv ...string) []string {
return append([]string{"-d", distro, "--exec"}, argv...)
}
// IsWindowsPath reports whether p is spelled as an absolute Windows path: a
// drive letter and a colon, or a UNC prefix. It reads the shape only, so a
// Linux path with a colon in a later component is not mistaken for one.
func IsWindowsPath(p string) bool {
if strings.HasPrefix(p, `\\`) {
return true
}
if len(p) < 2 || p[1] != ':' {
return false
}
c := p[0] | 0x20
return c >= 'a' && c <= 'z'
}
+11
View File
@@ -116,3 +116,14 @@ func TestSystemIsUnavailableOffWindows(t *testing.T) {
t.Fatalf("List = %v", err)
}
}
func TestAWindowsPathIsRecognizedByItsShape(t *testing.T) {
for p, want := range map[string]bool{
`C:\Users`: true, `c:/x`: true, `Z:`: true, `\\wsl.localhost\Ubuntu\x`: true, `\\server\share`: true,
`/mnt/c/x`: false, `/home/a:b`: false, `relative\dir`: false, `1:\x`: false, ``: false, `C`: false, `\x`: false,
} {
if got := IsWindowsPath(p); got != want {
t.Errorf("IsWindowsPath(%q) = %v, want %v", p, got, want)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Keeps integrations/dsh-plugin/market-index.json, the DeepSeek Harness plugin
// market index for this repository, pinned to the commit that last changed the
// package. Usage: dsh-market-index.mjs --pin | --check
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PKG_DIR = 'integrations/dsh-plugin'
const INDEX = `${PKG_DIR}/market-index.json`
const INDEX_URL = `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/${INDEX}`
const VALIDATOR = '@deepseek-ai/dsh-plugin-market-index'
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
const read = (path) => JSON.parse(readFileSync(path, 'utf8'))
const lastPackageCommit = () => git('log', '-1', '--format=%H', '--', `${PKG_DIR}/package.json`, `${PKG_DIR}/cordis.patch.yml`)
function validator(version) {
const dir = mkdtempSync(join(tmpdir(), 'dsh-market-'))
execFileSync('npm', ['install', '--prefix', dir, '--silent', `${VALIDATOR}@${version}`], { stdio: 'inherit' })
return import(pathToFileURL(join(dir, 'node_modules', VALIDATOR, 'lib', 'index.js')).href)
}
const mode = process.argv[2]
const index = read(INDEX)
const [plugin] = index.plugins
const [entry] = plugin.versions
if (mode === '--pin') {
const pkg = read(`${PKG_DIR}/package.json`)
const sha = lastPackageCommit()
entry.version = pkg.version
entry.engines = { dsh: pkg.engines.dsh }
entry.publishTimestamp = Number(git('show', '-s', '--format=%ct', sha))
entry.source.commit = sha
writeFileSync(INDEX, `${JSON.stringify(index, null, 2)}\n`)
console.log(`pinned ${pkg.name}@${pkg.version} to ${sha}`)
} else if (mode === '--check') {
const pkg = read(`${PKG_DIR}/package.json`)
const sha = entry.source.commit
const stale = []
if (entry.version !== pkg.version) stale.push(`version ${entry.version} != package ${pkg.version}`)
if (entry.engines?.dsh !== pkg.engines.dsh) stale.push(`engines.dsh ${entry.engines?.dsh} != package ${pkg.engines.dsh}`)
if (sha !== lastPackageCommit()) {
const moved = git('diff', '--name-only', sha, 'HEAD', '--', `${PKG_DIR}/package.json`, `${PKG_DIR}/cordis.patch.yml`)
if (moved) stale.push(`pin ${sha} predates changes to: ${moved.split('\n').join(', ')}`)
}
const { parseMarketIndex, marketInstallTarget } = await validator(pkg.engines.dsh)
parseMarketIndex(index, INDEX_URL)
console.log(`install spec: ${marketInstallTarget(plugin.name, entry).spec}`)
if (stale.length) {
console.error(`stale index: ${stale.join('; ')}\nrun: node scripts/dsh-market-index.mjs --pin`)
process.exit(1)
}
console.log('index is valid and pinned to the current package')
} else {
console.error('usage: dsh-market-index.mjs --pin | --check')
process.exit(2)
}