dsh-scratch-workspace 0.1.3 → 0.1.4
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.md +41 -27
- package/index.js +16 -2
- package/lib/patch.js +208 -1
- package/lib/web.js +130 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## 插件用途
|
|
26
26
|
|
|
27
27
|
在 DeepSeek Harness 日常开发中,同时运行多个会话或使用默认工作区即兴提问时,开发者普遍面临以下三大核心痛点:
|
|
28
28
|
|
|
@@ -42,20 +42,34 @@
|
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
45
|
-
##
|
|
45
|
+
## 效果一览
|
|
46
|
+
|
|
47
|
+
- 🚀 **并行开发零干扰**:多个会话同时对同一项目执行重构、分析或测试,产物互不交叉。
|
|
48
|
+
- 🛡️ **无感平滑升级**:不受 DSH 小版本架构调整影响,插件长久稳定运行。
|
|
49
|
+
- 📂 **侧边栏清爽如初**:所有会话紧密附着于所属工作区,消除孤儿会话打扰。
|
|
50
|
+
- 🧹 **磁盘常态零负担**:废弃临时目录自动清理,保持本地环境轻盈纯净。
|
|
51
|
+
|
|
52
|
+
| 侧边栏原生聚合 (DSH 界面) | 物理沙箱隔离 (Finder 真实目录) |
|
|
53
|
+
| :---: | :---: |
|
|
54
|
+
| <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/02-sidebar-workspace-grouping.png" width="280" alt="侧边栏工作区归集效果" /> | <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/01-finder-isolated-folders.png" width="280" alt="Finder 目录隔离效果" /> |
|
|
55
|
+
| **顶层统一归集**:会话在侧边栏完整归属母工作区,拒绝臃肿碎片化,保持工作区清爽 | **底层物理隔离**:每个会话分配专属 `session-<标题>-<ID>` 子目录,改名实时就地同步,产物互不踩踏 |
|
|
56
|
+
|
|
57
|
+
| 交互式斜杠指令 (Slash Command) | ⭐ 即时状态看板与向导 (/scratch) |
|
|
58
|
+
| :---: | :---: |
|
|
59
|
+
| <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/03-slash-command-menu.png" width="240" alt="斜杠指令菜单" /><br/><br/><img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/04-slash-command-args.png" width="240" alt="子命令提示" /> | <img src="https://raw.githubusercontent.com/MDX-Tom/dsh-scratch-workspace/main/assets/05-scratch-status-card.png" width="580" alt="即时状态看板" /> |
|
|
60
|
+
| **开箱即用的指令交互**:输入 `/scratch` 自动补全,支持 `status`、`enable`、`disable` 与 `gc` | **状态一目了然(核心看板)**:清晰展示母工作区映射、会话沙箱物理路径与快捷运维向导 |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 架构与技术原理
|
|
46
65
|
|
|
47
66
|
```mermaid
|
|
48
|
-
flowchart
|
|
49
|
-
|
|
50
|
-
Controller --> Check{"检测是否为隔离工作区或默认工作区"}
|
|
67
|
+
flowchart LR
|
|
68
|
+
Create["新建会话"] --> Check{"是否隔离?"}
|
|
51
69
|
Check -- 否 --> Native["原生创建流程"]
|
|
52
|
-
Check -- 是 --> Alloc["
|
|
53
|
-
Alloc -->
|
|
54
|
-
|
|
55
|
-
TempRedirect --> Attach["session 与 workspace 建立原生归集"]
|
|
56
|
-
Attach --> CompatIndex["兼容层索引拦截 (candidateMatches / sessionPath)"]
|
|
57
|
-
CompatIndex --> Ready["会话就绪:文件写入局限于子沙箱,侧边栏归属母工程"]
|
|
58
|
-
Ready --> GC["常态 GC 守护:空闲回收 + 会话销毁即时释放"]
|
|
70
|
+
Check -- 是 --> Alloc["分配专属子沙箱<br/>session-xxx"]
|
|
71
|
+
Alloc --> Safe["微秒重定向 & 防冲突<br/>解绑 blank cwd"] --> Compat["双轨索引拦截<br/>挂靠母工作区"]
|
|
72
|
+
Compat --> Ready["沙箱安全写入<br/>侧边栏原生聚合"] -. 守护 .-> GC["常态生命周期 GC<br/>空闲清理 & 销毁即释"]
|
|
59
73
|
```
|
|
60
74
|
|
|
61
75
|
### 核心方法与实现机制
|
|
@@ -73,25 +87,24 @@ flowchart TD
|
|
|
73
87
|
|
|
74
88
|
---
|
|
75
89
|
|
|
76
|
-
##
|
|
77
|
-
|
|
78
|
-
- 🚀 **并行开发零干扰**:多个会话同时对同一项目执行重构、分析或测试,产物互不交叉。
|
|
79
|
-
- 🛡️ **无感平滑升级**:不受 DSH 小版本架构调整影响,插件长久稳定运行。
|
|
80
|
-
- 📂 **侧边栏清爽如初**:所有会话紧密附着于所属工作区,消除孤儿会话打扰。
|
|
81
|
-
- 🧹 **磁盘常态零负担**:废弃临时目录自动清理,保持本地环境轻盈纯净。
|
|
90
|
+
## 安装与使用
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
### 方式一:从 npm 安装(推荐)
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
```bash
|
|
95
|
+
dsh plugin add dsh-scratch-workspace
|
|
96
|
+
# 或者通过 npm / pnpm
|
|
97
|
+
pnpm add dsh-scratch-workspace
|
|
98
|
+
```
|
|
86
99
|
|
|
87
|
-
###
|
|
100
|
+
### 方式二:从本地安装(开发调试)
|
|
88
101
|
|
|
89
102
|
在 DeepSeek Harness 所在配置 Profile 中添加本插件依赖(例如 `~/.dsh/profiles/desktop`):
|
|
90
103
|
|
|
91
104
|
```bash
|
|
92
105
|
# 1. 链接到你的 DSH Profile
|
|
93
106
|
cd ~/.dsh/profiles/desktop
|
|
94
|
-
pnpm add "link:/
|
|
107
|
+
pnpm add "link:/path/to/dsh-scratch-workspace"
|
|
95
108
|
|
|
96
109
|
# 2. 将 "dsh-scratch-workspace" 添加到 package.json 中的 dsh.profile.bundles 列表
|
|
97
110
|
|
|
@@ -108,16 +121,17 @@ dsh plugin --profile desktop add https://github.com/MDX-Tom/dsh-scratch-workspac
|
|
|
108
121
|
|
|
109
122
|
## 开启与控制隔离
|
|
110
123
|
|
|
111
|
-
|
|
124
|
+
插件支持多种灵活的开启与控制方式:
|
|
112
125
|
|
|
113
|
-
1. **对话内 Slash
|
|
126
|
+
1. **对话内 Slash 命令(推荐)**:
|
|
114
127
|
在聊天输入框键入 `/scratch` 或 `/isolate` 即可调出交互命令,支持:
|
|
115
128
|
- `/scratch status`:查看当前工作区隔离状态与草稿目录信息
|
|
116
|
-
- `/scratch enable
|
|
129
|
+
- `/scratch enable`:为当前工作区开启沙箱隔离(自动写入 `.dsh-isolate` 标记)
|
|
117
130
|
- `/scratch disable`:关闭隔离
|
|
118
131
|
- `/scratch gc`:立即执行磁盘垃圾清理,回收空闲与孤儿会话目录
|
|
119
|
-
2.
|
|
120
|
-
|
|
132
|
+
2. **目录标记文件 (`.dsh-isolate`) 与免配置自启**:
|
|
133
|
+
- **自定义工程/普通工作区**:在根目录下放置 `.dsh-isolate` 标记文件作为持久化依据,设置随目录与代码仓库移动。
|
|
134
|
+
- **默认工作区 (`default-workspace`)**:内置免配置自启,开箱即用,无需手动放置或生成标记文件。
|
|
121
135
|
3. **Agent 智能调用 (LLM Tool)**:
|
|
122
136
|
模型可自动调用 `session_isolation` 工具查看或切换隔离状态:
|
|
123
137
|
- `action: "enable"`:为当前工作区开启隔离
|
package/index.js
CHANGED
|
@@ -58,8 +58,22 @@ async function apply(ctx) {
|
|
|
58
58
|
await renameSessionDirToTitle(sessionId, title, iso, reg, ctx, session)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
const
|
|
61
|
+
let offRoutes = undefined
|
|
62
|
+
const setupWeb = (ws) => {
|
|
63
|
+
if (ws && !offRoutes) {
|
|
64
|
+
offRoutes = registerWebRoutes(ctx, ws, iso)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const initialWs = ctx.get?.('webServer', false) ?? ctx.root?.get?.('webServer', false) ?? ctx.webServer
|
|
69
|
+
if (initialWs) {
|
|
70
|
+
setupWeb(initialWs)
|
|
71
|
+
} else if (typeof ctx.inject === 'function') {
|
|
72
|
+
ctx.inject(['webServer'], (innerCtx) => {
|
|
73
|
+
const ws = innerCtx.get?.('webServer', false) ?? innerCtx.webServer
|
|
74
|
+
setupWeb(ws)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
63
77
|
|
|
64
78
|
ctx.logger?.info?.(
|
|
65
79
|
`dsh-scratch-workspace ready: ${iso.roots().length} isolating director${iso.roots().length === 1 ? 'y' : 'ies'}`,
|
package/lib/patch.js
CHANGED
|
@@ -24,6 +24,144 @@ import { cleanOrphans, isDirectoryEmpty, removeSessionDir, shortSessionId } from
|
|
|
24
24
|
/** The registry-internal host property. */
|
|
25
25
|
const HOST_PROPERTY = 'host'
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a session directory path to its actual existing on-disk location,
|
|
29
|
+
* reconciling title-based renames, cold restarts, and stale header cwds.
|
|
30
|
+
* @param {string} [targetPath] - candidate or stale path
|
|
31
|
+
* @param {object} [options]
|
|
32
|
+
* @param {string} [options.sessionId] - session ID if known
|
|
33
|
+
* @param {object} [options.iso] - isolation state
|
|
34
|
+
* @param {object} [options.ctx] - Cordis context
|
|
35
|
+
* @returns {Promise<string>} real on-disk directory or fallback targetPath
|
|
36
|
+
*/
|
|
37
|
+
export async function resolveSessionDir(targetPath, { sessionId, iso, ctx } = {}) {
|
|
38
|
+
// 1. If targetPath already exists on disk as a directory, it is valid
|
|
39
|
+
if (typeof targetPath === 'string' && targetPath !== '') {
|
|
40
|
+
try {
|
|
41
|
+
if ((await stat(targetPath)).isDirectory()) {
|
|
42
|
+
if (sessionId) iso?.recordSessionPath?.(sessionId, targetPath)
|
|
43
|
+
return targetPath
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Check if isolation state has a recorded rewrite
|
|
49
|
+
if (typeof targetPath === 'string' && targetPath !== '') {
|
|
50
|
+
const rewritten = iso?.rewritePath?.(targetPath)
|
|
51
|
+
if (rewritten && rewritten !== targetPath) {
|
|
52
|
+
try {
|
|
53
|
+
if ((await stat(rewritten)).isDirectory()) {
|
|
54
|
+
if (sessionId) iso?.recordSessionPath?.(sessionId, rewritten)
|
|
55
|
+
return rewritten
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. If sessionId is provided, check if iso already knows its session path
|
|
62
|
+
if (sessionId) {
|
|
63
|
+
const current = iso?.sessionPath?.(sessionId)
|
|
64
|
+
if (current && current !== targetPath) {
|
|
65
|
+
try {
|
|
66
|
+
if ((await stat(current)).isDirectory()) {
|
|
67
|
+
if (targetPath) iso?.recordRename?.(targetPath, current)
|
|
68
|
+
return current
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. If sessionId is not provided, try to find sessionId from ctx.sessions or ctx.sessionQuery
|
|
75
|
+
let effectiveSessionId = sessionId
|
|
76
|
+
if (!effectiveSessionId && typeof targetPath === 'string' && targetPath !== '' && ctx) {
|
|
77
|
+
const sessionsService = typeof ctx.get === 'function' ? ctx.get('sessions', false) : ctx.sessions
|
|
78
|
+
if (typeof sessionsService?.list === 'function') {
|
|
79
|
+
try {
|
|
80
|
+
for (const s of sessionsService.list()) {
|
|
81
|
+
if (s?.header?.cwd === targetPath) {
|
|
82
|
+
effectiveSessionId = s.id
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!effectiveSessionId) {
|
|
90
|
+
const query = typeof ctx.get === 'function' ? ctx.get('sessionQuery', false) : ctx.sessionQuery
|
|
91
|
+
if (typeof query?.listSessions === 'function') {
|
|
92
|
+
try {
|
|
93
|
+
const records = await query.listSessions()
|
|
94
|
+
for (const r of records) {
|
|
95
|
+
if (r?.header?.cwd === targetPath) {
|
|
96
|
+
effectiveSessionId = r.header.id
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 5. If effectiveSessionId is known, check workingDirectory service if available
|
|
106
|
+
if (effectiveSessionId && ctx) {
|
|
107
|
+
const wd = typeof ctx.get === 'function' ? ctx.get('workingDirectory', false) : ctx.workingDirectory
|
|
108
|
+
const sessionsService = typeof ctx.get === 'function' ? ctx.get('sessions', false) : ctx.sessions
|
|
109
|
+
const session = sessionsService?.get?.(effectiveSessionId)
|
|
110
|
+
if (session && typeof wd?.get === 'function') {
|
|
111
|
+
try {
|
|
112
|
+
const wdPath = wd.get(session)
|
|
113
|
+
if (wdPath && (await stat(wdPath)).isDirectory()) {
|
|
114
|
+
iso?.recordSessionPath?.(effectiveSessionId, wdPath)
|
|
115
|
+
if (targetPath && targetPath !== wdPath) iso?.recordRename?.(targetPath, wdPath)
|
|
116
|
+
return wdPath
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 6. Look for matching session directory on disk using candidate short IDs
|
|
123
|
+
const candidateShorts = new Set()
|
|
124
|
+
if (effectiveSessionId) {
|
|
125
|
+
candidateShorts.add(shortSessionId(effectiveSessionId))
|
|
126
|
+
}
|
|
127
|
+
if (typeof targetPath === 'string' && targetPath !== '') {
|
|
128
|
+
const b = basename(targetPath)
|
|
129
|
+
const match = b.match(SESSION_DIR_PATTERN)
|
|
130
|
+
if (match) {
|
|
131
|
+
const s = (match[2] ?? match[1] ?? '').split('-')[0].toLowerCase()
|
|
132
|
+
if (s) candidateShorts.add(s)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (candidateShorts.size > 0) {
|
|
137
|
+
const roots = new Set(iso?.roots?.() ?? [])
|
|
138
|
+
if (typeof targetPath === 'string' && targetPath !== '') {
|
|
139
|
+
const parent = iso?.ownerOf?.(targetPath) ?? (await stat(targetPath).catch(() => null) ? null : resolve(join(targetPath, '..')))
|
|
140
|
+
if (parent) roots.add(parent)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const root of roots) {
|
|
144
|
+
try {
|
|
145
|
+
const entries = await readdir(root, { withFileTypes: true })
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (!entry.isDirectory()) continue
|
|
148
|
+
const match = entry.name.match(SESSION_DIR_PATTERN)
|
|
149
|
+
if (!match) continue
|
|
150
|
+
const entryShort = (match[2] ?? match[1] ?? '').split('-')[0].toLowerCase()
|
|
151
|
+
if (candidateShorts.has(entryShort)) {
|
|
152
|
+
const foundDir = resolve(join(root, entry.name))
|
|
153
|
+
if (effectiveSessionId) iso?.recordSessionPath?.(effectiveSessionId, foundDir)
|
|
154
|
+
if (targetPath && targetPath !== foundDir) iso?.recordRename?.(targetPath, foundDir)
|
|
155
|
+
return foundDir
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return targetPath
|
|
163
|
+
}
|
|
164
|
+
|
|
27
165
|
/**
|
|
28
166
|
* Locate and remove a session's directory if it is empty.
|
|
29
167
|
* Uses 3-tier lookup: in-memory iso state -> registry index -> isolating roots scan by shortId.
|
|
@@ -347,6 +485,65 @@ export function patchSessionCreate(ctx, iso) {
|
|
|
347
485
|
}
|
|
348
486
|
}
|
|
349
487
|
|
|
488
|
+
// Hook sessionController.list to project real on-disk session paths to frontend useSessions
|
|
489
|
+
const originalList = controller()?.list
|
|
490
|
+
if (typeof originalList === 'function') {
|
|
491
|
+
controller().list = async function list(request, signal) {
|
|
492
|
+
const res = await originalList.call(this, request, signal)
|
|
493
|
+
if (Array.isArray(res?.items)) {
|
|
494
|
+
for (const item of res.items) {
|
|
495
|
+
if (!item?.sessionId) continue
|
|
496
|
+
const resolved = await resolveSessionDir(item.cwd, {
|
|
497
|
+
sessionId: item.sessionId,
|
|
498
|
+
iso,
|
|
499
|
+
ctx,
|
|
500
|
+
})
|
|
501
|
+
if (resolved) {
|
|
502
|
+
item.cwd = resolved
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return res
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Also hook controller().listState.list if present
|
|
511
|
+
const listState = controller()?.listState
|
|
512
|
+
const originalStateList = listState?.list
|
|
513
|
+
if (typeof originalStateList === 'function') {
|
|
514
|
+
listState.list = async function list(signal) {
|
|
515
|
+
const items = await originalStateList.call(this, signal)
|
|
516
|
+
if (Array.isArray(items)) {
|
|
517
|
+
for (const item of items) {
|
|
518
|
+
if (!item?.sessionId) continue
|
|
519
|
+
const resolved = await resolveSessionDir(item.cwd, {
|
|
520
|
+
sessionId: item.sessionId,
|
|
521
|
+
iso,
|
|
522
|
+
ctx,
|
|
523
|
+
})
|
|
524
|
+
if (resolved) {
|
|
525
|
+
item.cwd = resolved
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return items
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Hook controller().openWorkspacePath to rewrite paths for file reveals/opens
|
|
534
|
+
const originalOpenWorkspacePath = controller()?.openWorkspacePath
|
|
535
|
+
if (typeof originalOpenWorkspacePath === 'function') {
|
|
536
|
+
controller().openWorkspacePath = async function openWorkspacePath(request, signal) {
|
|
537
|
+
if (request && typeof request.path === 'string') {
|
|
538
|
+
const resolved = await resolveSessionDir(request.path, { iso, ctx })
|
|
539
|
+
if (resolved && resolved !== request.path) {
|
|
540
|
+
request = { ...request, path: resolved }
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return await originalOpenWorkspacePath.call(this, request, signal)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
350
547
|
// Hook workspaceController.archiveSession for frontend archive actions
|
|
351
548
|
const wsCtrl = ctx.root?.workspaceController ?? ctx.workspaceController
|
|
352
549
|
const originalWsArchive = wsCtrl?.archiveSession
|
|
@@ -365,6 +562,13 @@ export function patchSessionCreate(ctx, iso) {
|
|
|
365
562
|
if (typeof originalRename === 'function') controller().rename = originalRename
|
|
366
563
|
if (typeof originalDelete === 'function') controller().delete = originalDelete
|
|
367
564
|
if (typeof originalWsArchive === 'function') wsCtrl.archiveSession = originalWsArchive
|
|
565
|
+
if (typeof originalList === 'function') controller().list = originalList
|
|
566
|
+
if (typeof originalStateList === 'function' && controller()?.listState) {
|
|
567
|
+
controller().listState.list = originalStateList
|
|
568
|
+
}
|
|
569
|
+
if (typeof originalOpenWorkspacePath === 'function') {
|
|
570
|
+
controller().openWorkspacePath = originalOpenWorkspacePath
|
|
571
|
+
}
|
|
368
572
|
}
|
|
369
573
|
}
|
|
370
574
|
|
|
@@ -481,7 +685,10 @@ export function patchWorkingDirectory(ctx, iso) {
|
|
|
481
685
|
* @param {object} [sessionObj] - live session object if available
|
|
482
686
|
*/
|
|
483
687
|
export async function renameSessionDirToTitle(sessionId, title, iso, registry, ctx, sessionObj) {
|
|
484
|
-
|
|
688
|
+
let currentDir = iso.sessionPath(sessionId)
|
|
689
|
+
if (!currentDir) {
|
|
690
|
+
currentDir = await resolveSessionDir(undefined, { sessionId, iso, ctx })
|
|
691
|
+
}
|
|
485
692
|
if (!currentDir) return
|
|
486
693
|
|
|
487
694
|
const clean = sanitizeTitleForPath(title)
|
package/lib/web.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* @module dsh-scratch-workspace/lib/web
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
import { stat } from 'node:fs/promises'
|
|
8
|
+
import { resolveSessionDir } from './patch.js'
|
|
9
|
+
|
|
6
10
|
export const LIST_ROUTE = '/scratch-workspace/workspaces'
|
|
7
11
|
export const TOGGLE_ROUTE = '/scratch-workspace/toggle'
|
|
8
12
|
export const GC_ROUTE = '/scratch-workspace/gc'
|
|
@@ -11,6 +15,8 @@ export const GC_ROUTE = '/scratch-workspace/gc'
|
|
|
11
15
|
export const LEGACY_LIST_ROUTE = '/session-isolation/workspaces'
|
|
12
16
|
export const LEGACY_TOGGLE_ROUTE = '/session-isolation/toggle'
|
|
13
17
|
|
|
18
|
+
export const OPEN_IN_APP_OPEN_PATH = '/open-in-app/open'
|
|
19
|
+
|
|
14
20
|
const MAX_BODY_BYTES = 4096
|
|
15
21
|
|
|
16
22
|
function json(res, status, body) {
|
|
@@ -110,7 +116,131 @@ export function registerWebRoutes(ctx, webServer, iso) {
|
|
|
110
116
|
disposers.push(webServer.register({ kind: 'exact', path: LEGACY_LIST_ROUTE, handler: handleList }))
|
|
111
117
|
disposers.push(webServer.register({ kind: 'exact', path: LEGACY_TOGGLE_ROUTE, handler: handleToggle }))
|
|
112
118
|
|
|
119
|
+
// Intercept open-in-app to rewrite stale or renamed session directories
|
|
120
|
+
disposers.push(interceptOpenInApp(webServer, iso, ctx))
|
|
121
|
+
|
|
113
122
|
return () => {
|
|
114
123
|
for (const d of disposers) d?.()
|
|
115
124
|
}
|
|
116
125
|
}
|
|
126
|
+
|
|
127
|
+
function createFakeReq(originalReq, buffer) {
|
|
128
|
+
const fakeStream = Readable.from([buffer])
|
|
129
|
+
return new Proxy(originalReq, {
|
|
130
|
+
get(target, prop, receiver) {
|
|
131
|
+
if (
|
|
132
|
+
prop === Symbol.asyncIterator ||
|
|
133
|
+
prop === 'read' ||
|
|
134
|
+
prop === 'on' ||
|
|
135
|
+
prop === 'once' ||
|
|
136
|
+
prop === 'pipe' ||
|
|
137
|
+
prop === 'resume' ||
|
|
138
|
+
prop === 'pause'
|
|
139
|
+
) {
|
|
140
|
+
const val = fakeStream[prop]
|
|
141
|
+
return typeof val === 'function' ? val.bind(fakeStream) : val
|
|
142
|
+
}
|
|
143
|
+
if (prop === 'headers') {
|
|
144
|
+
return {
|
|
145
|
+
...target.headers,
|
|
146
|
+
'content-length': String(buffer.length),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const val = Reflect.get(target, prop, receiver)
|
|
150
|
+
return typeof val === 'function' ? val.bind(target) : val
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function interceptOpenInApp(webServer, iso, ctx) {
|
|
156
|
+
if (!webServer || typeof webServer.register !== 'function') return () => {}
|
|
157
|
+
|
|
158
|
+
const wrapHandler = (originalHandler) => {
|
|
159
|
+
if (typeof originalHandler !== 'function') return originalHandler
|
|
160
|
+
if (originalHandler.__dsh_scratch_wrapped) return originalHandler
|
|
161
|
+
|
|
162
|
+
const wrapped = async function openInAppHandler(req, res) {
|
|
163
|
+
if (req.method !== 'POST') {
|
|
164
|
+
return originalHandler(req, res)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const chunks = []
|
|
168
|
+
let size = 0
|
|
169
|
+
let tooLarge = false
|
|
170
|
+
try {
|
|
171
|
+
for await (const chunk of req) {
|
|
172
|
+
size += chunk.length
|
|
173
|
+
if (size > 65536) {
|
|
174
|
+
tooLarge = true
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
chunks.push(chunk)
|
|
178
|
+
}
|
|
179
|
+
} catch {}
|
|
180
|
+
|
|
181
|
+
const rawBuffer = Buffer.concat(chunks)
|
|
182
|
+
if (tooLarge) {
|
|
183
|
+
const fakeReq = createFakeReq(req, rawBuffer)
|
|
184
|
+
return originalHandler(fakeReq, res)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let body = null
|
|
188
|
+
try {
|
|
189
|
+
body = JSON.parse(rawBuffer.toString('utf8'))
|
|
190
|
+
} catch {}
|
|
191
|
+
|
|
192
|
+
if (!body || typeof body.path !== 'string') {
|
|
193
|
+
const fakeReq = createFakeReq(req, rawBuffer)
|
|
194
|
+
return originalHandler(fakeReq, res)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let isDir = false
|
|
198
|
+
try {
|
|
199
|
+
isDir = (await stat(body.path)).isDirectory()
|
|
200
|
+
} catch {
|
|
201
|
+
isDir = false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!isDir) {
|
|
205
|
+
const targetPath = await resolveSessionDir(body.path, { iso, ctx })
|
|
206
|
+
if (targetPath && targetPath !== body.path) {
|
|
207
|
+
body.path = targetPath
|
|
208
|
+
const newBuffer = Buffer.from(JSON.stringify(body), 'utf8')
|
|
209
|
+
const fakeReq = createFakeReq(req, newBuffer)
|
|
210
|
+
return originalHandler(fakeReq, res)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const fakeReq = createFakeReq(req, rawBuffer)
|
|
215
|
+
return originalHandler(fakeReq, res)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
wrapped.__dsh_scratch_wrapped = true
|
|
219
|
+
wrapped.__dsh_scratch_original = originalHandler
|
|
220
|
+
return wrapped
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 1. If /open-in-app/open is already registered in webServer.exact
|
|
224
|
+
const existingRoute = webServer.exact?.get(OPEN_IN_APP_OPEN_PATH)
|
|
225
|
+
let originalExactHandler = undefined
|
|
226
|
+
if (existingRoute && typeof existingRoute.handler === 'function') {
|
|
227
|
+
originalExactHandler = existingRoute.handler
|
|
228
|
+
existingRoute.handler = wrapHandler(existingRoute.handler)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Wrap webServer.register to catch future /open-in-app/open registrations
|
|
232
|
+
const originalRegister = webServer.register.bind(webServer)
|
|
233
|
+
webServer.register = function (route) {
|
|
234
|
+
if (route?.kind === 'exact' && route?.path === OPEN_IN_APP_OPEN_PATH && typeof route.handler === 'function') {
|
|
235
|
+
route.handler = wrapHandler(route.handler)
|
|
236
|
+
}
|
|
237
|
+
return originalRegister(route)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return () => {
|
|
241
|
+
webServer.register = originalRegister
|
|
242
|
+
if (existingRoute && originalExactHandler) {
|
|
243
|
+
existingRoute.handler = originalExactHandler
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
package/package.json
CHANGED