@meiyukichan/dsh-sandore 0.1.2-rc.2 → 0.1.2-rc.3

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.
@@ -236,7 +236,13 @@ export interface SandoreFileOpenDeps {
236
236
  * (`openpath-intercept.ts:23-27` 明说),缓存旧实例会让 shadow 后的新实例被绕过。
237
237
  */
238
238
  readonly sessionRemote?: (() => SessionRemoteLike | undefined) | undefined;
239
- /** host 侧落盘 RPC(写 `<cwd>/.sandore/artifacts/<instanceId>/…`,返回**进程绝对路径**)。 */
239
+ /**
240
+ * host 侧落盘 RPC(写 `<DSH_HOME>/storages/sandore/output/<instanceId>/…`,返回**进程绝对路径**)。
241
+ *
242
+ * 该落点**在会话 workspace 之外**(本批从 `<cwd>/.sandore/artifacts/` 搬出来)⇒ L3 的越界判据
243
+ * 对它恒成立,所以 better-sidebar 在场时 Agent 输出一律走自注册 tab(`sandore-tab`),
244
+ * 不再落进用户的仓库。
245
+ */
240
246
  readonly writeOutput?: ((request: WriteOutputRequest) => Promise<string>) | undefined;
241
247
  /** host 侧读文件 RPC(`readInstanceFile`;应用内兜底与 C6 tab 体共用的取数面)。 */
242
248
  readonly readFile?: ((request: ReadFileRequest) => Promise<ReadFileResultLike>) | undefined;
@@ -683,9 +683,10 @@ export function installSandoreFileOpen(deps) {
683
683
  * 内存里,不落盘就没有 path ⇒ 只能自家渲染 ⇒ 正是用户不接受的那件事。
684
684
  * 触发条件:**确认至少有一条落点通道**(`canHandOff`)。它不成立(服务缺席 / 无活跃会话 /
685
685
  * 无 openTab / 无漏斗)⇒ **零写盘**,直接用内存文本走 L4。
686
- * 为什么这不算"偷偷写文件":① 落点固定(`<cwd>/.sandore/artifacts/<instanceId>/`);
687
- * ② 文件名由 step/attempt 派生 ⇒ **重复点击是覆盖,不是堆积**;③ 写的是用户**刚刚点击**
688
- * 要看的那一段输出(不是后台产物)。
686
+ * 为什么这不算"偷偷写文件":① 落点固定且**在我们自己的数据目录**里
687
+ * (`<DSH_HOME>/storages/sandore/output/<instanceId>/`,与 `sandore.sqlite` / 只读锁同一个家
688
+ * —— 不往用户的项目工作区写);② 文件名由 step/attempt 派生 ⇒ **重复点击是覆盖,不是堆积**;
689
+ * ③ 写的是用户**刚刚点击**要看的那一段输出(不是后台产物)。
689
690
  */
690
691
  if (!canHandOff()) {
691
692
  const failure = landingFailureOf();
@@ -122,9 +122,9 @@ export async function apply(ctx) {
122
122
  sessions: sessionsCwdSource,
123
123
  // L1 的现取(`remote.session` 的漏斗 + 它的能力探针 `canOpenWorkspacePath`)。
124
124
  sessionRemote: () => sessionRemoteCtx?.get('remote.session'),
125
- // Agent 输出落盘:host 唯一构造落点(`.sandore/artifacts/<instanceId>/…`),client 只拿回
126
- // 真实绝对路径再交给 `openTab`。**它只在确认要交给 better-sidebar 时才写**
127
- // (没 path 就没有 tab 的内容种子,见 `openOutput` 的结论注释)。
125
+ // Agent 输出落盘:host 唯一构造落点(`<DSH_HOME>/storages/sandore/output/<instanceId>/…`,
126
+ // **在会话 workspace 之外**),client 只拿回真实绝对路径再交给 `openTab`。**它只在确认要交给
127
+ // better-sidebar 时才写**(没 path 就没有 tab 的内容种子,见 `openOutput` 的结论注释)。
128
128
  // 显式给 `unwrap` 类型实参:`remoteCtx.remote.sandore` 的成员在客户端图里是 `any`
129
129
  // (cordis 注入 ctx 的 `remote` 面不参与类型收窄),而 `unwrap(any)` 会退化成
130
130
  // `unknown`(无法从 `any` 反推 T)⇒ 不开显式实参就取不到 `.path`。
package/lib/index.js CHANGED
@@ -6,11 +6,11 @@ import { Service } from "@deepseek-ai/cordis";
6
6
  import z$1 from "@deepseek-ai/schemastery";
7
7
  import { SessionId, SessionSeq } from "@deepseek-ai/dsh-session";
8
8
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
9
+ import { homedir } from "node:os";
10
+ import { basename, dirname, join, resolve } from "node:path";
9
11
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
10
12
  import { randomUUID } from "node:crypto";
11
13
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
- import { homedir } from "node:os";
13
- import { basename, dirname, join, resolve } from "node:path";
14
14
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
15
15
  import { existsSync, readFileSync, readdirSync } from "node:fs";
16
16
  import { fileURLToPath } from "node:url";
@@ -5406,73 +5406,126 @@ function withMicroflow(definition, microflow, entryId, base) {
5406
5406
  //#endregion
5407
5407
  //#region packages/workflow/instance-output.ts
5408
5408
  /**
5409
- * **Agent 输出落盘**(result 叶子 → 工作区内的真实文件)。
5409
+ * **Agent 输出落盘**(result 叶子 → 磁盘上的真实文件)。
5410
5410
  *
5411
5411
  * ## 为什么需要落盘
5412
5412
  * 工作流导航树里 **result 叶子("Agent 输出")只有文本、没有文件**(`run-view-model.ts`
5413
- * 的 `buildRunTree` 从 `executedSteps[].output` 现算),而"打开文件"必须有文件。落点选择
5414
- * 直接决定用户体验:
5415
- *
5416
- * - **落 workspace 内**(本模块)= 零配置:better-sidebar 的 `workspaceFence`(0.18.0 默认
5417
- * 开启)只放行会话 cwd 之内的路径 ⇒ 打开即读得到内容;
5418
- * - 落系统临时目录 = 用户每次都要去设置页手动关掉**全局** fence(并承担"任何同源脚本可越界
5419
- * 读写"的代价),且**部署侧没有任何开关能预先关掉它**(`fenceEnabledOf` 只读 settings 服务,
5420
- * 不吃插件 config)—— 故不采用。
5413
+ * 的 `buildRunTree` 从 `executedSteps[].output` 现算),而"打开文件"必须有文件。
5421
5414
  *
5422
- * ## 落点(`<instanceId>/` 这一层**必需**)
5415
+ * ## 落点:**DSH home 下 sandore 自己的存储目录**(定稿,别再放回 workspace)
5423
5416
  * ```
5424
- * <session cwd>/.sandore/artifacts/<instanceId>/<stepId>-<attemptIndex>-output.md
5417
+ * <DSH_HOME>/storages/sandore/output/<instanceId>/<stepId>-<attemptIndex>-output.md
5425
5418
  * ```
5426
5419
  * | 路径段 | 为什么必需 |
5427
5420
  * |---|---|
5428
- * | `.sandore/artifacts/` | 单一隐藏目录,一条 gitignore(`.sandore/`)即可收敛 |
5421
+ * | `<DSH_HOME>/storages/sandore/` | 与 `storages/sandore.sqlite` / `storages/sandore-lock.json` **同一个家**:都是「sandore 自己的数据」,**不再往用户的项目工作区里写任何东西**(旧落点 `<实例 cwd>/.sandore/artifacts/` 会污染每个跑过工作流的仓库,还要用户自己加 `.gitignore`) |
5422
+ * | `output/` | 与其它 storage 子目录分开,便于整体清理("Agent 输出"这一类可以一次性删空而不碰定义/锁) |
5429
5423
  * | **`<instanceId>/`** | `stepId` 是**定义内 id**:同一工作流跑多个实例时必然重名,没有实例维度会让多实例并发的同一 `stepId` 输出**互相覆盖**(`instanceId ≡ 宿主会话 id`,见 AGENTS.md 的实例身份恒等式) |
5430
5424
  * | `<stepId>-<attemptIndex>-output.md` | 同一实例内同一 step 的**多次执行段/重试**靠 `attemptIndex` 区分(与 `run-view-model.ts` 的段快照语义一致) |
5431
5425
  *
5432
- * **不主动清理**:产出是用户的证据,删掉比留下更危险;清理权交给用户(文档写明落点与
5433
- * 建议 gitignore)。
5426
+ * **不主动清理**:产出是用户的证据,删掉比留下更危险;清理权交给用户(清理面 =
5427
+ * `<DSH_HOME>/storages/sandore/output/` 整个目录)。
5434
5428
  *
5435
- * 写盘走 **DSH 的 `ctx.fs` 服务**(本仓库 host 侧一律不直连 `node:fs`,与
5436
- * `wakeup-diag.ts` 的纪律一致;`writeText` 已含父目录 `mkdir(recursive)`)。
5429
+ * ## 路径解析(绝不硬编码绝对路径)
5430
+ * 三级,与 `definition-lock.ts` 的 `resolveDefinitionLockPath` **同口径**:
5431
+ * ① 显式覆盖(测试 / 非常规部署)→ ② DSH 在 root ctx 上 `provide` 的
5432
+ * `dshHomePath('storages','sandore','output')` → ③ 本地回退(`$DSH_HOME`(trim 后非空)> `~/.dsh`)。
5433
+ * 第三级永不失败(总有一个字符串结果),**绝不返回 undefined/空串**(那会让落点退化成"写当前目录"
5434
+ * 这类意外位置)。
5437
5435
  *
5438
- * ## 为什么必须**显式传 per-call 沙箱策略**(2026-09-20 真机报障的根因)
5436
+ * > ⚠️ 该回退与 `definition-lock.ts` 的 `fallbackDshHome` 是**同一套规则的两处实现**
5437
+ * > (两个模块都不依赖 `@deepseek-ai/dsh-home-paths`——本包 `node_modules` 里没有它)。
5438
+ * > 两边都有锁(`instance-output.spec.ts` / `definition-lock.spec.ts`),改规则要同时改。
5439
5439
  *
5440
- * `ctx.fs` 若是**沙箱后端**(`@deepseek-ai/dsh-fs-sandbox` 的 `SandboxedFileSystem`),
5441
- * 它的写盘判据是(`packages/fs/fs-sandbox/src/index.ts:122-142`):
5440
+ * ## 写盘走 `ctx.fs`,但**必须显式给 per-call 沙箱策略**(P14 真机报障的根因)
5441
+ * `ctx.fs` 若是沙箱后端(`@deepseek-ai/dsh-fs-sandbox` 的 `SandboxedFileSystem`),它的写盘判据是
5442
+ * (`packages/fs/fs-sandbox/src/index.ts:122-142`):
5442
5443
  * ```
5443
- * const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() // ← 注意:不传就用**无会话**的兜底
5444
+ * const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() // ← 不传就用**无会话**的兜底
5444
5445
  * ```
5445
- * 而无会话的 `resolve()`(`packages/sandbox/sandbox-policy/src/index.ts:163-170`)把
5446
- * `workspaceRoot` 取成**部署兜底根**(`config.workspaceRoot ?? process.cwd()`),**不是**任何会话的
5447
- * cwd;可写根集合 = `[workspaceRoot, '/tmp', tmpdir()]`(`packages/sandbox/sandbox/src/roots.ts:52-54`)。
5448
- * ⇒ 只调 `fs.writeText(target, text)` 时,落点 `<实例 cwd>/.sandore/artifacts/…` 会被拿去与
5449
- * **部署兜底根**比较,真机表现就是
5450
- * `cannot write "D:\…\.sandore\artifacts\…-output.md": file access denied under workspace-write mode`
5451
- * (`gateway/internal` 把这条内部错直接抛到了 UI)。
5452
- *
5453
- * 正解 = 复刻 DSH 自己的工具层做法(`packages/fs/tool-fs/src/sandbox.ts:87-92`):
5454
- * **每次写盘前解析"这个实例宿主会话"的策略并把第 5 个参数传给 `writeText`**,判据才是
5455
- * 「该会话的 cwd」(`resolve({session})` ⇒ `session.header.cwd`)。见 {@link resolveWritePolicy}。
5446
+ * 而无会话的 `resolve()`(`packages/sandbox/sandbox-policy/src/index.ts:163-170`)把 `workspaceRoot`
5447
+ * 取成**部署兜底根**(`config.workspaceRoot ?? process.cwd()`);`workspace-write` 的可写根集合 =
5448
+ * `[policy.workspaceRoot, '/tmp', tmpdir()]`(`packages/sandbox/sandbox/src/roots.ts:52-54`)。
5449
+ *
5450
+ * ⇒ 本批落点在 workspace **之外**,所以"解析出会话策略"还不够:还必须把**落点根**作为
5451
+ * `workspaceRoot` 交下去,否则 `workspace-write` 一定拒(真机形态正是
5452
+ * `cannot write "…": file access denied under workspace-write mode`)。见 {@link outputWritePolicy}。
5453
+ *
5454
+ * 三条纪律:
5455
+ * 1. **本模块自己拥有"落点根"**({@link resolveOutputRoot}),**不向调用方要 cwd** —— 落点与
5456
+ * 实例 cwd 无关,要 cwd 只会让将来又漂回 workspace;
5457
+ * 2. 策略取不到(无沙箱后端的裸 `LocalFileSystem`)⇒ 第 5 参不传(后端本来就不判);
5458
+ * 3. 被拒时由调用方折成 `INVALID_OPERATION` + 带**落点路径**的可见原因,
5459
+ * **绝不把内部错原文抛给 UI**。
5456
5460
  * @module @meiyukichan/dsh-sandore/workflow/src/instance-output
5457
5461
  */
5458
- /** Agent 输出落盘的固定目录段(相对会话 cwd)。 */
5459
- const ARTIFACTS_DIR = ".sandore/artifacts";
5462
+ /** DSH home 目录名(`$DSH_HOME` 缺省时用它;与 DSH `home-paths` 的 `DSH_HOME_DIR_NAME` 同值)。 */
5463
+ const DSH_HOME_DIR_NAME$1 = ".dsh";
5464
+ /** 覆盖 DSH home 的环境变量名(与 DSH `home-paths` 的 `DSH_HOME_ENV` 同值)。 */
5465
+ const DSH_HOME_ENV$1 = "DSH_HOME";
5466
+ /** 落点在 DSH home 下的目录段(`<DSH_HOME>/storages/sandore/output`)。 */
5467
+ const OUTPUT_ROOT_SEGMENTS = [
5468
+ "storages",
5469
+ "sandore",
5470
+ "output"
5471
+ ];
5472
+ /** 非空(trim 后)才算数;空白串视为"没给"。 */
5473
+ function nonBlank$1(value) {
5474
+ if (value === void 0) return void 0;
5475
+ return value.trim().length === 0 ? void 0 : value;
5476
+ }
5477
+ /** `~` / `~/x` 展开成 home 下的路径。 */
5478
+ function expandHomePath$1(path) {
5479
+ if (path === "~") return homedir();
5480
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
5481
+ return path;
5482
+ }
5460
5483
  /**
5461
- * **纯函数**:解析一次写盘应当使用的沙箱策略。
5484
+ * 本地回退解析 DSH home(`$DSH_HOME`(trim 后非空)> `<homeDir|os.homedir()>/.dsh`)。
5485
+ *
5486
+ * 与 `definition-lock.ts` 的 `fallbackDshHome` 同规则(那边有逐条出处注释);生产路径上优先走
5487
+ * {@link OutputRootOptions.dshHomePath} 注入函数,那时**这套回退根本不参与**。
5488
+ * @param options - 解析入参。
5489
+ * @returns 归一化后的绝对 home 路径。
5490
+ */
5491
+ function fallbackDshHome$1(options) {
5492
+ const selected = nonBlank$1((options.env ?? process.env)[DSH_HOME_ENV$1]) ?? join(options.homeDir ?? homedir(), DSH_HOME_DIR_NAME$1);
5493
+ return resolve(expandHomePath$1(selected));
5494
+ }
5495
+ /** 统一成 `/` 分隔(落点是**契约值**:调用方拿它拼 URL/展示,不随平台变)。 */
5496
+ function toSlashes(path) {
5497
+ return path.replace(/\\/g, "/");
5498
+ }
5499
+ /**
5500
+ * **纯函数**:解析 Agent 输出的落点根(`<DSH_HOME>/storages/sandore/output`)。
5501
+ *
5502
+ * 三级优先级与 `resolveDefinitionLockPath` 同口径(见模块头)。**永不失败**:拿不到注入函数只是
5503
+ * 回退到本地规则,**绝不**返回 `undefined`/空串。
5504
+ * @param options - 解析入参;缺省 = 只剩本地回退。
5505
+ * @returns 落点根的绝对路径(统一 `/` 分隔;真正的绝对化由 `fs.resolve` 完成)。
5506
+ */
5507
+ function resolveOutputRoot(options = {}) {
5508
+ const explicit = nonBlank$1(options.explicitRoot);
5509
+ if (explicit !== void 0) return toSlashes(explicit);
5510
+ if (typeof options.dshHomePath === "function") return toSlashes(options.dshHomePath(...OUTPUT_ROOT_SEGMENTS));
5511
+ return toSlashes(join(fallbackDshHome$1(options), ...OUTPUT_ROOT_SEGMENTS));
5512
+ }
5513
+ /**
5514
+ * **纯函数**:解析一次写盘"应当使用的会话策略"。
5462
5515
  *
5463
5516
  * 判据与 DSH 工具层逐字同源:`ctx.sandboxPolicy.resolve({ session })`,而 `session` 取
5464
- * **该实例的宿主会话**(恒等式 `instanceId ≡ 宿主会话 id`)—— 产物写在它的工作目录里,
5465
- * 判据就该是它的 cwd(`session.header.cwd`)。
5517
+ * **该实例的宿主会话**(恒等式 `instanceId ≡ 宿主会话 id`)——`mode` 由那个会话决定,
5518
+ * 它是**用户的意图**(read-only / workspace-write / danger-full-access),必须尊重。
5466
5519
  *
5467
5520
  * 三条"取不到就不假装知道"的纪律:
5468
5521
  * ① 部署没有沙箱后端(`sandboxPolicy` 缺席 = 裸 `LocalFileSystem`)⇒ `undefined`
5469
5522
  * (第 5 参不传,后端本来就不判);
5470
5523
  * ② 服务在但**会话取不到** ⇒ 也 `undefined`(不自己编一个 `workspaceRoot`:编错了就是
5471
- * "绕开用户的文件策略",编窄了就是本次报障);
5524
+ * "绕开用户的文件策略",编窄了就是 P14 报障);
5472
5525
  * ③ `resolve()` 抛错(服务面畸形)⇒ `undefined`(由 `ctx.fs` 自己的兜底判据决定,绝不抛穿)。
5473
5526
  * @param deps - 两个服务(都可缺席)。
5474
5527
  * @param instanceId - 实例 id(≡ 宿主会话 id)。
5475
- * @returns 该会话的沙箱策略;无法确定时 `undefined`。
5528
+ * @returns 该会话的策略;无法确定时 `undefined`。
5476
5529
  */
5477
5530
  function resolveWritePolicy(deps, instanceId) {
5478
5531
  const service = deps.sandboxPolicy;
@@ -5486,6 +5539,32 @@ function resolveWritePolicy(deps, instanceId) {
5486
5539
  }
5487
5540
  }
5488
5541
  /**
5542
+ * **纯函数**:把"会话策略"折成"这次写盘真正要用的策略"。
5543
+ *
5544
+ * 为什么需要它(落点搬到 workspace 之外后的**关键一步**):`workspace-write` 的可写根是
5545
+ * **`policy.workspaceRoot`**,而会话策略的根是**会话 cwd**;落点
5546
+ * `<DSH_HOME>/storages/sandore/output/…` 永远在它之外 ⇒ 后端必然拒(`FS_SANDBOX_DENIED`)。
5547
+ * 而这次写盘的**委托人是应用本身**(把 Agent 输出落到 sandore 自己的数据目录),不是被沙箱约束的
5548
+ * 模型动作 ⇒ 该把**落点根**当作这次写盘的可写根。
5549
+ *
5550
+ * 逐档语义(**不对模式做任何放宽/收紧**):
5551
+ * - `read-only` ⇒ **原样返回**(用户的"只读"意图包括"别在我机器上新增文件",落盘照旧被拒);
5552
+ * - `workspace-write` ⇒ 原模式 + `workspaceRoot` 换成**落点根**(只把可写面从"会话工作区"
5553
+ * 换成"我们自己的数据目录",**不会**顺带放开工作区之外的任意路径);
5554
+ * - `danger-full-access` ⇒ **原样返回**(后端本来就不判);
5555
+ * - `undefined` ⇒ `undefined`(没有沙箱后端,本来就不判)。
5556
+ * @param policy - 会话策略({@link resolveWritePolicy} 的结果)。
5557
+ * @param outputRoot - 落点根({@link resolveOutputRoot} 的结果)。
5558
+ * @returns 这次写盘要传给 `writeText` 第 5 参的策略。
5559
+ */
5560
+ function outputWritePolicy(policy, outputRoot) {
5561
+ if (policy === void 0 || policy.mode !== "workspace-write") return policy;
5562
+ return {
5563
+ mode: "workspace-write",
5564
+ workspaceRoot: outputRoot
5565
+ };
5566
+ }
5567
+ /**
5489
5568
  * **纯函数**:这次失败是不是"沙箱拒绝写入"。
5490
5569
  *
5491
5570
  * 走 `code` 而不是 `instanceof FsError`:本模块**不依赖** `@deepseek-ai/dsh-fs`
@@ -5503,13 +5582,13 @@ function safeSegment(value) {
5503
5582
  return cleaned === "" ? "step" : cleaned;
5504
5583
  }
5505
5584
  /**
5506
- * **纯函数**:Agent 输出的落盘目录(`<cwd>/.sandore/artifacts/<instanceId>`)。
5507
- * @param cwd - 会话工作目录。
5585
+ * **纯函数**:Agent 输出的落盘目录(`<落点根>/<instanceId>`)。
5586
+ * @param outputRoot - 落点根({@link resolveOutputRoot})。
5508
5587
  * @param instanceId - 实例 id(≡ 宿主会话 id)。
5509
5588
  * @returns 目录绝对路径(统一正斜杠;真正的绝对化由 `fs.resolve` 完成)。
5510
5589
  */
5511
- function outputDirPath(cwd, instanceId) {
5512
- return `${cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${ARTIFACTS_DIR}/${safeSegment(instanceId)}`;
5590
+ function outputDirPath(outputRoot, instanceId) {
5591
+ return `${outputRoot.replace(/\\/g, "/").replace(/\/+$/, "")}/${safeSegment(instanceId)}`;
5513
5592
  }
5514
5593
  /**
5515
5594
  * **纯函数**:Agent 输出的文件名。
@@ -5522,19 +5601,22 @@ function outputFileName(stepId, attemptIndex) {
5522
5601
  return `${safeSegment(stepId)}-${attempt}-output.md`;
5523
5602
  }
5524
5603
  /**
5525
- * 把一段文本落到实例的产物目录,返回**进程绝对路径**。
5604
+ * 把一段文本落到实例的输出目录,返回**进程绝对路径**。
5526
5605
  *
5527
5606
  * 幂等:同一 `(instanceId, stepId, attemptIndex)` 重复调用覆盖同一文件(输出是快照语义,
5528
5607
  * 不是追加日志)。
5608
+ *
5609
+ * **落点根由本函数自己解析**({@link resolveOutputRoot}):调用方**不需要也不该**传实例 cwd ——
5610
+ * 落点与工作区无关。
5529
5611
  * @param fs - host 的 `ctx.fs` 接缝。
5530
- * @param cwd - 会话工作目录(`state.cwd`)。
5531
5612
  * @param input - `{instanceId, stepId, attemptIndex, text}`。
5532
- * @param sandboxPolicy - **该实例宿主会话**的沙箱策略({@link resolveWritePolicy});传了它
5533
- * `workspace-write` 的可写根才是那个会话的 cwd(不传 = 部署兜底根 ⇒ 真机报障)。
5534
- * @returns 落点进程绝对路径(供 client 交给 better-sidebar 打开)。
5613
+ * @param sandboxPolicy - 这次写盘要用的策略(**已经过 {@link outputWritePolicy} 折算**的那个);
5614
+ * 不传 ⇒ 沙箱后端按部署兜底根判(P14 报障形态)。
5615
+ * @param rootOptions - 落点根解析入参(测试 / 非常规部署;缺省 = 生产三级解析)。
5616
+ * @returns 落点进程绝对路径(供 client 交给 better-sidebar / 应用内卡片打开)。
5535
5617
  */
5536
- async function writeInstanceOutput(fs, cwd, input, sandboxPolicy) {
5537
- const target = await fs.resolve(`${outputDirPath(cwd, input.instanceId)}/${outputFileName(input.stepId, input.attemptIndex)}`);
5618
+ async function writeInstanceOutput(fs, input, sandboxPolicy, rootOptions = {}) {
5619
+ const target = await fs.resolve(`${outputDirPath(resolveOutputRoot(rootOptions), input.instanceId)}/${outputFileName(input.stepId, input.attemptIndex)}`);
5538
5620
  await fs.writeText(target, input.text, void 0, void 0, sandboxPolicy);
5539
5621
  return fs.processPath(target);
5540
5622
  }
@@ -5925,12 +6007,13 @@ const SendStepMessageInputSchema = z.object({
5925
6007
  message: z.string().min(1)
5926
6008
  });
5927
6009
  /**
5928
- * `writeInstanceOutputFile` 入参:把一段 **Agent 输出文本** 落到实例的产物目录。
6010
+ * `writeInstanceOutputFile` 入参:把一段 **Agent 输出文本** 落到实例的输出目录。
5929
6011
  *
5930
6012
  * 落点规则由 host 唯一构造(`instance-output.ts`):
5931
- * `<state.cwd>/.sandore/artifacts/<instanceId>/<stepId>-<attemptIndex>-output.md`。
5932
- * `attemptIndex` 区分同一 step 的多次执行段/重试(0-based);`<instanceId>/` 这一层
5933
- * 防止多实例并发的同一 `stepId` 互相覆盖。`text` 上限 1 MiB(输出是快照,不是日志)。
6013
+ * `<DSH_HOME>/storages/sandore/output/<instanceId>/<stepId>-<attemptIndex>-output.md`
6014
+ * —— **与实例 cwd 无关**(不再往用户的工作区里写)。`attemptIndex` 区分同一 step 的多次执行段/
6015
+ * 重试(0-based);`<instanceId>/` 这一层防止多实例并发的同一 `stepId` 互相覆盖。
6016
+ * `text` 上限 1 MiB(输出是快照,不是日志)。
5934
6017
  */
5935
6018
  const WriteInstanceOutputFileInputSchema = z.object({
5936
6019
  instanceId: z.string(),
@@ -6794,44 +6877,46 @@ let SandoreRemote = (() => {
6794
6877
  return await this.ctx.sandoreEngine.startInstance(request);
6795
6878
  }
6796
6879
  /**
6797
- * 把一段 **Agent 输出文本** 落到实例的产物目录,返回落点绝对路径(需求 7)。
6880
+ * 把一段 **Agent 输出文本** 落到实例的输出目录,返回落点绝对路径(需求 7)。
6798
6881
  *
6799
6882
  * 为什么需要这条 RPC:工作流导航树里 **result 叶子("Agent 输出")只有文本、没有文件**,
6800
- * 而"点开文件"必须有文件;落点必须在**会话 workspace 内**(better-sidebar 的
6801
- * `workspaceFence` 默认开启,越界路径打开只会得到一张 fence 报错,而部署侧没有开关能预关它)。
6883
+ * 而"点开文件"必须有文件。
6802
6884
  *
6803
6885
  * 落点(`<instanceId>/` 层**必需**:`stepId` 是定义内 id,多实例并发必然重名):
6804
- * `<state.cwd>/.sandore/artifacts/<instanceId>/<stepId>-<attemptIndex>-output.md`。
6805
- * 写盘走 `ctx.fs`(本仓库 host 侧一律不直连 `node:fs`);**不主动清理**(产出是用户的证据)。
6886
+ * `<DSH_HOME>/storages/sandore/output/<instanceId>/<stepId>-<attemptIndex>-output.md`。
6887
+ * 该根**与实例 cwd 无关**(`instance-output.ts` 自己按 DSH home 三级解析),所以这里**不读
6888
+ * `state.cwd`**;写盘走 `ctx.fs`(本仓库 host 侧一律不直连 `node:fs`);**不主动清理**
6889
+ * (产出是用户的证据)。
6806
6890
  *
6807
6891
  * 快照优先取内存中的 live state(收尾中间态也能落),否则回退持久化快照;两者皆无抛
6808
- * `NODE_NOT_FOUND`。
6892
+ * `NODE_NOT_FOUND`(取 state 只为**校验实例存在**,不再用于拼路径)。
6809
6893
  *
6810
- * **沙箱纪律(2026-09-20 真机报障的修复)**:写盘必须带**该实例宿主会话**的
6811
- * per-call 沙箱策略(`resolveWritePolicy`)—— `ctx.fs` 的沙箱后端在不给策略时按
6812
- * **部署兜底根**(`config.workspaceRoot ?? process.cwd()`)判可写性,于是
6813
- * `<实例 cwd>/.sandore/artifacts/…` 会被判成"越界"并抛 `FS_SANDBOX_DENIED`
6814
- * (真机原文:`file access denied under workspace-write mode`)。拒绝时**不再把内部错
6815
- * 抛给 UI**:折成 `INVALID_OPERATION` + 带落点路径的可见原因(client 侧据此回退到
6816
- * 内存文本的应用内展示,见 `better-sidebar-open.ts` 的 `write-failed`)。
6894
+ * **沙箱纪律(两步,P14 报障的完整修复)**:写盘必须带**该实例宿主会话**的 per-call 沙箱策略
6895
+ * (`resolveWritePolicy`)—— `ctx.fs` 的沙箱后端在不给策略时按**部署兜底根**
6896
+ * (`config.workspaceRoot ?? process.cwd()`)判可写性。**落点搬到 workspace 之外后还要第二步**:
6897
+ * 用 `outputWritePolicy` 把策略的 `workspaceRoot` 折成**落点根**(`workspace-write` 的可写根就是
6898
+ * `policy.workspaceRoot`,不折就一定越界);`read-only` 原样保留 ⇒ 照旧被拒(用户的"只读"意图
6899
+ * 包括"别在我机器上新增文件")。被拒时**不再把内部错抛给 UI**:折成 `INVALID_OPERATION` +
6900
+ * 带落点路径的可见原因(client 侧据此回退到内存文本的应用内展示,见 `better-sidebar-open.ts`
6901
+ * 的 `write-failed`)。
6817
6902
  */
6818
6903
  async writeInstanceOutputFile(input) {
6819
6904
  const parsed = WriteInstanceOutputFileInputSchema.parse(input);
6820
- const live = this.ctx.sandoreEngine.getInstanceState(parsed.instanceId);
6821
- const state = live !== void 0 ? live : this.requireInstanceState(parsed.instanceId);
6905
+ if (this.ctx.sandoreEngine.getInstanceState(parsed.instanceId) === void 0) this.requireInstanceState(parsed.instanceId);
6822
6906
  const services = this.ctx;
6907
+ const outputRoot = resolveOutputRoot({ ...typeof services.get("dshHomePath") === "function" ? { dshHomePath: services.get("dshHomePath") } : {} });
6823
6908
  try {
6824
- return { path: await writeInstanceOutput(this.ctx.fs, state.cwd, {
6909
+ return { path: await writeInstanceOutput(this.ctx.fs, {
6825
6910
  instanceId: parsed.instanceId,
6826
6911
  stepId: parsed.stepId,
6827
6912
  attemptIndex: parsed.attemptIndex,
6828
6913
  text: parsed.text
6829
- }, resolveWritePolicy({
6914
+ }, outputWritePolicy(resolveWritePolicy({
6830
6915
  sandboxPolicy: services.get("sandboxPolicy"),
6831
6916
  sessions: services.get("sessions")
6832
- }, parsed.instanceId)) };
6917
+ }, parsed.instanceId), outputRoot), { explicitRoot: outputRoot }) };
6833
6918
  } catch (error) {
6834
- if (isSandboxDenial(error)) throw new SandoreError(`cannot write the step output into "${outputDirPath(state.cwd, parsed.instanceId)}": the file sandbox of this instance session denies that location`, "INVALID_OPERATION");
6919
+ if (isSandboxDenial(error)) throw new SandoreError(`cannot write the step output into "${outputDirPath(outputRoot, parsed.instanceId)}": the file sandbox of this instance session denies that location`, "INVALID_OPERATION");
6835
6920
  throw error;
6836
6921
  }
6837
6922
  }
@@ -172,13 +172,13 @@ export declare const INSTANCE_LIST_FRAGMENTS: {
172
172
  };
173
173
  status: ArgumentFragment & {
174
174
  readonly zod: z.ZodEnum<{
175
- interacting: "interacting";
175
+ pending: "pending";
176
176
  running: "running";
177
+ interacting: "interacting";
177
178
  completed: "completed";
178
179
  failed: "failed";
179
- paused: "paused";
180
- pending: "pending";
181
180
  aborted: "aborted";
181
+ paused: "paused";
182
182
  }>;
183
183
  };
184
184
  limit: ArgumentFragment & {
@@ -221,8 +221,8 @@ export declare const SINGLE_TOOL_FRAGMENTS: {
221
221
  detail: ArgumentFragment & {
222
222
  readonly zod: z.ZodEnum<{
223
223
  steps: "steps";
224
- messages: "messages";
225
224
  step: "step";
225
+ messages: "messages";
226
226
  summary: "summary";
227
227
  }>;
228
228
  };
@@ -342,8 +342,8 @@ export declare const VisualizeArgsSchema: z.ZodObject<{
342
342
  export declare const StatusArgsSchema: z.ZodObject<{
343
343
  detail: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>> & z.ZodEnum<{
344
344
  steps: "steps";
345
- messages: "messages";
346
345
  step: "step";
346
+ messages: "messages";
347
347
  summary: "summary";
348
348
  }>;
349
349
  instanceId: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>> & z.ZodString;
@@ -18,14 +18,14 @@ export declare const sandoreDomainSpec: {
18
18
  taskId: string;
19
19
  workflowId: string;
20
20
  cwd: string;
21
- status: "interacting" | "running" | "completed" | "failed" | "paused" | "pending" | "aborted";
21
+ status: "pending" | "running" | "interacting" | "completed" | "failed" | "aborted" | "paused";
22
22
  originalInput: string;
23
23
  currentStageId: string;
24
24
  executedSteps: {
25
25
  id: string;
26
26
  stageId: string;
27
27
  name: string;
28
- status: "blocked" | "ready" | "interacting" | "running" | "completed" | "failed" | "paused";
28
+ status: "running" | "interacting" | "completed" | "failed" | "paused" | "blocked" | "ready";
29
29
  visitCount: number;
30
30
  stepId?: string | undefined;
31
31
  command?: {
@@ -78,7 +78,7 @@ export declare const sandoreDomainSpec: {
78
78
  }[] | undefined;
79
79
  sessionMeta?: {
80
80
  synthetic: boolean;
81
- command: "agent" | "prompt" | "shell" | "skill" | "mcp";
81
+ command: "shell" | "agent" | "prompt" | "skill" | "mcp";
82
82
  childId?: string | undefined;
83
83
  hostSessionId?: string | undefined;
84
84
  } | undefined;
@@ -181,7 +181,7 @@ export declare const sandoreDomainSpec: {
181
181
  error?: string | undefined;
182
182
  pausedAt?: string | undefined;
183
183
  sessionMessages?: {
184
- type: "text" | "reasoning" | "tool";
184
+ type: "text" | "tool" | "reasoning";
185
185
  text?: string | undefined;
186
186
  tool?: string | undefined;
187
187
  args?: unknown;
@@ -213,7 +213,7 @@ export declare const sandoreDomainSpec: {
213
213
  stageInfos: {
214
214
  id: string;
215
215
  name: string;
216
- status: "blocked" | "ready" | "interacting" | "running" | "completed" | "failed" | "paused";
216
+ status: "running" | "interacting" | "completed" | "failed" | "paused" | "blocked" | "ready";
217
217
  }[];
218
218
  pendingInteractions: ({
219
219
  id: string;
@@ -268,7 +268,7 @@ export declare const sandoreDomainSpec: {
268
268
  stageContracts: Record<string, {
269
269
  stageId: string;
270
270
  stageName: string;
271
- status: "blocked" | "ready" | "interacting" | "running" | "completed" | "failed" | "paused";
271
+ status: "running" | "interacting" | "completed" | "failed" | "paused" | "blocked" | "ready";
272
272
  artifacts: {
273
273
  stepId: string;
274
274
  outputToId: string;
@@ -298,7 +298,7 @@ export declare const sandoreDomainSpec: {
298
298
  permission?: string | undefined;
299
299
  userMessages?: {
300
300
  id: string;
301
- kind: "input" | "answer" | "resume" | "retry" | "message";
301
+ kind: "message" | "input" | "answer" | "resume" | "retry";
302
302
  target: string;
303
303
  content: string;
304
304
  createdAt: string;