@steerable/agent-shell 0.6.17 → 0.6.19
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/dist/local-backend/router.js +28 -3
- package/dist/local-executor.d.ts +29 -0
- package/dist/local-executor.js +81 -1
- package/dist/preload.cjs +4 -4
- package/dist/preload.js +4 -6
- package/dist/runtime.js +5 -1
- package/dist/server/http-server.d.ts +12 -3
- package/dist/server/http-server.js +34 -3
- package/dist/tool-router.js +15 -2
- package/package.json +4 -4
|
@@ -898,7 +898,19 @@ export class LocalBackendRouter {
|
|
|
898
898
|
}
|
|
899
899
|
}
|
|
900
900
|
if (method === 'DELETE' && pathname.startsWith('/api/v2/chat-agents/skills/delete/')) {
|
|
901
|
-
|
|
901
|
+
// URL.pathname 保留百分号编码——目录名/frontmatter 名含空格等字符时
|
|
902
|
+
// 客户端发来的是 fancy%20skill,不 decode 永远匹配不上 fancy skill
|
|
903
|
+
// (projects/mcp 路由段都 decode,这里对齐)。非法编码按 400 处理。
|
|
904
|
+
let skillName;
|
|
905
|
+
try {
|
|
906
|
+
skillName = decodeURIComponent(pathname.slice('/api/v2/chat-agents/skills/delete/'.length)).trim();
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
return {
|
|
910
|
+
status: 400,
|
|
911
|
+
data: { error: 'skillName is not valid percent-encoding' },
|
|
912
|
+
};
|
|
913
|
+
}
|
|
902
914
|
if (!skillName) {
|
|
903
915
|
return {
|
|
904
916
|
status: 400,
|
|
@@ -1743,6 +1755,14 @@ export class LocalBackendRouter {
|
|
|
1743
1755
|
// user+assistant pair onto history while the original (possibly bad)
|
|
1744
1756
|
// assistant reply stayed in place — the model saw both and had no real
|
|
1745
1757
|
// reason to answer differently.
|
|
1758
|
+
//
|
|
1759
|
+
// sidecar 门必须先于任何写操作:rerun 回合只能跑在 sidecar 上
|
|
1760
|
+
// (handleCoreLoopTurn 无 sidecar 直接 503)。若先截断再 503,旧回复
|
|
1761
|
+
// 已删、新回复不会产生、record 也不存在——非破坏性承诺破窗。
|
|
1762
|
+
if (!getSidecarSupervisor()) {
|
|
1763
|
+
emit(this.sse('error', { message: 'coreloop enabled but sidecar is not running' }));
|
|
1764
|
+
return { status: 503 };
|
|
1765
|
+
}
|
|
1746
1766
|
const targetMessageId = regenerateMatch[2];
|
|
1747
1767
|
// The user turn that prompted the target reply is whatever immediately
|
|
1748
1768
|
// precedes it — re-derive its text so buildConversationMessages gets
|
|
@@ -2897,9 +2917,14 @@ export class LocalBackendRouter {
|
|
|
2897
2917
|
onNotice: (kind, notice) => {
|
|
2898
2918
|
if (kind === 'budget_exhausted') {
|
|
2899
2919
|
// 预算维度(rounds/tokens)在 notice.budget;notice.kind 是信封
|
|
2900
|
-
// 类型(loop 事件 data 里的 budget 键经 sidecar 透传)。
|
|
2920
|
+
// 类型(loop 事件 data 里的 budget 键经 sidecar 透传)。message
|
|
2921
|
+
// 是客户端读取的人类可读原因(chat-transport 的 reason 字段)。
|
|
2901
2922
|
const budgetKind = typeof notice?.budget === 'string' ? notice.budget : undefined;
|
|
2902
|
-
emit(this.sseData({
|
|
2923
|
+
emit(this.sseData({
|
|
2924
|
+
type: 'budget_exhausted',
|
|
2925
|
+
budget: { kind: budgetKind },
|
|
2926
|
+
message: budgetKind ? `budget_exhausted: ${budgetKind}` : 'budget_exhausted',
|
|
2927
|
+
}));
|
|
2903
2928
|
}
|
|
2904
2929
|
},
|
|
2905
2930
|
onChildEvent: (event) => {
|
package/dist/local-executor.d.ts
CHANGED
|
@@ -30,12 +30,22 @@ export interface LocalFileReadRequest {
|
|
|
30
30
|
path: string;
|
|
31
31
|
encoding?: BufferEncoding;
|
|
32
32
|
maxSize?: number;
|
|
33
|
+
/** 1-based 起始行(CC Read 风格分页)。与 limit 配合做部分视图读取。 */
|
|
34
|
+
offset?: number;
|
|
35
|
+
/** 最多返回的行数(配合 offset 分页);缺省返回到文件尾。 */
|
|
36
|
+
limit?: number;
|
|
33
37
|
}
|
|
34
38
|
export interface LocalFileReadResult {
|
|
35
39
|
success: boolean;
|
|
36
40
|
content?: string;
|
|
37
41
|
/** 内容版本令牌(SHA-256)。读后写场景把它回传给 edit/write 做冲突检测。 */
|
|
38
42
|
version?: string;
|
|
43
|
+
/**
|
|
44
|
+
* 本次返回的只是部分视图(offset/limit 分页或超长裁剪)——模型没看到完整
|
|
45
|
+
* 内容,本会话对该路径的整体覆写将被拒绝(框架 partial_reads / CC
|
|
46
|
+
* isPartialView 对齐);定点修改走 local_edit_file 不受此限。
|
|
47
|
+
*/
|
|
48
|
+
partial?: boolean;
|
|
39
49
|
error?: string;
|
|
40
50
|
}
|
|
41
51
|
export interface LocalFileWriteRequest {
|
|
@@ -105,6 +115,18 @@ export interface LocalRunCodeResult extends LocalExecResult {
|
|
|
105
115
|
/** 本次调用实际安装成功的依赖。 */
|
|
106
116
|
installedPackages?: string[];
|
|
107
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* 部分视图裁剪(框架 `_clip` / CC Read 对齐)。version 永远基于完整内容计算,
|
|
120
|
+
* 部分视图的 CAS 令牌保持有效;返回的 partial 标记驱动整体覆写门。
|
|
121
|
+
*
|
|
122
|
+
* - 给了 offset/limit:按 1-based 行号切片,并在省略处打行数标记(含继续
|
|
123
|
+
* 分页的下一个 offset);
|
|
124
|
+
* - 没给但内容超长:头尾裁剪,中间打省略字符数标记。
|
|
125
|
+
*/
|
|
126
|
+
export declare function slicePartialView(content: string, offset?: number, limit?: number): {
|
|
127
|
+
content: string;
|
|
128
|
+
partial: boolean;
|
|
129
|
+
};
|
|
108
130
|
/** 内容版本令牌:UTF-8 内容的 SHA-256。用于 read-before-write 冲突检测。 */
|
|
109
131
|
/**
|
|
110
132
|
* read-before-write 冲突令牌:内容的 SHA-256(UTF-8)十六进制。与框架
|
|
@@ -167,6 +189,13 @@ export declare class LocalExecutor {
|
|
|
167
189
|
* 由 sidecar 的 `read_state.seed` 反向调用重灌(seedReadState)。
|
|
168
190
|
*/
|
|
169
191
|
private readonly readFileState;
|
|
192
|
+
/**
|
|
193
|
+
* 部分视图门(框架 partial_reads / CC isPartialView 对齐):本会话只读到
|
|
194
|
+
* 部分内容的分页/裁剪读取把路径登记在这里;writeLocalFile 拒绝整体覆写
|
|
195
|
+
* 未见全文的文件。完整读取或本会话自己的写/编辑成功后销记。会话恢复不
|
|
196
|
+
* 重灌此集合(与框架一致:resume 只重建 version 证据,门退化为 CAS)。
|
|
197
|
+
*/
|
|
198
|
+
private readonly partialReadPaths;
|
|
170
199
|
constructor(maxOutputBytes?: number, applyEditsFn?: ApplyEditsFn);
|
|
171
200
|
updateSafetyConfig(config: CommandSafetyConfigPayload): void;
|
|
172
201
|
/**
|
package/dist/local-executor.js
CHANGED
|
@@ -61,6 +61,55 @@ const NPM_PACKAGE_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+(@[A-Za-z0-9._~^<>=
|
|
|
61
61
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
62
62
|
const DEFAULT_MAX_OUTPUT_BYTES = 100 * 1024;
|
|
63
63
|
const DEFAULT_MAX_READ_SIZE = 1024 * 1024;
|
|
64
|
+
/**
|
|
65
|
+
* 单次读取返回给模型的字符上限(框架 workspace_tools `_MAX_OUTPUT` 对齐)。
|
|
66
|
+
* 超出部分头尾裁剪并打标记——保留尾部,日志/编译错误的关键行通常在末尾。
|
|
67
|
+
*/
|
|
68
|
+
const MAX_READ_OUTPUT_CHARS = 100_000;
|
|
69
|
+
/**
|
|
70
|
+
* 部分视图裁剪(框架 `_clip` / CC Read 对齐)。version 永远基于完整内容计算,
|
|
71
|
+
* 部分视图的 CAS 令牌保持有效;返回的 partial 标记驱动整体覆写门。
|
|
72
|
+
*
|
|
73
|
+
* - 给了 offset/limit:按 1-based 行号切片,并在省略处打行数标记(含继续
|
|
74
|
+
* 分页的下一个 offset);
|
|
75
|
+
* - 没给但内容超长:头尾裁剪,中间打省略字符数标记。
|
|
76
|
+
*/
|
|
77
|
+
export function slicePartialView(content, offset, limit) {
|
|
78
|
+
if (offset !== undefined || limit !== undefined) {
|
|
79
|
+
const start = offset ?? 1;
|
|
80
|
+
if (!Number.isInteger(start) || start < 1) {
|
|
81
|
+
throw new Error(`offset 必须是 >= 1 的整数(1-based 行号),收到 ${offset}`);
|
|
82
|
+
}
|
|
83
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
84
|
+
throw new Error(`limit 必须是 >= 1 的整数,收到 ${limit}`);
|
|
85
|
+
}
|
|
86
|
+
const lines = content.split('\n');
|
|
87
|
+
if (start > lines.length) {
|
|
88
|
+
throw new Error(`offset ${start} 超出文件行数(共 ${lines.length} 行)。`);
|
|
89
|
+
}
|
|
90
|
+
const end = limit === undefined ? lines.length : Math.min(lines.length, start - 1 + limit);
|
|
91
|
+
let view = lines.slice(start - 1, end).join('\n');
|
|
92
|
+
const omittedAbove = start - 1;
|
|
93
|
+
const omittedBelow = lines.length - end;
|
|
94
|
+
if (omittedAbove > 0) {
|
|
95
|
+
view = `...[{上文省略 ${omittedAbove} 行}]...\n${view}`;
|
|
96
|
+
}
|
|
97
|
+
if (omittedBelow > 0) {
|
|
98
|
+
view = `${view}\n...[{下文省略 ${omittedBelow} 行;用 offset=${end + 1} 继续}]...`;
|
|
99
|
+
}
|
|
100
|
+
return { content: view, partial: omittedAbove > 0 || omittedBelow > 0 };
|
|
101
|
+
}
|
|
102
|
+
if (content.length <= MAX_READ_OUTPUT_CHARS) {
|
|
103
|
+
return { content, partial: false };
|
|
104
|
+
}
|
|
105
|
+
const head = Math.floor(MAX_READ_OUTPUT_CHARS / 5);
|
|
106
|
+
const tail = MAX_READ_OUTPUT_CHARS - head;
|
|
107
|
+
const omitted = content.length - head - tail;
|
|
108
|
+
return {
|
|
109
|
+
content: `${content.slice(0, head)}\n...[{省略 ${omitted} 字符;用 offset/limit 分段读取}]...\n${content.slice(-tail)}`,
|
|
110
|
+
partial: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
64
113
|
/** 内容版本令牌:UTF-8 内容的 SHA-256。用于 read-before-write 冲突检测。 */
|
|
65
114
|
/**
|
|
66
115
|
* read-before-write 冲突令牌:内容的 SHA-256(UTF-8)十六进制。与框架
|
|
@@ -175,6 +224,13 @@ export class LocalExecutor {
|
|
|
175
224
|
* 由 sidecar 的 `read_state.seed` 反向调用重灌(seedReadState)。
|
|
176
225
|
*/
|
|
177
226
|
readFileState = new Map();
|
|
227
|
+
/**
|
|
228
|
+
* 部分视图门(框架 partial_reads / CC isPartialView 对齐):本会话只读到
|
|
229
|
+
* 部分内容的分页/裁剪读取把路径登记在这里;writeLocalFile 拒绝整体覆写
|
|
230
|
+
* 未见全文的文件。完整读取或本会话自己的写/编辑成功后销记。会话恢复不
|
|
231
|
+
* 重灌此集合(与框架一致:resume 只重建 version 证据,门退化为 CAS)。
|
|
232
|
+
*/
|
|
233
|
+
partialReadPaths = new Set();
|
|
178
234
|
constructor(maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, applyEditsFn = applyEdits) {
|
|
179
235
|
this.maxOutputBytes = maxOutputBytes;
|
|
180
236
|
this.applyEditsFn = applyEditsFn;
|
|
@@ -405,10 +461,19 @@ export class LocalExecutor {
|
|
|
405
461
|
return { success: false, error: `File too large (${fileStat.size} bytes), max=${maxSize}` };
|
|
406
462
|
}
|
|
407
463
|
const content = await readFile(filePath, { encoding });
|
|
464
|
+
// version 基于完整内容:部分视图的读证据 CAS 仍然有效(框架对齐)。
|
|
408
465
|
const version = hashContent(content);
|
|
409
466
|
// P2b: 记录读证据,后续 write/edit 缺省 expectedVersion 时自动 CAS。
|
|
410
467
|
this.readFileState.set(filePath, version);
|
|
411
|
-
|
|
468
|
+
const view = slicePartialView(content, request.offset, request.limit);
|
|
469
|
+
// 部分视图登记/销记:整体覆写门(writeLocalFile)据此拒绝未见全文的覆写。
|
|
470
|
+
if (view.partial) {
|
|
471
|
+
this.partialReadPaths.add(filePath);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
this.partialReadPaths.delete(filePath);
|
|
475
|
+
}
|
|
476
|
+
return { success: true, content: view.content, version, partial: view.partial };
|
|
412
477
|
}
|
|
413
478
|
catch (error) {
|
|
414
479
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -427,6 +492,17 @@ export class LocalExecutor {
|
|
|
427
492
|
return this.serializeFileOp(filePath, async () => {
|
|
428
493
|
try {
|
|
429
494
|
const encoding = request.encoding ?? 'utf-8';
|
|
495
|
+
// 部分视图门:本会话对该路径只读到分页/裁剪后的内容,整体覆写会销毁
|
|
496
|
+
// 未见内容(框架 partial_reads / CC isPartialView 对齐)。定点修改走
|
|
497
|
+
// local_edit_file;确需整体重写时先用 offset/limit 分段读完。
|
|
498
|
+
if (this.partialReadPaths.has(filePath)) {
|
|
499
|
+
return {
|
|
500
|
+
success: false,
|
|
501
|
+
error: `拒绝整体覆写:${filePath} 本会话只读到部分内容(分页/裁剪),` +
|
|
502
|
+
'覆写会销毁未见内容。请改用 local_edit_file 做定点修改;' +
|
|
503
|
+
'确需整体重写时先用 offset/limit 分段读完。',
|
|
504
|
+
};
|
|
505
|
+
}
|
|
430
506
|
// P2b 自动 CAS:调用方没给 expectedVersion 时回落到本会话的读证据
|
|
431
507
|
// (含 read_state.seed 重灌的)。隐式期望只对仍存在的文件生效——
|
|
432
508
|
// 读后被删除的文件重新写入是新建,没有可覆盖的他人改动。
|
|
@@ -453,6 +529,8 @@ export class LocalExecutor {
|
|
|
453
529
|
const version = hashContent(request.content);
|
|
454
530
|
// 写成功后回写读证据:本会话自己的连续写不互相冲突。
|
|
455
531
|
this.readFileState.set(filePath, version);
|
|
532
|
+
// 模型亲自写了全文,部分视图门不再适用(框架对齐)。
|
|
533
|
+
this.partialReadPaths.delete(filePath);
|
|
456
534
|
return { success: true, version };
|
|
457
535
|
}
|
|
458
536
|
catch (error) {
|
|
@@ -498,6 +576,8 @@ export class LocalExecutor {
|
|
|
498
576
|
await this.atomicWrite(filePath, result.content, encoding);
|
|
499
577
|
const version = hashContent(result.content);
|
|
500
578
|
this.readFileState.set(filePath, version);
|
|
579
|
+
// 注意:编辑不销记 partialReadPaths——定点编辑不代表模型见过全文,
|
|
580
|
+
// 后续盲写仍会销毁未见内容(框架「Cleared by a full read or a write」对齐)。
|
|
501
581
|
return {
|
|
502
582
|
success: true,
|
|
503
583
|
version,
|
package/dist/preload.cjs
CHANGED
|
@@ -122,14 +122,14 @@ function buildPreloadApi(packContributions = {}) {
|
|
|
122
122
|
},
|
|
123
123
|
/**
|
|
124
124
|
* 4.6a 后台任务状态推送:任务到达终态(completed/failed)或 worktree
|
|
125
|
-
* 合并/丢弃完成时,主进程广播 `task-updated`。载荷只带 chatId/taskId
|
|
126
|
-
*
|
|
125
|
+
* 合并/丢弃完成时,主进程广播 `task-updated`。载荷只带 chatId/taskId
|
|
126
|
+
* ——renderer 收到后重新拉任务列表(GET /chats/:id/tasks),
|
|
127
127
|
* 不把整条任务记录塞进 IPC(避免与 SQLite 双写漂移)。
|
|
128
128
|
*/
|
|
129
129
|
onTaskUpdated: (callback) => {
|
|
130
130
|
const handler = (_event, payload) => {
|
|
131
|
-
if (typeof payload?.chatId === "string" && typeof payload?.taskId === "string"
|
|
132
|
-
callback({ chatId: payload.chatId, taskId: payload.taskId
|
|
131
|
+
if (typeof payload?.chatId === "string" && typeof payload?.taskId === "string") {
|
|
132
|
+
callback({ chatId: payload.chatId, taskId: payload.taskId });
|
|
133
133
|
}
|
|
134
134
|
};
|
|
135
135
|
import_electron.ipcRenderer.on("task-updated", handler);
|
package/dist/preload.js
CHANGED
|
@@ -138,16 +138,14 @@ export function buildPreloadApi(packContributions = {}) {
|
|
|
138
138
|
},
|
|
139
139
|
/**
|
|
140
140
|
* 4.6a 后台任务状态推送:任务到达终态(completed/failed)或 worktree
|
|
141
|
-
* 合并/丢弃完成时,主进程广播 `task-updated`。载荷只带 chatId/taskId
|
|
142
|
-
*
|
|
141
|
+
* 合并/丢弃完成时,主进程广播 `task-updated`。载荷只带 chatId/taskId
|
|
142
|
+
* ——renderer 收到后重新拉任务列表(GET /chats/:id/tasks),
|
|
143
143
|
* 不把整条任务记录塞进 IPC(避免与 SQLite 双写漂移)。
|
|
144
144
|
*/
|
|
145
145
|
onTaskUpdated: (callback) => {
|
|
146
146
|
const handler = (_event, payload) => {
|
|
147
|
-
if (typeof payload?.chatId === 'string' &&
|
|
148
|
-
|
|
149
|
-
typeof payload?.status === 'string') {
|
|
150
|
-
callback({ chatId: payload.chatId, taskId: payload.taskId, status: payload.status });
|
|
147
|
+
if (typeof payload?.chatId === 'string' && typeof payload?.taskId === 'string') {
|
|
148
|
+
callback({ chatId: payload.chatId, taskId: payload.taskId });
|
|
151
149
|
}
|
|
152
150
|
};
|
|
153
151
|
ipcRenderer.on('task-updated', handler);
|
package/dist/runtime.js
CHANGED
|
@@ -124,8 +124,12 @@ export function resolveProductWebDist(entryUrl) {
|
|
|
124
124
|
const here = path.dirname(fileURLToPath(entryUrl));
|
|
125
125
|
const productId = path.basename(here);
|
|
126
126
|
for (const candidate of [
|
|
127
|
+
// 源码平面:入口在 products/<id>/(web dist 在同级 web/dist)。
|
|
127
128
|
path.join(here, 'web', 'dist'),
|
|
128
|
-
|
|
129
|
+
// 编译平面 A:入口在仓根 dist/products/<id>/(上溯 3 级到仓根)。
|
|
130
|
+
path.resolve(here, '..', '..', '..', 'products', productId, 'web', 'dist'),
|
|
131
|
+
// 编译平面 B:入口在产品内 products/<id>/dist/products/<id>/(上溯 5 级到仓根)。
|
|
132
|
+
path.resolve(here, '..', '..', '..', '..', '..', 'products', productId, 'web', 'dist'),
|
|
129
133
|
]) {
|
|
130
134
|
if (fs.existsSync(path.join(candidate, 'index.html')))
|
|
131
135
|
return candidate;
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
* (terminal / local / approval 为宿主自带;
|
|
13
13
|
* 场景包路由经 host/http-routes 注册表匹配)。
|
|
14
14
|
*
|
|
15
|
-
* 安全约定:默认绑定 127.0.0.1
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* 安全约定:默认绑定 127.0.0.1。仅绑回环挡不住 DNS rebinding(恶意网站
|
|
16
|
+
* 重绑定后受害者浏览器即同源驱动本服务),所以有两道门:
|
|
17
|
+
* 1. Host 白名单——所有请求(含静态)的 Host 必须是 127.0.0.1 /
|
|
18
|
+
* localhost / [::1],浏览器无法伪造 Host,rebinding 在此被拒;
|
|
19
|
+
* 2. Bearer token——每次启动随机生成,经 index.html 注入
|
|
20
|
+
* `window.__DEEPPATH_BS__.token`;`/api/v2/*` 与 `/host/*` 全部要求
|
|
21
|
+
* `Authorization: Bearer <token>`(EventSource 不能设头,SSE 走
|
|
22
|
+
* `?token=` query)。静态资源不验 token(浏览器要靠它拿到 token)。
|
|
23
|
+
* 不要绑到非回环地址,那会把 shell / 文件读写 / 场景包工具控制暴露给整个
|
|
24
|
+
* 网络(token 只能挡没有 token 的人)。
|
|
18
25
|
*/
|
|
19
26
|
import { type Server } from 'node:http';
|
|
20
27
|
import type { LocalBackendRouter } from '../local-backend/router.js';
|
|
@@ -35,5 +42,7 @@ export interface BsServerDeps {
|
|
|
35
42
|
bus: SseBus;
|
|
36
43
|
/** apps/web/dist 的绝对路径。 */
|
|
37
44
|
webDistDir: string;
|
|
45
|
+
/** Bearer token;缺省每次启动随机生成。测试注入固定值。 */
|
|
46
|
+
authToken?: string;
|
|
38
47
|
}
|
|
39
48
|
export declare function createBsServer(deps: BsServerDeps): Server;
|
|
@@ -12,11 +12,19 @@
|
|
|
12
12
|
* (terminal / local / approval 为宿主自带;
|
|
13
13
|
* 场景包路由经 host/http-routes 注册表匹配)。
|
|
14
14
|
*
|
|
15
|
-
* 安全约定:默认绑定 127.0.0.1
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* 安全约定:默认绑定 127.0.0.1。仅绑回环挡不住 DNS rebinding(恶意网站
|
|
16
|
+
* 重绑定后受害者浏览器即同源驱动本服务),所以有两道门:
|
|
17
|
+
* 1. Host 白名单——所有请求(含静态)的 Host 必须是 127.0.0.1 /
|
|
18
|
+
* localhost / [::1],浏览器无法伪造 Host,rebinding 在此被拒;
|
|
19
|
+
* 2. Bearer token——每次启动随机生成,经 index.html 注入
|
|
20
|
+
* `window.__DEEPPATH_BS__.token`;`/api/v2/*` 与 `/host/*` 全部要求
|
|
21
|
+
* `Authorization: Bearer <token>`(EventSource 不能设头,SSE 走
|
|
22
|
+
* `?token=` query)。静态资源不验 token(浏览器要靠它拿到 token)。
|
|
23
|
+
* 不要绑到非回环地址,那会把 shell / 文件读写 / 场景包工具控制暴露给整个
|
|
24
|
+
* 网络(token 只能挡没有 token 的人)。
|
|
18
25
|
*/
|
|
19
26
|
import { createServer } from 'node:http';
|
|
27
|
+
import { randomBytes } from 'node:crypto';
|
|
20
28
|
import { readFile } from 'node:fs/promises';
|
|
21
29
|
import { existsSync } from 'node:fs';
|
|
22
30
|
import path from 'node:path';
|
|
@@ -52,6 +60,8 @@ const MIME = {
|
|
|
52
60
|
'.ttf': 'font/ttf',
|
|
53
61
|
'.txt': 'text/plain; charset=utf-8',
|
|
54
62
|
};
|
|
63
|
+
/** Host 白名单:仅回环(可带端口)。浏览器禁止伪造 Host,rebinding 到此为止。 */
|
|
64
|
+
const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;
|
|
55
65
|
function sendJson(res, status, data) {
|
|
56
66
|
const body = JSON.stringify(data ?? null);
|
|
57
67
|
res.writeHead(status, {
|
|
@@ -81,6 +91,13 @@ function asRecord(body) {
|
|
|
81
91
|
}
|
|
82
92
|
export function createBsServer(deps) {
|
|
83
93
|
const { localBackendRouter, localExecutor, localScriptRegistry, terminalManager, approvalBridge, askUserBridge, maybeExecInTerminal, bus, webDistDir, } = deps;
|
|
94
|
+
const authToken = deps.authToken ?? randomBytes(24).toString('base64url');
|
|
95
|
+
/** `/api/v2/*` 与 `/host/*` 的 Bearer 门;EventSource 不能设头,收 query token。 */
|
|
96
|
+
function isAuthorized(req, url) {
|
|
97
|
+
if (req.headers.authorization === `Bearer ${authToken}`)
|
|
98
|
+
return true;
|
|
99
|
+
return url.searchParams.get('token') === authToken;
|
|
100
|
+
}
|
|
84
101
|
async function handleApi(req, res, pathname) {
|
|
85
102
|
const isStream = req.method === 'POST' && STREAM_PATH_PATTERNS.some((p) => p.test(pathname));
|
|
86
103
|
const body = req.method === 'POST' || req.method === 'PATCH' || req.method === 'PUT'
|
|
@@ -315,6 +332,7 @@ export function createBsServer(deps) {
|
|
|
315
332
|
platform: process.platform,
|
|
316
333
|
flavor: brand.flavor,
|
|
317
334
|
brandName: brand.displayName,
|
|
335
|
+
token: authToken,
|
|
318
336
|
})};</script>`;
|
|
319
337
|
content = content.toString('utf8').replace('<head>', `<head>${bootstrap}`);
|
|
320
338
|
}
|
|
@@ -331,8 +349,21 @@ export function createBsServer(deps) {
|
|
|
331
349
|
}
|
|
332
350
|
return createServer((req, res) => {
|
|
333
351
|
void (async () => {
|
|
352
|
+
// 门 1:Host 白名单(含静态——rebound 浏览器连 index.html 里的
|
|
353
|
+
// token 都不许拿到)。
|
|
354
|
+
if (!LOOPBACK_HOST.test(req.headers.host ?? '')) {
|
|
355
|
+
sendJson(res, 403, { detail: 'forbidden host' });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
334
358
|
const url = new URL(req.url ?? '/', 'http://bs.local');
|
|
335
359
|
const pathname = url.pathname;
|
|
360
|
+
// 门 2:API 与宿主能力要 Bearer token;静态资源不验(浏览器靠它
|
|
361
|
+
// 拿 token),静态面不暴露任何能力。
|
|
362
|
+
const needsAuth = pathname.startsWith('/api/v2/') || pathname.startsWith('/host/');
|
|
363
|
+
if (needsAuth && !isAuthorized(req, url)) {
|
|
364
|
+
sendJson(res, 401, { detail: 'unauthorized' });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
336
367
|
if (pathname === '/api/v2/events' && req.method === 'GET') {
|
|
337
368
|
bus.attach(res);
|
|
338
369
|
return;
|
package/dist/tool-router.js
CHANGED
|
@@ -125,11 +125,24 @@ export class ToolRouter {
|
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
127
|
name: 'local_read_file',
|
|
128
|
-
description: 'Read a local text file'
|
|
128
|
+
description: 'Read a local text file. Large files can be paged with offset/limit (1-based line numbers); ' +
|
|
129
|
+
'a paged or clipped result is marked `partial: true`, and a full-file overwrite of a path ' +
|
|
130
|
+
'only partially read in this session is rejected — use local_edit_file for targeted changes, ' +
|
|
131
|
+
'or finish reading all segments before rewriting.',
|
|
129
132
|
mode: 'read',
|
|
130
133
|
inputSchema: {
|
|
131
134
|
type: 'object',
|
|
132
|
-
properties: {
|
|
135
|
+
properties: {
|
|
136
|
+
path: { type: 'string' },
|
|
137
|
+
offset: {
|
|
138
|
+
type: 'number',
|
|
139
|
+
description: 'Optional 1-based line number to start reading from (for paging large files).',
|
|
140
|
+
},
|
|
141
|
+
limit: {
|
|
142
|
+
type: 'number',
|
|
143
|
+
description: 'Optional maximum number of lines to return (for paging large files).',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
133
146
|
required: ['path'],
|
|
134
147
|
},
|
|
135
148
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steerable/agent-shell",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.19",
|
|
4
4
|
"description": "Steerable framework — product-neutral desktop/headless agent host (Tier 5). Electron main + preload + headless HTTP server (BS) sharing one storage/sidecar/tooling core; scenario packs extend it through the @steerable/pack-sdk contract and are composed at build time by the consuming product.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://steerableframework.com/",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"electron-store": "^8.1.0",
|
|
42
42
|
"he": "^1.2.0",
|
|
43
43
|
"node-pty": "^1.1.0",
|
|
44
|
-
"@steerable/agent-harness": "0.6.
|
|
45
|
-
"@steerable/
|
|
46
|
-
"@steerable/
|
|
44
|
+
"@steerable/agent-harness": "0.6.19",
|
|
45
|
+
"@steerable/agent-protocol": "0.6.19",
|
|
46
|
+
"@steerable/pack-sdk": "0.6.19"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/better-sqlite3": "^7.6.13",
|