@deepseek-ai/dsh-spill-local 0.1.1-rc.2 → 0.1.2-alpha.2
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.i18n.yaml +2 -2
- package/README.md +120 -12
- package/README.zh.md +123 -15
- package/lib/index.js +475 -42
- package/lib/types/cleanup.d.ts +60 -0
- package/lib/types/index.d.ts +65 -2
- package/lib/types/store.d.ts +29 -29
- package/package.json +13 -11
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/spill/spill-local/README.md
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: 840e1dff3bf541636354e3a8dc3be619e0dd4cf4
|
|
6
|
+
README.zh.md: 1f4ddf911ea11301c9ccb5520197debb95b02352
|
package/README.md
CHANGED
|
@@ -1,28 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "The local filesystem spill backend: how spilled tool output is saved to private session-scoped files and retrieved with read or grep."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @deepseek-ai/dsh-spill-local
|
|
2
7
|
|
|
3
8
|
English | [中文](README.zh.md)
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
`dsh-spill-local` saves a tool's oversized text to a private, session-scoped file on the host filesystem and returns that file's path as the locator, with retrieval guidance telling the model to read or grep it. Mount it whenever a composition needs spill storage on the same machine the agent runs on. Files are private to the current user, names are unpredictable, and each session's files group under a stable directory, so a shared root cannot leak output or be redirected by a planted symlink. Configuration selects the root and the startup-cleanup retention period; previews and spill decisions live in other packages.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
6
24
|
|
|
7
|
-
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
8
27
|
|
|
9
|
-
|
|
28
|
+
Mount this backend in a composition that spills tool output to the local filesystem. It registers as the `ctx.spillStore` service that the `dsh-spill-policy` plugin and other callers use.
|
|
10
29
|
|
|
11
|
-
|
|
12
|
-
- **`session-<hash>`** — a short `sha256(sessionId)` prefix, so a session's spill files group together and a future cleanup can drop them per session.
|
|
13
|
-
- **`<random>-<safeName>`** — an unpredictable hex prefix (defeats symlink planting in a shared root) plus the caller's `suggestedName` sanitized to one safe path segment (traversal-proof; mirrors the JSONL persistence backend's `encodeSegment`). The write is exclusive + owner-only (`open(path, 'wx', 0o600)`): it fails on any pre-existing path, symlink or not, so a planted target cannot redirect it.
|
|
30
|
+
### Minimal configuration
|
|
14
31
|
|
|
15
|
-
|
|
32
|
+
Loading the plugin with no config is safe: files land in a lazily-created private (0700) per-process directory under the OS temp directory. Set `root` when the files must live under a known location.
|
|
16
33
|
|
|
17
|
-
|
|
34
|
+
```yaml
|
|
35
|
+
- name: '@deepseek-ai/dsh-spill-local'
|
|
36
|
+
config:
|
|
37
|
+
root: /absolute/path/to/spill
|
|
38
|
+
cleanupPeriodDays: 30
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Field | Default | Meaning |
|
|
18
42
|
|---|---|---|
|
|
19
|
-
| `root` | private 0700 temp dir | Root directory for spill files
|
|
43
|
+
| `root` | private 0700 temp dir | Root directory for spill files; set to keep them under a known location |
|
|
44
|
+
| `cleanupPeriodDays` | `30` | File age in days before the one-shot startup cleanup may delete it; `0` disables cleanup |
|
|
45
|
+
|
|
46
|
+
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-spill-local) is the exhaustive source for every accepted field.
|
|
47
|
+
|
|
48
|
+
### What you get back
|
|
49
|
+
|
|
50
|
+
Each `saveText` call writes the full text to a fresh file and returns three fields: `locator` (the file path), `bytes` (the exact UTF-8 byte count), and `retrievalHint` — "Use read with offset/limit, or grep this path to search within it." A consumer shows that hint to the model, which can then read or search the file with its ordinary file tools.
|
|
51
|
+
|
|
52
|
+
### Where files land
|
|
53
|
+
|
|
54
|
+
Files are stored at `<root>/session-<hash>/<random>-<safeName>`, where `session-<hash>` is a short hash of the owning session id (so one session's files group together) and `<random>-<safeName>` pairs an unpredictable hex prefix with the caller's suggested name sanitized to one safe path segment. A relative `root` resolves from the process working directory.
|
|
55
|
+
|
|
56
|
+
### Startup cleanup
|
|
57
|
+
|
|
58
|
+
One best-effort sweep starts after activation without delaying service availability. It scans the configured root and prior default `dsh-spill-*` roots under the OS temp directory, deletes regular files whose modification time is strictly older than the configured cutoff, prunes empty session directories, and removes only empty prior-default roots. A long-lived process does not sweep again until restart. Disposal waits for the sweep, and a concurrent write recreates a session directory if cleanup removes it.
|
|
59
|
+
|
|
60
|
+
The sweep resolves filesystem identities, never follows or deletes symlinks, and skips unrelated entries. On POSIX it admits only roots and session directories owned by the current user, not writable by group or others, and protected from replacement through their ancestor path; writable sticky temporary directories such as `/tmp` are permitted. Unsafe paths produce a warning and remain untouched. Filesystem and warning-sink failures are contained, so cleanup cannot fail activation or a concurrent spill write.
|
|
61
|
+
|
|
62
|
+
### Failures and recovery
|
|
63
|
+
|
|
64
|
+
A real storage failure — permissions, no space left, an unwritable root — rejects the `saveText` call; the caller decides how to degrade. The shipped policy treats the rejection as best-effort and keeps the original inline result.
|
|
65
|
+
|
|
66
|
+
-----
|
|
67
|
+
|
|
68
|
+
<a id="understand-the-implementation"></a>
|
|
69
|
+
## Understand the implementation
|
|
70
|
+
|
|
71
|
+
<details>
|
|
72
|
+
<summary>Implementation internals — click to expand</summary>
|
|
73
|
+
|
|
74
|
+
This section explains the design decisions behind the backend; the observable behavior is fully covered in [Use this package](#use-this-package).
|
|
20
75
|
|
|
21
|
-
|
|
76
|
+
### Design philosophy
|
|
22
77
|
|
|
78
|
+
The backend owns storage details only, on one principle: **a spilled tool result must be private and unredirectable**. The root is private (0700), the session directory is a stable hash, the leaf name is unpredictable, and the write is exclusive and owner-only. The storage mechanics live in a Cordis-free module so they are unit-testable without a context.
|
|
79
|
+
|
|
80
|
+
### Source map
|
|
81
|
+
|
|
82
|
+
| File | Role |
|
|
83
|
+
|---|---|
|
|
84
|
+
| [`src/index.ts`](src/index.ts) | Plugin entry: `Config`, the `LocalSpillStore` service, cleanup lifecycle, locator and retrieval-hint assembly |
|
|
85
|
+
| [`src/cleanup.ts`](src/cleanup.ts) | One-shot age sweep, filesystem-identity checks, symlink and ownership safeguards |
|
|
86
|
+
| [`src/store.ts`](src/store.ts) | Cordis-free storage mechanics: private root, session directory, safe-name encoding, exclusive write |
|
|
87
|
+
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; contracts are enforced at the seam) |
|
|
88
|
+
|
|
89
|
+
### File naming and write
|
|
90
|
+
|
|
91
|
+
`suggestedName` is untrusted input, so `encodeSegment` escapes every character outside `[A-Za-z0-9._-]` (and `~` itself) into a `~XXXX` form, making the mapping injective over all JS strings: separators, `../`, NUL, and absolute paths can never escape one segment, and the whole-segment tokens `.`/`..` are escaped too. The write is `open(path, 'wx', 0o600)` — it fails on any existing path, symlink or not, so a pre-planted target cannot redirect it. Two saves of the same suggested name get distinct random prefixes.
|
|
92
|
+
|
|
93
|
+
</details>
|
|
94
|
+
|
|
95
|
+
-----
|
|
96
|
+
|
|
97
|
+
<a id="further-exploration"></a>
|
|
98
|
+
## Further Exploration
|
|
99
|
+
|
|
100
|
+
Read these pages when the package-level contract is not enough.
|
|
101
|
+
|
|
102
|
+
- [Spill storage service](../spill/README.md) — the `saveText` contract and vocabulary this backend implements.
|
|
103
|
+
- [Spill package map](../README.md) — the three-package family and each role.
|
|
104
|
+
- [dsh-spill-policy](../spill-policy/README.md) — the policy that calls this backend when a result is too large.
|
|
105
|
+
- [Spill subsystem](../../../docs/subsystems/spill.md) — the exhaustive vocabulary and ownership.
|
|
106
|
+
- [Tool output spill decision](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) — the capability boundary and design rationale.
|
|
107
|
+
- [Local spill startup cleanup](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) — retention, race handling, and safe deletion rules.
|
|
108
|
+
|
|
109
|
+
-----
|
|
110
|
+
|
|
111
|
+
<a id="model-experience"></a>
|
|
23
112
|
## Model Experience
|
|
24
113
|
|
|
25
|
-
Indirectly, through spill consumers
|
|
114
|
+
Indirectly, through spill consumers, which render the saved file path and read/grep retrieval guidance to the model.
|
|
26
115
|
|
|
27
116
|
#### KV Cache effect
|
|
28
117
|
|
|
@@ -30,5 +119,24 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
|
|
30
119
|
|
|
31
120
|
## Known Limitations and Deferred Work
|
|
32
121
|
|
|
33
|
-
|
|
122
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
These limits define when the local backend is a poor fit or needs operational care. They are current package constraints.
|
|
126
|
+
|
|
127
|
+
- **A long-lived deployment is not swept until restart** — the one-shot sweep runs only after activation, so files that cross the age cutoff during a run are reclaimed on the next start.
|
|
34
128
|
- **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there.
|
|
129
|
+
|
|
130
|
+
<a id="dev-note"></a>
|
|
131
|
+
### Dev Note
|
|
132
|
+
|
|
133
|
+
<details>
|
|
134
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
135
|
+
|
|
136
|
+
This Dev Note is working context for maintainers: open directions. It is explicitly non-authoritative.
|
|
137
|
+
|
|
138
|
+
#### Future: workspace-confinement interplay
|
|
139
|
+
|
|
140
|
+
The retrieval model assumes the model's `read`/`grep` tools can inspect the returned path even when the spill directory is outside the session working directory. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend.
|
|
141
|
+
|
|
142
|
+
</details>
|
package/README.zh.md
CHANGED
|
@@ -1,34 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "本地文件系统 spill 后端:spill 工具输出如何保存到私有会话级文件,并用 read 或 grep 取回。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @deepseek-ai/dsh-spill-local
|
|
2
7
|
|
|
3
8
|
[English](README.md) | 中文
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
`dsh-spill-local` 把工具的超大文本保存到宿主文件系统中私有的会话级文件,并以该文件路径作为定位信息返回,同时给出告诉模型读取或搜索它的取回指引。只要组合需要在与 agent 相同的机器上进行 spill 存储,就挂载它。文件对当前用户私有、名称不可预测,且每个会话的文件归入稳定的目录,因此共享根目录既不会泄露输出,也不会被预置的符号链接重定向。配置选择根目录与启动清理保留期;预览与 spill 决策由其他包负责。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
6
24
|
|
|
7
|
-
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
8
27
|
|
|
9
|
-
|
|
28
|
+
在需要把工具输出 spill 到本地文件系统的组合中挂载此后端。它注册为 `dsh-spill-policy` 插件与其他调用方使用的 `ctx.spillStore` 服务。
|
|
10
29
|
|
|
11
|
-
|
|
12
|
-
- **`session-<hash>`**:截短的 `sha256(sessionId)` 前缀,用于将同一会话的 spill 文件归在一起,以便未来的清理操作可按会话删除。
|
|
13
|
-
- **`<random>-<safeName>`**:不可预测的十六进制前缀(防止在共享根目录中预置符号链接),加上经过清理的调用方 `suggestedName`,使其成为单个安全路径段(防路径遍历;与 JSONL 持久化后端的 `encodeSegment` 一致)。写入操作采用排他方式,且权限仅限所有者(`open(path, 'wx', 0o600)`):如果路径已经存在,无论是否为符号链接,操作都会失败,因此预置的目标无法重定向写入。
|
|
30
|
+
### 最小配置
|
|
14
31
|
|
|
15
|
-
|
|
32
|
+
不带配置加载插件是安全的:文件会落在操作系统临时目录下延迟创建的私有(0700)每进程目录中。当文件必须位于已知位置时,设置 `root`。
|
|
16
33
|
|
|
17
|
-
|
|
34
|
+
```yaml
|
|
35
|
+
- name: '@deepseek-ai/dsh-spill-local'
|
|
36
|
+
config:
|
|
37
|
+
root: /absolute/path/to/spill
|
|
38
|
+
cleanupPeriodDays: 30
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| 字段 | 默认值 | 含义 |
|
|
18
42
|
|---|---|---|
|
|
19
|
-
| `root` | 私有 0700 临时目录 | spill
|
|
43
|
+
| `root` | 私有 0700 临时目录 | spill 文件的根目录;设置后可将文件保存在已知位置 |
|
|
44
|
+
| `cleanupPeriodDays` | `30` | 文件在一次性启动清理中可被删除前需经过的天数;`0` 禁用清理 |
|
|
45
|
+
|
|
46
|
+
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-spill-local)是每个受支持字段的穷尽式真源。
|
|
47
|
+
|
|
48
|
+
### 你会得到什么
|
|
49
|
+
|
|
50
|
+
每次 `saveText` 调用都会把完整文本写入一个新文件,并返回三个字段:`locator`(文件路径)、`bytes`(精确的 UTF-8 字节数)与 `retrievalHint`——"Use read with offset/limit, or grep this path to search within it."。消费方把该提示展示给模型,模型随后可以用其常规文件工具读取或搜索该文件。
|
|
51
|
+
|
|
52
|
+
### 文件存放位置
|
|
53
|
+
|
|
54
|
+
文件存放在 `<root>/session-<hash>/<random>-<safeName>`:`session-<hash>` 是所属会话 id 的短哈希(让同一会话的文件归在一起),`<random>-<safeName>` 把不可预测的十六进制前缀与清理为单个安全路径段的调用方建议名配对。相对 `root` 从进程工作目录解析。
|
|
55
|
+
|
|
56
|
+
### 启动清理
|
|
57
|
+
|
|
58
|
+
一次尽力而为的扫描会在激活后启动,不延迟服务可用性。它扫描配置的根目录和操作系统临时目录下先前的默认 `dsh-spill-*` 根目录,删除修改时间严格早于配置截止时间的常规文件,修剪空会话目录,并只删除已经变空的先前默认根目录。长期运行的进程要到重启时才会再次扫描。dispose 会等待扫描结束;如果清理移除了会话目录,并发写入会重新创建它。
|
|
59
|
+
|
|
60
|
+
扫描会解析文件系统身份,绝不跟随或删除符号链接,并跳过无关条目。在 POSIX 上,它只接受当前用户拥有、组用户和其他用户不可写、且祖先路径能防止替换的根目录与会话目录;`/tmp` 等带 sticky 位的可写临时目录仍然允许使用。不安全路径会产生警告并保持不变。文件系统和警告接收方故障都会被兜底,因此清理无法使激活或并发 spill 写入失败。
|
|
61
|
+
|
|
62
|
+
### 故障与恢复
|
|
63
|
+
|
|
64
|
+
真实存储故障——权限不足、磁盘已满、根目录不可写——会让 `saveText` 调用以拒绝结束;由调用方决定如何降级。随附策略把拒绝当作尽力而为处理并保留原始内联结果。
|
|
65
|
+
|
|
66
|
+
-----
|
|
67
|
+
|
|
68
|
+
<a id="understand-the-implementation"></a>
|
|
69
|
+
## 理解实现
|
|
70
|
+
|
|
71
|
+
<details>
|
|
72
|
+
<summary>实现细节——点击展开</summary>
|
|
73
|
+
|
|
74
|
+
本节解释此后端背后的设计决策;可观察行为已在[使用本包](#use-this-package)中完整说明。
|
|
20
75
|
|
|
21
|
-
|
|
76
|
+
### 设计理念
|
|
22
77
|
|
|
78
|
+
后端只负责存储细节,建立在一个原则之上:**spill 工具结果必须私有且不可重定向**。根目录私有(0700)、会话目录是稳定哈希、文件名不可预测、写入采用排他且仅所有者模式。存储机制放在与 Cordis 无关的模块中,以便无需上下文即可单元测试。
|
|
79
|
+
|
|
80
|
+
### 源码地图
|
|
81
|
+
|
|
82
|
+
| 文件 | 职责 |
|
|
83
|
+
|---|---|
|
|
84
|
+
| [`src/index.ts`](src/index.ts) | 插件入口:`Config`、`LocalSpillStore` 服务、清理生命周期、定位信息与取回提示的组装 |
|
|
85
|
+
| [`src/cleanup.ts`](src/cleanup.ts) | 一次性按年龄扫描、文件系统身份检查、符号链接和所有权保护 |
|
|
86
|
+
| [`src/store.ts`](src/store.ts) | 与 Cordis 无关的存储机制:私有根目录、会话目录、安全名称编码、排他写入 |
|
|
87
|
+
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定在 seam 处强制执行) |
|
|
88
|
+
|
|
89
|
+
### 文件命名与写入
|
|
90
|
+
|
|
91
|
+
`suggestedName` 是不可信输入,因此 `encodeSegment` 会把 `[A-Za-z0-9._-]`(以及 `~` 本身)之外的每个字符转义成 `~XXXX` 形式,使映射对所有 JS 字符串都是单射:分隔符、`../`、NUL 与绝对路径永远无法逃出单个路径段,整段 token `.`/`..` 也会被转义。写入采用 `open(path, 'wx', 0o600)`——任何已存在路径(无论是否符号链接)都会失败,因此预置目标无法重定向写入。对同一建议名的两次保存会得到不同的随机前缀。
|
|
92
|
+
|
|
93
|
+
</details>
|
|
94
|
+
|
|
95
|
+
-----
|
|
96
|
+
|
|
97
|
+
<a id="further-exploration"></a>
|
|
98
|
+
## 进一步探索
|
|
99
|
+
|
|
100
|
+
当包级约定不够用时阅读以下页面。
|
|
101
|
+
|
|
102
|
+
- [spill 存储服务](../spill/README.zh.md)——此后端实现的 `saveText` 约定与词汇。
|
|
103
|
+
- [spill 包映射](../README.zh.md)——三包家族与各自职责。
|
|
104
|
+
- [dsh-spill-policy](../spill-policy/README.zh.md)——结果过大时调用此后端的策略。
|
|
105
|
+
- [spill 子系统](../../../docs/subsystems/spill.zh.md)——穷尽式词汇与归属。
|
|
106
|
+
- [工具输出 spill 决策](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)——能力边界与设计依据。
|
|
107
|
+
- [本地 spill 启动清理](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md)——保留期、竞态处理与安全删除规则。
|
|
108
|
+
|
|
109
|
+
-----
|
|
110
|
+
|
|
111
|
+
<a id="model-experience"></a>
|
|
23
112
|
## 模型体验
|
|
24
113
|
|
|
25
|
-
|
|
114
|
+
间接地,通过把已保存文件路径与 read/grep 取回指引渲染给模型的 spill 消费方。
|
|
26
115
|
|
|
27
116
|
#### KV Cache 影响
|
|
28
117
|
|
|
29
|
-
|
|
118
|
+
无直接失效;请求前缀变更由上述消费方负责。
|
|
119
|
+
|
|
120
|
+
## 已知限制与延期工作
|
|
121
|
+
|
|
122
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
这些限制说明本地后端何时不合适或需要特别的运维注意。它们是当前的包约束。
|
|
126
|
+
|
|
127
|
+
- **长期运行的部署要等到重启才会被扫描**——一次性扫描只在激活后运行,因此运行期间超过年龄截止值的文件会在下次启动时回收。
|
|
128
|
+
- **定位信息需要与其位于同一文件系统的消费方**——远程或虚拟部署需要另一个 `SpillStore` 后端,其定位信息与取回提示在该环境中有明确含义。
|
|
129
|
+
|
|
130
|
+
<a id="dev-note"></a>
|
|
131
|
+
### 开发备注
|
|
132
|
+
|
|
133
|
+
<details>
|
|
134
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
135
|
+
|
|
136
|
+
本开发备注是维护者的工作上下文:开放方向。它明确不具权威性。
|
|
137
|
+
|
|
138
|
+
#### 未来:工作区隔离的交互
|
|
30
139
|
|
|
31
|
-
|
|
140
|
+
取回模型假定模型的 `read`/`grep` 工具可以检查返回的路径,即使 spill 目录在会话工作目录之外。未来的工作区隔离策略必须显式允许本地 spill 路径,或者改用非文件 spill 后端。
|
|
32
141
|
|
|
33
|
-
|
|
34
|
-
- **定位信息需要与其位于同一文件系统的消费方**:远程或虚拟部署需要另一个 `SpillStore` 后端,其定位信息和取回指引在该环境中有明确含义。
|
|
142
|
+
</details>
|
package/lib/index.js
CHANGED
|
@@ -1,31 +1,38 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
2
3
|
import z from "@deepseek-ai/schemastery";
|
|
3
4
|
import { SpillLocator, SpillStore } from "@deepseek-ai/dsh-spill";
|
|
5
|
+
import { lstat, mkdir, open, readdir, realpath, rmdir, unlink } from "node:fs/promises";
|
|
4
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
7
|
import { mkdtempSync } from "node:fs";
|
|
6
|
-
import { mkdir, open } from "node:fs/promises";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
8
|
//#region lib/types/store.js
|
|
9
9
|
/**
|
|
10
10
|
* Cordis-free storage mechanics for the local spill backend: private
|
|
11
11
|
* session-scoped directory selection, safe-name derivation, path-traversal
|
|
12
|
-
* protection, and the exclusive owner-only write.
|
|
13
|
-
* (like `dsh-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable
|
|
14
|
-
* without a `ctx` and without the OS temp dir.
|
|
12
|
+
* protection, and the exclusive owner-only write.
|
|
15
13
|
*
|
|
16
14
|
* @module @deepseek-ai/dsh-spill-local/store
|
|
17
15
|
*/
|
|
16
|
+
/** Prefix shared by default-root creation and startup discovery. */
|
|
17
|
+
const DEFAULT_ROOT_PREFIX = "dsh-spill-";
|
|
18
|
+
/**
|
|
19
|
+
* Test a caught value for a Node system error code.
|
|
20
|
+
*
|
|
21
|
+
* @param error The caught value.
|
|
22
|
+
* @param code The expected system error code.
|
|
23
|
+
* @returns Whether the code matches.
|
|
24
|
+
*/
|
|
25
|
+
function isErrno(error, code) {
|
|
26
|
+
return error instanceof Error && error.code === code;
|
|
27
|
+
}
|
|
18
28
|
let defaultRoot;
|
|
19
29
|
/**
|
|
20
|
-
*
|
|
21
|
-
* tmpdir, created lazily. Predictable world-readable paths would let other
|
|
22
|
-
* local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
|
|
23
|
-
* an unpredictable suffix and 0700 semantics.
|
|
30
|
+
* Return the lazily-created private per-process spill root.
|
|
24
31
|
*
|
|
25
|
-
* @returns The
|
|
32
|
+
* @returns The private root path.
|
|
26
33
|
*/
|
|
27
34
|
function privateRoot() {
|
|
28
|
-
defaultRoot ??= mkdtempSync(join(tmpdir(),
|
|
35
|
+
defaultRoot ??= mkdtempSync(join(tmpdir(), DEFAULT_ROOT_PREFIX));
|
|
29
36
|
return defaultRoot;
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
@@ -36,10 +43,9 @@ function privateRoot() {
|
|
|
36
43
|
* as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct
|
|
37
44
|
* inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
|
|
38
45
|
* can never traverse. An empty string encodes to `~` (never an empty segment).
|
|
39
|
-
* (Mirrors the JSONL persistence backend's `encodeSegment`.)
|
|
40
46
|
*
|
|
41
|
-
* @param raw
|
|
42
|
-
* @returns
|
|
47
|
+
* @param raw Untrusted text.
|
|
48
|
+
* @returns One injective filesystem-safe path segment.
|
|
43
49
|
*/
|
|
44
50
|
function encodeSegment(raw) {
|
|
45
51
|
if (raw.length === 0) return "~";
|
|
@@ -49,42 +55,44 @@ function encodeSegment(raw) {
|
|
|
49
55
|
for (let i = 0; i < raw.length; i++) {
|
|
50
56
|
const code = raw.charCodeAt(i);
|
|
51
57
|
const ch = String.fromCharCode(code);
|
|
52
|
-
|
|
53
|
-
else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
58
|
+
out += ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch) ? ch : "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
54
59
|
}
|
|
55
60
|
return out;
|
|
56
61
|
}
|
|
57
62
|
/**
|
|
58
|
-
*
|
|
63
|
+
* Derive the stable session-scoped directory under a spill root.
|
|
59
64
|
*
|
|
60
|
-
* @param root The spill root
|
|
61
|
-
* @param sessionId The owning session id
|
|
62
|
-
* @returns The
|
|
65
|
+
* @param root The spill root.
|
|
66
|
+
* @param sessionId The owning session id.
|
|
67
|
+
* @returns The stable session-scoped directory.
|
|
63
68
|
*/
|
|
64
69
|
function sessionDir(root, sessionId) {
|
|
65
70
|
return join(root, `session-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}`);
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
68
|
-
* Write
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* a shared root) AND stays readable. The open is exclusive + owner-only
|
|
72
|
-
* (`'wx', 0o600`): it fails on any existing path — symlink or not — so a
|
|
73
|
-
* pre-planted target cannot redirect the write.
|
|
74
|
-
*
|
|
75
|
-
* @param options The resolved root and request fields required to save the file.
|
|
76
|
-
* @returns The written file path and UTF-8 byte length.
|
|
73
|
+
* Write text to a fresh 0600 file below its private session directory.
|
|
74
|
+
* @param options The save request.
|
|
75
|
+
* @returns The saved path and UTF-8 byte length.
|
|
77
76
|
*/
|
|
78
77
|
async function saveTextFile(options) {
|
|
79
78
|
const dir = sessionDir(options.root, options.sessionId);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
const path = join(dir, `${randomBytes(6).toString("hex")}-${encodeSegment(options.suggestedName)}`);
|
|
80
|
+
let handle;
|
|
81
|
+
for (;;) {
|
|
82
|
+
await mkdir(dir, {
|
|
83
|
+
recursive: true,
|
|
84
|
+
mode: 448
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
handle = await open(path, "wx", 384);
|
|
88
|
+
break;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
/* v8 ignore start -- requires another process to remove the directory
|
|
91
|
+
between mkdir and open, or an external permission/IO race. */
|
|
92
|
+
if (isErrno(error, "ENOENT")) continue;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
88
96
|
try {
|
|
89
97
|
await handle.writeFile(options.content);
|
|
90
98
|
} finally {
|
|
@@ -92,9 +100,359 @@ async function saveTextFile(options) {
|
|
|
92
100
|
}
|
|
93
101
|
return {
|
|
94
102
|
path,
|
|
95
|
-
bytes
|
|
103
|
+
bytes: Buffer.byteLength(options.content, "utf8")
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region lib/types/cleanup.js
|
|
108
|
+
/** Startup cleanup mechanics for local spill roots. */
|
|
109
|
+
/**
|
|
110
|
+
* A backend-generated default root name: `dsh-spill-` plus the 6-character
|
|
111
|
+
* suffix `mkdtemp` appends. Discovery matches this
|
|
112
|
+
* EXACT shape, not the bare prefix, so an unrelated `dsh-spill-test-*` fixture
|
|
113
|
+
* or a foreign tool's differently-shaped `dsh-spill-…` directory is never
|
|
114
|
+
* mistaken for a backend root to sweep.
|
|
115
|
+
*/
|
|
116
|
+
const DEFAULT_ROOT_RE = new RegExp(`^${DEFAULT_ROOT_PREFIX}[A-Za-z0-9]{6}$`);
|
|
117
|
+
/**
|
|
118
|
+
* A backend-generated session directory name: `session-` plus the 12 lowercase
|
|
119
|
+
* hex characters {@link sessionDir} derives from `sha256(sessionId)`. The sweep
|
|
120
|
+
* only descends into entries of this EXACT shape, so an unrelated
|
|
121
|
+
* `session-backup` directory under a shared configured root is never swept.
|
|
122
|
+
*/
|
|
123
|
+
const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/;
|
|
124
|
+
/** Report a best-effort sweep failure without allowing the warning sink to reject cleanup. */
|
|
125
|
+
function warnSafely(warn, message) {
|
|
126
|
+
try {
|
|
127
|
+
warn(message);
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
/** Whether another local OS user cannot replace children of this directory. */
|
|
131
|
+
function isTrustedDirectory(stats) {
|
|
132
|
+
if (!stats.isDirectory()) return false;
|
|
133
|
+
/* v8 ignore next -- POSIX ownership and mode bits have no Windows equivalent. */
|
|
134
|
+
if (process.platform === "win32" || process.geteuid === void 0) return true;
|
|
135
|
+
/* v8 ignore start -- Windows takes the return above; POSIX tests exercise
|
|
136
|
+
owner and mode rejection. */
|
|
137
|
+
return stats.uid === process.geteuid() && (stats.mode & 18) === 0;
|
|
138
|
+
/* v8 ignore stop */
|
|
139
|
+
}
|
|
140
|
+
/** Stable identity for de-duplicating aliases of one root. */
|
|
141
|
+
function rootIdentity(path, stats) {
|
|
142
|
+
/* v8 ignore next -- Windows file indexes are not portable inode identities. */
|
|
143
|
+
if (process.platform === "win32") return path.toLowerCase();
|
|
144
|
+
/* v8 ignore start -- Windows uses the canonical path identity above; POSIX
|
|
145
|
+
tests exercise device and inode identity. */
|
|
146
|
+
return `${String(stats.dev)}:${String(stats.ino)}`;
|
|
147
|
+
/* v8 ignore stop */
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check that no ancestor permits another local OS user to replace the selected
|
|
151
|
+
* child. A sticky writable ancestor is safe because the child is owned by the
|
|
152
|
+
* current user; this admits normal per-process roots below `/tmp`.
|
|
153
|
+
*/
|
|
154
|
+
async function hasProtectedAncestors(path) {
|
|
155
|
+
/* v8 ignore next -- POSIX ancestry checks have no Windows ACL equivalent. */
|
|
156
|
+
if (process.platform === "win32" || process.geteuid === void 0) return true;
|
|
157
|
+
/* v8 ignore start -- Windows takes the return above; POSIX tests exercise
|
|
158
|
+
the ancestor ownership and mode policy. */
|
|
159
|
+
const currentUid = process.geteuid();
|
|
160
|
+
let child = path;
|
|
161
|
+
let childStats = await lstat(child);
|
|
162
|
+
for (;;) {
|
|
163
|
+
const parent = dirname(child);
|
|
164
|
+
if (parent === child) return true;
|
|
165
|
+
const stats = await lstat(parent);
|
|
166
|
+
/* v8 ignore next -- every ancestor of a successfully resolved path is a directory. */
|
|
167
|
+
if (!stats.isDirectory()) return false;
|
|
168
|
+
const writableByOthers = (stats.mode & 18) !== 0;
|
|
169
|
+
const sticky = (stats.mode & 512) !== 0;
|
|
170
|
+
if (writableByOthers && !sticky) return false;
|
|
171
|
+
/* v8 ignore next -- requires an ancestor owned by another OS account inside
|
|
172
|
+
a writable sticky parent; ordinary test fixtures cannot change uid. */
|
|
173
|
+
if (writableByOthers && childStats.uid !== currentUid) return false;
|
|
174
|
+
child = parent;
|
|
175
|
+
childStats = stats;
|
|
176
|
+
}
|
|
177
|
+
/* v8 ignore stop */
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve one existing root without admitting a directory another local user
|
|
181
|
+
* can replace during the path-based sweep. A configured root may be a symlink;
|
|
182
|
+
* discovery passes `false` so a symlink cannot impersonate a default root.
|
|
183
|
+
*
|
|
184
|
+
* @param path Candidate root path.
|
|
185
|
+
* @param allowSymlink Whether the candidate itself may be a configured symlink.
|
|
186
|
+
* @param warn Sink for skipped or failed inspection.
|
|
187
|
+
* @returns The trusted canonical root, or `undefined` when it is absent or unsafe.
|
|
188
|
+
*/
|
|
189
|
+
async function resolveRoot(path, allowSymlink, warn) {
|
|
190
|
+
let initial;
|
|
191
|
+
try {
|
|
192
|
+
initial = await lstat(path);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
/* v8 ignore start -- non-ENOENT inspection failures depend on host ACL or
|
|
195
|
+
an entry racing away and cannot be reproduced portably. */
|
|
196
|
+
if (!isErrno(error, "ENOENT")) warnSafely(warn, `spill-local: failed to inspect root ${path}: ${String(error)}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (initial.isSymbolicLink()) {
|
|
200
|
+
if (!allowSymlink) return void 0;
|
|
201
|
+
} else if (!isTrustedDirectory(initial)) {
|
|
202
|
+
warnSafely(warn, `spill-local: skipped unsafe root ${path}: expected a directory owned by the current user and not writable by group or others`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
let canonical;
|
|
206
|
+
let stats;
|
|
207
|
+
try {
|
|
208
|
+
canonical = await realpath(path);
|
|
209
|
+
stats = await lstat(canonical);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
/* v8 ignore start -- a root lstat'd above reaches this only by racing away
|
|
212
|
+
or by a host-specific realpath failure. */
|
|
213
|
+
if (!isErrno(error, "ENOENT")) warnSafely(warn, `spill-local: failed to resolve root ${path}: ${String(error)}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let protectedAncestors = false;
|
|
217
|
+
try {
|
|
218
|
+
protectedAncestors = await hasProtectedAncestors(canonical);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
/* v8 ignore start -- a canonical ancestor disappears only through a race;
|
|
221
|
+
other failures depend on host ACLs. */
|
|
222
|
+
if (!isErrno(error, "ENOENT")) warnSafely(warn, `spill-local: failed to inspect ancestors of root ${canonical}: ${String(error)}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
/* v8 ignore start -- Windows has no POSIX ownership or mode rejection path;
|
|
226
|
+
POSIX tests exercise both unsafe-directory conditions. */
|
|
227
|
+
if (!isTrustedDirectory(stats) || !protectedAncestors) {
|
|
228
|
+
warnSafely(warn, `spill-local: skipped unsafe root ${canonical}: expected a current-user-owned directory with protected write and ancestor permissions`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
/* v8 ignore stop */
|
|
232
|
+
return {
|
|
233
|
+
path: canonical,
|
|
234
|
+
identity: rootIdentity(canonical, stats)
|
|
96
235
|
};
|
|
97
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Delete a single path, treating a concurrent-race disappearance as success.
|
|
239
|
+
* A parallel process (or another sweep) may `unlink` the same file between our
|
|
240
|
+
* scan and our own `unlink` — ENOENT then means the goal (file gone) already
|
|
241
|
+
* holds, so it is not a failure. Any other error is reported and swallowed.
|
|
242
|
+
*
|
|
243
|
+
* @param path The absolute file path to remove.
|
|
244
|
+
* @param warn Sink for a non-ENOENT failure message.
|
|
245
|
+
* @returns Resolves once the removal was attempted (never rejects).
|
|
246
|
+
*/
|
|
247
|
+
async function unlinkIdempotent(path, warn) {
|
|
248
|
+
try {
|
|
249
|
+
await unlink(path);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
/* v8 ignore start -- reached only when a file selected for deletion (a
|
|
252
|
+
regular file that passed lstat) then fails to unlink: either it raced away
|
|
253
|
+
(ENOENT) or a permission/IO fault struck between the stat and the unlink.
|
|
254
|
+
Neither is deterministically reproducible in-process. */
|
|
255
|
+
if (isErrno(error, "ENOENT")) return;
|
|
256
|
+
warnSafely(warn, `spill-local: failed to delete ${path}: ${String(error)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Sweep one spill session directory: delete expired regular files, skip
|
|
261
|
+
* everything else, and report the directory empty afterward so the caller can
|
|
262
|
+
* prune it. The `dir` entry MUST be a real directory — the caller `lstat`s it
|
|
263
|
+
* first and skips a symlink, so this never follows a `session-*` symlink into a
|
|
264
|
+
* foreign tree. Inside, a symlink or any non-regular entry (socket, fifo, nested
|
|
265
|
+
* dir) is left untouched — `lstat` never follows a link, so a planted symlink
|
|
266
|
+
* can neither be deleted nor redirect the age check. Every per-entry failure is
|
|
267
|
+
* contained: one unreadable file does not abort the directory.
|
|
268
|
+
*
|
|
269
|
+
* @param dir The absolute session directory to scan (already confirmed a real dir).
|
|
270
|
+
* @param cutoffMs Files with `mtime` strictly older than this are deleted.
|
|
271
|
+
* @param warn Sink for contained filesystem failures.
|
|
272
|
+
* @returns `true` when the directory holds no entries after the sweep (a prune candidate).
|
|
273
|
+
*/
|
|
274
|
+
async function sweepSessionDir(dir, cutoffMs, warn) {
|
|
275
|
+
let names;
|
|
276
|
+
try {
|
|
277
|
+
names = await readdir(dir);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
/* v8 ignore start -- the caller lstat'd this entry and confirmed a real
|
|
280
|
+
directory just before the call, so readdir fails only when the dir races
|
|
281
|
+
away (ENOENT) or a permission/IO fault strikes in that window; not
|
|
282
|
+
deterministically reproducible. False keeps it out of the prune step. */
|
|
283
|
+
warnSafely(warn, `spill-local: failed to read ${dir}: ${String(error)}`);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
let remaining = names.length;
|
|
287
|
+
for (const name of names) {
|
|
288
|
+
const path = join(dir, name);
|
|
289
|
+
let stats;
|
|
290
|
+
try {
|
|
291
|
+
stats = await lstat(path);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
/* v8 ignore start -- an entry that readdir just returned then fails to
|
|
294
|
+
lstat only by racing away (ENOENT) or a permission/IO fault; keep it out
|
|
295
|
+
of the deterministic test surface. */
|
|
296
|
+
if (isErrno(error, "ENOENT")) {
|
|
297
|
+
remaining--;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
warnSafely(warn, `spill-local: failed to stat ${path}: ${String(error)}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!stats.isFile()) continue;
|
|
304
|
+
if (stats.mtimeMs >= cutoffMs) continue;
|
|
305
|
+
await unlinkIdempotent(path, warn);
|
|
306
|
+
remaining--;
|
|
307
|
+
}
|
|
308
|
+
return remaining === 0;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Best-effort one-shot cleanup: across each root, delete expired regular files
|
|
312
|
+
* under its `session-*` directories and prune every empty session directory.
|
|
313
|
+
* Only a discovered prior-default root is itself removed. Writes recreate a
|
|
314
|
+
* session directory when pruning races a local write. Every filesystem and
|
|
315
|
+
* warning-sink failure is contained, so a caller can await this during
|
|
316
|
+
* activation/disposal without it ever rejecting.
|
|
317
|
+
*
|
|
318
|
+
* @param options The roots to sweep, the age cutoff, and the failure sink.
|
|
319
|
+
* @returns Resolves when the sweep finishes (never rejects).
|
|
320
|
+
*/
|
|
321
|
+
async function sweepSpillRoots(options) {
|
|
322
|
+
const { cutoffMs, warn } = options;
|
|
323
|
+
const roots = /* @__PURE__ */ new Map();
|
|
324
|
+
for (const candidate of options.roots) {
|
|
325
|
+
const resolved = await resolveRoot(candidate.path, false, warn);
|
|
326
|
+
if (resolved === void 0) continue;
|
|
327
|
+
const existing = roots.get(resolved.identity);
|
|
328
|
+
roots.set(resolved.identity, {
|
|
329
|
+
path: resolved.path,
|
|
330
|
+
pruneWhenEmpty: (existing?.pruneWhenEmpty ?? true) && candidate.pruneWhenEmpty
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
for (const root of roots.values()) {
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = await readdir(root.path);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
/* v8 ignore start -- the trusted root was resolved immediately above; a
|
|
339
|
+
read failure now requires a race or host-specific ACL fault. */
|
|
340
|
+
if (!isErrno(error, "ENOENT")) warnSafely(warn, `spill-local: failed to read root ${root.path}: ${String(error)}`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
let rootEmptiable = true;
|
|
344
|
+
for (const name of entries) {
|
|
345
|
+
if (!SESSION_DIR_RE.test(name)) {
|
|
346
|
+
rootEmptiable = false;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const dir = join(root.path, name);
|
|
350
|
+
let stats;
|
|
351
|
+
try {
|
|
352
|
+
stats = await lstat(dir);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
/* v8 ignore start -- an entry readdir just returned fails to lstat only
|
|
355
|
+
by racing away (ENOENT) or a permission/IO fault; not deterministically
|
|
356
|
+
reproducible. */
|
|
357
|
+
if (!isErrno(error, "ENOENT")) warnSafely(warn, `spill-local: failed to stat ${dir}: ${String(error)}`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!isTrustedDirectory(stats)) {
|
|
361
|
+
warnSafely(warn, `spill-local: skipped unsafe session directory ${dir}`);
|
|
362
|
+
rootEmptiable = false;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (!await sweepSessionDir(dir, cutoffMs, warn)) {
|
|
366
|
+
rootEmptiable = false;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
await rmdir(dir);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
/* v8 ignore start -- prune runs only on a dir observed empty; a failure
|
|
373
|
+
here means a concurrent writer added a file (ENOTEMPTY) or a
|
|
374
|
+
permission/IO fault struck — both are races outside deterministic
|
|
375
|
+
in-process testing. */
|
|
376
|
+
rootEmptiable = false;
|
|
377
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY")) warnSafely(warn, `spill-local: failed to prune ${dir}: ${String(error)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (root.pruneWhenEmpty && rootEmptiable) try {
|
|
381
|
+
await rmdir(root.path);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
/* v8 ignore start -- prune runs only on a root whose every child was
|
|
384
|
+
reclaimed; a failure here means a concurrent writer added a fresh
|
|
385
|
+
spill after our scan (ENOTEMPTY) or removed the root already (ENOENT)
|
|
386
|
+
or a permission/IO fault struck — all races outside deterministic
|
|
387
|
+
in-process testing. */
|
|
388
|
+
if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY")) warnSafely(warn, `spill-local: failed to prune root ${root.path}: ${String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Discover prior default spill roots: the `dsh-spill-<6 chars>` directories
|
|
394
|
+
* directly under `base` (the OS tmpdir) that earlier default-root runs created.
|
|
395
|
+
* A long-lived deployment
|
|
396
|
+
* with a configured root will find none; a series of default-root runs
|
|
397
|
+
* accumulates one per process, so the startup sweep reclaims them all. Matching
|
|
398
|
+
* is the EXACT `mkdtemp` shape (see {@link DEFAULT_ROOT_RE}), not the bare
|
|
399
|
+
* prefix, so an unrelated `dsh-spill-test-*` fixture or a foreign
|
|
400
|
+
* differently-shaped directory is never swept; symlinks and non-directories are
|
|
401
|
+
* excluded too — only real directories the backend could have created.
|
|
402
|
+
*
|
|
403
|
+
* @param warn Sink for a failure reading `base` (returns `[]` on failure).
|
|
404
|
+
* @param base The directory to scan; defaults to the OS tmpdir (a test seam).
|
|
405
|
+
* @returns Absolute paths of the discovered default roots (possibly empty).
|
|
406
|
+
*/
|
|
407
|
+
async function discoverDefaultRootRecords(warn, base) {
|
|
408
|
+
let entries;
|
|
409
|
+
try {
|
|
410
|
+
entries = await readdir(base);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
warnSafely(warn, `spill-local: failed to scan ${base} for default roots: ${String(error)}`);
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
const roots = [];
|
|
416
|
+
for (const name of entries) {
|
|
417
|
+
if (!DEFAULT_ROOT_RE.test(name)) continue;
|
|
418
|
+
const resolved = await resolveRoot(join(base, name), false, warn);
|
|
419
|
+
if (resolved !== void 0) roots.push(resolved);
|
|
420
|
+
}
|
|
421
|
+
return roots;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Discover trusted prior default roots below the OS temporary directory.
|
|
425
|
+
*
|
|
426
|
+
* @param warn Sink for contained discovery failures.
|
|
427
|
+
* @param base Directory to scan; defaults to the OS temporary directory.
|
|
428
|
+
* @returns Canonical paths of trusted default roots.
|
|
429
|
+
*/
|
|
430
|
+
async function discoverDefaultRoots(warn, base = tmpdir()) {
|
|
431
|
+
return (await discoverDefaultRootRecords(warn, base)).map((root) => root.path);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Gather and de-duplicate the trusted roots for one startup sweep. The active
|
|
435
|
+
* configured path may be a symlink; its resolved identity overrides a matching
|
|
436
|
+
* discovered root so the live target is never marked prunable.
|
|
437
|
+
*
|
|
438
|
+
* @param activeRoot Active configured root.
|
|
439
|
+
* @param warn Sink for contained inspection failures.
|
|
440
|
+
* @param defaultRootsBase Directory holding prior default roots.
|
|
441
|
+
* @returns Trusted roots with the active identity marked non-prunable.
|
|
442
|
+
*/
|
|
443
|
+
async function gatherSweepRoots(activeRoot, warn, defaultRootsBase = tmpdir()) {
|
|
444
|
+
const [discovered, active] = await Promise.all([discoverDefaultRootRecords(warn, defaultRootsBase), resolveRoot(activeRoot, true, warn)]);
|
|
445
|
+
const roots = /* @__PURE__ */ new Map();
|
|
446
|
+
for (const root of discovered) roots.set(root.identity, {
|
|
447
|
+
path: root.path,
|
|
448
|
+
pruneWhenEmpty: true
|
|
449
|
+
});
|
|
450
|
+
if (active !== void 0) roots.set(active.identity, {
|
|
451
|
+
path: active.path,
|
|
452
|
+
pruneWhenEmpty: false
|
|
453
|
+
});
|
|
454
|
+
return [...roots.values()];
|
|
455
|
+
}
|
|
98
456
|
//#endregion
|
|
99
457
|
//#region lib/types/index.js
|
|
100
458
|
/**
|
|
@@ -102,23 +460,98 @@ async function saveTextFile(options) {
|
|
|
102
460
|
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
|
|
103
461
|
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
|
|
104
462
|
* and exclusive owner-only write) and returns a path locator plus local
|
|
105
|
-
* read/grep retrieval guidance.
|
|
463
|
+
* read/grep retrieval guidance. After activation it runs one best-effort
|
|
464
|
+
* startup sweep that reclaims spill files older than `cleanupPeriodDays`.
|
|
106
465
|
*
|
|
107
466
|
* @module @deepseek-ai/dsh-spill-local
|
|
108
467
|
*/
|
|
468
|
+
/** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */
|
|
469
|
+
const MS_PER_DAY = 1440 * 60 * 1e3;
|
|
109
470
|
/**
|
|
110
471
|
* Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
|
|
111
472
|
* with unpredictable names, an exclusive owner-only (0600) write, and a private
|
|
112
473
|
* (0700) root — a spilled tool result must not be readable by other local users
|
|
113
474
|
* or redirectable via a planted symlink.
|
|
475
|
+
*
|
|
476
|
+
* After activation it launches ONE best-effort cleanup sweep (see
|
|
477
|
+
* {@link cleanupPeriodDays}) that reclaims expired spill files without delaying
|
|
478
|
+
* service availability; the sweep is owned by the plugin fiber and awaited
|
|
479
|
+
* during disposal, so a fiber unload never returns before it quiesces.
|
|
114
480
|
*/
|
|
115
481
|
var LocalSpillStore = class extends SpillStore {
|
|
116
|
-
static Config = z.object({
|
|
482
|
+
static Config = z.object({
|
|
483
|
+
root: z.string(),
|
|
484
|
+
cleanupPeriodDays: z.number().step(1).min(0).default(30)
|
|
485
|
+
});
|
|
117
486
|
/** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
|
|
118
487
|
root;
|
|
488
|
+
/** Validated config (schemastery applied the `cleanupPeriodDays` default before construction). */
|
|
489
|
+
config;
|
|
490
|
+
/**
|
|
491
|
+
* The in-flight (or settled) startup cleanup sweep. Held so disposal can await
|
|
492
|
+
* it; `undefined` when cleanup is disabled (`cleanupPeriodDays === 0`).
|
|
493
|
+
*/
|
|
494
|
+
cleanup;
|
|
119
495
|
constructor(ctx, config) {
|
|
120
496
|
super(ctx);
|
|
497
|
+
this.config = config;
|
|
121
498
|
this.root = config.root !== void 0 ? resolve(config.root) : privateRoot();
|
|
499
|
+
ctx.effect(function* () {
|
|
500
|
+
if (this.config.cleanupPeriodDays > 0) {
|
|
501
|
+
const warn = (message) => {
|
|
502
|
+
this.ctx.logger.warn(message);
|
|
503
|
+
};
|
|
504
|
+
this.cleanup = this.runCleanup(warn);
|
|
505
|
+
}
|
|
506
|
+
yield async () => {
|
|
507
|
+
await this.cleanup;
|
|
508
|
+
};
|
|
509
|
+
}.bind(this), "spill-local cleanup sweep");
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Run the one-shot cleanup: gather the roots to sweep (see {@link gatherRoots})
|
|
513
|
+
* and sweep all of them at the age cutoff. Best-effort —
|
|
514
|
+
* {@link sweepSpillRoots} contains every filesystem failure, so this never
|
|
515
|
+
* rejects and cannot fail activation or a concurrent spill write.
|
|
516
|
+
*
|
|
517
|
+
* @param warn - sink for a contained filesystem failure.
|
|
518
|
+
* @returns Resolves when the sweep finishes (never rejects).
|
|
519
|
+
*/
|
|
520
|
+
async runCleanup(warn) {
|
|
521
|
+
const cutoffMs = Date.now() - this.config.cleanupPeriodDays * MS_PER_DAY;
|
|
522
|
+
await sweepSpillRoots({
|
|
523
|
+
roots: await this.gatherRoots(warn),
|
|
524
|
+
cutoffMs,
|
|
525
|
+
warn
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* The roots the startup sweep covers: each discovered prior-default
|
|
530
|
+
* `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when
|
|
531
|
+
* emptied, plus the active/configured root, which is never itself pruned while
|
|
532
|
+
* the live process may write into it. Empty session directories are pruned in
|
|
533
|
+
* every root. Filesystem identity de-duplicates aliases before the active root
|
|
534
|
+
* overrides a discovered match as non-prunable. A test overrides this to
|
|
535
|
+
* inject an isolated root set — and, being the sweep's one async gather point,
|
|
536
|
+
* to hold the sweep open across a disposal for the quiescence check; it is a
|
|
537
|
+
* test seam, not a deployment knob.
|
|
538
|
+
*
|
|
539
|
+
* @param warn - sink for a contained discovery failure.
|
|
540
|
+
* @returns The roots to sweep, each flagged for prune-when-empty.
|
|
541
|
+
*/
|
|
542
|
+
async gatherRoots(warn) {
|
|
543
|
+
return gatherSweepRoots(this.root, warn, this.defaultRootsBase());
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The directory scanned for prior default `dsh-spill-*` roots — the OS tmpdir,
|
|
547
|
+
* where {@link privateRoot} creates them (accumulation only happens there). A
|
|
548
|
+
* test overrides this to point discovery at an isolated fixture instead of the
|
|
549
|
+
* real tmpdir; it is a test seam, not a deployment knob.
|
|
550
|
+
*
|
|
551
|
+
* @returns The base directory to scan for default spill roots.
|
|
552
|
+
*/
|
|
553
|
+
defaultRootsBase() {
|
|
554
|
+
return tmpdir();
|
|
122
555
|
}
|
|
123
556
|
async saveText(input) {
|
|
124
557
|
const saved = await saveTextFile({
|
|
@@ -135,4 +568,4 @@ var LocalSpillStore = class extends SpillStore {
|
|
|
135
568
|
}
|
|
136
569
|
};
|
|
137
570
|
//#endregion
|
|
138
|
-
export { LocalSpillStore, LocalSpillStore as default, encodeSegment, privateRoot, saveTextFile, sessionDir };
|
|
571
|
+
export { DEFAULT_ROOT_PREFIX, LocalSpillStore, LocalSpillStore as default, discoverDefaultRoots, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir, sweepSpillRoots };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */
|
|
2
|
+
export type WarnFn = (message: string) => void;
|
|
3
|
+
/** One root to sweep, plus whether the root itself may be pruned once empty. */
|
|
4
|
+
export interface SweepRoot {
|
|
5
|
+
/** Absolute spill root to sweep. */
|
|
6
|
+
path: string;
|
|
7
|
+
/**
|
|
8
|
+
* When `true`, remove the root after its empty `session-*` children are
|
|
9
|
+
* pruned. Set for DISCOVERED prior-default `dsh-spill-*` roots (one per past
|
|
10
|
+
* process — otherwise they accumulate empty forever), never for the active
|
|
11
|
+
* root the live process is still writing into. Every root prunes empty session
|
|
12
|
+
* directories; writes retry if that races their removal.
|
|
13
|
+
*/
|
|
14
|
+
pruneWhenEmpty: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */
|
|
17
|
+
export interface SweepOptions {
|
|
18
|
+
/** Roots to sweep (configured/active root and/or discovered prior-default roots). */
|
|
19
|
+
roots: SweepRoot[];
|
|
20
|
+
/**
|
|
21
|
+
* Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly
|
|
22
|
+
* older than this. The caller derives it from `now - cleanupPeriodDays`, so a
|
|
23
|
+
* file written exactly at the boundary is kept (only strictly-older expires).
|
|
24
|
+
*/
|
|
25
|
+
cutoffMs: number;
|
|
26
|
+
/** Where a contained filesystem failure is reported; the sweep itself never throws. */
|
|
27
|
+
warn: WarnFn;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort one-shot cleanup: across each root, delete expired regular files
|
|
31
|
+
* under its `session-*` directories and prune every empty session directory.
|
|
32
|
+
* Only a discovered prior-default root is itself removed. Writes recreate a
|
|
33
|
+
* session directory when pruning races a local write. Every filesystem and
|
|
34
|
+
* warning-sink failure is contained, so a caller can await this during
|
|
35
|
+
* activation/disposal without it ever rejecting.
|
|
36
|
+
*
|
|
37
|
+
* @param options The roots to sweep, the age cutoff, and the failure sink.
|
|
38
|
+
* @returns Resolves when the sweep finishes (never rejects).
|
|
39
|
+
*/
|
|
40
|
+
export declare function sweepSpillRoots(options: SweepOptions): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Discover trusted prior default roots below the OS temporary directory.
|
|
43
|
+
*
|
|
44
|
+
* @param warn Sink for contained discovery failures.
|
|
45
|
+
* @param base Directory to scan; defaults to the OS temporary directory.
|
|
46
|
+
* @returns Canonical paths of trusted default roots.
|
|
47
|
+
*/
|
|
48
|
+
export declare function discoverDefaultRoots(warn: WarnFn, base?: string): Promise<string[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Gather and de-duplicate the trusted roots for one startup sweep. The active
|
|
51
|
+
* configured path may be a symlink; its resolved identity overrides a matching
|
|
52
|
+
* discovered root so the live target is never marked prunable.
|
|
53
|
+
*
|
|
54
|
+
* @param activeRoot Active configured root.
|
|
55
|
+
* @param warn Sink for contained inspection failures.
|
|
56
|
+
* @param defaultRootsBase Directory holding prior default roots.
|
|
57
|
+
* @returns Trusted roots with the active identity marked non-prunable.
|
|
58
|
+
*/
|
|
59
|
+
export declare function gatherSweepRoots(activeRoot: string, warn: WarnFn, defaultRootsBase?: string): Promise<SweepRoot[]>;
|
|
60
|
+
//# sourceMappingURL=cleanup.d.ts.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
|
|
4
4
|
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
|
|
5
5
|
* and exclusive owner-only write) and returns a path locator plus local
|
|
6
|
-
* read/grep retrieval guidance.
|
|
6
|
+
* read/grep retrieval guidance. After activation it runs one best-effort
|
|
7
|
+
* startup sweep that reclaims spill files older than `cleanupPeriodDays`.
|
|
7
8
|
*
|
|
8
9
|
* @module @deepseek-ai/dsh-spill-local
|
|
9
10
|
*/
|
|
@@ -11,7 +12,10 @@ import { Context } from '@deepseek-ai/cordis';
|
|
|
11
12
|
import z from '@deepseek-ai/schemastery';
|
|
12
13
|
import { SpillStore } from '@deepseek-ai/dsh-spill';
|
|
13
14
|
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill';
|
|
14
|
-
|
|
15
|
+
import type { SweepRoot, WarnFn } from './cleanup.ts';
|
|
16
|
+
export { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts';
|
|
17
|
+
export type { SweepOptions, SweepRoot, WarnFn } from './cleanup.ts';
|
|
18
|
+
export { DEFAULT_ROOT_PREFIX, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir } from './store.ts';
|
|
15
19
|
export type { SavedText, SaveTextOptions } from './store.ts';
|
|
16
20
|
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
|
17
21
|
export interface Config {
|
|
@@ -21,18 +25,77 @@ export interface Config {
|
|
|
21
25
|
* a local deployment. Set it to keep spill files under a known location.
|
|
22
26
|
*/
|
|
23
27
|
root?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Age in days after which a spill file is eligible for the one-shot startup
|
|
30
|
+
* cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose
|
|
31
|
+
* `mtime` is strictly older than the cutoff are deleted and emptied
|
|
32
|
+
* directories are pruned; fresh files, symlinks, and unrelated entries are
|
|
33
|
+
* left untouched. On POSIX, cleanup skips roots and session directories that
|
|
34
|
+
* another local user could modify or replace. Retention is deliberate — a
|
|
35
|
+
* resumed or forked session may still reference an older locator until it
|
|
36
|
+
* ages out.
|
|
37
|
+
*/
|
|
38
|
+
cleanupPeriodDays?: number;
|
|
24
39
|
}
|
|
40
|
+
/** The shape after schemastery applied the defaults. */
|
|
41
|
+
type ResolvedConfig = Required<Omit<Config, 'root'>> & Pick<Config, 'root'>;
|
|
25
42
|
/**
|
|
26
43
|
* Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
|
|
27
44
|
* with unpredictable names, an exclusive owner-only (0600) write, and a private
|
|
28
45
|
* (0700) root — a spilled tool result must not be readable by other local users
|
|
29
46
|
* or redirectable via a planted symlink.
|
|
47
|
+
*
|
|
48
|
+
* After activation it launches ONE best-effort cleanup sweep (see
|
|
49
|
+
* {@link cleanupPeriodDays}) that reclaims expired spill files without delaying
|
|
50
|
+
* service availability; the sweep is owned by the plugin fiber and awaited
|
|
51
|
+
* during disposal, so a fiber unload never returns before it quiesces.
|
|
30
52
|
*/
|
|
31
53
|
export declare class LocalSpillStore extends SpillStore {
|
|
32
54
|
static Config: z<Config>;
|
|
33
55
|
/** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
|
|
34
56
|
readonly root: string;
|
|
57
|
+
/** Validated config (schemastery applied the `cleanupPeriodDays` default before construction). */
|
|
58
|
+
readonly config: ResolvedConfig;
|
|
59
|
+
/**
|
|
60
|
+
* The in-flight (or settled) startup cleanup sweep. Held so disposal can await
|
|
61
|
+
* it; `undefined` when cleanup is disabled (`cleanupPeriodDays === 0`).
|
|
62
|
+
*/
|
|
63
|
+
private cleanup;
|
|
35
64
|
constructor(ctx: Context, config: Config);
|
|
65
|
+
/**
|
|
66
|
+
* Run the one-shot cleanup: gather the roots to sweep (see {@link gatherRoots})
|
|
67
|
+
* and sweep all of them at the age cutoff. Best-effort —
|
|
68
|
+
* {@link sweepSpillRoots} contains every filesystem failure, so this never
|
|
69
|
+
* rejects and cannot fail activation or a concurrent spill write.
|
|
70
|
+
*
|
|
71
|
+
* @param warn - sink for a contained filesystem failure.
|
|
72
|
+
* @returns Resolves when the sweep finishes (never rejects).
|
|
73
|
+
*/
|
|
74
|
+
private runCleanup;
|
|
75
|
+
/**
|
|
76
|
+
* The roots the startup sweep covers: each discovered prior-default
|
|
77
|
+
* `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when
|
|
78
|
+
* emptied, plus the active/configured root, which is never itself pruned while
|
|
79
|
+
* the live process may write into it. Empty session directories are pruned in
|
|
80
|
+
* every root. Filesystem identity de-duplicates aliases before the active root
|
|
81
|
+
* overrides a discovered match as non-prunable. A test overrides this to
|
|
82
|
+
* inject an isolated root set — and, being the sweep's one async gather point,
|
|
83
|
+
* to hold the sweep open across a disposal for the quiescence check; it is a
|
|
84
|
+
* test seam, not a deployment knob.
|
|
85
|
+
*
|
|
86
|
+
* @param warn - sink for a contained discovery failure.
|
|
87
|
+
* @returns The roots to sweep, each flagged for prune-when-empty.
|
|
88
|
+
*/
|
|
89
|
+
protected gatherRoots(warn: WarnFn): Promise<SweepRoot[]>;
|
|
90
|
+
/**
|
|
91
|
+
* The directory scanned for prior default `dsh-spill-*` roots — the OS tmpdir,
|
|
92
|
+
* where {@link privateRoot} creates them (accumulation only happens there). A
|
|
93
|
+
* test overrides this to point discovery at an isolated fixture instead of the
|
|
94
|
+
* real tmpdir; it is a test seam, not a deployment knob.
|
|
95
|
+
*
|
|
96
|
+
* @returns The base directory to scan for default spill roots.
|
|
97
|
+
*/
|
|
98
|
+
protected defaultRootsBase(): string;
|
|
36
99
|
saveText(input: SaveTextSpill): Promise<SpillRef>;
|
|
37
100
|
}
|
|
38
101
|
export default LocalSpillStore;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cordis-free storage mechanics for the local spill backend: private
|
|
3
3
|
* session-scoped directory selection, safe-name derivation, path-traversal
|
|
4
|
-
* protection, and the exclusive owner-only write.
|
|
5
|
-
* (like `dsh-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable
|
|
6
|
-
* without a `ctx` and without the OS temp dir.
|
|
4
|
+
* protection, and the exclusive owner-only write.
|
|
7
5
|
*
|
|
8
6
|
* @module @deepseek-ai/dsh-spill-local/store
|
|
9
7
|
*/
|
|
8
|
+
/** Prefix shared by default-root creation and startup discovery. */
|
|
9
|
+
export declare const DEFAULT_ROOT_PREFIX = "dsh-spill-";
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
* tmpdir, created lazily. Predictable world-readable paths would let other
|
|
13
|
-
* local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
|
|
14
|
-
* an unpredictable suffix and 0700 semantics.
|
|
11
|
+
* Test a caught value for a Node system error code.
|
|
15
12
|
*
|
|
16
|
-
* @
|
|
13
|
+
* @param error The caught value.
|
|
14
|
+
* @param code The expected system error code.
|
|
15
|
+
* @returns Whether the code matches.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isErrno(error: unknown, code: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Return the lazily-created private per-process spill root.
|
|
20
|
+
*
|
|
21
|
+
* @returns The private root path.
|
|
17
22
|
*/
|
|
18
23
|
export declare function privateRoot(): string;
|
|
19
24
|
/**
|
|
@@ -24,46 +29,41 @@ export declare function privateRoot(): string;
|
|
|
24
29
|
* as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct
|
|
25
30
|
* inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
|
|
26
31
|
* can never traverse. An empty string encodes to `~` (never an empty segment).
|
|
27
|
-
* (Mirrors the JSONL persistence backend's `encodeSegment`.)
|
|
28
32
|
*
|
|
29
|
-
* @param raw
|
|
30
|
-
* @returns
|
|
33
|
+
* @param raw Untrusted text.
|
|
34
|
+
* @returns One injective filesystem-safe path segment.
|
|
31
35
|
*/
|
|
32
36
|
export declare function encodeSegment(raw: string): string;
|
|
33
37
|
/**
|
|
34
|
-
*
|
|
38
|
+
* Derive the stable session-scoped directory under a spill root.
|
|
35
39
|
*
|
|
36
|
-
* @param root The spill root
|
|
37
|
-
* @param sessionId The owning session id
|
|
38
|
-
* @returns The
|
|
40
|
+
* @param root The spill root.
|
|
41
|
+
* @param sessionId The owning session id.
|
|
42
|
+
* @returns The stable session-scoped directory.
|
|
39
43
|
*/
|
|
40
44
|
export declare function sessionDir(root: string, sessionId: string): string;
|
|
41
|
-
/**
|
|
45
|
+
/** Inputs needed to save a local spill file. */
|
|
42
46
|
export interface SaveTextOptions {
|
|
43
|
-
/**
|
|
47
|
+
/** Spill root. */
|
|
44
48
|
root: string;
|
|
45
|
-
/**
|
|
49
|
+
/** Owning session id. */
|
|
46
50
|
sessionId: string;
|
|
47
|
-
/** Caller-suggested
|
|
51
|
+
/** Caller-suggested filename. */
|
|
48
52
|
suggestedName: string;
|
|
49
|
-
/**
|
|
53
|
+
/** Full text to persist. */
|
|
50
54
|
content: string;
|
|
51
55
|
}
|
|
52
56
|
/** A written spill file. */
|
|
53
57
|
export interface SavedText {
|
|
58
|
+
/** Absolute saved path. */
|
|
54
59
|
path: string;
|
|
60
|
+
/** UTF-8 content length. */
|
|
55
61
|
bytes: number;
|
|
56
62
|
}
|
|
57
63
|
/**
|
|
58
|
-
* Write
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* a shared root) AND stays readable. The open is exclusive + owner-only
|
|
62
|
-
* (`'wx', 0o600`): it fails on any existing path — symlink or not — so a
|
|
63
|
-
* pre-planted target cannot redirect the write.
|
|
64
|
-
*
|
|
65
|
-
* @param options The resolved root and request fields required to save the file.
|
|
66
|
-
* @returns The written file path and UTF-8 byte length.
|
|
64
|
+
* Write text to a fresh 0600 file below its private session directory.
|
|
65
|
+
* @param options The save request.
|
|
66
|
+
* @returns The saved path and UTF-8 byte length.
|
|
67
67
|
*/
|
|
68
68
|
export declare function saveTextFile(options: SaveTextOptions): Promise<SavedText>;
|
|
69
69
|
//# sourceMappingURL=store.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-spill-local",
|
|
3
3
|
"description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2-alpha.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -32,19 +32,21 @@
|
|
|
32
32
|
],
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@deepseek-ai/dsh-
|
|
36
|
-
"@deepseek-ai/
|
|
37
|
-
"@deepseek-ai/
|
|
35
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
37
|
+
"@deepseek-ai/dsh-spill": "^0.1.2-alpha.2"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
40
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@deepseek-ai/
|
|
44
|
-
"@deepseek-ai/
|
|
45
|
-
"@deepseek-ai/dsh-
|
|
46
|
-
"@deepseek-ai/dsh-
|
|
47
|
-
"@deepseek-ai/
|
|
48
|
-
"@deepseek-ai/dsh-
|
|
43
|
+
"@deepseek-ai/cordis-plugin-include": "^1.0.7",
|
|
44
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.3",
|
|
45
|
+
"@deepseek-ai/dsh-brand": "^0.1.2-alpha.2",
|
|
46
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
|
|
47
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
|
|
48
|
+
"@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
|
|
49
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
50
|
+
"@deepseek-ai/dsh-spill": "^0.1.2-alpha.2"
|
|
49
51
|
}
|
|
50
52
|
}
|