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 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, run `node scripts/verify-post-restart.mjs` once and only keep going when
139
- everything is green.
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 two proposals to the harness upstream
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
- `node scripts/verify-post-restart.mjs`,全绿再继续用。
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
- error.params = envelope !== null && typeof envelope?.params === 'object' && envelope?.params !== null
366
- ? envelope.params
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
- /** Trust surface consumed here; the browser-side connection package owns the full type. */
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
- const developer = await isDeveloperCaller(req, config)
1497
- const rejection = fenceRejection({
1498
- rejection: connectionOf(ctx).requestRejection(req),
1499
- developer,
1500
- })
1501
- if (rejection !== undefined) {
1502
- res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
1503
- res.end()
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
- const agent = agents.get(summary?.sessionId)
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;danger-full-access 不受影响。',
201
- en: 'Restricted mode needs passwordless sudo plus unshare/setpriv, or the dsh-wsl-confine helper; danger-full-access is unaffected.',
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
- internal: ['ref'],
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
  })
@@ -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.0",
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",