dsh-yolo-mode 0.4.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/CHANGELOG.md +62 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/lib/bridge-entry.js +51 -0
- package/lib/client/index.js +1268 -0
- package/lib/index.js +306 -0
- package/lib/judge.js +240 -0
- package/lib/policy.js +363 -0
- package/lib/remote.js +489 -0
- package/lib/settings.js +102 -0
- package/lib/state.js +92 -0
- package/package.json +73 -0
package/lib/remote.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-yolo-mode —— 自发布设置桥(lib/remote.js)
|
|
3
|
+
*
|
|
4
|
+
* 镜像参考项目 dsh-subagents-options/src/envelope.ts + src/remote.ts:
|
|
5
|
+
* 宿主在 webServer 上注册 `/yolo-mode` 前缀路由(kind:'prefix', path),绕过
|
|
6
|
+
* apiproxy 的 exposedNamespaces() 白名单(树外插件无法往该名单加命名空间),
|
|
7
|
+
* 直接用 cordis `inject` 拿到的 `settings` 读写自身 `yolo-mode` 命名空间。
|
|
8
|
+
*
|
|
9
|
+
* 线协议镜像 Connection RPC 通道(dsh-client-connection lib/index.js 275-328):
|
|
10
|
+
* - 非 POST → 404
|
|
11
|
+
* - content-type 非 json → 415
|
|
12
|
+
* - Host 非 loopback → 403(轻量信任围栏)
|
|
13
|
+
* - body 非法 JSON → 400
|
|
14
|
+
* - 信封非法 → 200 bad-request(固定 rpcId 'invalid-request')
|
|
15
|
+
* - method 与端点不符 → 200 bad-request
|
|
16
|
+
* - dispatch 抛错 → 500(headersSent 则 destroy)
|
|
17
|
+
* 响应一律 `RpcResult` 信封({ rpcId, result: { ok, value|error } })。
|
|
18
|
+
*
|
|
19
|
+
* 三个端点:
|
|
20
|
+
* settingsView → { writable, view }
|
|
21
|
+
* settingsMutate → 应用带 expectedRevision 乐观锁的路径 op,返回新 redacted view;
|
|
22
|
+
* 冲突 → settings-conflict(含 expected/actual),其余拒绝 → settings-rejected。
|
|
23
|
+
* statusView → getStatusPayload()(本插件扩展;不触碰 settings)。
|
|
24
|
+
*
|
|
25
|
+
* 纯映射 / 线信封辅助在顶部(无 cordis),可在普通 node 环境单测。
|
|
26
|
+
*
|
|
27
|
+
* @module lib/remote.js
|
|
28
|
+
*/
|
|
29
|
+
import {
|
|
30
|
+
SettingsConflictError,
|
|
31
|
+
settingsNamespace,
|
|
32
|
+
} from '@deepseek-ai/dsh-settings';
|
|
33
|
+
import { YOLO_SETTINGS_NAMESPACE } from './settings.js';
|
|
34
|
+
|
|
35
|
+
/** 桥接通道绝对前缀路由(design.md §12.4)。 */
|
|
36
|
+
export const YOLO_RPC_CHANNEL = '/yolo-mode';
|
|
37
|
+
/** settingsView 端点。 */
|
|
38
|
+
export const YOLO_RPC_VIEW = 'settingsView';
|
|
39
|
+
/** settingsMutate 端点。 */
|
|
40
|
+
export const YOLO_RPC_MUTATE = 'settingsMutate';
|
|
41
|
+
/** statusView 端点(本插件扩展)。 */
|
|
42
|
+
export const YOLO_RPC_STATUS = 'statusView';
|
|
43
|
+
|
|
44
|
+
/** 顶层校验失败时宿主用的固定 rpcId(镜像 invalidEnvelopeResponse)。 */
|
|
45
|
+
export const INVALID_REQUEST_RPC_ID = 'invalid-request';
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------ *
|
|
48
|
+
* 线信封辅助(纯函数,无 cordis;镜像 src/envelope.ts)
|
|
49
|
+
* ------------------------------------------------------------------ */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 解析并校验一个解码后的 JSON body 是否为 "client-request" 信封。
|
|
53
|
+
* 仅接受 type 恰为 "client-request"、rpcId/method 为字符串的对象。
|
|
54
|
+
* @param {unknown} body
|
|
55
|
+
* @returns {{ok:true, envelope:{type:'client-request',rpcId:string,method:string,payload:unknown}}
|
|
56
|
+
* |{ok:false, issues:string[]}}
|
|
57
|
+
*/
|
|
58
|
+
export function parseClientRequestEnvelope(body) {
|
|
59
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
60
|
+
return { ok: false, issues: ['body must be a JSON object'] };
|
|
61
|
+
}
|
|
62
|
+
const record = body;
|
|
63
|
+
const issues = [];
|
|
64
|
+
if (record.type !== 'client-request') issues.push('type must equal "client-request"');
|
|
65
|
+
if (typeof record.rpcId !== 'string') issues.push('rpcId must be a string');
|
|
66
|
+
if (typeof record.method !== 'string') issues.push('method must be a string');
|
|
67
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
envelope: {
|
|
71
|
+
type: 'client-request',
|
|
72
|
+
rpcId: record.rpcId,
|
|
73
|
+
method: record.method,
|
|
74
|
+
payload: record.payload,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 构造请求 rpcId 的 "server-response" 信封,携带一个 RpcResult。
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {string} rpcId
|
|
83
|
+
* @param {{ok:boolean, value?:T, error?:object}} result
|
|
84
|
+
* @returns {{type:'server-response', rpcId:string, result:object}}
|
|
85
|
+
*/
|
|
86
|
+
export function buildServerResponse(rpcId, result) {
|
|
87
|
+
return { type: 'server-response', rpcId, result };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 构造坏请求 "server-response":对非法 client-request 信封,宿主无法信任调用方
|
|
92
|
+
* rpcId,故用固定 INVALID_REQUEST_RPC_ID(镜像 invalidEnvelopeResponse)。
|
|
93
|
+
* @param {readonly string[]} issues
|
|
94
|
+
* @returns {{type:'server-response', rpcId:string, result:object}}
|
|
95
|
+
*/
|
|
96
|
+
export function buildBadRequestResponse(issues) {
|
|
97
|
+
const error = {
|
|
98
|
+
code: 'bad-request',
|
|
99
|
+
message: 'invalid client-request message',
|
|
100
|
+
details: { issues: [] },
|
|
101
|
+
};
|
|
102
|
+
return buildServerResponse(INVALID_REQUEST_RPC_ID, { ok: false, error });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 构造 method 与端点不符的 "server-response"(镜像 289-293)。
|
|
107
|
+
* @param {string} rpcId
|
|
108
|
+
* @param {unknown} method
|
|
109
|
+
* @param {string} endpoint
|
|
110
|
+
* @returns {{type:'server-response', rpcId:string, result:object}}
|
|
111
|
+
*/
|
|
112
|
+
export function buildMethodMismatchResponse(rpcId, method, endpoint) {
|
|
113
|
+
const error = {
|
|
114
|
+
code: 'bad-request',
|
|
115
|
+
message: 'method ' + JSON.stringify(method) + ' does not match endpoint ' + JSON.stringify(endpoint),
|
|
116
|
+
details: { issues: [] },
|
|
117
|
+
};
|
|
118
|
+
return buildServerResponse(rpcId, { ok: false, error });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const IPV4_OCTET = /^\d{1,3}$/;
|
|
122
|
+
|
|
123
|
+
/** 最小 127/8 IPv4 段常量。 */
|
|
124
|
+
const IPV4_LOOPBACK = '127';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* WHATWG URL hostname 是否命名本机回环权威。
|
|
128
|
+
* 接受 "localhost"、"[::1]"(保留括号)或 127/8 任意 IPv4。
|
|
129
|
+
* @param {string} hostname
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
export function isLoopbackHostname(hostname) {
|
|
133
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true;
|
|
134
|
+
const parts = hostname.split('.');
|
|
135
|
+
return (
|
|
136
|
+
parts.length === 4 &&
|
|
137
|
+
parts[0] === IPV4_LOOPBACK &&
|
|
138
|
+
parts.every((part) => IPV4_OCTET.test(part) && Number(part) <= 255)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 将原始 Host 头分类为 loopback。剥离端口后缀并括号归一化权威后再判定。
|
|
144
|
+
* 无端口时视为裸主机名。
|
|
145
|
+
* @param {string} rawHost
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
export function isLoopbackHost(rawHost) {
|
|
149
|
+
let host = typeof rawHost === 'string' ? rawHost.trim() : '';
|
|
150
|
+
if (host.length === 0) return false;
|
|
151
|
+
if (host.startsWith('[')) {
|
|
152
|
+
const close = host.indexOf(']');
|
|
153
|
+
if (close === -1) return false;
|
|
154
|
+
host = host.slice(0, close + 1);
|
|
155
|
+
} else {
|
|
156
|
+
const colon = host.indexOf(':');
|
|
157
|
+
if (colon !== -1) host = host.slice(0, colon);
|
|
158
|
+
}
|
|
159
|
+
return isLoopbackHostname(host);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 从通道前缀下派生端点段。路径必须为 "<channel>/<单一合法段>"。
|
|
164
|
+
* 合法端点=非空、不含 "/"、"."、".." 遍历段。
|
|
165
|
+
* @param {string} channel 如 '/yolo-mode'
|
|
166
|
+
* @param {string} pathname 如 '/yolo-mode/settingsView'
|
|
167
|
+
* @returns {string|undefined}
|
|
168
|
+
*/
|
|
169
|
+
export function endpointFromPath(channel, pathname) {
|
|
170
|
+
if (!pathname.startsWith(channel + '/')) return undefined;
|
|
171
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
172
|
+
if (endpoint.length === 0) return undefined;
|
|
173
|
+
if (endpoint.split('/').some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return endpoint;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ------------------------------------------------------------------ *
|
|
180
|
+
* 命名空间视图映射(镜像 src/remote.ts toDirectorNamespaceView / pick)
|
|
181
|
+
* ------------------------------------------------------------------ */
|
|
182
|
+
|
|
183
|
+
/** 把 describe 的一个设置描述符映射为线视图。 */
|
|
184
|
+
function toNamespaceView(descriptor) {
|
|
185
|
+
return {
|
|
186
|
+
ns: String(descriptor.ns),
|
|
187
|
+
schema: descriptor.schema,
|
|
188
|
+
value: descriptor.value,
|
|
189
|
+
...(descriptor.base === undefined ? {} : { base: descriptor.base }),
|
|
190
|
+
...(descriptor.user === undefined ? {} : { user: descriptor.user }),
|
|
191
|
+
applies: descriptor.applies,
|
|
192
|
+
secrets: (descriptor.secrets ?? []).map((secret) => ({
|
|
193
|
+
path: [...secret.path],
|
|
194
|
+
set: secret.set,
|
|
195
|
+
})),
|
|
196
|
+
revision: descriptor.revision,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 在 redacted describe 结果中挑出 yolo-mode 命名空间视图;未注册则 undefined。 */
|
|
201
|
+
export function pickYoloNamespaceView(descriptors) {
|
|
202
|
+
for (const descriptor of descriptors) {
|
|
203
|
+
if (descriptor.ns === YOLO_SETTINGS_NAMESPACE) {
|
|
204
|
+
return toNamespaceView(descriptor);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ------------------------------------------------------------------ *
|
|
211
|
+
* settings 维度映射(镜像 src/remote.ts directorRejected / directorConflict / directorMutate)
|
|
212
|
+
* ------------------------------------------------------------------ */
|
|
213
|
+
|
|
214
|
+
/** 构造 settings-rejected RPC 错误。 */
|
|
215
|
+
function yoloRejected(ns, error) {
|
|
216
|
+
return {
|
|
217
|
+
code: 'settings-rejected',
|
|
218
|
+
message: error instanceof Error ? error.message : String(error),
|
|
219
|
+
details: { ns },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 构造 settings-conflict RPC 错误(镜像 apiproxy 映射)。 */
|
|
224
|
+
function yoloConflict(conflict) {
|
|
225
|
+
const structural = conflict;
|
|
226
|
+
const ns = structural.ns;
|
|
227
|
+
return {
|
|
228
|
+
code: 'settings-conflict',
|
|
229
|
+
message: conflict.message,
|
|
230
|
+
details: {
|
|
231
|
+
ns: ns === undefined ? 'yolo-mode' : String(ns),
|
|
232
|
+
expected: conflict.expected,
|
|
233
|
+
actual: conflict.actual,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 对 settings seam 执行一次路径 op 变更并映射为 RpcResult(携带新 redacted view,
|
|
240
|
+
* 或 settings-conflict / settings-rejected 错误)。纯函数可注入 mutate/describe 测试。
|
|
241
|
+
* @param {Function} mutate settings.mutate 绑定
|
|
242
|
+
* @param {Function} describe settings.describe 绑定
|
|
243
|
+
* @param {string} ns 命名空间字符串
|
|
244
|
+
* @param {Array<{op:'set'|'unset', path:string[], value?:unknown}>} ops
|
|
245
|
+
* @param {number|undefined} expectedRevision
|
|
246
|
+
* @returns {Promise<{ok:boolean, value?:object, error?:object}>}
|
|
247
|
+
*/
|
|
248
|
+
export async function yoloMutate(mutate, describe, ns, ops, expectedRevision) {
|
|
249
|
+
const branded = settingsNamespace(ns);
|
|
250
|
+
try {
|
|
251
|
+
await mutate(branded, ops, expectedRevision);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof SettingsConflictError) {
|
|
254
|
+
return { ok: false, error: yoloConflict(error) };
|
|
255
|
+
}
|
|
256
|
+
return { ok: false, error: yoloRejected(ns, error) };
|
|
257
|
+
}
|
|
258
|
+
const descriptor = describe({ redactSecrets: true }).find(
|
|
259
|
+
(candidate) => candidate.ns === branded,
|
|
260
|
+
);
|
|
261
|
+
if (descriptor === undefined) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: {
|
|
265
|
+
code: 'internal',
|
|
266
|
+
message: 'settings namespace "' + ns + '" was disposed after the mutate',
|
|
267
|
+
details: {},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return { ok: true, value: toNamespaceView(descriptor) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ------------------------------------------------------------------ *
|
|
275
|
+
* 请求处理器(镜像 src/remote.ts handleDirectorBridgeRequest + dispatch)
|
|
276
|
+
* ------------------------------------------------------------------ */
|
|
277
|
+
|
|
278
|
+
/** HTTP body 读取上限(design.md §11.1,64KB)。 */
|
|
279
|
+
const BODY_LIMIT = 64 * 1024;
|
|
280
|
+
|
|
281
|
+
/** 读 IncomingMessage body 至多 limit 字节;超出抛 {code:'LIMIT'}。 */
|
|
282
|
+
function readRequestBody(req, limit) {
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
const chunks = [];
|
|
285
|
+
let size = 0;
|
|
286
|
+
let done = false;
|
|
287
|
+
const fail = (err) => {
|
|
288
|
+
if (done) return;
|
|
289
|
+
done = true;
|
|
290
|
+
reject(err);
|
|
291
|
+
};
|
|
292
|
+
req.on('data', (chunk) => {
|
|
293
|
+
if (done) return;
|
|
294
|
+
const buf = Buffer.isBuffer(chunk)
|
|
295
|
+
? chunk
|
|
296
|
+
: Buffer.from(typeof chunk === 'string' ? chunk : String(chunk), 'utf8');
|
|
297
|
+
size += buf.length;
|
|
298
|
+
if (size > limit) {
|
|
299
|
+
fail(Object.assign(new Error('request body too large'), { code: 'LIMIT' }));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
chunks.push(buf);
|
|
303
|
+
});
|
|
304
|
+
req.on('end', () => {
|
|
305
|
+
if (done) return;
|
|
306
|
+
done = true;
|
|
307
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
308
|
+
});
|
|
309
|
+
req.on('error', (err) => fail(err));
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** pathname 部分(安全传给 endpointFromPath)。 */
|
|
314
|
+
function pathnameOf(rawUrl) {
|
|
315
|
+
try {
|
|
316
|
+
return new URL(rawUrl, 'http://dsh.internal').pathname;
|
|
317
|
+
} catch {
|
|
318
|
+
return rawUrl;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 纯文本响应。 */
|
|
323
|
+
function sendPlain(res, status, text) {
|
|
324
|
+
res.statusCode = status;
|
|
325
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
326
|
+
res.end(text);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** JSON 响应。 */
|
|
330
|
+
function sendJson(res, status, payload) {
|
|
331
|
+
res.statusCode = status;
|
|
332
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
333
|
+
res.end(JSON.stringify(payload));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 分派一个已校验端点。未知端点抛错(由调用方兜底为 500)。
|
|
338
|
+
* @param {object} settings settings 服务对象
|
|
339
|
+
* @param {() => object} getStatusPayload statusView 载荷装配(由桥接条目注入)
|
|
340
|
+
* @param {string} endpoint
|
|
341
|
+
* @param {unknown} payload
|
|
342
|
+
* @returns {Promise<{ok:boolean, value?:unknown, error?:object}>}
|
|
343
|
+
*/
|
|
344
|
+
async function dispatchYoloEndpoint(settings, getStatusPayload, endpoint, payload) {
|
|
345
|
+
if (endpoint === YOLO_RPC_VIEW) {
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
value: {
|
|
349
|
+
writable: settings.writable,
|
|
350
|
+
view: pickYoloNamespaceView(settings.describe({ redactSecrets: true })),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (endpoint === YOLO_RPC_STATUS) {
|
|
355
|
+
return { ok: true, value: getStatusPayload() };
|
|
356
|
+
}
|
|
357
|
+
if (endpoint === YOLO_RPC_MUTATE) {
|
|
358
|
+
const request = payload ?? null;
|
|
359
|
+
if (request === null || typeof request !== 'object' || request.ns !== String(YOLO_SETTINGS_NAMESPACE)) {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: {
|
|
363
|
+
code: 'bad-request',
|
|
364
|
+
message: 'settingsMutate: expected ns "' + String(YOLO_SETTINGS_NAMESPACE) + '"',
|
|
365
|
+
details: { issues: [] },
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const ops = Array.isArray(request.ops) ? request.ops : [];
|
|
370
|
+
return yoloMutate(
|
|
371
|
+
(n, o, r) => settings.mutate(n, o, r),
|
|
372
|
+
(opts) => settings.describe(opts),
|
|
373
|
+
String(YOLO_SETTINGS_NAMESPACE),
|
|
374
|
+
ops,
|
|
375
|
+
typeof request.expectedRevision === 'number' ? request.expectedRevision : undefined,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
throw new Error('unknown bridge endpoint ' + JSON.stringify(endpoint));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* `/yolo-mode` 前缀路由处理器(node:http 原语)。拥有完整响应生命周期。
|
|
383
|
+
* @param {object} settings settings 服务对象(注入)
|
|
384
|
+
* @param {() => object} getStatusPayload statusView 载荷装配(由桥接条目注入)
|
|
385
|
+
* @param {import('node:http').IncomingMessage} req
|
|
386
|
+
* @param {import('node:http').ServerResponse} res
|
|
387
|
+
* @returns {Promise<void>}
|
|
388
|
+
*/
|
|
389
|
+
export async function handleYoloBridgeRequest(settings, getStatusPayload, req, res) {
|
|
390
|
+
const rawUrl = typeof req.url === 'string' ? req.url : '/';
|
|
391
|
+
const hostHeader = req.headers.host;
|
|
392
|
+
|
|
393
|
+
if (typeof req.method !== 'string') {
|
|
394
|
+
sendPlain(res, 404, 'not found');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// 非 POST → 404。
|
|
398
|
+
if (req.method !== 'POST') {
|
|
399
|
+
sendPlain(res, 404, 'not found');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// content-type 必须 application/json → 415。
|
|
403
|
+
const contentType = (req.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase();
|
|
404
|
+
if (contentType !== 'application/json') {
|
|
405
|
+
sendPlain(res, 415, 'content type must be application/json');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Host 非 loopback → 403(轻量信任围栏)。
|
|
409
|
+
if (hostHeader === undefined || !isLoopbackHost(hostHeader)) {
|
|
410
|
+
sendPlain(res, 403, 'forbidden');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// 读 body → 400 非法 JSON / 413 超长。
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = await readRequestBody(req, BODY_LIMIT);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (err && err.code === 'LIMIT') {
|
|
419
|
+
sendPlain(res, 413, 'request body too large');
|
|
420
|
+
} else {
|
|
421
|
+
// body 读取错误 → 400。
|
|
422
|
+
sendPlain(res, 400, 'body is not JSON');
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let body;
|
|
427
|
+
try {
|
|
428
|
+
body = raw.length === 0 ? {} : JSON.parse(raw);
|
|
429
|
+
} catch {
|
|
430
|
+
sendPlain(res, 400, 'body is not JSON');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// 信封校验 → 200 bad-request。
|
|
434
|
+
const parsed = parseClientRequestEnvelope(body);
|
|
435
|
+
if (!parsed.ok) {
|
|
436
|
+
sendJson(res, 200, buildBadRequestResponse(parsed.issues));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const envelope = parsed.envelope;
|
|
440
|
+
// method 必须匹配路径派生端点。
|
|
441
|
+
const endpoint = endpointFromPath(YOLO_RPC_CHANNEL, pathnameOf(rawUrl));
|
|
442
|
+
if (endpoint === undefined || envelope.method !== endpoint) {
|
|
443
|
+
sendJson(
|
|
444
|
+
res,
|
|
445
|
+
200,
|
|
446
|
+
buildMethodMismatchResponse(envelope.rpcId, envelope.method, endpoint ?? '(invalid path)'),
|
|
447
|
+
);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// 分派。
|
|
451
|
+
const result = await dispatchYoloEndpoint(settings, getStatusPayload, endpoint, envelope.payload);
|
|
452
|
+
sendJson(res, 200, buildServerResponse(envelope.rpcId, result));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 在宿主 webServer 上安装 `/yolo-mode` 前缀路由。惰性读取 webServer/settings;
|
|
457
|
+
* 服务缺失时打 debug 日志并安装空操作(不抛错)。返回 disposer。
|
|
458
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
459
|
+
* @param {object} settings settings 服务对象(注入)
|
|
460
|
+
* @param {() => object} getStatusPayload statusView 载荷装配(注入)
|
|
461
|
+
* @returns {() => void} 卸载时反注册路由
|
|
462
|
+
*/
|
|
463
|
+
export function installYoloRemoteBridge(ctx, settings, getStatusPayload) {
|
|
464
|
+
const webServer = ctx.get('webServer');
|
|
465
|
+
if (settings === undefined || webServer === undefined) {
|
|
466
|
+
ctx.logger.debug(
|
|
467
|
+
'[yolo-mode] settings bridge not installed ' +
|
|
468
|
+
'(settings:' + String(settings !== undefined) +
|
|
469
|
+
', webServer:' + String(webServer !== undefined) + ')',
|
|
470
|
+
);
|
|
471
|
+
return () => {};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const handler = (req, res) => {
|
|
475
|
+
handleYoloBridgeRequest(settings, getStatusPayload, req, res).catch((error) => {
|
|
476
|
+
// 分派抛错(未知端点或 settings seam 爆掉)→ 500;headersSent 则 destroy。
|
|
477
|
+
if (res.headersSent) {
|
|
478
|
+
res.destroy();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
sendPlain(res, 500, 'handler failure: ' + String(error));
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
return ctx.effect(
|
|
486
|
+
() => webServer.register({ kind: 'prefix', path: YOLO_RPC_CHANNEL, handler }),
|
|
487
|
+
'yolo-mode: settings bridge route',
|
|
488
|
+
);
|
|
489
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-yolo-mode —— 宿主设置分区(lib/settings.js)
|
|
3
|
+
*
|
|
4
|
+
* 镜像参考项目 dsh-subagents-options/src/settings.ts:注册 settings 命名空间
|
|
5
|
+
* `yolo-mode`,用 schemastery 宽松 schema 解析分区值,写时校验委托
|
|
6
|
+
* ./policy.js 的 `normalizeConfig`(fail-loud 抛错即拒绝写入)。
|
|
7
|
+
*
|
|
8
|
+
* 分层层(design.md §12.3):插件行 config 作为 settings 的 `base` 层,
|
|
9
|
+
* 用户文档写在上层;`installYoloSettings` 走 `installSettingsSection` 的
|
|
10
|
+
* 可选-settings 消费者接线,部署无 settings 服务时打 debug 日志跳过(零侵入)。
|
|
11
|
+
*
|
|
12
|
+
* 纯 JavaScript(ESM),宿主代码仅 import node: 内置与同包 peer
|
|
13
|
+
* (@deepseek-ai/dsh-settings、@deepseek-ai/schemastery)。
|
|
14
|
+
*
|
|
15
|
+
* @module lib/settings.js
|
|
16
|
+
*/
|
|
17
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
18
|
+
import z from '@deepseek-ai/schemastery';
|
|
19
|
+
import { normalizeConfig } from './policy.js';
|
|
20
|
+
|
|
21
|
+
/** settings 命名空间:`yolo-mode`(与插件行 id 一致,纯小写 kebab-case)。 */
|
|
22
|
+
export const YOLO_SETTINGS_NAMESPACE = settingsNamespace('yolo-mode');
|
|
23
|
+
|
|
24
|
+
/** 预设枚举(与 ./policy.js PRSESTS 保持一致,供 union schema 使用)。 */
|
|
25
|
+
const PRESETS = ['off', 'strict', 'balanced', 'permissive', 'yolo', 'custom'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* schemastery 宽松 schema:全字段可选(object 字段默认可选),
|
|
29
|
+
* 使未写入的空白分区解析为空对象,绝不泄漏默认值覆盖插件行 config。
|
|
30
|
+
* 真正的形状/语义校验由写时 `validateYoloSettings = normalizeConfig` 完成(fail-loud)。
|
|
31
|
+
*/
|
|
32
|
+
export const YoloSettingsSchema = z.object({
|
|
33
|
+
preset: z.union([...PRESETS]).default('balanced'),
|
|
34
|
+
modes: z.array(z.string()),
|
|
35
|
+
levels: z.dict(z.any()),
|
|
36
|
+
judge: z.object({
|
|
37
|
+
provider: z.string(),
|
|
38
|
+
model: z.string(),
|
|
39
|
+
systemPrompt: z.string(),
|
|
40
|
+
timeoutMs: z.natural(),
|
|
41
|
+
maxTokens: z.natural(),
|
|
42
|
+
concurrency: z.natural(),
|
|
43
|
+
}),
|
|
44
|
+
includeSubagents: z.boolean(),
|
|
45
|
+
auditFile: z.string(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 剔除 schema 解析出的"空集合默认值"(schemastery 对 array/dict 缺省解析为
|
|
50
|
+
* []/{},标量缺省为 absent),避免把这些默认空值当成用户配置喂给
|
|
51
|
+
* normalizeConfig(其会 fail-loud 拒绝空 modes 数组)。供 validate 钩子与
|
|
52
|
+
* 主条目 effectiveConfig 复用。
|
|
53
|
+
* @param {object} value 解析值
|
|
54
|
+
* @returns {object} 仅含非空字段的浅拷贝
|
|
55
|
+
*/
|
|
56
|
+
export function pruneEmpty(value) {
|
|
57
|
+
const out = {};
|
|
58
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return out;
|
|
59
|
+
for (const key of Object.keys(value)) {
|
|
60
|
+
const v = value[key];
|
|
61
|
+
if (v === undefined || v === null) continue;
|
|
62
|
+
if (Array.isArray(v)) {
|
|
63
|
+
if (v.length > 0) out[key] = v;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (typeof v === 'object') {
|
|
67
|
+
if (Object.keys(v).length > 0) out[key] = v;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
out[key] = v;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 分区写时/注册时校验:剔除空默认值后 = `normalizeConfig`(design.md §12.2)。
|
|
77
|
+
* 任何非法值抛错 → 拒绝写入/注册;空 modes/levels 等 schema 默认回落为
|
|
78
|
+
* normalizeConfig 的默认值(空 modes 即"未设",回落 ['workspace-write'])。
|
|
79
|
+
* @param {object} value 当前解析出的分区值(schema 合法)
|
|
80
|
+
* @returns {Readonly<Config>} 规范化后的冻结配置
|
|
81
|
+
*/
|
|
82
|
+
export function validateYoloSettings(value) {
|
|
83
|
+
return normalizeConfig(pruneEmpty(value));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 安装 yolo-mode settings 分区(可选-settings 消费者接线)。
|
|
88
|
+
* settings 服务缺失时打 debug 日志并跳过(插件继续按插件行 config 运行)。
|
|
89
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
90
|
+
* @param {object} entry 插件行 config(成为 settings `base` 层)
|
|
91
|
+
* @param {import('@deepseek-ai/dsh-settings').SettingsSectionHooks<object>} hooks
|
|
92
|
+
* { setSource, onChange, validate? }
|
|
93
|
+
*/
|
|
94
|
+
export function installYoloSettings(ctx, entry, hooks) {
|
|
95
|
+
if (ctx.get('settings') === undefined) {
|
|
96
|
+
ctx.logger.debug(
|
|
97
|
+
'[yolo-mode] no settings service mounted; using composition config and skipping settings section registration',
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
installSettingsSection(ctx, YOLO_SETTINGS_NAMESPACE, YoloSettingsSchema, entry, hooks);
|
|
102
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-yolo-mode —— 模块级共享状态(lib/state.js)
|
|
3
|
+
*
|
|
4
|
+
* 主插件条目(lib/index.js)在裁决时调用 `recordDecision(entry)` 写入审计统计
|
|
5
|
+
* 与最近决策环形缓冲;桥接条目(lib/bridge-entry.js)经 `getStatusPayload(cfg)`
|
|
6
|
+
* 读取。二者为同一包内的模块单例,天然共享(design.md §12.3「stats/recent 写入
|
|
7
|
+
* lib/state.js 模块级单例」)。
|
|
8
|
+
*
|
|
9
|
+
* 本模块不触碰 settings / webServer;只含纯数据与纯装配(design.md §12.4 statusView)。
|
|
10
|
+
* statusView 附带 presetDefaults:六预设 id → { systemPrompt, levels },供设置页
|
|
11
|
+
* 选中预设时预填充(systemPrompt 来自 lib/judge.js 的 defaultJudgePromptFor,
|
|
12
|
+
* levels 来自 lib/policy.js 的 defaultLevelsFor;二者均为无副作用纯函数)。
|
|
13
|
+
*
|
|
14
|
+
* @module lib/state.js
|
|
15
|
+
*/
|
|
16
|
+
import { PRESETS, defaultLevelsFor } from './policy.js'
|
|
17
|
+
import { defaultJudgePromptFor } from './judge.js'
|
|
18
|
+
|
|
19
|
+
/** recent 环形缓冲上限(design.md §11.1/12.4,≤20)。 */
|
|
20
|
+
export const RECENT_CAP = 20;
|
|
21
|
+
|
|
22
|
+
/** 模块级统计计数(设计 §3.8/12.3;主条目内存写,桥接条目只读)。 */
|
|
23
|
+
export const stats = { total: 0, allowed: 0, rejected: 0, delegated: 0 };
|
|
24
|
+
|
|
25
|
+
/** recent 环形缓冲:新条目 unshift 到头部,超过上限截断尾部(倒序,≤RECENT_CAP)。 */
|
|
26
|
+
const recent = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 六预设的默认 { systemPrompt, levels } 映射(design.md §2/§13.1;statusView 携带,
|
|
30
|
+
* 设置页选中预设时预填充)。纯函数静态装配一次:值为不可变字符串与冻结对象,只读。
|
|
31
|
+
*/
|
|
32
|
+
const presetDefaults = (() => {
|
|
33
|
+
const map = {};
|
|
34
|
+
for (const id of PRESETS) {
|
|
35
|
+
map[id] = { systemPrompt: defaultJudgePromptFor(id), levels: defaultLevelsFor(id) };
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 判定某个会话的审计 origin:'subagent' 或 'main'(design.md §3.8)。
|
|
42
|
+
* 由 session 的 delegation 信息判定,无法判定时回退 'main'。
|
|
43
|
+
* @param {object|undefined} session 申请方的 agent session
|
|
44
|
+
* @returns {'subagent'|'main'}
|
|
45
|
+
*/
|
|
46
|
+
export function sessionOrigin(session) {
|
|
47
|
+
if (!session) return 'main';
|
|
48
|
+
const header = session.header;
|
|
49
|
+
if (header && header.origin === 'subagent') return 'subagent';
|
|
50
|
+
return 'main';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 记录一次裁决到模块级统计与 recent 环(主条目 audit() 调用)。
|
|
55
|
+
* @param {object} entry 审计条目:
|
|
56
|
+
* { time, sessionId, origin, toolName?, callId?, targetMode, currentMode?,
|
|
57
|
+
* justification, decision, outcome, reason? }
|
|
58
|
+
*/
|
|
59
|
+
export function recordDecision(entry) {
|
|
60
|
+
recent.unshift({
|
|
61
|
+
time: entry.time,
|
|
62
|
+
toolName: entry.toolName,
|
|
63
|
+
targetMode: entry.targetMode,
|
|
64
|
+
decision: entry.decision,
|
|
65
|
+
outcome: entry.outcome,
|
|
66
|
+
...(entry.reason !== undefined ? { reason: entry.reason } : {}),
|
|
67
|
+
});
|
|
68
|
+
if (recent.length > RECENT_CAP) recent.length = RECENT_CAP;
|
|
69
|
+
|
|
70
|
+
stats.total += 1;
|
|
71
|
+
if (entry.outcome === 'delegate') stats.delegated += 1;
|
|
72
|
+
else if (entry.outcome === 'allowed-once') stats.allowed += 1;
|
|
73
|
+
else if (entry.outcome === 'rejected') stats.rejected += 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 以浅拷贝装配 statusView 载荷(design.md §12.4)。
|
|
78
|
+
* @param {object} cfg 含 { preset, judge?: { provider?, model? } } 的配置字形
|
|
79
|
+
* (主条目传 effectiveConfig();桥接条目传 settings.describe 推导的 view 字形)
|
|
80
|
+
* @returns {{preset:string, judgeConfigured:boolean, presetDefaults:object, stats:object, recent:Array<object>}}
|
|
81
|
+
*/
|
|
82
|
+
export function getStatusPayload(cfg) {
|
|
83
|
+
const c = cfg && typeof cfg === 'object' ? cfg : {};
|
|
84
|
+
const j = c.judge && typeof c.judge === 'object' ? c.judge : {};
|
|
85
|
+
return {
|
|
86
|
+
preset: c.preset,
|
|
87
|
+
judgeConfigured: Boolean(j.provider && j.model),
|
|
88
|
+
presetDefaults,
|
|
89
|
+
stats: { ...stats },
|
|
90
|
+
recent: recent.map((r) => ({ ...r })),
|
|
91
|
+
};
|
|
92
|
+
}
|