dsh-wsl-desktop 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +13 -3
- package/README.md +11 -3
- package/lib/client.js +18 -3
- package/lib/host/http.js +56 -2
- package/lib/host/state.js +34 -0
- package/lib/index.js +91 -13
- package/lib/protocol.js +8 -3
- package/lib/release-check.js +77 -0
- package/lib/wsl/preset.js +8 -0
- package/package.json +13 -2
package/README.en.md
CHANGED
|
@@ -133,10 +133,19 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
|
|
|
133
133
|
target is invalidated by entry changes only** — adds, removes and renames are seen, a content change
|
|
134
134
|
of a file inside it is not; watch that file's own path to follow content. Mechanisms and
|
|
135
135
|
measurements are in [WSL file watching](docs/WATCH.md).
|
|
136
|
+
- **Confined mode needs passwordless sudo; without it there is no fence, and it fails loudly.** The
|
|
137
|
+
default permission mode is `workspace-write`, which has to build a mount namespace inside the
|
|
138
|
+
distro — and the WSL kernel does not allow bind mounts in a user namespace, so `sudo -n` is the only
|
|
139
|
+
way. Without that grant a confined command **fails outright** (with a structured error code, never a
|
|
140
|
+
silent unfenced run). Two ways out: give the session user NOPASSWD sudo, or switch the permission
|
|
141
|
+
mode to full access, which needs no sudo at all. See
|
|
142
|
+
[Linux-side confinement](docs/CONFINEMENT.md).
|
|
136
143
|
- **A desktop update can break the plugin.** The plugin depends on the desktop framework's APIs — the
|
|
137
144
|
harness — and changes to them interrupt it (0.1.6 → 0.1.7 broke four places in one go). So after
|
|
138
|
-
updating the desktop,
|
|
139
|
-
|
|
145
|
+
updating the desktop, open the W dialog once and confirm it lists your distributions, that
|
|
146
|
+
`bootstrap` reports the `pluginVersion` you just installed, and that `capabilities.status` is not
|
|
147
|
+
`degraded`; if any of those is wrong, take the desktop and the plugin back to a matching pair — the
|
|
148
|
+
order is in [Compatibility and upgrades](docs/COMPATIBILITY.md).
|
|
140
149
|
- **Failures now carry a code, so you can diagnose first.** The browser receives a stable code and
|
|
141
150
|
the whitelisted params, never the host message, stderr or resolved paths; after the user is
|
|
142
151
|
confirmed, the W dialog shows an advisory health check whose failed rows carry the fix. Codes,
|
|
@@ -164,9 +173,10 @@ These are real boundaries, not a disclaimer list. Each one tells you where to re
|
|
|
164
173
|
- [Engineering notes](docs/ENGINEERING-NOTES.md) — measured constraints, repository layout, release flow, install shape
|
|
165
174
|
- [Distro support matrix](docs/DISTRO-SUPPORT.md) — which distros were verified, and what each one needs
|
|
166
175
|
- [Compatibility and upgrades](docs/COMPATIBILITY.md) — plugin/desktop version relation, upgrade order, rollback steps
|
|
167
|
-
- [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the
|
|
176
|
+
- [Upstream API proposals](docs/UPSTREAM-PROPOSALS.md) — the proposals to the harness upstream, including the record that an execution-world plugin cannot declare its session's initial permission posture
|
|
168
177
|
- [Upstream defect reports](issues/README.md) — the harness issues filed, their measured evidence, and how the local workarounds relate
|
|
169
178
|
- [Architecture and flow diagrams](docs/diagrams.md) — interactive architecture, sequence and lifecycle diagrams
|
|
179
|
+
- [Real-machine acceptance runbook](docs/ACCEPTANCE-RUNBOOK.md) — the checks that only run on Windows + WSL2, their expected output, and the misjudgement list (Chinese only)
|
|
170
180
|
|
|
171
181
|
Three of the linked documents — the distro support matrix, the upstream proposals and the diagram
|
|
172
182
|
index — are **Chinese-only**, because no English text for them exists. Translating them is a separate
|
package/README.md
CHANGED
|
@@ -107,9 +107,16 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
107
107
|
文件每 300ms `stat` 一次。**目录目标只对条目变化生效**——新增、删除、改名可见,目录里某个文件的
|
|
108
108
|
内容变化不可见;要盯内容就按文件路径监听它。机制与实测见
|
|
109
109
|
[WSL 文件变更监听](docs/WATCH.md)。
|
|
110
|
+
- **受限模式要免密 sudo;没有就当不了围栏,而且会明确失败。** 默认权限模式是
|
|
111
|
+
`workspace-write`,它要求在发行版里搭 mount namespace,而 WSL 内核不允许在 user namespace
|
|
112
|
+
里做 bind mount,所以只能用 `sudo -n`。缺这份授权时受限命令**直接失败**(结构化错误码,
|
|
113
|
+
不是静默裸跑)。两条出路:给会话用户配 NOPASSWD sudo,或者把权限模式切到「完全权限」——
|
|
114
|
+
后者不需要任何 sudo。详见 [Linux 侧约束](docs/CONFINEMENT.md)。
|
|
110
115
|
- **桌面更新后插件可能失效。** 插件依赖桌面内部的框架(harness)的 API,这些 API 变化会打断它
|
|
111
|
-
(0.1.6 → 0.1.7
|
|
112
|
-
`
|
|
116
|
+
(0.1.6 → 0.1.7 一次断了四处)。所以更新桌面之后,先打开一次 W 对话框确认它能列出你的
|
|
117
|
+
发行版、`bootstrap` 里的 `pluginVersion` 是你刚装的版本、`capabilities.status` 不是
|
|
118
|
+
`degraded`;任何一项不对就把桌面和插件退回彼此匹配的版本,
|
|
119
|
+
顺序见 [兼容性与升级](docs/COMPATIBILITY.md)。
|
|
113
120
|
- **失败现在带 code,可以先自己诊断。** 浏览器侧的错误只回稳定 code 与允许的 params,
|
|
114
121
|
宿主 message、stderr 与解析后的路径不会过线;W 对话框在确认用户后会显示一条建议性的
|
|
115
122
|
「环境检查」,失败项带修复命令。错误码、检查项与降级行为见 [诊断](docs/DIAGNOSTICS.md)。
|
|
@@ -134,9 +141,10 @@ plugin_manager install_bundle dsh-wsl-desktop@<version>
|
|
|
134
141
|
- [工程笔记](docs/ENGINEERING-NOTES.md) — 实测约束、仓库布局、发版流程、安装形态
|
|
135
142
|
- [发行版支持矩阵](docs/DISTRO-SUPPORT.md) — 哪些发行版验证过,每种缺什么
|
|
136
143
|
- [兼容性与升级](docs/COMPATIBILITY.md) — 插件与桌面的版本关系、升级顺序、回退步骤
|
|
137
|
-
- [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness
|
|
144
|
+
- [上游 API 提案](docs/UPSTREAM-PROPOSALS.md) — 对 harness 上游的提案(含执行世界类插件无法声明初始权限姿态的记录)
|
|
138
145
|
- [上游缺陷记录](issues/README.md) — 已上报 harness 的问题、实测证据与本地规避的关系
|
|
139
146
|
- [架构与流程图](docs/diagrams.md) — 可交互的架构、时序与生命周期图
|
|
147
|
+
- [真实机器验收手册](docs/ACCEPTANCE-RUNBOOK.md) — 只能在 Windows + WSL2 上跑的检查项、期望输出与误判清单
|
|
140
148
|
|
|
141
149
|
其中三份——发行版支持矩阵、上游 API 提案、架构与流程图索引——**只有中文版**,因为目前没有对应的
|
|
142
150
|
英文文本。把它们翻成英文是一件独立的工作,不是改个文件名。
|
package/lib/client.js
CHANGED
|
@@ -362,9 +362,24 @@ window.__ModuleLoader__.load({
|
|
|
362
362
|
function routeFailure(method, status, envelope) {
|
|
363
363
|
const error = new Error(envelope?.error ?? `${method} failed (HTTP ${status})`)
|
|
364
364
|
error.code = typeof envelope?.code === 'string' ? envelope.code : status === 0 ? 'transport' : 'internal'
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
365
|
+
// A refusal the route never answered carries no envelope — the transport
|
|
366
|
+
// fence answers an empty-bodied 401/403, and the webserver's own admission
|
|
367
|
+
// answers 401/403/503 the same way. Without the status this collapsed to a
|
|
368
|
+
// bare `internal` with empty params, which is what a real GUI check hit on
|
|
369
|
+
// Desktop 0.2.2-rc.3: every method failed and the diagnostic said nothing.
|
|
370
|
+
// The status is the ONE fact that separates "not authenticated" from
|
|
371
|
+
// "not permitted" from "host has no admission provider", so it is carried
|
|
372
|
+
// in the params the copyable report prints. It is produced locally, so it
|
|
373
|
+
// is not subject to the wire param allowlist.
|
|
374
|
+
const carried = envelope !== null && typeof envelope?.params === 'object' && envelope?.params !== null
|
|
375
|
+
if (carried) {
|
|
376
|
+
error.params = envelope.params
|
|
377
|
+
} else {
|
|
378
|
+
// No envelope means the route never answered in its own vocabulary; the
|
|
379
|
+
// HTTP status is then the only fact that separates "not authenticated"
|
|
380
|
+
// from "not permitted" from "the host has no admission provider".
|
|
381
|
+
error.params = status === 0 ? {} : { status }
|
|
382
|
+
}
|
|
368
383
|
return error
|
|
369
384
|
}
|
|
370
385
|
|
package/lib/host/http.js
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
12
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
15
|
import { MAX_BODY_BYTES, DEV_TOKEN_HEADER, tokenMatches } from '../http-admission.js'
|
|
16
16
|
|
|
17
|
+
/** Ceiling for the route-failure log, so a repeated failure cannot fill the disk. */
|
|
18
|
+
const ROUTE_LOG_MAX_BYTES = 256 * 1024
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* The harness home that holds the user preset root.
|
|
19
22
|
* @returns {string} the resolved `$DSH_HOME`.
|
|
@@ -67,11 +70,62 @@ export function sendRejection(res, rejection) {
|
|
|
67
70
|
sendJson(res, rejection.status, { ok: false, code: rejection.code, error: rejection.message })
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Trust surface consumed here; the browser-side connection package owns the full type.
|
|
75
|
+
*
|
|
76
|
+
* `ctx.connection` is the only spelling that works, and it works because this
|
|
77
|
+
* plugin declares `connection` in its `inject` list. There is deliberately NO
|
|
78
|
+
* `ctx.get` fallback: Cordis resolves a service property against the CALLING
|
|
79
|
+
* context and THROWS for a service the caller never injected (`cannot get
|
|
80
|
+
* property "connection" without inject`) rather than returning `undefined`, so a
|
|
81
|
+
* fallback could not rescue that case — and when the service is genuinely
|
|
82
|
+
* unprovided, `ctx.get` returns `undefined` too, which the caller already
|
|
83
|
+
* handles. A fallback here would suggest a protection that does not exist.
|
|
84
|
+
* @param {object} ctx - the plugin context.
|
|
85
|
+
* @returns {object | undefined} the connection handle, when this composition has one.
|
|
86
|
+
*/
|
|
71
87
|
export function connectionOf(ctx) {
|
|
72
88
|
return Reflect.get(ctx, 'connection')
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Append one route failure to a log the operator can read.
|
|
93
|
+
*
|
|
94
|
+
* The route answers the browser with a code and nothing else — deliberately, so
|
|
95
|
+
* a host path or a wsl.exe stderr never crosses the wire. That is right for
|
|
96
|
+
* production and useless when the ROUTE ITSELF throws before it can name a
|
|
97
|
+
* reason: the browser shows `internal`, the host logs through a channel the
|
|
98
|
+
* desktop does not surface, and the only remaining evidence is this file. It is
|
|
99
|
+
* written on the operator's own disk, never sent anywhere.
|
|
100
|
+
* @param {string} step - the admission step that failed.
|
|
101
|
+
* @param {unknown} error - the thrown value.
|
|
102
|
+
* @returns {Promise<void>} settlement; a logging failure never masks the refusal.
|
|
103
|
+
*/
|
|
104
|
+
export async function logRouteFailure(step, error) {
|
|
105
|
+
try {
|
|
106
|
+
const path = join(dshHome(), 'wsl-desktop-route-error.log')
|
|
107
|
+
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error)
|
|
108
|
+
const line = `${new Date().toISOString()} [${step}] ${detail}\n`
|
|
109
|
+
await mkdir(dirname(path), { recursive: true })
|
|
110
|
+
// Bounded, because a route that fails on every request would otherwise fill
|
|
111
|
+
// the operator's disk. Crossing the cap restarts the file with a note, so
|
|
112
|
+
// the NEWEST failure is always present and the file never grows without
|
|
113
|
+
// limit. A silently truncated diagnostic is worse than a fresh one.
|
|
114
|
+
let size = 0
|
|
115
|
+
try {
|
|
116
|
+
size = (await stat(path)).size
|
|
117
|
+
} catch {
|
|
118
|
+
size = 0
|
|
119
|
+
}
|
|
120
|
+
if (size + Buffer.byteLength(line) > ROUTE_LOG_MAX_BYTES) {
|
|
121
|
+
await writeFile(path, `[truncated above ${ROUTE_LOG_MAX_BYTES} bytes]\n`, 'utf8')
|
|
122
|
+
}
|
|
123
|
+
await writeFile(path, line, { flag: 'a' })
|
|
124
|
+
} catch {
|
|
125
|
+
// A diagnostic that throws must not change the response the caller gets.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
75
129
|
/**
|
|
76
130
|
* This installation's development token, minted on first use.
|
|
77
131
|
*
|
package/lib/host/state.js
CHANGED
|
@@ -56,3 +56,37 @@ export const bindingTape = (() => {
|
|
|
56
56
|
},
|
|
57
57
|
}
|
|
58
58
|
})()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Wait briefly for a session's agent to appear in the registry.
|
|
62
|
+
*
|
|
63
|
+
* `api-session/added` fires BEFORE the harness registers the session's agent, so
|
|
64
|
+
* the post-hoc preset fallback raced it: `agents.get(sessionId)` was `undefined`,
|
|
65
|
+
* the fallback recorded "agent not registered" and returned — leaving a session
|
|
66
|
+
* whose cwd is a WSL path running in the WINDOWS world, permanently, because the
|
|
67
|
+
* harness fixes a preset at creation and refuses a later select. Measured on
|
|
68
|
+
* Desktop 0.2.2-rc.3 (a session in a WSL-UNC workspace, `ok:false` in the binding
|
|
69
|
+
* log). A bounded wait turns a guaranteed loss into a wait the registry wins.
|
|
70
|
+
*
|
|
71
|
+
* `now` and `sleep` are injectable so the wait is testable without real time.
|
|
72
|
+
* @param {{ get: (sessionId: unknown) => unknown }} agents - the agents registry.
|
|
73
|
+
* @param {unknown} sessionId - the session whose agent is wanted.
|
|
74
|
+
* @param {{ deadlineMs?: number, pollMs?: number, now?: () => number, sleep?: (ms: number) => Promise<void> }} [options] -
|
|
75
|
+
* the bound, the poll interval, and the clock/sleeper (defaults are real).
|
|
76
|
+
* @returns {Promise<unknown>} the agent, or `undefined` when the bound expires.
|
|
77
|
+
*/
|
|
78
|
+
export async function waitForAgent(agents, sessionId, options = {}) {
|
|
79
|
+
const deadlineMs = options.deadlineMs ?? 2_000
|
|
80
|
+
const pollMs = options.pollMs ?? 50
|
|
81
|
+
const now = options.now ?? Date.now
|
|
82
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => { setTimeout(resolve, ms) }))
|
|
83
|
+
const startedAt = now()
|
|
84
|
+
for (;;) {
|
|
85
|
+
const agent = agents.get(sessionId)
|
|
86
|
+
if (agent !== undefined) return agent
|
|
87
|
+
// Checked AFTER a read so an already-registered agent never waits at all, and
|
|
88
|
+
// so the deadline is a bound on waiting rather than on attempts.
|
|
89
|
+
if (now() - startedAt >= deadlineMs) return undefined
|
|
90
|
+
await sleep(pollMs)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -22,12 +22,12 @@ import {
|
|
|
22
22
|
MAX_BODY_BYTES, DEV_TOKEN_HEADER, bodyAdmission, dispatchAdmission, fenceRejection, methodAdmission, preflight, tokenMatches,
|
|
23
23
|
} from './http-admission.js'
|
|
24
24
|
import {
|
|
25
|
-
connectionOf, devToken, isDeveloperCaller, readJsonBody, sendJson, sendRejection,
|
|
25
|
+
connectionOf, devToken, isDeveloperCaller, logRouteFailure, readJsonBody, sendJson, sendRejection,
|
|
26
26
|
} from './host/http.js'
|
|
27
27
|
import {
|
|
28
28
|
GENERATED_PREFIX, basePluginsOf, runShellSpec, sourceDirFor, variantIdFor,
|
|
29
29
|
} from './host/presets.js'
|
|
30
|
-
import { bindingTape, hostSession, presetRun } from './host/state.js'
|
|
30
|
+
import { bindingTape, hostSession, presetRun, waitForAgent } from './host/state.js'
|
|
31
31
|
import { buildVariantPlugins } from './wsl/preset.js'
|
|
32
32
|
import { setLocalSubprocess } from './wsl/host-refs.js'
|
|
33
33
|
import { checkLinuxPath, defaultWorkspaceUnc, listLinuxDir, resolveDistroHome, runWslShell } from './wsl/world.js'
|
|
@@ -49,8 +49,17 @@ export const name = 'wsl-desktop'
|
|
|
49
49
|
* carries the composition's trust fence: its Host/Origin check defeats DNS
|
|
50
50
|
* rebinding, and its browser authentication (the login-token cookie) gates every
|
|
51
51
|
* caller before any method runs.
|
|
52
|
+
*
|
|
53
|
+
* `connection.authentication` is a SERVICE IN ITS OWN RIGHT (`super(ctx,
|
|
54
|
+
* 'connection.authentication')`), and `connection.admit` reads it internally.
|
|
55
|
+
* Cordis resolves that read against the CALLING context, so a plugin that calls
|
|
56
|
+
* `admit` must inject it too — otherwise the call dies with `cannot get property
|
|
57
|
+
* "connection.authentication" without inject`, thrown from inside `admit`
|
|
58
|
+
* itself. That is exactly how the whole W dialog broke on Desktop 0.2.2-rc.3:
|
|
59
|
+
* the route was reachable, admission threw, and the host's last-resort listener
|
|
60
|
+
* answered an empty-bodied 400 that the browser could only call `internal`.
|
|
52
61
|
*/
|
|
53
|
-
export const inject = ['webServer', 'connection']
|
|
62
|
+
export const inject = ['webServer', 'connection', 'connection.authentication']
|
|
54
63
|
|
|
55
64
|
/** Plugin config. */
|
|
56
65
|
export const Config = z.object({
|
|
@@ -1484,6 +1493,17 @@ export function apply(ctx, config) {
|
|
|
1484
1493
|
ctx.effect(() => ctx.webServer.register({
|
|
1485
1494
|
kind: 'exact',
|
|
1486
1495
|
path: config.routePath,
|
|
1496
|
+
// Admission is this handler's job, so the route must not also be gated by
|
|
1497
|
+
// the webserver. Harness 0.2.2-rc.3 (commit 71753fdf91, "centralize route
|
|
1498
|
+
// admission with required policy") added this field and defaults an omitted
|
|
1499
|
+
// one to `required` — which refuses the request BEFORE this handler runs.
|
|
1500
|
+
// The observable result was a silent total failure: the W dialog rendered,
|
|
1501
|
+
// every method answered an empty-bodied 401, and the browser half could only
|
|
1502
|
+
// report it as `internal`. Declaring `anonymous` hands admission back to the
|
|
1503
|
+
// fence below, which applies the same two arms and additionally lets a
|
|
1504
|
+
// development token replace the 401 (a script cannot present a browser
|
|
1505
|
+
// cookie). The host still applies its Host/Origin check to anonymous routes.
|
|
1506
|
+
auth: 'anonymous',
|
|
1487
1507
|
handler: async (req, res) => {
|
|
1488
1508
|
// The fence first, exactly as every shipped host route does it: without
|
|
1489
1509
|
// this, a cross-site `text/plain` POST is a CORS-simple request with no
|
|
@@ -1493,14 +1513,66 @@ export function apply(ctx, config) {
|
|
|
1493
1513
|
// replaces the SECOND arm only — a local script cannot present a browser
|
|
1494
1514
|
// cookie — and never the first: a token holder gains nothing from a
|
|
1495
1515
|
// trusted Host, but a foreign Host must stay refused for every caller.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1516
|
+
//
|
|
1517
|
+
// `admit` is the accessor to use: `requestRejection` was the same two arms
|
|
1518
|
+
// and was REMOVED in the same 0.2.2-rc.3 commit. `admit` predates it and
|
|
1519
|
+
// survives it, so this call site works on both generations.
|
|
1520
|
+
// EVERYTHING from here to the response is inside one guard, because the
|
|
1521
|
+
// host's last-resort listener turns an escaping throw into an EMPTY-bodied
|
|
1522
|
+
// 400 (`webserver/src/index.ts`: `dispatch().catch(() => res.writeHead(400)`).
|
|
1523
|
+
// That answer is indistinguishable, at the browser, from a transport
|
|
1524
|
+
// refusal: the client's `json()` throws and the dialog shows only
|
|
1525
|
+
// `internal`. Measured on Desktop 0.2.2-rc.3, where admission threw and the
|
|
1526
|
+
// whole W dialog became an opaque "plugin internal error" — the single
|
|
1527
|
+
// hardest failure to diagnose this route has produced. A JSON answer keeps
|
|
1528
|
+
// the code, the ref and (for a developer caller) the message.
|
|
1529
|
+
let developer = false
|
|
1530
|
+
// The step is tracked so the refusal can NAME where admission broke. A
|
|
1531
|
+
// browser cannot present the development token (it is a header a page
|
|
1532
|
+
// never sends), so `developer` is false for every real page and the
|
|
1533
|
+
// response can carry nothing about the cause — the file log is the only
|
|
1534
|
+
// channel left. `step` itself is a fixed vocabulary word, not host data,
|
|
1535
|
+
// so it is safe to return.
|
|
1536
|
+
let step = 'developer'
|
|
1537
|
+
try {
|
|
1538
|
+
developer = await isDeveloperCaller(req, config)
|
|
1539
|
+
step = 'connection'
|
|
1540
|
+
const connection = connectionOf(ctx)
|
|
1541
|
+
if (connection === undefined || typeof connection.admit !== 'function') {
|
|
1542
|
+
throw new Error(`the composition exposes no connection admission (got ${connection === undefined ? 'undefined' : typeof connection})`)
|
|
1543
|
+
}
|
|
1544
|
+
step = 'admit'
|
|
1545
|
+
const admission = connection.admit(req)
|
|
1546
|
+
step = 'fence'
|
|
1547
|
+
const rejection = fenceRejection({
|
|
1548
|
+
rejection: 'rejection' in admission ? admission.rejection : undefined,
|
|
1549
|
+
developer,
|
|
1550
|
+
})
|
|
1551
|
+
if (rejection !== undefined) {
|
|
1552
|
+
res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1553
|
+
res.end()
|
|
1554
|
+
return
|
|
1555
|
+
}
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
// The stack goes to the operator's disk; the browser gets the shared
|
|
1558
|
+
// classifier's envelope. The `step` is carried INSIDE a WslRouteError so
|
|
1559
|
+
// it passes through `browserFailure`'s SAFE_PARAMS filter like every
|
|
1560
|
+
// other param — appending it afterwards would make the allowlist entry
|
|
1561
|
+
// decorative and the filter a lie. The constructor also supplies the
|
|
1562
|
+
// correlation `ref` that `internal` always carries. The message itself
|
|
1563
|
+
// is never sent — that is where host paths live — which is why the file
|
|
1564
|
+
// log above exists.
|
|
1565
|
+
//
|
|
1566
|
+
// `internal` is stated rather than inferred, because the four steps above
|
|
1567
|
+
// are admission plumbing: they raise TypeErrors and plain Errors, never a
|
|
1568
|
+
// WslDomainError whose code would mean something to the browser. Passing
|
|
1569
|
+
// the original as `cause` keeps it attached for anything that inspects the
|
|
1570
|
+
// error object, without letting it change the wire code.
|
|
1571
|
+
void logRouteFailure(step, error)
|
|
1572
|
+
sendJson(res, 500, {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
...browserFailure(new WslRouteError('internal', { step }, { cause: error }), { discovery: false }),
|
|
1575
|
+
})
|
|
1504
1576
|
return
|
|
1505
1577
|
}
|
|
1506
1578
|
const early = preflight({ httpMethod: req.method, contentType: req.headers['content-type'] })
|
|
@@ -1643,9 +1715,15 @@ async function bindWslSession(ctx, summary) {
|
|
|
1643
1715
|
if (parseWslUnc(cwd) === null) return
|
|
1644
1716
|
const agents = ctx.get('agents')
|
|
1645
1717
|
if (agents === undefined) return
|
|
1646
|
-
|
|
1718
|
+
// WAIT, do not give up. `api-session/added` fires before the agent is
|
|
1719
|
+
// registered, so the immediate `agents.get` below used to lose that race EVERY
|
|
1720
|
+
// time: the fallback recorded "agent not registered" and returned, and the
|
|
1721
|
+
// session stayed in the Windows world for good (the harness fixes a preset at
|
|
1722
|
+
// creation). A bounded wait is the difference between a guaranteed loss and a
|
|
1723
|
+
// wait the registry wins.
|
|
1724
|
+
const agent = await waitForAgent(agents, summary?.sessionId)
|
|
1647
1725
|
if (agent === undefined) {
|
|
1648
|
-
recordBinding({ sessionId: String(summary?.sessionId), cwd, ok: false, error: 'agent
|
|
1726
|
+
recordBinding({ sessionId: String(summary?.sessionId), cwd, ok: false, error: 'agent 尚未注册(已等待注册上限)' })
|
|
1649
1727
|
return
|
|
1650
1728
|
}
|
|
1651
1729
|
await bindWslPreset(ctx, agent, cwd, String(summary?.sessionId))
|
package/lib/protocol.js
CHANGED
|
@@ -197,8 +197,8 @@ export const CHECK_REMEDIES = deepFreeze({
|
|
|
197
197
|
},
|
|
198
198
|
runner: {
|
|
199
199
|
kind: 'text',
|
|
200
|
-
zh: '受限模式需要免密 sudo + unshare/setpriv,或安装 dsh-wsl-confine helper
|
|
201
|
-
en: 'Restricted mode needs passwordless sudo plus unshare/setpriv, or the dsh-wsl-confine helper
|
|
200
|
+
zh: '受限模式需要免密 sudo + unshare/setpriv,或安装 dsh-wsl-confine helper。两条出路:给会话用户配 NOPASSWD sudo,或在权限菜单里选「完全权限」(后者不需要任何 sudo,也不再受文件围栏约束)。',
|
|
201
|
+
en: 'Restricted mode needs passwordless sudo plus unshare/setpriv, or the dsh-wsl-confine helper. Two ways out: give the session user NOPASSWD sudo, or pick full access in the permission menu — that needs no sudo at all, and is no longer governed by the file fence.',
|
|
202
202
|
},
|
|
203
203
|
helper: {
|
|
204
204
|
kind: 'text',
|
|
@@ -236,7 +236,12 @@ export const SAFE_PARAMS = deepFreeze({
|
|
|
236
236
|
'host-capability-missing': ['feature'],
|
|
237
237
|
'host-plugin-stale': ['method'],
|
|
238
238
|
'too-busy': [],
|
|
239
|
-
|
|
239
|
+
// `step` names WHICH admission step refused the request (a fixed vocabulary:
|
|
240
|
+
// developer / connection / admit / fence). It exists because the route can
|
|
241
|
+
// throw before it has any other way to say where — and a browser caller can
|
|
242
|
+
// never be told the message, since that is where host paths live. It is a
|
|
243
|
+
// vocabulary word, not host data, which is what makes it safe to send.
|
|
244
|
+
internal: ['ref', 'step'],
|
|
240
245
|
transport: [],
|
|
241
246
|
'catalog-unavailable': [],
|
|
242
247
|
})
|
package/lib/release-check.js
CHANGED
|
@@ -67,3 +67,80 @@ export function installWindow({ publishedAt, minimumReleaseAgeMinutes, now = new
|
|
|
67
67
|
: `inside the minimumReleaseAge window until ${readyAt.toISOString()}`,
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/** The receipt schema this check knows how to read. */
|
|
72
|
+
const RECEIPT_VERSION = 1
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one suite that reaches the route handler, and therefore the only evidence
|
|
76
|
+
* that the seam works on the installed plugin. Its admitted `bootstrap` check is
|
|
77
|
+
* what failed to run before 2026-11-14 while the offline gate stayed green.
|
|
78
|
+
*/
|
|
79
|
+
const COVERING_SUITE = 'verify-route.mjs'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Decide whether a recorded gate run may vouch for this release.
|
|
83
|
+
*
|
|
84
|
+
* The route-admission seam is covered ONLY by the live suites: an offline run
|
|
85
|
+
* cannot execute the handler at all, because reaching it needs a browser cookie
|
|
86
|
+
* or the development token. On 2026-11-14 a host change broke every route call
|
|
87
|
+
* while the offline gate stayed green at 25/27 — `verify-route.mjs` already
|
|
88
|
+
* asserted the admitted `bootstrap`, it simply never ran, because it sits in the
|
|
89
|
+
* LIVE list. The test was there; the release path never demanded it.
|
|
90
|
+
*
|
|
91
|
+
* So the release path now demands the receipt of a run that INCLUDED the live
|
|
92
|
+
* suites, describes the version being released, and failed nothing. The version
|
|
93
|
+
* comparison is what stops a receipt from an older build vouching for this one.
|
|
94
|
+
*
|
|
95
|
+
* `receipt.version` is read from the REPOSITORY manifest, while the live suites
|
|
96
|
+
* exercise the INSTALLED plugin — so the two could describe different code. They
|
|
97
|
+
* cannot here, and the reason is transitive: `verify-live-env.mjs` compares the
|
|
98
|
+
* running host's reported `pluginVersion` against that same manifest and turns a
|
|
99
|
+
* mismatch into a FAILED check, which lands in `counts.failed` and trips the
|
|
100
|
+
* rule above. A stale install therefore cannot produce a usable receipt; it
|
|
101
|
+
* produces a red one.
|
|
102
|
+
* @param {{ receipt: object|null, target: string }} input - the parsed receipt, or null when absent.
|
|
103
|
+
* @returns {{ ready: boolean, blockers: string[] }} the verdict.
|
|
104
|
+
*/
|
|
105
|
+
export function liveGateEvidence({ receipt, target }) {
|
|
106
|
+
if (receipt === null || typeof receipt !== 'object') {
|
|
107
|
+
return {
|
|
108
|
+
ready: false,
|
|
109
|
+
blockers: ['no gate receipt: run `node scripts/verify-all.mjs --live --receipt` before releasing'],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const blockers = []
|
|
113
|
+
// The schema version is checked FIRST because every rule below reads fields this
|
|
114
|
+
// function assumes it understands. A future receipt that redefines `counts` or
|
|
115
|
+
// `liveRan` would otherwise be interpreted as if it still meant the old thing.
|
|
116
|
+
if (receipt.receiptVersion !== RECEIPT_VERSION) {
|
|
117
|
+
blockers.push(`the receipt is schema version ${String(receipt.receiptVersion)}, and this check understands ${RECEIPT_VERSION}`)
|
|
118
|
+
return { ready: false, blockers }
|
|
119
|
+
}
|
|
120
|
+
if (receipt.liveRan !== true) {
|
|
121
|
+
blockers.push('the recorded gate run did NOT include the live suites (--live), and those are the only cover for the route-admission seam')
|
|
122
|
+
}
|
|
123
|
+
const failed = receipt?.counts?.failed
|
|
124
|
+
if (typeof failed !== 'number' || failed > 0) {
|
|
125
|
+
blockers.push(`the recorded gate run reported ${typeof failed === 'number' ? failed : 'an unreadable number of'} failure(s)`)
|
|
126
|
+
}
|
|
127
|
+
if (receipt.version !== target) {
|
|
128
|
+
blockers.push(`the receipt describes version ${String(receipt.version)}, not the target ${target} — re-run the gate after the version bump`)
|
|
129
|
+
}
|
|
130
|
+
// `liveRan` records the FLAG, not that anything ran. Without this the gate would
|
|
131
|
+
// accept a receipt whose live suites all skipped — a run that never reached the
|
|
132
|
+
// host — which is the "a green summary hides what did not run" failure this
|
|
133
|
+
// whole artefact exists to prevent, reintroduced one layer up.
|
|
134
|
+
const rows = Array.isArray(receipt.rows) ? receipt.rows : null
|
|
135
|
+
if (rows === null) {
|
|
136
|
+
blockers.push('the receipt carries no per-suite rows, so it cannot show that the suite covering the route-admission seam passed')
|
|
137
|
+
} else {
|
|
138
|
+
const covering = rows.find((row) => row?.suite === COVERING_SUITE)
|
|
139
|
+
if (covering === undefined) {
|
|
140
|
+
blockers.push(`the receipt has no row for ${COVERING_SUITE}, the only suite that reaches the route handler`)
|
|
141
|
+
} else if (covering.outcome !== 'passed') {
|
|
142
|
+
blockers.push(`${COVERING_SUITE} is recorded as ${String(covering.outcome)}${covering.reason === undefined || covering.reason === '' ? '' : ` (${covering.reason})`}, and it is the only suite that reaches the route handler`)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { ready: blockers.length === 0, blockers }
|
|
146
|
+
}
|
package/lib/wsl/preset.js
CHANGED
|
@@ -97,6 +97,14 @@ export const VERDICTED_KEPT_MODULES = new Set([
|
|
|
97
97
|
'@deepseek-ai/dsh-tool-web',
|
|
98
98
|
'@deepseek-ai/dsh-tool-cordis',
|
|
99
99
|
'@deepseek-ai/dsh-tool-present',
|
|
100
|
+
// Added when harness 0.2.2-rc.3 shipped it (measured 2026-11-15: a `wsl-standard`
|
|
101
|
+
// and a `wsl-ptc` row survived unclassified). KEPT, and the reason is its inject
|
|
102
|
+
// list: `['tools', 'fs', 'workingDirectory', 'agents']` — it reaches files through
|
|
103
|
+
// the `fs` SERVICE, which a WSL preset mounts from this plugin, and its source
|
|
104
|
+
// carries no `node:fs`, no `spawn` and no `execFile`. So it neither runs a host
|
|
105
|
+
// command nor touches host files behind the model's back: it is world-agnostic,
|
|
106
|
+
// unlike `dsh-tool-executor-ptc`, which injects a host runtime and is removed.
|
|
107
|
+
'@deepseek-ai/dsh-tool-office',
|
|
100
108
|
])
|
|
101
109
|
|
|
102
110
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wsl-desktop",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"icon": "./lib/icon.svg",
|
|
@@ -13,8 +13,19 @@
|
|
|
13
13
|
"prepack": "node -e \"const fs=require('node:fs'),p=require('node:path');(function w(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){if(e.isDirectory()){const f=p.join(d,e.name);if(e.name==='__pycache__'){fs.rmSync(f,{recursive:true,force:true})}else{w(f)}}}})('lib')\""
|
|
14
14
|
},
|
|
15
15
|
"engines": {
|
|
16
|
-
"node": ">=22.19"
|
|
16
|
+
"node": ">=22.19",
|
|
17
|
+
"dsh": ">=0.1.7-0"
|
|
17
18
|
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"wsl",
|
|
24
|
+
"wsl2",
|
|
25
|
+
"workspace",
|
|
26
|
+
"execution-world",
|
|
27
|
+
"windows"
|
|
28
|
+
],
|
|
18
29
|
"files": [
|
|
19
30
|
"lib",
|
|
20
31
|
"cordis.patch.yml",
|