dsh-remote-bridge 2.4.2 → 2.5.0

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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import path, { join } from "node:path";
1
+ import path, { join, sep } from "node:path";
2
2
  import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
3
3
  import { execFile, spawn } from "node:child_process";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -134,10 +134,22 @@ function isLoopbackOrigin(origin) {
134
134
  }
135
135
  //#endregion
136
136
  //#region src/host/cloudflared.ts
137
- /** 平台/架构 -> cloudflared 归档后缀。 */
138
- function archSuffix(platform = process.platform) {
137
+ /**
138
+ * 平台/架构 -> cloudflared 归档后缀。
139
+ *
140
+ * **架构必须能单独传**,不能读 `process.arch`:
141
+ * 2026-10-20 CI 实锤 —— 旧实现里 platform 是参数、arch 却写死 `process.arch`,
142
+ * 于是 `archSuffix('linux')` 在 macOS runner(arm64)上返回 `linux-arm64`,
143
+ * 而调用方要的是「linux 平台该用哪个官方归档」。同一函数里 platform 可控、
144
+ * arch 不可控,是**自相矛盾的设计**;测试在 macOS 上红、Linux 上绿,全平台矩阵
145
+ * (ubuntu/windows/macos × 两个 Node 版本)里只有 Linux 那两格能过。
146
+ *
147
+ * 两个参数都带默认值时保持旧行为(本机平台 + 本机架构);显式传入时,
148
+ * 结果只由入参决定 —— 这才让「算一个别的平台的后缀」成为可能。
149
+ */
150
+ function archSuffix(platform = process.platform, arch = process.arch) {
139
151
  if (platform !== "linux") throw new Error(`cloudflared 尚不支持 ${platform} 平台。`);
140
- switch (process.arch) {
152
+ switch (arch) {
141
153
  case "x64": return "linux-amd64";
142
154
  case "arm64": return "linux-arm64";
143
155
  default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
@@ -197,8 +209,20 @@ function isDebSource() {
197
209
  return mirrorBase().startsWith("deb+");
198
210
  }
199
211
  /** 返回要下载的源 URL:deb 源返回完整 .deb 地址;裸镜像基址拼架构后缀;否则官方 GitHub。 */
200
- function downloadUrlFor() {
201
- const suffix = archSuffix();
212
+ /**
213
+ * cloudflared 官方归档的下载地址。
214
+ *
215
+ * **platform/arch 都能显式传入**(2026-10-20 CI 实锤后加):旧实现里
216
+ * `downloadUrlFor()` 走 `archSuffix()` 的默认参数 —— 也就是**本机** platform/arch。
217
+ * 于是这个函数在 macOS runner 上直接抛「尚不支持 darwin 平台」,
218
+ * 而它要回答的问题其实是「linux 的 cloudflared 该从哪下」。
219
+ *
220
+ * 默认值保持本机(生产调用点 `ensureCloudflared` 就在 linux 上跑,行为不变),
221
+ * 但显式传入后结果只由入参决定 —— 这才让「算别的平台的 URL」成为可能,
222
+ * 也让测试不依赖跑在什么机器上。
223
+ */
224
+ function downloadUrlFor(platform = process.platform, arch = process.arch) {
225
+ const suffix = archSuffix(platform, arch);
202
226
  const mirror = mirrorBase();
203
227
  if (mirror === "") return `${OFFICIAL_BASE}${suffix}`;
204
228
  if (mirror.startsWith("deb+")) return mirror.slice(4);
@@ -796,7 +820,33 @@ function parentWirePath(relativePath) {
796
820
  * 调用方必须自己给出可读的错误(见 readAttachment),而不是让用户看到一个
797
821
  * 语焉不详的 500。
798
822
  */
823
+ /**
824
+ * 读一个**可选**宿主服务:存在就返回,不存在返回 null。
825
+ *
826
+ * ## 为什么必须走 `ctx.reflect.get(name, false)`,不能直接读属性
827
+ *
828
+ * cordis 的 `inject` 是**硬依赖**(`registry.d.ts` 的 `Inject` 类型只有数组与映射两种
829
+ * 形式,注释原文是 "required services"):inject 里任何一个服务缺失,`_refresh()`
830
+ * 就把 epoch 置为 INACTIVE,**整个插件不激活**。
831
+ *
832
+ * 所以「可选服务」**不能**写进 inject —— 写了就变成「哪个内核版本缺它、插件在哪一版
833
+ * 整个死掉」。而直接读属性(`host[name]`)也不行:不在 inject 里的属性会抛
834
+ * `cannot get property "x" without inject`,与「服务不存在」**报同一个错**,
835
+ * 于是这个函数会永远返回 null(这正是它此前的实际行为,
836
+ * `attachments` / `fileUploads` 一直在走降级路径)。
837
+ *
838
+ * `ctx.reflect.get(name, false)` 是 cordis 给这件事准备的正路
839
+ * (`cordis/lib/index.js` 的 ReflectService.get,注释原文:
840
+ * "Read a service from the store **without the inject requirement**"):
841
+ * 不要求 inject 声明,服务不存在时返回 `undefined` 而不是抛错。
842
+ */
799
843
  function optionalService(host, name) {
844
+ const reflect = host.reflect;
845
+ if (reflect !== void 0 && typeof reflect.get === "function") try {
846
+ const value = reflect.get(name, false);
847
+ if (value === void 0 || value === null) return null;
848
+ return value;
849
+ } catch {}
800
850
  try {
801
851
  return host[name];
802
852
  } catch {
@@ -1413,7 +1463,7 @@ function writeHandoffFile(dir, workspace, mediaType, data) {
1413
1463
  if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
1414
1464
  return {
1415
1465
  absolute,
1416
- relative: workspace !== void 0 && absolute.startsWith(workspace + "/") ? absolute.slice(workspace.length + 1) : fileName
1466
+ relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
1417
1467
  };
1418
1468
  }
1419
1469
  /** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
@@ -1528,12 +1578,36 @@ const FALLBACK_PERMISSION_CATALOG = {
1528
1578
  defaultPreset: "workspace-write"
1529
1579
  };
1530
1580
  /**
1581
+ * 从 follow 的 snapshot 帧里取投影基线。
1582
+ *
1583
+ * 0.2.1 起 `SessionFollowFrame` 的 snapshot 分支自带
1584
+ * `projections: SessionProjectionBaseline`,所以正常情况下**不需要**再打一次
1585
+ * `sessionController.projections()`。这个函数就是那个判断:有就用,没有返回 undefined
1586
+ * 让调用方回落。
1587
+ *
1588
+ * 为什么用结构探测而不是类型收窄:本插件刻意不 import `@deepseek-ai/*`,
1589
+ * `snapshot` 在这里是结构类型,看不到内核的判别联合。按形状判断是唯一办法,
1590
+ * 也与本文件其余部分的风格一致。
1591
+ */
1592
+ function projectionsOf(snapshot) {
1593
+ if (snapshot === null || typeof snapshot !== "object") return void 0;
1594
+ const projections = snapshot.projections;
1595
+ if (projections === null || typeof projections !== "object") return void 0;
1596
+ const values = projections.values;
1597
+ if (values === null || typeof values !== "object") return void 0;
1598
+ const asOfSeq = projections.asOfSeq;
1599
+ return {
1600
+ ...typeof asOfSeq === "number" ? { asOfSeq } : {},
1601
+ values
1602
+ };
1603
+ }
1604
+ /**
1531
1605
  * 读会话的**投影基线**(`permissions` / `modelSelection` / `title` …)。
1532
1606
  *
1533
1607
  * @patch
1534
1608
  * id: history-projections-passthrough
1535
1609
  * for: 内核 `sessionController.follow()` 的 snapshot 与 `page()` 都**不返回 projections**
1536
- * (类型上就是 `{ cursor, records, hasMore }`),而客户端要靠
1610
+ * (0.2.0 的类型上就是 `{ cursor, records, hasMore }`),而客户端要靠
1537
1611
  * `projections.values.permissions` 才能显示会话的当前权限档位。
1538
1612
  * 内核那边确实注册了这条投影(`dsh-permission-presets` 里
1539
1613
  * `ctx.sessionProjections.register({ key: 'permissions', … })`),
@@ -1542,6 +1616,9 @@ const FALLBACK_PERMISSION_CATALOG = {
1542
1616
  * 不补的后果是 Android 客户端的权限档位 Pill 与 ⋯ 菜单权限项**从不渲染**
1543
1617
  * —— v2.2.44 真机验收实测到了这个,而当时 `PARITY.md` 还把它标成 `done`。
1544
1618
  * until: 内核把 projections 并进 follow/page 的返回。
1619
+ * **0.2.1 已达成**:snapshot 分支现在自带 `projections`,history 首屏走
1620
+ * `projectionsOf(snapshot)`,不再打这次 RPC。本函数降级为**老内核回落路径**,
1621
+ * 保留到最低支持版本升过 0.2.1 之后再删。
1545
1622
  * test: tests/history-projections.spec.ts
1546
1623
  */
1547
1624
  async function readSessionProjections(host, sessionId, signal) {
@@ -1755,7 +1832,7 @@ function createDsh02ApiProxy(host, options = {}) {
1755
1832
  events: [],
1756
1833
  hasMore: false
1757
1834
  };
1758
- const projections = await readSessionProjections(host, sessionId, controller.signal);
1835
+ const projections = projectionsOf(snapshot) ?? await readSessionProjections(host, sessionId, controller.signal);
1759
1836
  const withProjections = (payload) => projections === void 0 ? payload : {
1760
1837
  ...payload,
1761
1838
  projections
@@ -1986,7 +2063,13 @@ function createDsh02ApiProxy(host, options = {}) {
1986
2063
  settingsNs: entry.settingsNs,
1987
2064
  settingsPath: [...entry.settingsPath],
1988
2065
  active: live.has(entry.provider),
1989
- ...entry.declared === void 0 ? {} : { declared: entry.declared }
2066
+ ...entry.declared === void 0 ? {} : { declared: entry.declared },
2067
+ ...entry.defaultBaseURL === void 0 ? {} : { defaultBaseURL: entry.defaultBaseURL },
2068
+ ...entry.baseURLEnv === void 0 ? {} : { baseURLEnv: [...entry.baseURLEnv] },
2069
+ ...entry.nativeAuth === void 0 ? {} : { nativeAuth: {
2070
+ ...entry.nativeAuth,
2071
+ ...entry.nativeAuth.env === void 0 ? {} : { env: [...entry.nativeAuth.env] }
2072
+ } }
1990
2073
  })) };
1991
2074
  }),
1992
2075
  models: () => call(async () => {
@@ -2009,7 +2092,7 @@ function createDsh02ApiProxy(host, options = {}) {
2009
2092
  }
2010
2093
  //#endregion
2011
2094
  //#region src/shared/version.ts
2012
- const PLUGIN_VERSION = "2.4.2";
2095
+ const PLUGIN_VERSION = "2.5.0";
2013
2096
  //#endregion
2014
2097
  //#region src/host/capabilities.ts
2015
2098
  /**
@@ -2087,7 +2170,10 @@ const BRIDGE_CAPABILITIES = [
2087
2170
  "turn-failure-v1",
2088
2171
  "history-byte-budget-v1",
2089
2172
  "interaction-replay-v1",
2090
- "session-snapshot-v1"
2173
+ "session-snapshot-v1",
2174
+ "job-list-v1",
2175
+ "job-output-v1",
2176
+ "job-kill-v1"
2091
2177
  ];
2092
2178
  /**
2093
2179
  * `connection.ready` 事件的载荷。
@@ -3205,10 +3291,51 @@ function redactApprovalText(value, maxLength = 1200) {
3205
3291
  }
3206
3292
  //#endregion
3207
3293
  //#region src/host/database.ts
3294
+ /**
3295
+ * 本进程内打开的库(弱引用集合)。
3296
+ *
3297
+ * ## 为什么需要它
3298
+ *
3299
+ * `WorkspaceDatabase` 用 `PRAGMA journal_mode=WAL` 打开 sqlite,会留下
3300
+ * `workspace.sqlite` / `-wal` / `-shm` 三个文件,**且句柄不关就删不掉**。
3301
+ * Linux/macOS 上删一个仍被打开的文件是允许的(unlink 只是断链),所以问题
3302
+ * 在那里看不出来;**Windows 上直接 EBUSY**:
3303
+ *
3304
+ * ```
3305
+ * EBUSY: resource busy or locked, unlink 'C:\...\Temp\daw-xxx\state\workspace.sqlite'
3306
+ * ```
3307
+ *
3308
+ * 2026-10-20 CI 在 windows-latest 上实锤(macOS/Linux 全绿、Windows 全红)。
3309
+ *
3310
+ * ## 它的作用
3311
+ *
3312
+ * 进程退出时统一关闭所有还开着的库 —— 无论调用方记不记得 `close()`。
3313
+ * 这不是「代替 close」,而是**兜底**:正常路径仍应显式 close(早关早释放),
3314
+ * 但漏掉的那一个不会在 Windows 上变成 EBUSY 式的删除失败。
3315
+ *
3316
+ * 弱引用:不阻止 GC,也不阻止正常 close 后的移除(close 时会从集合里摘掉)。
3317
+ */
3318
+ const openDatabases = /* @__PURE__ */ new Set();
3319
+ /** 进程退出时尽力关闭所有还开着的库。 */
3320
+ function closeAllOpenDatabases() {
3321
+ for (const ref of [...openDatabases]) {
3322
+ const db = ref.deref();
3323
+ if (db === void 0) {
3324
+ openDatabases.delete(ref);
3325
+ continue;
3326
+ }
3327
+ try {
3328
+ db.close();
3329
+ } catch {}
3330
+ }
3331
+ openDatabases.clear();
3332
+ }
3333
+ process.once("exit", closeAllOpenDatabases);
3208
3334
  var WorkspaceDatabase = class {
3209
3335
  dataDir;
3210
3336
  trashDir;
3211
3337
  db;
3338
+ closed = false;
3212
3339
  constructor(dataDir) {
3213
3340
  this.dataDir = path.resolve(dataDir);
3214
3341
  this.trashDir = path.join(this.dataDir, "trash");
@@ -3216,8 +3343,13 @@ var WorkspaceDatabase = class {
3216
3343
  this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
3217
3344
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
3218
3345
  this.migrate();
3346
+ openDatabases.add(new WeakRef(this));
3219
3347
  }
3348
+ /** 关闭数据库。**幂等** —— 重复调用不抛错。 */
3220
3349
  close() {
3350
+ if (this.closed) return;
3351
+ this.closed = true;
3352
+ for (const ref of [...openDatabases]) if (ref.deref() === this) openDatabases.delete(ref);
3221
3353
  this.db.close();
3222
3354
  }
3223
3355
  /**
@@ -4011,6 +4143,309 @@ function trashView(record) {
4011
4143
  status: record.status
4012
4144
  };
4013
4145
  }
4146
+ //#endregion
4147
+ //#region src/host/host-service-probe.ts
4148
+ /**
4149
+ * 宿主服务注入面探针。
4150
+ *
4151
+ * 为什么需要它:本插件刻意不 import 任何 `@deepseek-ai/*`(见
4152
+ * docs/project/COMPATIBILITY.md),全部宿主服务按结构类型 + Host 注入消费。
4153
+ * 好处是跨内核版本零漂移,**代价是「服务到底注进来了没有」只能靠猜** ——
4154
+ * cordis 的注入属性是惰性访问器,读一个没人提供的服务会抛
4155
+ * `cannot get property "x" without inject`,而这个错误只在真的去读的那一刻出现。
4156
+ *
4157
+ * 于是历史上反复出现同一类事故:某功能静默不工作,两侧日志都不说一句话,
4158
+ * 排错者先从客户端查起,查到服务端才发现服务压根没注入。
4159
+ * 2.0.0 的 `attachments`、2.0.2 的 `fileUploads` 都是这么炸的(见
4160
+ * dsh-0.2-bridge.ts 里那段长注释)。
4161
+ *
4162
+ * 这个探针把「猜」变成「问」:在 apply 期间(注入属性还可读时)逐个试读一次,
4163
+ * 记录每个服务的**可解析性**,之后任何时候都能回答「0.2.x 上到底有哪些服务可用」。
4164
+ * 它不改变任何行为 —— 纯粹是观测。
4165
+ *
4166
+ * 使用方式:`GET /dsh-workspace-api/api/v1/debug/host-services`
4167
+ * (需要设备 token 或 loopback admin,与其它 REST 端点同一套鉴权)。
4168
+ */
4169
+ /**
4170
+ * 插件 `cordis.patch.yml` 与 `src/index.ts` 里 inject 声明的全部服务名
4171
+ * (**两份清单必须一致**,docs:check 有对齐门禁)。
4172
+ *
4173
+ * ⚠️ 只能放「两个支持版本(0.2.0-rc.2 与 0.2.1-rc.2)上都存在的服务」——
4174
+ * cordis 的 inject 是硬依赖,缺一个整个插件不激活(2026-10-20 在 0.2.0-rc.2 的
4175
+ * smoke-profile-install 上实锤过)。可选服务一律走 OPTIONAL_SERVICE_NAMES。
4176
+ */
4177
+ const INJECTED_SERVICE_NAMES = [
4178
+ "sessionController",
4179
+ "workspaceController",
4180
+ "workspaceRegistry",
4181
+ "settingsController",
4182
+ "credentialsController",
4183
+ "agentPresets",
4184
+ "llm",
4185
+ "webServer",
4186
+ "agents",
4187
+ "commands"
4188
+ ];
4189
+ /**
4190
+ * **可选服务**:不写进 inject(inject 是硬依赖),由插件经
4191
+ * `ctx.reflect.get(name, false)` 运行时读取 —— 读到就走该能力,读不到按能力位降级。
4192
+ *
4193
+ * 探针用同一条 `reflect.get` 读取它们:这样报告的是「内核**真的**提供了什么」,
4194
+ * 而不是「我们 inject 了什么」。两者的差就是「可选能力在这台内核上可用与否」。
4195
+ */
4196
+ const OPTIONAL_SERVICE_NAMES = [
4197
+ "permissionPresets",
4198
+ "jobController",
4199
+ "fileUploads",
4200
+ "attachments",
4201
+ "workingDirectory",
4202
+ "workspaceFiles"
4203
+ ];
4204
+ /**
4205
+ * 在注入属性**仍可读**的时机(即 `apply` 执行期间)逐个试读服务。
4206
+ *
4207
+ * 为什么必须在 apply 期间读、不能留到 HTTP 回调里:cordis 的注入属性是
4208
+ * 有生命周期的惰性访问器,`apply` 一结束就读不到了(会抛
4209
+ * `cannot get property "attachments" without inject`)。这正是
4210
+ * dsh-0.2-bridge.ts 里 `optionalService` 那段注释记的教训 ——
4211
+ * 2.0.1 把读取留到回调里,结果整个插件激活失败。
4212
+ *
4213
+ * @param host - `ctx` 本身(不是取到的服务对象)。
4214
+ * @returns 每个服务的可解析性报告。
4215
+ */
4216
+ function probeHostServices(host, pluginVersion) {
4217
+ const reflect = host.reflect;
4218
+ const probeOptional = (name) => {
4219
+ if (reflect === void 0 || typeof reflect.get !== "function") return {
4220
+ name,
4221
+ available: false,
4222
+ resolvable: false,
4223
+ error: "ctx.reflect.get unavailable on this cordis"
4224
+ };
4225
+ try {
4226
+ const value = reflect.get(name, false);
4227
+ const resolvable = value !== null && value !== void 0;
4228
+ return {
4229
+ name,
4230
+ available: true,
4231
+ resolvable,
4232
+ ...resolvable ? {} : { error: "not provided by this kernel/profile" }
4233
+ };
4234
+ } catch (caught) {
4235
+ return {
4236
+ name,
4237
+ available: false,
4238
+ resolvable: false,
4239
+ error: caught instanceof Error ? caught.message : String(caught)
4240
+ };
4241
+ }
4242
+ };
4243
+ const probeOne = (name) => {
4244
+ let available = false;
4245
+ let resolvable = false;
4246
+ let error;
4247
+ try {
4248
+ const value = host[name];
4249
+ available = true;
4250
+ resolvable = value !== null && value !== void 0;
4251
+ if (!resolvable) error = "resolved to null/undefined";
4252
+ } catch (caught) {
4253
+ error = caught instanceof Error ? caught.message : String(caught);
4254
+ }
4255
+ return {
4256
+ name,
4257
+ available,
4258
+ resolvable,
4259
+ ...error === void 0 ? {} : { error }
4260
+ };
4261
+ };
4262
+ const injected = INJECTED_SERVICE_NAMES.map(probeOne);
4263
+ const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
4264
+ return {
4265
+ pluginVersion,
4266
+ injected,
4267
+ observed,
4268
+ summary: {
4269
+ injectedTotal: injected.length,
4270
+ injectedAvailable: injected.filter((service) => service.available).length,
4271
+ injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
4272
+ observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
4273
+ }
4274
+ };
4275
+ }
4276
+ //#endregion
4277
+ //#region src/host/job-adapter.ts
4278
+ /**
4279
+ * job 适配层的错误。
4280
+ *
4281
+ * **继承 `ApiError` 而不是另起一套**:两者的形状(status + code + message)
4282
+ * 完全一致,继承之后 router 现有的 `asApiError` 直接就能把它转成正确的
4283
+ * HTTP 响应,不必在路由里再写一层转换。这是刻意少造一个概念。
4284
+ */
4285
+ var JobApiError = class extends ApiError {};
4286
+ /** 把内核 `JobView` 收敛成对外形状。
4287
+ *
4288
+ * 只挑客户端真用得上的字段,并且**逐个校验类型** —— 内核返回的是结构类型,
4289
+ * 一个字段形状变了就该在这里显形,而不是把一个 `undefined` 透给客户端
4290
+ * 让它的严格解码器报 `Field 'x' is required`。
4291
+ */
4292
+ function toJobView(raw) {
4293
+ if (raw === null || typeof raw !== "object") throw new JobApiError(502, "JOB_SHAPE_INVALID", "The kernel returned a malformed job row.");
4294
+ const job = raw;
4295
+ const id = job["id"];
4296
+ const kind = job["kind"];
4297
+ const label = job["label"];
4298
+ const status = job["status"];
4299
+ const startedAt = job["startedAt"];
4300
+ if (typeof id !== "string" || typeof kind !== "string" || typeof label !== "string") throw new JobApiError(502, "JOB_SHAPE_INVALID", "The kernel returned a job row without id/kind/label.");
4301
+ if (typeof status !== "string" || ![
4302
+ "running",
4303
+ "stopping",
4304
+ "completed",
4305
+ "killed",
4306
+ "failed"
4307
+ ].includes(status)) throw new JobApiError(502, "JOB_SHAPE_INVALID", `The kernel returned an unknown job status: ${String(status)}`);
4308
+ if (typeof startedAt !== "number") throw new JobApiError(502, "JOB_SHAPE_INVALID", "The kernel returned a job row without startedAt.");
4309
+ const owner = job["owner"];
4310
+ const progress = job["progress"];
4311
+ const detail = job["detail"];
4312
+ const finishedAt = job["finishedAt"];
4313
+ return {
4314
+ id,
4315
+ kind,
4316
+ label,
4317
+ ...typeof owner === "string" ? { owner } : {},
4318
+ status,
4319
+ ...typeof progress === "string" ? { progress } : {},
4320
+ ...typeof detail === "string" ? { detail } : {},
4321
+ startedAt,
4322
+ ...typeof finishedAt === "number" ? { finishedAt } : {}
4323
+ };
4324
+ }
4325
+ var JobAdapter = class {
4326
+ host;
4327
+ constructor(host) {
4328
+ this.host = host;
4329
+ }
4330
+ /**
4331
+ * 列出某个会话可见的后台任务。
4332
+ *
4333
+ * 读流的**第一帧**(内核保证它是全量 truth)。若流首帧就不是 `rows`
4334
+ * (内核升级改了帧型),明确报错而不是返回空清单 ——
4335
+ * 空清单会被客户端理解成「没有任务」,那是**假的**。
4336
+ */
4337
+ async list(sessionId, signal) {
4338
+ const frame = (await this.host.jobController.list({ sessionId }, signal)[Symbol.asyncIterator]().next()).value;
4339
+ if (frame === void 0 || frame === null) throw new JobApiError(502, "JOB_STREAM_EMPTY", "The job roster stream closed before its first frame.");
4340
+ if (frame.type !== "rows") throw new JobApiError(502, "JOB_FRAME_UNKNOWN", `Unexpected first job frame: ${String(frame.type)}`);
4341
+ const jobs = frame.jobs;
4342
+ if (!Array.isArray(jobs)) throw new JobApiError(502, "JOB_SHAPE_INVALID", "The job roster frame carried no rows array.");
4343
+ return { jobs: jobs.map(toJobView) };
4344
+ }
4345
+ /**
4346
+ * 读一个任务已保留的输出(从指定字节偏移继续)。
4347
+ *
4348
+ * 内核的 `follow` 是流:`opened` 锚点 → 若干 `output` 批次 → 终态 `status`。
4349
+ * 这里**只读到第一个终态帧或流结束为止**,把批次摊平成一个文本块 ——
4350
+ * 手机端要看的是「这任务打印了什么」,不是逐帧的字节偏移记账。
4351
+ *
4352
+ * 但 `next` 偏移会一并返回:客户端想增量续读时,拿它当 `from` 再调一次即可,
4353
+ * 不必从头拉。`lossy` 也透出去,让客户端知道「有字节被留存策略吃掉了」,
4354
+ * 而不是把一段对不上的输出当成完整的。
4355
+ */
4356
+ async follow(sessionId, jobId, from, signal) {
4357
+ const stream = this.host.jobController.follow({
4358
+ sessionId,
4359
+ jobId,
4360
+ ...from === void 0 ? {} : { from }
4361
+ }, signal);
4362
+ const parts = [];
4363
+ let next = from ?? 0;
4364
+ let lossy = false;
4365
+ let status;
4366
+ let sawOpened = false;
4367
+ try {
4368
+ for await (const frame of stream) {
4369
+ if (frame === null || typeof frame !== "object") continue;
4370
+ if (frame.type === "opened") {
4371
+ sawOpened = true;
4372
+ status = toJobView(frame.job).status;
4373
+ next = typeof frame.from === "number" ? frame.from : next;
4374
+ continue;
4375
+ }
4376
+ if (frame.type === "output") {
4377
+ const chunks = frame.chunks;
4378
+ if (Array.isArray(chunks)) {
4379
+ for (const chunk of chunks) if (chunk !== null && typeof chunk === "object" && typeof chunk.text === "string") parts.push(chunk.text);
4380
+ }
4381
+ if (typeof frame.next === "number") next = frame.next;
4382
+ if (frame.lossy === true) lossy = true;
4383
+ continue;
4384
+ }
4385
+ if (frame.type === "status") {
4386
+ status = toJobView(frame.job).status;
4387
+ break;
4388
+ }
4389
+ }
4390
+ } catch (error) {
4391
+ if (isJobNotFound(error)) throw new JobApiError(404, "JOB_NOT_FOUND", `No observable job ${jobId} for this session.`);
4392
+ throw new JobApiError(502, "JOB_FOLLOW_FAILED", error instanceof Error ? error.message : String(error));
4393
+ }
4394
+ if (!sawOpened) throw new JobApiError(502, "JOB_STREAM_EMPTY", "The job observation stream closed before its anchor.");
4395
+ return {
4396
+ jobId,
4397
+ text: parts.join(""),
4398
+ next,
4399
+ lossy,
4400
+ ...status === void 0 ? {} : { status }
4401
+ };
4402
+ }
4403
+ /**
4404
+ * 终止一个后台任务。
4405
+ *
4406
+ * 内核的 `kill` 是**同步**的,且只有 `requested` / `already-finished` 两种返回;
4407
+ * 「这个任务不存在或该会话看不到它」走 `job/not-found` 远程错误,在这里转成 404。
4408
+ */
4409
+ kill(sessionId, jobId) {
4410
+ let value;
4411
+ try {
4412
+ value = this.host.jobController.kill({
4413
+ sessionId,
4414
+ jobId
4415
+ });
4416
+ } catch (error) {
4417
+ if (isJobNotFound(error)) throw new JobApiError(404, "JOB_NOT_FOUND", `No killable job ${jobId} in this session.`);
4418
+ throw new JobApiError(502, "JOB_KILL_FAILED", error instanceof Error ? error.message : String(error));
4419
+ }
4420
+ return {
4421
+ jobId,
4422
+ outcome: value.outcome
4423
+ };
4424
+ }
4425
+ };
4426
+ /** 内核 `job/not-found` 远程错误的识别。
4427
+ *
4428
+ * 内核的「任务不存在」有**两种错误形态**,判别必须都覆盖(2026-10-20 真机实测):
4429
+ *
4430
+ * 1. **`kill`(同步 Remote 调用)**:抛带 `code: 'job/not-found'` 的 Remote 错误
4431
+ * (`RemoteErrorDetailsMap` 里登记的那种)。看 code,不猜文案。
4432
+ * 2. **`follow` / `list`(AsyncIterable 流)**:流里 throw 的是普通
4433
+ * `Error("unknown job <id>")`,**没有 code 元数据** —— 流帧协议里没有
4434
+ * Remote 错误通道。只能认 message 前缀 `unknown job`。
4435
+ *
4436
+ * 这条曾漏过第二形态:output 对不存在的 job 返回 502 JOB_FOLLOW_FAILED
4437
+ * (「内核失败」)而非 404 JOB_NOT_FOUND(「任务不存在」)——
4438
+ * 客户端会把一次查错当成一次内核故障。真机一打就现形。
4439
+ */
4440
+ function isJobNotFound(error) {
4441
+ if (error === null || typeof error !== "object") return false;
4442
+ const candidate = error;
4443
+ if (candidate.code === "job/not-found") return true;
4444
+ const details = candidate.details;
4445
+ if (details !== null && typeof details === "object" && details.code === "job/not-found") return true;
4446
+ if (typeof candidate.message !== "string") return false;
4447
+ return candidate.message.includes("job/not-found") || candidate.message.startsWith("unknown job");
4448
+ }
4014
4449
  function normalizeListenerHost(value) {
4015
4450
  if (typeof value !== "string" || value.trim() === "" || isIP(value.trim()) === 0) throw new ApiError(400, "LISTENER_HOST_INVALID", "host must be a numeric IPv4 or IPv6 address.");
4016
4451
  return value.trim();
@@ -4496,20 +4931,36 @@ function timestamp() {
4496
4931
  //#endregion
4497
4932
  //#region src/host/root-resolver.ts
4498
4933
  /**
4499
- * 在授权根集合里找出包含该绝对路径的那一个(**纯字符串匹配,不触碰文件系统**)。
4934
+ * 与 [matchAuthorizedRoot] 相同,但**两侧都做 realpath 归一**再比较。
4935
+ *
4936
+ * ## 为什么需要它
4937
+ *
4938
+ * 授权根在入库时走了 `canonicalRoot()`(`database.ts` 的 addRoot),存的是**解析过
4939
+ * 符号链接**的路径;而客户端请求里的路径通常**没解析**。两者字面不同就匹配不上,
4940
+ * 结果是「明明在授权根内」却报 404 `PATH_OUTSIDE_ROOTS`。
4941
+ *
4942
+ * 最常见的触发场景就是 **macOS 的临时目录**:`os.tmpdir()` 给的是
4943
+ * `/var/folders/...`,而它是指向 `/private/var/folders/...` 的**符号链接**。
4944
+ * 于是测试里 `mkdtemp(os.tmpdir())` 造出的根与请求路径字面不一致 → 404。
4945
+ * 2026-10-20 CI 在 macOS runner 上实锤(Linux 上 tmpdir 不带 symlink,所以只在 macOS 炸)。
4500
4946
  *
4501
- * 多个根相互嵌套时取**最长**的那个 —— 否则外层根会把内层根下的文件解析成更长的相对路径,
4502
- * 虽然仍能读到同一文件,但会绕过内层根更严格的授权语义。
4947
+ * 这不只是测试问题:用户给一个经 symlink 的路径加授权根、或客户端用 symlink 别名
4948
+ * 请求,都会踩到。**归一后两侧是同一条真实路径**,语义也就正确了。
4503
4949
  *
4504
- * 注意这只是粗筛:字符串前缀相等不代表真实落在根内(根内可能存在指向外部的符号链接)。
4505
- * 真正的安全校验由 [inspectResolvedPath] 经 `resolveAuthorizedPath` 完成。
4950
+ * ## 安全边界不变
4951
+ *
4952
+ * 归一只解决「同一个目录的两种写法」,**不放宽**任何权限:
4953
+ * 真正的越权校验仍由 [inspectResolvedPath] → `resolveAuthorizedPath` 逐段做
4954
+ * (它会拒绝指向根外的符号链接)。这里只是把「找哪个根」这一步做准。
4506
4955
  */
4507
- function matchAuthorizedRoot(absolutePath, roots) {
4956
+ async function matchAuthorizedRootResolved(absolutePath, roots) {
4957
+ const candidate = await canonicalOrSelf(absolutePath);
4508
4958
  let best;
4509
4959
  for (const root of roots) {
4510
- const relative = wireRelative(root.realPath, absolutePath);
4960
+ const canonicalRoot = await canonicalOrSelf(root.realPath);
4961
+ const relative = wireRelative(canonicalRoot, candidate);
4511
4962
  if (relative === void 0) continue;
4512
- if (best === void 0 || root.realPath.length > best.root.realPath.length) best = {
4963
+ if (best === void 0 || canonicalRoot.length > best.root.realPath.length) best = {
4513
4964
  root,
4514
4965
  relative
4515
4966
  };
@@ -4519,6 +4970,14 @@ function matchAuthorizedRoot(absolutePath, roots) {
4519
4970
  path: best.relative
4520
4971
  };
4521
4972
  }
4973
+ /** 取路径的真实形式;取不到(路径不存在、无权限等)就原样返回。 */
4974
+ async function canonicalOrSelf(target) {
4975
+ try {
4976
+ return await realpath(target);
4977
+ } catch {
4978
+ return target;
4979
+ }
4980
+ }
4522
4981
  const CONTENT_TYPES = {
4523
4982
  ".md": "text/markdown",
4524
4983
  ".txt": "text/plain",
@@ -4662,6 +5121,32 @@ function stringifyWire(value) {
4662
5121
  }
4663
5122
  //#endregion
4664
5123
  //#region src/host/router.ts
5124
+ function matchJobRoute(pathname) {
5125
+ const jobs = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs$/);
5126
+ if (jobs !== null) return {
5127
+ kind: "list",
5128
+ sessionId: decodeURIComponent(jobs[1])
5129
+ };
5130
+ const output = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/output$/);
5131
+ if (output !== null) return {
5132
+ kind: "output",
5133
+ sessionId: decodeURIComponent(output[1]),
5134
+ jobId: decodeURIComponent(output[2])
5135
+ };
5136
+ const kill = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)\/kill$/);
5137
+ if (kill !== null) return {
5138
+ kind: "kill",
5139
+ sessionId: decodeURIComponent(kill[1]),
5140
+ jobId: decodeURIComponent(kill[2])
5141
+ };
5142
+ const bare = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/jobs\/([^/]+)$/);
5143
+ if (bare !== null) return {
5144
+ kind: "unknown",
5145
+ sessionId: decodeURIComponent(bare[1]),
5146
+ jobId: decodeURIComponent(bare[2])
5147
+ };
5148
+ return null;
5149
+ }
4665
5150
  var ApiRouter = class {
4666
5151
  options;
4667
5152
  constructor(options) {
@@ -4706,6 +5191,12 @@ var ApiRouter = class {
4706
5191
  });
4707
5192
  return;
4708
5193
  }
5194
+ if (method === "GET" && pathname === "/api/v1/debug/host-services") {
5195
+ const report = this.options.hostServiceReport;
5196
+ if (report === void 0) throw new ApiError(404, "NOT_FOUND", "host service probe is not available in this build.");
5197
+ sendJson(res, 200, report);
5198
+ return;
5199
+ }
4709
5200
  if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
4710
5201
  this.options.auth.pairingAttempts.consume(requestAddress(req));
4711
5202
  const body = await readJson(req, 16384);
@@ -4748,7 +5239,7 @@ var ApiRouter = class {
4748
5239
  const requested = url.searchParams.get("path")?.trim() ?? "";
4749
5240
  if (requested === "") throw new ApiError(400, "BODY_INVALID", "path is required.");
4750
5241
  if (!path.isAbsolute(requested)) throw new ApiError(400, "PATH_NOT_ABSOLUTE", "path must be an absolute path.");
4751
- const match = matchAuthorizedRoot(path.resolve(requested), this.authorizedRoots(principal));
5242
+ const match = await matchAuthorizedRootResolved(path.resolve(requested), this.authorizedRoots(principal));
4752
5243
  if (match === void 0) throw new ApiError(404, "PATH_OUTSIDE_ROOTS", "The path is not inside any root this device may access.");
4753
5244
  sendJson(res, 200, await inspectResolvedPath(this.requireRoot(principal, match.rootId), match.path));
4754
5245
  return;
@@ -4990,6 +5481,35 @@ var ApiRouter = class {
4990
5481
  sendJson(res, 200, { archived: true });
4991
5482
  return;
4992
5483
  }
5484
+ const jobDispatch = matchJobRoute(pathname);
5485
+ if (jobDispatch !== null) {
5486
+ const jobs = this.options.jobs;
5487
+ if (jobs === void 0) throw new ApiError(404, "NOT_FOUND", "Background jobs are not available in this build.");
5488
+ if (method === "GET" && jobDispatch.kind === "list") {
5489
+ this.options.auth.requireScope(principal, "chat.read");
5490
+ sendJson(res, 200, await jobs.list(jobDispatch.sessionId, AbortSignal.timeout(15e3)));
5491
+ return;
5492
+ }
5493
+ if (method === "GET" && jobDispatch.kind === "output") {
5494
+ this.options.auth.requireScope(principal, "chat.read");
5495
+ const from = url.searchParams.get("from");
5496
+ const offset = from === null ? void 0 : Number(from);
5497
+ if (offset !== void 0 && !Number.isFinite(offset)) throw new ApiError(400, "QUERY_INVALID", "from must be a finite byte offset.");
5498
+ sendJson(res, 200, await jobs.follow(jobDispatch.sessionId, jobDispatch.jobId, offset, AbortSignal.timeout(3e4)));
5499
+ return;
5500
+ }
5501
+ if (method === "POST" && jobDispatch.kind === "kill") {
5502
+ this.options.auth.requireScope(principal, "chat.write");
5503
+ const result = jobs.kill(jobDispatch.sessionId, jobDispatch.jobId);
5504
+ this.audit(principal, "chat.job.kill", void 0, void 0, requestId, {
5505
+ sessionId: jobDispatch.sessionId,
5506
+ jobId: jobDispatch.jobId
5507
+ });
5508
+ sendJson(res, 200, result);
5509
+ return;
5510
+ }
5511
+ throw new ApiError(405, "METHOD_NOT_ALLOWED", "Unsupported method for the jobs resource.");
5512
+ }
4993
5513
  const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
4994
5514
  if (messagesRoute !== null) {
4995
5515
  const sessionId = decodeURIComponent(messagesRoute[1]);
@@ -6072,6 +6592,10 @@ var DshSettingsAdapter = class {
6072
6592
  configurable: entry.settingsNs !== "",
6073
6593
  configured,
6074
6594
  removable,
6595
+ ...entry.defaultBaseURL === void 0 ? {} : { defaultBaseURL: entry.defaultBaseURL },
6596
+ ...entry.baseURLEnv === void 0 ? {} : { baseURLEnv: entry.baseURLEnv },
6597
+ ...entry.nativeAuth === void 0 ? {} : { nativeAuth: entry.nativeAuth },
6598
+ ...entry.settingsNs === "" ? {} : { settingsNs: entry.settingsNs },
6075
6599
  credential: {
6076
6600
  ref,
6077
6601
  ...credential
@@ -6111,12 +6635,18 @@ var DshSettingsAdapter = class {
6111
6635
  });
6112
6636
  }
6113
6637
  if (patch.models !== void 0) {
6114
- validateModels(patch.models);
6115
- ops.push({
6116
- op: "set",
6117
- path: [...context.entry.settingsPath, "models"],
6118
- value: patch.models
6638
+ if (patch.models === null) ops.push({
6639
+ op: "unset",
6640
+ path: [...context.entry.settingsPath, "models"]
6119
6641
  });
6642
+ else {
6643
+ validateModels(patch.models);
6644
+ ops.push({
6645
+ op: "set",
6646
+ path: [...context.entry.settingsPath, "models"],
6647
+ value: patch.models
6648
+ });
6649
+ }
6120
6650
  }
6121
6651
  const ref = text(context.profile?.apiKeyEnv) ?? keyRef(providerId);
6122
6652
  if (context.entry.settingsPath.length > 0 && context.profile === void 0) ops.unshift({
@@ -6366,13 +6896,10 @@ const inject = [
6366
6896
  "settingsController",
6367
6897
  "credentialsController",
6368
6898
  "agentPresets",
6369
- "permissionPresets",
6370
6899
  "llm",
6371
6900
  "webServer",
6372
6901
  "agents",
6373
- "commands",
6374
- "fileUploads",
6375
- "attachments"
6902
+ "commands"
6376
6903
  ];
6377
6904
  async function apply(ctx, config = {}) {
6378
6905
  const dataDir = path.resolve(config.dataDir ?? path.join(process.cwd(), ".dsh-workspace"));
@@ -6406,7 +6933,20 @@ async function apply(ctx, config = {}) {
6406
6933
  commands: ctx.commands
6407
6934
  });
6408
6935
  const settings = new DshSettingsAdapter(apiProxy);
6936
+ let hostServiceReport;
6937
+ try {
6938
+ hostServiceReport = probeHostServices(ctx, PLUGIN_VERSION);
6939
+ const { injectedAvailable, injectedTotal, injectedMissing } = hostServiceReport.summary;
6940
+ const detail = injectedMissing.length === 0 ? "" : `,缺失:${injectedMissing.join(", ")}`;
6941
+ ctx.logger.info(`dsh-remote-bridge: host services ${injectedAvailable}/${injectedTotal} 可注入${detail}`);
6942
+ if (hostServiceReport.summary.observedAvailable.length > 0) ctx.logger.info(`dsh-remote-bridge: 观测到可用但未 inject 的服务:${hostServiceReport.summary.observedAvailable.join(", ")}`);
6943
+ } catch (error) {
6944
+ ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: host service probe failed: ${String(error)}`));
6945
+ }
6409
6946
  let router;
6947
+ const reflect = ctx.reflect;
6948
+ const jobControllerAvailable = reflect?.get !== void 0 && reflect.get("jobController", false) !== void 0;
6949
+ if (!jobControllerAvailable) ctx.logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: jobController not provided by this kernel, background-job endpoints disabled"));
6410
6950
  const remote = new RemoteApiServer({
6411
6951
  port,
6412
6952
  remoteHost,
@@ -6426,7 +6966,9 @@ async function apply(ctx, config = {}) {
6426
6966
  tunnels: new CloudflaredTunnelManager(),
6427
6967
  cloudflaredBinDir: path.join(dataDir, "cloudflared-bin"),
6428
6968
  maxUploadBytes,
6429
- imageFallbackDir: imageHandoffDir
6969
+ imageFallbackDir: imageHandoffDir,
6970
+ ...hostServiceReport === void 0 ? {} : { hostServiceReport },
6971
+ ...jobControllerAvailable && reflect?.get ? { jobs: new JobAdapter({ jobController: reflect.get("jobController", false) }) } : {}
6430
6972
  });
6431
6973
  try {
6432
6974
  await remote.start();