@t4r71/dsh-dual-axis 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/cordis.patch.yml +68 -0
- package/lib/fs.js +419 -0
- package/lib/index.js +2591 -0
- package/lib/read-guard.js +1812 -0
- package/lib/session-store.js +978 -0
- package/lib/types/axis-command.d.ts +59 -0
- package/lib/types/axis-entry.d.ts +45 -0
- package/lib/types/axis.d.ts +131 -0
- package/lib/types/config.d.ts +173 -0
- package/lib/types/containment.d.ts +27 -0
- package/lib/types/content.d.ts +68 -0
- package/lib/types/fs-fence.d.ts +196 -0
- package/lib/types/fs.d.ts +15 -0
- package/lib/types/groups.d.ts +226 -0
- package/lib/types/index.d.ts +205 -0
- package/lib/types/read-guard.d.ts +34 -0
- package/lib/types/scope-prompt.d.ts +130 -0
- package/lib/types/scope.d.ts +108 -0
- package/lib/types/session-axes.d.ts +51 -0
- package/lib/types/session-store.d.ts +496 -0
- package/package.json +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 T4R71
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @t4r71/dsh-dual-axis
|
|
2
|
+
|
|
3
|
+
The dual-axis access-mode bundle (host half): the file sandbox's read and write axes — each `deny | workspace | all | custom`, with `custom` carrying absolute `allow` and `deny` path lists — as an installable profile layer with its Plugins-page configuration.
|
|
4
|
+
|
|
5
|
+
## ⚠ This bundle disables an official UI row
|
|
6
|
+
|
|
7
|
+
Installing this bundle turns OFF the upstream row `ui-permission`
|
|
8
|
+
(`@deepseek-ai/dsh-client-ui-permission-presets`, declared in
|
|
9
|
+
`@deepseek-ai/dsh-web-app`'s `cordis.patch.yml`) — see `cordis.patch.yml` in this package.
|
|
10
|
+
That is a deliberate REPLACEMENT, not a coincidental clash, and it is not reversible by
|
|
11
|
+
flipping `disabled: false` while this bundle is installed.
|
|
12
|
+
|
|
13
|
+
**What is replaced.** This bundle's client half registers the same three surfaces the
|
|
14
|
+
upstream row registers, which is why the two cannot both be active:
|
|
15
|
+
|
|
16
|
+
| Surface | Upstream | This bundle |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| Composer control | `conversation.input.permission` | same slot, two axis dropdowns instead of one preset picker |
|
|
19
|
+
| General-settings row | `settings.general.item`, id `permission`, order `-20` | same slot and id |
|
|
20
|
+
| `/permission` picker | `command.decorate({ name: 'permission' })` | same decoration, over the same command |
|
|
21
|
+
|
|
22
|
+
Two independent mechanisms would fail a client boot with both rows active, so removing
|
|
23
|
+
the disable is not an option:
|
|
24
|
+
|
|
25
|
+
1. **`conversation.input.permission` is a single-occupant slot.** `ui-conversation`
|
|
26
|
+
declares it `{ kind: 'single', scope: 'session' }`, and a second registration throws
|
|
27
|
+
`single slot "<name>" already has a registration` (`packages/client/ui-slots/src/index.ts:1216-1218`).
|
|
28
|
+
Renaming a locale namespace does not address this.
|
|
29
|
+
2. **Locale namespaces are single-occupant per (namespace, locale).** Both rows register
|
|
30
|
+
`permission.access` and `settings.permission`; the second `register` throws
|
|
31
|
+
`locale namespace "<ns>" already has locale "<locale>"`
|
|
32
|
+
(`packages/client/locale/src/client/index.ts:415-419`).
|
|
33
|
+
|
|
34
|
+
**Behaviour that changes.** The composer control becomes two independent read/write axis
|
|
35
|
+
dropdowns (`deny | workspace | all | custom`) submitting `/axis <axis>:<value>` instead of
|
|
36
|
+
one preset picker submitting `/permission <preset>`. The General-settings row keeps its
|
|
37
|
+
position and its default-preset meaning. The `/permission` slash picker keeps the upstream
|
|
38
|
+
preset list.
|
|
39
|
+
|
|
40
|
+
**To get the official UI back**, uninstall this bundle (drop `@t4r71/dsh-dual-axis` from the
|
|
41
|
+
profile's `dsh.profile.bundles` and from its `package.json` dependencies). The row that
|
|
42
|
+
carries the disable is this package's `cordis.patch.yml`, so it leaves with the bundle and
|
|
43
|
+
`ui-permission` activates again. Leaving the bundle installed and editing the row to
|
|
44
|
+
`disabled: false` restarts the collision above and the client hangs on "Loading plugins".
|
|
45
|
+
|
|
46
|
+
## Model Experience
|
|
47
|
+
|
|
48
|
+
The axes are stated to the model in a runtime-context paragraph of their own, contributed under the section name `sandbox:dual-axis` (order `SANDBOX_POLICY + 1`, so it follows 0.1.7's own `sandbox:policy` line rather than interrupting it). The paragraph names both axes as resolved ranges — every allow root and every deny root, not a mode name — and states how they are enforced, including the fact that this build does not intersect them. It is re-rendered at every assembly, so a mid-session axis change reaches the model on the next request.
|
|
49
|
+
|
|
50
|
+
The paragraph is not a new model-visible input in the log sense: it is derived from the session's stored axis pair, the current definitions of the rule groups that pair references, and the session header's `cwd`, and the agent loop logs the rendered snapshot as model history like every other runtime-context entry. The refusal a fence returns is built from the same `src/scope-prompt.ts` functions, so the range the model read and the range it is denied by cannot disagree.
|
|
51
|
+
|
|
52
|
+
The axes are NOT in the session log. A package-declared event type is outside 0.1.7's `KNOWN_SESSION_EVENT_TYPES`, and `Session.append` takes no envelope option, so such an event cannot be marked `ignorable` and a log carrying it is refused whole by `validateStoredEvents` — the session becomes unopenable after a restart. The pair therefore lives in this package's own settings namespace (see Settings below), and the only axis record left in a session log is the write base mirrored onto 0.1.7's own `sandbox/mode`.
|
|
53
|
+
|
|
54
|
+
## What 0.1.7 removed, and what this package carries instead
|
|
55
|
+
|
|
56
|
+
| Capability | 0.1.6 home | 0.1.7 state | This package |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| Axis algebra | `dsh-sandbox/access`, `dsh-sandbox/scope` | deleted (0 hits tree-wide) | `src/axis.ts`, `src/scope.ts` |
|
|
59
|
+
| Per-session axis pair | `sandbox/mode` payload `readScope`/`writeScope` | payload locked to `{ mode, source }`; a plugin event type is refused whole by the log validator | `src/session-store.ts` (settings namespace `dual-axis-sessions`), with `src/session-axes.ts` mirroring the write base onto `sandbox/mode` |
|
|
60
|
+
| Read-path fence | `dsh-fs-sandbox` (358 lines, 8 read overrides) | deleted; reads pass through | `src/fs-fence.ts` |
|
|
61
|
+
| Settings section | `settings.register(ns, schema)` | only Loader-entry Config | `src/config.ts` |
|
|
62
|
+
| Dual write entry | `setSandboxScopes` | only `setSandboxMode(session, mode)` | the store writes the pair; `mirrorWriteMode` writes the mode |
|
|
63
|
+
|
|
64
|
+
## Layout
|
|
65
|
+
|
|
66
|
+
| Path | Role |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `src/axis.ts` | The four axis kinds, the mode bijection, `effectiveScopes` |
|
|
69
|
+
| `src/scope.ts` | `resolveScope` / `scopeContains`: the pure allow/deny range algebra |
|
|
70
|
+
| `src/config.ts` | Loader-entry `Config` with both axes `.volatile()`, and `normalizeScope` |
|
|
71
|
+
| `src/session-store.ts` | The per-session axis pair: namespace `dual-axis-sessions`, the memoized read, the whole-document write with its revision-fence retry, and the GC sweep |
|
|
72
|
+
| `src/session-axes.ts` | The write base's mirror onto 0.1.7's own `sandbox/mode` event |
|
|
73
|
+
| `src/fs-fence.ts` | `DualAxisFileSystem`: the read-path fence |
|
|
74
|
+
| `src/read-guard.ts` | The `/read-guard` entry point: the tool-dispatch fence for both axes |
|
|
75
|
+
| `src/scope-prompt.ts` | The model-facing range text: one source for the runtime-context paragraph and the refusal |
|
|
76
|
+
| `src/index.ts` | The plugin class: registers the axis command and the range paragraph, seeds each new session — a subagent child from its parent's stored pair (`inheritedAxes`), every other session from the settings row's live pair — and schedules the GC sweep |
|
|
77
|
+
| `tests/seed-parity.spec.ts` | The cross-package drift gate: the same rows through this package's `seedPair` and through `@t4r71/dsh-dual-axis-ui`'s `sessionAxesSeed`, asserted byte for byte |
|
|
78
|
+
| `src/fs.ts` | The `/fs` entry point exporting the backend |
|
|
79
|
+
|
|
80
|
+
## Settings
|
|
81
|
+
|
|
82
|
+
Two namespaces, both Loader entry ids, each field declared `.volatile()` because 0.1.7 refuses to project or write a non-volatile field.
|
|
83
|
+
|
|
84
|
+
`dual-axis` is the row the Plugins page edits: the two axes a NEW session starts from (`read`, `write`), the rule-group library (`groups`), and the group ids a new session starts out referencing (`defaultGroups`). All four are SEEDS. For a session that has been USED they are read once, at creation, and never again; for one nobody has used yet the row stays live, and editing it moves that session's axes (see "A session with no record").
|
|
85
|
+
|
|
86
|
+
A newly created session reads these values through `ctx.settings.describe()` rather than through the `Config` the plugin captured at mount: 0.1.7's loader does not commit a settings-page save back into that captured accessor, so reading it would keep seeding sessions with the value the process started on until the next restart. `describe()` re-projects the entry's live config on every call, which is the same read the settings row's own summary uses. A composition without the `settings` service falls back to the captured `Config`.
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
- id: dual-axis
|
|
90
|
+
name: '@t4r71/dsh-dual-axis'
|
|
91
|
+
config:
|
|
92
|
+
read: { kind: all }
|
|
93
|
+
write: { kind: workspace }
|
|
94
|
+
- id: dual-axis-sessions
|
|
95
|
+
name: '@t4r71/dsh-dual-axis/session-store'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`dual-axis-sessions` is the store: one field, `axes`, `session id -> { read, write }`. It is a separate entry rather than a field on the row above because the row's namespace carries the Plugins-page form, and a field the row's form does not declare is one more thing that form has to render around. The client half registers no page for this namespace.
|
|
99
|
+
|
|
100
|
+
Every decision path reads the STORE, plus the group library by id, and nothing else — except for a session the store holds NO record of, where the row's seeds are the session's pair until the repair write lands (see "A session with no record"). Once a session has a record, the row's seeds are never consulted for it again. Reading is memoized on the namespace's `revision`, so a decision path pays one `settings.describe()` per document change rather than one per tool dispatch.
|
|
101
|
+
|
|
102
|
+
### Writing, and what a lost write does
|
|
103
|
+
|
|
104
|
+
The whole `axes` field is written in one `replace`, because removing a record needs the whole map anyway. Every attempt re-reads the namespace revision first, and a refused write is retried up to four times (0/25/75/200 ms) against the revision the other writer produced. If every attempt is refused the store throws `SessionAxesConflictError` (`code: 'DUAL_AXIS_AXES_CONFLICT'`); `/axis` renders that as a command error beside the picker, and the session-creation path logs it. A write is never reported as applied when it was not stored.
|
|
105
|
+
|
|
106
|
+
### A session with no record
|
|
107
|
+
|
|
108
|
+
A session with no record is not an error and it never hides a control. Every live decision path — the model-facing paragraph, the read fence, and `/axis` — reads through `SessionAxesStore.ensure`, which answers `seedPair` (`seedAxesFor`'s pure core): the settings row's `read`/`write` plus its `defaultGroups`, or, for a subagent child, its parent's stored pair. That value is what the session runs under and what the pickers show, so "interface shows A, host enforces B" cannot happen.
|
|
109
|
+
|
|
110
|
+
### When the record is written
|
|
111
|
+
|
|
112
|
+
The record is written when the session is USED, not when it is created. `pin` writes at creation only for a session that already has content, and `ensure` schedules its repair write only for one that does; a session with neither a record nor any content is answered by a seed RECOMPUTED ON EVERY READ, so the settings row stays live for it.
|
|
113
|
+
|
|
114
|
+
This matters because of how a conversation actually starts. The workspace picker reopens the SAME session id when you start a new conversation in the same workspace — measured, not assumed: two consecutive "new session in nvidiaDlssGlom" clicks left `[data-conversation-session]` at the same id, and the workspace's session list was unchanged. Freezing at creation therefore pinned a conversation nobody had had to whatever the row said the day the picker first opened it, and later edits never reached it. A session that has been used freezes as before: the record is the session's own pair from then on.
|
|
115
|
+
|
|
116
|
+
**"Used" means content beyond the loop's own runtime-context snapshot** (`src/content.ts`, `hasContent`). The agent loop appends that snapshot on every turn, the first included, before any human input exists, so counting it would make every session look used the moment anything rendered its prompt. It is identified by `source.kind === 'runtime-context'`, the discriminant the loop writes and reads back, rather than by its rendered text, which is prose that changes with every contribution. Every other message counts — a real user turn, an assistant reply, the skill catalogue — because each means the session has entered a turn. The predicate walks the session's already-materialized event array with no copy or parse per event, and the sessions it is asked about are exactly the ones nobody has used, so the scan is short.
|
|
117
|
+
|
|
118
|
+
Two writes therefore freeze a pair, and both are deliberate: the first real turn, and `/axis` — which calls `SessionAxesStore.set` unconditionally, so a manual pick freezes an empty session too, and the manual value wins over any later settings-row edit.
|
|
119
|
+
|
|
120
|
+
### Degenerate states
|
|
121
|
+
|
|
122
|
+
A repair never overwrites a record that appeared while it was waiting, and a repair that cannot be stored is logged and re-armed, never thrown at the decision path that scheduled it.
|
|
123
|
+
|
|
124
|
+
`DEFAULT_AXES` (read `all`, write `workspace`) is what `SessionAxesStore.getOr` answers for a caller that holds no session to build a seed from; it is not the pair a session with no record is held to. `getOr`'s remaining callers hold no session, because `ensure` — which the live paths use — is the one that can answer a seed. An unreadable settings row answers `DEFAULT_AXES` on BOTH sides — `seedPair` and the client half's `SESSION_AXES_FALLBACK` — so even the failure branch agrees.
|
|
125
|
+
|
|
126
|
+
The client half recomputes that same seed locally (`sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`, which shares no import with this package), because it must display the pair this host is enforcing for a session the document carries no record of. `tests/seed-parity.spec.ts` feeds both implementations the same rows and asserts the answers are the same bytes, key order included; it is the only thing that stops the two from drifting.
|
|
127
|
+
|
|
128
|
+
### Garbage collection
|
|
129
|
+
|
|
130
|
+
Records are dropped when the session they belong to no longer exists, and the signal is the persistence layer: an id absent from `sessionPersistence.list()` has no stored log. The sweep runs at start-up (15 s in, after the loader and the persistence backend are up) and every 30 s after that. It never runs on `session/disposed` — residency churn disposes a session whose file is still on disk, and dropping that record would silently reset a session about to be resumed. A sweep that cannot judge (no persistence service, or a failed listing) removes nothing and says so in the log.
|
|
131
|
+
|
|
132
|
+
## Building
|
|
133
|
+
|
|
134
|
+
The package resolves every `@deepseek-ai/*` import from its own `node_modules`: `pnpm install` then `tsc -b` and `tsdown` work in a directory tree that contains nothing but this package. `tsconfig.base.json` holds the compiler options this package needs and declares no `paths`; `tsconfig.json` adds only the package's own entry point.
|
|
135
|
+
|
|
136
|
+
Tests run from source through tsx (`TSX_TSCONFIG_PATH=tsconfig.runtime.json node --import tsx/esm --test tests/*.spec.ts`) and resolve the same way, with one exception: `tests/settings.spec.ts` and `tests/session-store.spec.ts` read `isVolatilePath` and `volatileForm` from `@deepseek-ai/dsh-settings/schema`, and that package ships `lib/types/schema.js` while declaring no `./schema` subpath in its exports map, so Node cannot resolve the specifier. `tsconfig.runtime.json` maps that one specifier to the shipped file inside the installed dependency. Dropping the mapping requires `@deepseek-ai/dsh-settings` to export `./schema`.
|
|
137
|
+
|
|
138
|
+
## Known Limitations and Deferred Work
|
|
139
|
+
|
|
140
|
+
- **The write axis is NOT intersected with the read axis.** This build enforces the two axes independently: `read-guard` grades a write call with the write axis's deny list alone, and the inherited write fence grades the same call by the write axis's base mode. A path the read axis denies is therefore still writable when the write axis allows it. The client half's settings copy states the opposite (`读轴放行、写轴不放行,仍然写不了`), so the copy and the enforcement disagree. The runtime-context paragraph states the enforced behavior, not the intended invariant.
|
|
141
|
+
- **The read axis is enforced at the tool-dispatch layer only.** `read-guard` inspects the four read entry points in its `READ_TOOLS` table (`read`, `read_image`, `grep`, `glob`); a command tool, a child process, or a script is fenced for writes by the sandbox mode but never for reads. Mounting `DualAxisFileSystem` closes that path for `ctx.fs` consumers, which does not include command tools.
|
|
142
|
+
- The refusal text tells the model that reaching a refused path through another tool is not a permitted workaround. That is a rule statement; the enforcement gap above is the reason it cannot be stated as a fence. It also states the layering per axis: the read axis and either axis's allow/deny path entries bind the tool layer alone, while only a write axis's BASE tier is mirrored onto the session sandbox mode and enforced again below that layer — an `all` base mirrors to `danger-full-access` and so has no layer below.
|
|
143
|
+
- **A subagent child whose parent session holds no record falls back to the settings row.** `inheritedAxes` looks the parent's record up by the id the child's header names, resident or not, and returns `undefined` when that id has no record; the child is then seeded with the settings-page pair. It is still seeded, which is what keeps the read fence from falling back to the deployment defaults for that child. Residency deliberately does not participate: the client half reads the same document by the same id and cannot observe which sessions this process has materialized, so keying the seed on residency would make the two halves disagree for exactly that child.
|
|
144
|
+
|
|
145
|
+
- `DualAxisFileSystem` (`src/fs-fence.ts`) is **not mounted and not wired**. No profile composes its row — the shipped `cordis.patch.yml` carries it commented out, because mounting it means taking the `fs` service from the composition's own `fs-sandbox` row — and its per-call `ReadAxes` trailing parameter has no producer: 0.1.7's `SandboxExecutionPolicy` has no `readScope`/`writeScope` members, so a consumer that does not thread the pair gets the deployment DEFAULT read axis, never this package's per-session store. The read fence actually in force is `read-guard.ts`. The module is kept for `readAxisRefusal` / `readAxisRefusalAsync`, the canonical read-axis containment predicates, and for an operator who replaces the filesystem backend by hand (disable `fs-sandbox`, mount this row, and thread the axes).
|
|
146
|
+
- The client half lives in a separate package (`@t4r71/dsh-dual-axis-ui`); this package declares no `dsh.client`.
|
|
147
|
+
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# The dual-axis bundle patch: one row inserting the dual-axis host half, with the
|
|
2
|
+
# two axes a newly created session starts from as its composition config.
|
|
3
|
+
#
|
|
4
|
+
# The row id `dual-axis` is load-bearing twice over in 0.1.7:
|
|
5
|
+
# - it is the Loader entry id, and thus the settings namespace
|
|
6
|
+
# (`SettingsForms.write` looks the entry up by `options.id`);
|
|
7
|
+
# - prefixed with this package's name it forms the `plugins.row.config` slot
|
|
8
|
+
# entry key the client half registers under.
|
|
9
|
+
#
|
|
10
|
+
# The browser half rides this same row: in 0.1.7 a client entry's id IS its
|
|
11
|
+
# package name (WebBootEntry.id, client/modules/src/client/manifest.ts:53), and
|
|
12
|
+
# dsh.client is one object per package, so both client surfaces — the settings
|
|
13
|
+
# row and the two composer dropdowns — live in the single bundle behind
|
|
14
|
+
# exports["./client"].
|
|
15
|
+
|
|
16
|
+
- insert:
|
|
17
|
+
- id: dual-axis
|
|
18
|
+
name: '@t4r71/dsh-dual-axis'
|
|
19
|
+
config:
|
|
20
|
+
read: { kind: all }
|
|
21
|
+
write: { kind: workspace }
|
|
22
|
+
# 文件系统半边刻意不在这里挂行:它要接管 `fs` 服务,而本体那一行已经注册了同一个
|
|
23
|
+
# 服务,两行同时活动会让启动直接失败(required 行不激活 = startup failed)。它同时
|
|
24
|
+
# 也救不了读轴 —— 0.1.7 的读路径不带会话,那一半永远拿不到要判的轴。要手工换掉
|
|
25
|
+
# 文件系统后端的话,得同时禁用本体的 `fs-sandbox` 行,见本包 README。
|
|
26
|
+
|
|
27
|
+
# 读轴围栏:本体 0.1.7 的读路径不带会话,读轴只能靠工具派发层执行。
|
|
28
|
+
- id: dual-axis-read-guard
|
|
29
|
+
name: '@t4r71/dsh-dual-axis/read-guard'
|
|
30
|
+
|
|
31
|
+
# 每条会话那对轴的权威存储。这是一行**独立**的 Loader entry,id 就是设置命名空间
|
|
32
|
+
# (0.1.7 里 ns == entry id),字段只有 `axes`:会话 id → 轴对。
|
|
33
|
+
#
|
|
34
|
+
# 为什么不塞进上面那行的 Config:那一行的 ns 绑着设置页的行表单,多一个未声明字段
|
|
35
|
+
# 会被投影进那张表单;而这一行的 Config 只声明 `axes` 一个 volatile 字段,
|
|
36
|
+
# 设置页不渲染它(客户端半边没有为它注册行)。
|
|
37
|
+
- id: dual-axis-sessions
|
|
38
|
+
name: '@t4r71/dsh-dual-axis/session-store'
|
|
39
|
+
|
|
40
|
+
# 纯客户端半边所在的包。它的宿主半边是空插件,这一行存在的唯一理由是
|
|
41
|
+
# 0.1.7 的客户端扫描只处理有 Loader 行的包。
|
|
42
|
+
- id: dual-axis-ui
|
|
43
|
+
name: '@t4r71/dsh-dual-axis-ui'
|
|
44
|
+
|
|
45
|
+
# 本体的权限界面行:本包的两个界面面取代它,因此必须先禁掉它。
|
|
46
|
+
#
|
|
47
|
+
# 为什么必须禁:本包的浏览器半边注册的是本体那三个界面面本身 —— 同一个
|
|
48
|
+
# `conversation.input.permission` 槽、同一条 `settings.general.item` 行
|
|
49
|
+
# (id `permission`、order -20)、同一个 `/permission` 命令装饰。两条独立的独占
|
|
50
|
+
# 规则因此都会在第二方 apply() 里抛错,fiber 落进 FAILED,客户端启动审计报
|
|
51
|
+
# 「1 entry did not activate」:
|
|
52
|
+
# - 槽是**单占**的:ui-conversation 把 `conversation.input.permission` 声明为
|
|
53
|
+
# `{ kind: 'single', scope: 'session' }`,二次注册抛
|
|
54
|
+
# `single slot "…" already has a registration`
|
|
55
|
+
# (client/ui-slots/src/index.ts:1216-1218)。改 locale 命名空间绕不开它。
|
|
56
|
+
# - locale 命名空间按 (ns, locale) **单占**:两侧都注册 `permission.access` 与
|
|
57
|
+
# `settings.permission`,二次注册抛
|
|
58
|
+
# `locale namespace "…" already has locale "…"`
|
|
59
|
+
# (client/locale/src/client/index.ts:415-419)。
|
|
60
|
+
# 这不是可接受的降级:取代关系必须由这个补丁表达,而不是由安装方记得少装一个包。
|
|
61
|
+
# 用户要拿回官方界面,得卸掉本组合包(本行随它一起走),而不是把这里改回 enabled。
|
|
62
|
+
#
|
|
63
|
+
# 这里刻意写出 name:补丁引擎在 name 与目标行不符时打一条警告并跳过
|
|
64
|
+
# (vendor/include/src/index.ts:115-118)。那条警告是安全阀 —— 本体改了那行的包名时,
|
|
65
|
+
# 补丁不再生效而不是误伤同 id 的别的行。
|
|
66
|
+
- id: ui-permission
|
|
67
|
+
name: '@deepseek-ai/dsh-client-ui-permission-presets'
|
|
68
|
+
disabled: true
|
package/lib/fs.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
|
|
2
|
+
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, sep } from "node:path";
|
|
5
|
+
import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
|
|
6
|
+
//#region lib/types/containment.js
|
|
7
|
+
/**
|
|
8
|
+
* 目标路径包含判定:把上游 `@deepseek-ai/dsh-fs-sandbox/containment` 的
|
|
9
|
+
* `isPathUnder` 原样内联到本包。
|
|
10
|
+
*
|
|
11
|
+
* 为什么必须内联:fs-sandbox@0.1.7-rc.2 的**发布包** exports 只开出
|
|
12
|
+
* `.`、`./src/*` 与 `./package.json` 三条,**没有 `./containment`**;
|
|
13
|
+
* 而源码工作区里该文件存在,靠路径解析能直接命中。于是同一个 import 在
|
|
14
|
+
* 工作区里编译通过、装成 tgz 后运行期必然抛
|
|
15
|
+
* `Package subpath './containment' is not defined by "exports"`,
|
|
16
|
+
* 使 dual-axis 这个 entry 永远无法激活(fiberPhase 恒为 null)。
|
|
17
|
+
* 自有副本消除这个 install-shape 依赖。
|
|
18
|
+
* @module @t4r71/dsh-dual-axis/containment
|
|
19
|
+
*/
|
|
20
|
+
const MISSING_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
|
|
21
|
+
function isMissing(error) {
|
|
22
|
+
const code = error.code;
|
|
23
|
+
return MISSING_CODES.has(code);
|
|
24
|
+
}
|
|
25
|
+
function comparablePath(path, caseSensitive) {
|
|
26
|
+
return caseSensitive ? path : path.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
function isLexicallyUnder$1(path, root, caseSensitive) {
|
|
29
|
+
const comparableTarget = comparablePath(path, caseSensitive);
|
|
30
|
+
const comparableRoot = comparablePath(root, caseSensitive);
|
|
31
|
+
if (comparableTarget === comparableRoot) return true;
|
|
32
|
+
const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
|
|
33
|
+
return comparableTarget.startsWith(prefix);
|
|
34
|
+
}
|
|
35
|
+
async function statIfPresent(path) {
|
|
36
|
+
try {
|
|
37
|
+
return await stat(path, { bigint: true });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (isMissing(error)) return void 0;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function sameIdentity(left, right) {
|
|
44
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Determine whether a canonical target is a writable root or lies beneath it.
|
|
48
|
+
* The lexical fast path handles normal canonical spellings. When spellings
|
|
49
|
+
* differ, walk the target's existing ancestors and compare filesystem identity
|
|
50
|
+
* with the root; this recognizes Windows long-name/8.3 aliases and casing
|
|
51
|
+
* without weakening containment to a textual approximation.
|
|
52
|
+
* @param path - canonical target key, which may end in a missing suffix.
|
|
53
|
+
* @param root - canonical writable root.
|
|
54
|
+
* @param caseSensitive - whether lexical comparison preserves case; defaults
|
|
55
|
+
* to the host filesystem convention used by supported platforms.
|
|
56
|
+
* @returns whether the target is the root or a descendant of it.
|
|
57
|
+
*/
|
|
58
|
+
async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
|
|
59
|
+
if (isLexicallyUnder$1(path, root, caseSensitive)) return true;
|
|
60
|
+
const rootInfo = await statIfPresent(root);
|
|
61
|
+
if (!rootInfo) return false;
|
|
62
|
+
let ancestor = path;
|
|
63
|
+
while (true) {
|
|
64
|
+
const ancestorInfo = await statIfPresent(ancestor);
|
|
65
|
+
if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true;
|
|
66
|
+
const parent = dirname(ancestor);
|
|
67
|
+
if (parent === ancestor) return false;
|
|
68
|
+
ancestor = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region lib/types/axis.js
|
|
73
|
+
/** The default READ axis: every mode permitted reading before the axes existed, so the whole host. */
|
|
74
|
+
const DEFAULT_READ_SCOPE = { kind: "all" };
|
|
75
|
+
/**
|
|
76
|
+
* The write scope a legacy `mode` means.
|
|
77
|
+
* @param mode - the sandbox mode.
|
|
78
|
+
* @returns the equivalent write scope.
|
|
79
|
+
*/
|
|
80
|
+
function scopeOfMode(mode) {
|
|
81
|
+
switch (mode) {
|
|
82
|
+
case "read-only": return { kind: "deny" };
|
|
83
|
+
case "workspace-write": return { kind: "workspace" };
|
|
84
|
+
case "danger-full-access": return { kind: "all" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Read the axis pair a resolved policy carries, falling back to the defaults.
|
|
89
|
+
* The policy's `mode` is authoritative for the write axis's BASE: a policy
|
|
90
|
+
* that carries no write axes writes exactly what its mode always meant, so
|
|
91
|
+
* every pre-existing policy keeps its exact behavior.
|
|
92
|
+
*
|
|
93
|
+
* 0.1.7's `SandboxExecutionPolicy` has no `readScope` / `writeScope`
|
|
94
|
+
* members (only `mode`, `workspaceRoot`, `sessionId?`), so the axes arrive
|
|
95
|
+
* through the extra argument this package's own fence passes. The
|
|
96
|
+
* `mode`-derived fallback keeps the function total for the upstream type.
|
|
97
|
+
* @param policy - the resolved policy (supplies `mode`).
|
|
98
|
+
* @param axes - the session's axis pair, when the caller holds one.
|
|
99
|
+
* @returns both axes in force.
|
|
100
|
+
*/
|
|
101
|
+
function effectiveScopes(policy, axes) {
|
|
102
|
+
return {
|
|
103
|
+
read: axes?.read ?? DEFAULT_READ_SCOPE,
|
|
104
|
+
write: axes?.write ?? scopeOfMode(policy.mode)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region lib/types/scope.js
|
|
109
|
+
/**
|
|
110
|
+
* The shared path-range ALGEBRA behind both access axes: one pure evaluation
|
|
111
|
+
* from an {@link AxisScope} to the canonical allow and deny root sets the
|
|
112
|
+
* fence consumes.
|
|
113
|
+
*
|
|
114
|
+
* This is the 0.1.7 port of 0.1.6's `@deepseek-ai/dsh-sandbox/scope`
|
|
115
|
+
* (152 lines), reduced to what a fence needs and made source-agnostic:
|
|
116
|
+
*
|
|
117
|
+
* - `workspace` derives its roots from 0.1.7's own
|
|
118
|
+
* `writableRoots(policy)` (`@deepseek-ai/dsh-sandbox`, re-exported from
|
|
119
|
+
* `packages/sandbox/sandbox/src/roots.ts:52`), so the fence agrees with
|
|
120
|
+
* the Seatbelt profile and the write fence by construction.
|
|
121
|
+
* - Containment itself is NOT evaluated here. 0.1.6 took the enforcement
|
|
122
|
+
* layer's `contains` predicate as a parameter; this port keeps that shape
|
|
123
|
+
* but narrows it to the SYNCHRONOUS lexical predicate the pure tests use,
|
|
124
|
+
* and the filesystem-identity fallback lives in the fence
|
|
125
|
+
* (`fs-fence.ts`), which is where the canonical target key exists.
|
|
126
|
+
*
|
|
127
|
+
* `resolveScope` returns the sets; the fence applies them with deny-wins
|
|
128
|
+
* precedence through {@link scopeContains}.
|
|
129
|
+
*
|
|
130
|
+
* @module @t4r71/dsh-dual-axis/scope
|
|
131
|
+
*/
|
|
132
|
+
/** Thrown when a configured `custom` scope entry cannot name an absolute path. */
|
|
133
|
+
var ScopeConfigError = class extends Error {
|
|
134
|
+
entry;
|
|
135
|
+
value;
|
|
136
|
+
constructor(entry, value) {
|
|
137
|
+
super(`sandbox scope: \`${entry}\` entry ${JSON.stringify(value)} must be a non-empty absolute path`);
|
|
138
|
+
this.entry = entry;
|
|
139
|
+
this.value = value;
|
|
140
|
+
this.name = "ScopeConfigError";
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/** Canonicalize one configured custom entry, failing closed on anything that cannot name an absolute host path. */
|
|
144
|
+
function canonicalEntry(value, entry) {
|
|
145
|
+
if (value.trim().length === 0 || !isAbsoluteSpelling(value)) throw new ScopeConfigError(entry, value);
|
|
146
|
+
return canonicalPath(value);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether a configured path is spelled absolutely on this host. Both POSIX
|
|
150
|
+
* (`/x`) and Windows (`C:\\x`, `\\\\server\\share`) spellings are accepted
|
|
151
|
+
* because a policy may be authored for one world and resolved in another; the
|
|
152
|
+
* canonical resolution that follows is what actually binds it to this host.
|
|
153
|
+
* @param path - the configured path spelling.
|
|
154
|
+
* @returns whether the spelling is absolute.
|
|
155
|
+
*/
|
|
156
|
+
function isAbsoluteSpelling(path) {
|
|
157
|
+
return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Evaluate one axis against a workspace root. `deny` permits nothing;
|
|
161
|
+
* `workspace` yields the shared writable roots; `all` is unbounded;
|
|
162
|
+
* `custom` yields its base plus its own additions, with its removals listed
|
|
163
|
+
* separately so the fence can apply them with precedence.
|
|
164
|
+
*
|
|
165
|
+
* A `custom` scope whose base is `all` stays unbounded: "everything except
|
|
166
|
+
* these directories" is still everything-except, so its removals must survive
|
|
167
|
+
* into {@link ResolvedScope.deny} rather than being flattened into an allow
|
|
168
|
+
* list that could not express them.
|
|
169
|
+
* @param scope - the axis value.
|
|
170
|
+
* @param policy - the workspace root `workspace` and `custom` scopes resolve against.
|
|
171
|
+
* @returns the evaluated range.
|
|
172
|
+
* @throws {ScopeConfigError} when a `custom` entry is not a non-empty absolute path.
|
|
173
|
+
*/
|
|
174
|
+
function resolveScope(scope, policy) {
|
|
175
|
+
if (scope.kind !== "custom") return resolveBase(scope.kind, policy);
|
|
176
|
+
const base = resolveBase(scope.base, policy);
|
|
177
|
+
const allow = [...base.allow, ...scope.allow.map((entry) => canonicalEntry(entry, "allow"))];
|
|
178
|
+
const deny = scope.deny.map((entry) => canonicalEntry(entry, "deny"));
|
|
179
|
+
return {
|
|
180
|
+
unbounded: base.unbounded,
|
|
181
|
+
allow: dedupe(allow),
|
|
182
|
+
deny: dedupe(deny)
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Evaluate a base (or closed) kind — the shared half of {@link resolveScope}. */
|
|
186
|
+
function resolveBase(base, policy) {
|
|
187
|
+
switch (base) {
|
|
188
|
+
case "deny": return {
|
|
189
|
+
unbounded: false,
|
|
190
|
+
allow: [],
|
|
191
|
+
deny: []
|
|
192
|
+
};
|
|
193
|
+
case "workspace": return {
|
|
194
|
+
unbounded: false,
|
|
195
|
+
allow: dedupe(writableRoots({
|
|
196
|
+
mode: "workspace-write",
|
|
197
|
+
workspaceRoot: policy.workspaceRoot
|
|
198
|
+
})),
|
|
199
|
+
deny: []
|
|
200
|
+
};
|
|
201
|
+
case "all": return {
|
|
202
|
+
unbounded: true,
|
|
203
|
+
allow: [],
|
|
204
|
+
deny: []
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Stable-order dedupe: keeps the first spelling of each value. */
|
|
209
|
+
function dedupe(values) {
|
|
210
|
+
return [...new Set(values)];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Whether `target` is the root itself or lies beneath it, by canonical
|
|
214
|
+
* spelling. Case-insensitive on Windows, matching that filesystem's
|
|
215
|
+
* convention.
|
|
216
|
+
* @param target - canonical target path.
|
|
217
|
+
* @param root - canonical root path.
|
|
218
|
+
* @param caseSensitive - whether lexical comparison preserves case; defaults to the host convention.
|
|
219
|
+
* @returns whether the target is the root or a descendant of it.
|
|
220
|
+
*/
|
|
221
|
+
function isLexicallyUnder(target, root, caseSensitive = process.platform !== "win32") {
|
|
222
|
+
const comparableTarget = caseSensitive ? target : target.toLowerCase();
|
|
223
|
+
const comparableRoot = caseSensitive ? root : root.toLowerCase();
|
|
224
|
+
if (comparableTarget === comparableRoot) return true;
|
|
225
|
+
const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
|
|
226
|
+
return comparableTarget.startsWith(prefix);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether `targetKey` is permitted by an evaluated scope. Removal wins over
|
|
230
|
+
* addition: a deny root containing the target refuses it even when an allow
|
|
231
|
+
* root — or an unbounded axis — would otherwise permit it.
|
|
232
|
+
* @param scope - the evaluated scope.
|
|
233
|
+
* @param targetKey - the target's canonical identity key (the resolved path).
|
|
234
|
+
* @param contains - the containment predicate; defaults to {@link isLexicallyUnder}.
|
|
235
|
+
* @returns whether the scope permits the target.
|
|
236
|
+
*/
|
|
237
|
+
function scopeContains(scope, targetKey, contains = isLexicallyUnder) {
|
|
238
|
+
for (const root of scope.deny) if (contains(targetKey, root)) return false;
|
|
239
|
+
if (scope.unbounded) return true;
|
|
240
|
+
for (const root of scope.allow) if (contains(targetKey, root)) return true;
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region lib/types/fs-fence.js
|
|
245
|
+
/**
|
|
246
|
+
* Decide whether one read axis permits one canonical target key. This is the
|
|
247
|
+
* fence's entire decision, as a pure function: it takes the evaluated range and
|
|
248
|
+
* the containment predicate, so a test can exercise every branch with no
|
|
249
|
+
* filesystem, no session, and no service.
|
|
250
|
+
*
|
|
251
|
+
* The error message names the READ axis's kind, never the mode that would
|
|
252
|
+
* spell it: a read axis of `workspace` would otherwise be reported as
|
|
253
|
+
* "workspace-write", a WRITE mode name this axis has no concept of.
|
|
254
|
+
* @param read - the read axis in force.
|
|
255
|
+
* @param policy - the workspace root a `workspace` read axis derives from.
|
|
256
|
+
* @param targetKey - the target's canonical identity key.
|
|
257
|
+
* @param contains - the containment predicate.
|
|
258
|
+
* @returns `undefined` when permitted, or the refusal message.
|
|
259
|
+
*/
|
|
260
|
+
function readAxisRefusal(read, policy, targetKey, contains) {
|
|
261
|
+
if (scopeContains(resolveScope(read, policy), targetKey, contains)) return void 0;
|
|
262
|
+
return refuse(read);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The async twin of {@link readAxisRefusal}: production containment
|
|
266
|
+
* (`isPathUnder`) walks the filesystem identity chain for alias-equivalent
|
|
267
|
+
* roots, so the fence cannot use the lexical predicate alone.
|
|
268
|
+
* @param read - the read axis in force.
|
|
269
|
+
* @param policy - the workspace root a `workspace` read axis derives from.
|
|
270
|
+
* @param targetKey - the target's canonical identity key.
|
|
271
|
+
* @param contains - the async containment predicate.
|
|
272
|
+
* @returns `undefined` when permitted, or the refusal message.
|
|
273
|
+
*/
|
|
274
|
+
async function readAxisRefusalAsync(read, policy, targetKey, contains) {
|
|
275
|
+
const scope = resolveScope(read, policy);
|
|
276
|
+
for (const root of scope.deny) if (await contains(targetKey, root)) return refuse(read);
|
|
277
|
+
if (scope.unbounded) return void 0;
|
|
278
|
+
for (const root of scope.allow) if (await contains(targetKey, root)) return void 0;
|
|
279
|
+
return refuse(read);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The single refusal message both predicates return, naming the READ axis's
|
|
283
|
+
* kind rather than the write mode that would spell it.
|
|
284
|
+
* @param read - the refused read axis.
|
|
285
|
+
* @returns the refusal message.
|
|
286
|
+
*/
|
|
287
|
+
function refuse(read) {
|
|
288
|
+
return `file access denied under the ${read.kind} read axis`;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The read-axis-enforcing filesystem backend. Mount it in place of
|
|
292
|
+
* `@deepseek-ai/dsh-fs-sandbox`: it registers the same `fs` service and
|
|
293
|
+
* inherits that backend's write fence verbatim.
|
|
294
|
+
*/
|
|
295
|
+
var DualAxisFileSystem = class extends SandboxedFileSystem {
|
|
296
|
+
constructor(ctx, config) {
|
|
297
|
+
super(ctx, config);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The read axis pair of the session a read belongs to. Reads carry no
|
|
301
|
+
* session, so the fence resolves the DEPLOYMENT default axes here; a caller
|
|
302
|
+
* that knows the calling session passes them per call instead.
|
|
303
|
+
* @returns the deployment default axis pair.
|
|
304
|
+
*/
|
|
305
|
+
defaultAxes() {
|
|
306
|
+
return effectiveScopes(this.ctx.sandboxPolicy.resolve());
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Fence the caller's read path against the READ axis, then return the target
|
|
310
|
+
* for the inherited read. The check happens BEFORE any disk access.
|
|
311
|
+
* @param target - the target the caller already resolved.
|
|
312
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
313
|
+
* @returns the same target, once the read axis permits it.
|
|
314
|
+
* @throws {FsError} `FS_SANDBOX_DENIED` when the read axis refuses the target.
|
|
315
|
+
*/
|
|
316
|
+
async checkedRead(target, axes) {
|
|
317
|
+
const policy = this.ctx.sandboxPolicy.resolve();
|
|
318
|
+
const refusal = await readAxisRefusalAsync(axes?.read ?? this.defaultAxes().read, policy, target.targetKey, isPathUnder);
|
|
319
|
+
if (refusal !== void 0) throw new FsError(`cannot read "${target.displayPath}": ${refusal}`, "FS_SANDBOX_DENIED");
|
|
320
|
+
return target;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Fence `lstat`'s string path by resolving the same target the inherited
|
|
324
|
+
* implementation will inspect, then applying the read axis. The resolution
|
|
325
|
+
* happens BEFORE the metadata read, so a refusal never touches the disk, and
|
|
326
|
+
* the inherited call still receives the original arguments verbatim.
|
|
327
|
+
* @param path - the requested path, relative to `opts.cwd` or the backend cwd.
|
|
328
|
+
* @param opts - optional cwd for the resolution.
|
|
329
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
330
|
+
* @returns the target the inherited `lstat` will inspect.
|
|
331
|
+
*/
|
|
332
|
+
async checkedReadPath(path, opts, axes) {
|
|
333
|
+
return this.checkedRead(await this.resolve(path, opts), axes);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Fence the read by the per-call read axis, then delegate to the inherited
|
|
337
|
+
* UTF-8 read.
|
|
338
|
+
* @param target - the resolved target to read.
|
|
339
|
+
* @param signal - aborts the read.
|
|
340
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
341
|
+
* @returns the file content from the inherited backend.
|
|
342
|
+
*/
|
|
343
|
+
async readText(target, signal, axes) {
|
|
344
|
+
return super.readText(await this.checkedRead(target, axes), signal);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Fence the read by the per-call read axis, then delegate to the inherited
|
|
348
|
+
* streaming read. The fence runs BEFORE the iterable is handed out: a refused
|
|
349
|
+
* stream never produces a chunk.
|
|
350
|
+
* @param target - the resolved target to stream.
|
|
351
|
+
* @param signal - aborts the read.
|
|
352
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
353
|
+
* @returns the inherited chunk iterable.
|
|
354
|
+
*/
|
|
355
|
+
async streamText(target, signal, axes) {
|
|
356
|
+
return super.streamText(await this.checkedRead(target, axes), signal);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Fence the read by the per-call read axis, then delegate to the inherited
|
|
360
|
+
* bounded byte read.
|
|
361
|
+
* @param target - the resolved target to read.
|
|
362
|
+
* @param signal - aborts the read.
|
|
363
|
+
* @param maxBytes - inclusive byte cap on the complete content.
|
|
364
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
365
|
+
* @returns the file bytes from the inherited backend.
|
|
366
|
+
*/
|
|
367
|
+
async readBytes(target, signal, maxBytes, axes) {
|
|
368
|
+
return super.readBytes(await this.checkedRead(target, axes), signal, maxBytes);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Fence the read by the per-call read axis, then delegate to the inherited
|
|
372
|
+
* byte-window read.
|
|
373
|
+
* @param target - the resolved target to read.
|
|
374
|
+
* @param range - `offset` and `length` of the window.
|
|
375
|
+
* @param signal - aborts the read.
|
|
376
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
377
|
+
* @returns the byte window from the inherited backend.
|
|
378
|
+
*/
|
|
379
|
+
async readByteRange(target, range, signal, axes) {
|
|
380
|
+
return super.readByteRange(await this.checkedRead(target, axes), range, signal);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Fence the listing by the per-call read axis, then delegate to the inherited
|
|
384
|
+
* directory listing.
|
|
385
|
+
* @param target - the resolved directory to list.
|
|
386
|
+
* @param signal - aborts the listing.
|
|
387
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
388
|
+
* @returns the directory entries from the inherited backend.
|
|
389
|
+
*/
|
|
390
|
+
async listDir(target, signal, axes) {
|
|
391
|
+
return super.listDir(await this.checkedRead(target, axes), signal);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Fence the metadata read by the per-call read axis, then delegate to the
|
|
395
|
+
* inherited stat.
|
|
396
|
+
* @param target - the resolved target to inspect.
|
|
397
|
+
* @param signal - aborts the metadata read.
|
|
398
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
399
|
+
* @returns the metadata, or `undefined` when the target is absent.
|
|
400
|
+
*/
|
|
401
|
+
async stat(target, signal, axes) {
|
|
402
|
+
return super.stat(await this.checkedRead(target, axes), signal);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Fence the no-follow metadata read by the per-call read axis, then delegate
|
|
406
|
+
* to the inherited lstat with the caller's arguments unchanged.
|
|
407
|
+
* @param path - the requested path, relative to `opts.cwd` or the backend cwd.
|
|
408
|
+
* @param opts - optional cwd for the resolution.
|
|
409
|
+
* @param signal - aborts the metadata read.
|
|
410
|
+
* @param axes - the calling session's axis pair; omit for the deployment default.
|
|
411
|
+
* @returns the path-entry metadata, or `undefined` when the entry is absent.
|
|
412
|
+
*/
|
|
413
|
+
async lstat(path, opts, signal, axes) {
|
|
414
|
+
await this.checkedReadPath(path, opts, axes);
|
|
415
|
+
return super.lstat(path, opts, signal);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
//#endregion
|
|
419
|
+
export { DualAxisFileSystem, DualAxisFileSystem as default, readAxisRefusal, readAxisRefusalAsync };
|