mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
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:
commit
17f816d11c
33 files changed
+2401
-23
No files matched your search
@@ -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
@@ -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>
|
||||
·
|
||||
<a href="https://github.com/deepseek-ai/deepseek-harness/tree/main/packages/subagent/subagent-acp">DSH subagent-acp</a>
|
||||
·
|
||||
<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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user