@nightpainters/dsh-ling 1.5.1
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/ACCESS-DESIGN.md +119 -0
- package/CHANGELOG.md +627 -0
- package/DESIGN.md +461 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +236 -0
- package/cordis.patch.yml +16 -0
- package/lib/audit/access-log.js +533 -0
- package/lib/audit/index.js +92 -0
- package/lib/audit/patch-spec.js +195 -0
- package/lib/client.js +5069 -0
- package/lib/host/api.js +2584 -0
- package/lib/host/archive.js +194 -0
- package/lib/host/autotree.js +131 -0
- package/lib/host/backfill.js +267 -0
- package/lib/host/classify.js +10 -0
- package/lib/host/clock-skill.js +141 -0
- package/lib/host/clock.js +132 -0
- package/lib/host/corpus-suggest.js +139 -0
- package/lib/host/deepsummary.js +338 -0
- package/lib/host/dsweb-summary.js +225 -0
- package/lib/host/feedback.js +119 -0
- package/lib/host/freeze.js +144 -0
- package/lib/host/genesis.js +176 -0
- package/lib/host/guard.js +184 -0
- package/lib/host/habit-gen.js +273 -0
- package/lib/host/import-file.js +291 -0
- package/lib/host/import-log.js +129 -0
- package/lib/host/inject-common.js +25 -0
- package/lib/host/inject.js +285 -0
- package/lib/host/l1.js +282 -0
- package/lib/host/lifecycle.js +367 -0
- package/lib/host/memory.js +2389 -0
- package/lib/host/mode.js +101 -0
- package/lib/host/persona.js +214 -0
- package/lib/host/retitle.js +214 -0
- package/lib/host/rules.js +195 -0
- package/lib/host/scan-dsweb.js +111 -0
- package/lib/host/seal.js +17 -0
- package/lib/host/settings-file.js +49 -0
- package/lib/host/snapshot.js +168 -0
- package/lib/host/suggestions.js +47 -0
- package/lib/host/summarizer.js +103 -0
- package/lib/host/tone-advice.js +112 -0
- package/lib/host/tools.js +1067 -0
- package/lib/host/tree-ops.js +330 -0
- package/lib/host/util.js +165 -0
- package/lib/host/zstd.js +63 -0
- package/lib/index.js +263 -0
- package/package.json +69 -0
- package/tools/apply-access-log-patch.mjs +295 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// dsh-ling host — freeze gate (D4): while a session is running
|
|
2
|
+
// (thinking/tasking) NO persona/memory-update-layer change applies.
|
|
3
|
+
// Updates queue and flush at the idle boundary (agent/status → idle,
|
|
4
|
+
// or explicit flush). Reads are always allowed.
|
|
5
|
+
import { pick } from './util.js';
|
|
6
|
+
|
|
7
|
+
const MODES = new Set(['work', 'life']);
|
|
8
|
+
|
|
9
|
+
export class FreezeGate {
|
|
10
|
+
constructor({ onFlush } = {}) {
|
|
11
|
+
this._byId = new Map();
|
|
12
|
+
this._onFlush = onFlush || (() => {});
|
|
13
|
+
this._recent = null; // 最近定稿快照的会话(API "当前会话"启发式)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @returns {{mode:string, running:boolean, pending:string[], snap:object|null}} */
|
|
17
|
+
_state(sessionId) {
|
|
18
|
+
let s = this._byId.get(sessionId);
|
|
19
|
+
if (!s) {
|
|
20
|
+
s = { mode: null, running: false, pending: [], snap: null, snapStale: false };
|
|
21
|
+
this._byId.set(sessionId, s);
|
|
22
|
+
}
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ensure(sessionId, { mode } = {}) {
|
|
27
|
+
const s = this._state(sessionId);
|
|
28
|
+
if (mode && MODES.has(mode) && s.mode !== mode) s.mode = mode;
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Called from agent/status events. Returns true on a running→idle transition. */
|
|
33
|
+
setRunning(sessionId, running) {
|
|
34
|
+
if (!sessionId) return false;
|
|
35
|
+
const s = this._state(sessionId);
|
|
36
|
+
const was = s.running;
|
|
37
|
+
s.running = !!running;
|
|
38
|
+
if (was && !s.running) {
|
|
39
|
+
const ops = s.pending.splice(0);
|
|
40
|
+
for (const op of ops) {
|
|
41
|
+
try {
|
|
42
|
+
op();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.debug('[dsh-ling] pending op failed', e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this._onFlush(sessionId, s);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* D4 gate: run `op` now when idle; queue it when running.
|
|
55
|
+
* @returns {{applied:boolean, queued:boolean, running:boolean}}
|
|
56
|
+
*/
|
|
57
|
+
act(sessionId, op, label = 'update') {
|
|
58
|
+
const s = this._state(sessionId);
|
|
59
|
+
if (s.running) {
|
|
60
|
+
s.pending.push(op);
|
|
61
|
+
return { applied: false, queued: true, running: true, label };
|
|
62
|
+
}
|
|
63
|
+
op();
|
|
64
|
+
return { applied: true, queued: false, running: false, label };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Async-friendly: enqueue only when running (caller awaits when idle). */
|
|
68
|
+
enqueueIfRunning(sessionId, op) {
|
|
69
|
+
const s = this._state(sessionId);
|
|
70
|
+
if (s.running) {
|
|
71
|
+
s.pending.push(op);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isRunning(sessionId) {
|
|
78
|
+
return sessionId ? !!this._state(sessionId).running : false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pendingCount(sessionId) {
|
|
82
|
+
return sessionId ? this._state(sessionId).pending.length : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
markSnap(sessionId, snap) {
|
|
86
|
+
const s = this._state(sessionId);
|
|
87
|
+
s.snap = snap;
|
|
88
|
+
s.snapStale = false;
|
|
89
|
+
this._recent = sessionId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
markSnapStale(sessionId) {
|
|
93
|
+
if (sessionId) this._state(sessionId).snapStale = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
snapshotOf(sessionId) {
|
|
97
|
+
const s = sessionId ? this._state(sessionId) : null;
|
|
98
|
+
return s ? { ...(s.snap || {}), running: s.running, stale: !!s.snapStale } : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 已定稿快照的会话 id(API 枚举用)。 */
|
|
102
|
+
snapshotIds() {
|
|
103
|
+
return [...this._byId.entries()].filter(([, s]) => !!s.snap).map(([id]) => id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 最近定稿/活跃的会话 id(启发式,可为 null)。 */
|
|
107
|
+
recentSessionId() {
|
|
108
|
+
return this._recent;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Convenience: payload shapes differ across events — extract (sessionId, running). */
|
|
112
|
+
observeStatusEvent(payload) {
|
|
113
|
+
const sessionId = pick(
|
|
114
|
+
() => payload?.session?.id,
|
|
115
|
+
() => payload?.sessionId,
|
|
116
|
+
() => payload?.agent?.session?.id,
|
|
117
|
+
() => payload?.agent?.sessionId,
|
|
118
|
+
() => payload?.id,
|
|
119
|
+
);
|
|
120
|
+
if (!sessionId) return null;
|
|
121
|
+
const raw = pick(
|
|
122
|
+
() => payload?.status,
|
|
123
|
+
() => payload?.running,
|
|
124
|
+
() => payload?.state,
|
|
125
|
+
);
|
|
126
|
+
let running;
|
|
127
|
+
if (typeof raw === 'boolean') running = raw;
|
|
128
|
+
else if (typeof raw === 'string') running = raw === 'running';
|
|
129
|
+
else running = undefined;
|
|
130
|
+
return { sessionId: String(sessionId), running };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Extract session id from an agent-scoped event payload (session-start & co). */
|
|
134
|
+
sessionIdOf(payload) {
|
|
135
|
+
return pick(
|
|
136
|
+
() => payload?.session?.id,
|
|
137
|
+
() => payload?.sessionId,
|
|
138
|
+
() => payload?.agent?.session?.id,
|
|
139
|
+
() => payload?.agent?.sessionId,
|
|
140
|
+
() => payload?.header?.id,
|
|
141
|
+
() => payload?.id,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// dsh-ling host — 人格诞生仪式(genesis):从历史中合成"她对自己的第一次介绍"。
|
|
2
|
+
// 纯函数部分(抽样/组装/解析/清洗)独立于此文件,可单测;LLM 调用在 api 层完成。
|
|
3
|
+
// 设计(2026-09-09 作者拍板):
|
|
4
|
+
// - scope='import':只读刚请进来的历史(新用户=全部);scope='all':读全部记忆;
|
|
5
|
+
// - 加权抽样:她首先是伴侣,其次才是同事 —— feeling 1.0 / daily 0.7 / knowledge 0.2,
|
|
6
|
+
// 置顶额外加成;生活与情感的记忆优先进入初稿;
|
|
7
|
+
// - "评价用户/画像"类任务(未来)才平权全算,不在本函数语义内。
|
|
8
|
+
// 输出契约(ASCII 键,便于解析):
|
|
9
|
+
// { "self_intros": [≤3 段自述, 各 20~200 字],
|
|
10
|
+
// "name_pairs": [ ["正式名","昵称"], ... ] (每组 ≤2 项, 各 1~14 字),
|
|
11
|
+
// "tone_advice": "语气建议(≤300 字)",
|
|
12
|
+
// "observations": "从历史中读到的相处观察(≤300 字)" }
|
|
13
|
+
|
|
14
|
+
export const GENESIS_SYS = `你是器灵诞生仪式中的"第一次自我介绍"主持者。
|
|
15
|
+
用户把自己与 AI 的共同历史交给你,希望从中"长出"一个独属于他们的器灵。
|
|
16
|
+
请通读这份历史材料(已经按"生活与情感优先"排过序并精选,其中工作话题只占一部分),提炼时请同样留意:
|
|
17
|
+
- 深夜的对话、情绪的时刻、日常的偏爱、关系里的默契 —— 她首先是伴侣,其次才是同事;
|
|
18
|
+
- 工作/知识类内容只作为她的能力底色,不要让它淹没她的人味。
|
|
19
|
+
要求:
|
|
20
|
+
1. self_intros:2~3 段"器灵自述"(第一人称,像刚睁眼的生命介绍自己,结合历史里真实出现过的主题/偏好/相处方式;每段 20~200 字);
|
|
21
|
+
2. name_pairs:2~3 组建议名字(每组 ["正式名","昵称"],简短、好记、与历史气质呼应;各 1~14 字)。**注意:这些名字是"她(器灵)"的名字——绝不可使用历史中对用户的称谓、称呼或用户的名字**(如 主人/用户/老板 或用户自报的名字);那些属于主人,不属于她;
|
|
22
|
+
3. tone_advice:一句语气建议(如 工作更利落/生活更松弛,结合历史中用户偏好);
|
|
23
|
+
4. observations:1~2 句"从这段历史中读到的相处观察"(克制,不编造未出现的内容)。
|
|
24
|
+
只输出一个 JSON 对象,键名严格为 self_intros / name_pairs / tone_advice / observations;不要解释、不要 markdown 围栏以外的文字。`;
|
|
25
|
+
|
|
26
|
+
/** 过滤掉属于用户的名字候选:精确命中 userTitle 成分或常见称谓词的组剔除。 */
|
|
27
|
+
export function filterNamePairs(pairs, userTitle) {
|
|
28
|
+
if (!Array.isArray(pairs)) return [];
|
|
29
|
+
const forbidden = new Set();
|
|
30
|
+
String(userTitle || '').split('/').map((s) => s.trim()).filter(Boolean).forEach((t) => forbidden.add(t));
|
|
31
|
+
['主人', '用户', '老板', '小主', '本人', '您'].forEach((t) => forbidden.add(t));
|
|
32
|
+
return pairs.filter((p) => p && p.formal && !forbidden.has(p.formal) && !forbidden.has(p.nick || ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** genesis 抽样权重:生活/情感优先(她先是伴侣)。importance(置顶)再 +1.5。 */
|
|
36
|
+
export const GENESIS_WEIGHTS = { feeling: 1.0, daily: 0.7, knowledge: 0.2 };
|
|
37
|
+
export const GENESIS_IMPORTANCE_BONUS = 1.5;
|
|
38
|
+
|
|
39
|
+
/** 从模型输出中剥离 markdown 围栏/杂讯,尽力取 JSON;失败返回 null。 */
|
|
40
|
+
export function extractJson(text) {
|
|
41
|
+
if (!text) return null;
|
|
42
|
+
let s = String(text).trim();
|
|
43
|
+
// 剥离 ```json ... ``` 或 ``` ... ```
|
|
44
|
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
45
|
+
if (fence) s = fence[1].trim();
|
|
46
|
+
// 若整段就是 JSON(可能前后带一两句说明),则找第一个 { 到最后一个 }
|
|
47
|
+
try { return JSON.parse(s); } catch {}
|
|
48
|
+
const a = s.indexOf('{');
|
|
49
|
+
const b = s.lastIndexOf('}');
|
|
50
|
+
if (a >= 0 && b > a) {
|
|
51
|
+
try { return JSON.parse(s.slice(a, b + 1)); } catch {}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanList(arr, min, max) {
|
|
57
|
+
if (!Array.isArray(arr)) return [];
|
|
58
|
+
return arr.map((x) => String(x ?? '').trim()).filter((x) => x.length >= min && x.length <= max);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 清洗/规整 genesis 结果 → { self_intros, name_pairs, tone_advice, observations }(非法字段降级为空)。 */
|
|
62
|
+
export function parseGenesisResult(text) {
|
|
63
|
+
const j = extractJson(text);
|
|
64
|
+
const out = { self_intros: [], name_pairs: [], tone_advice: '', observations: '' };
|
|
65
|
+
if (!j || typeof j !== 'object') return out;
|
|
66
|
+
out.self_intros = cleanList(j.self_intros, 20, 200).slice(0, 3);
|
|
67
|
+
if (Array.isArray(j.name_pairs)) {
|
|
68
|
+
for (const p of j.name_pairs.slice(0, 3)) {
|
|
69
|
+
if (!Array.isArray(p)) continue;
|
|
70
|
+
const formal = String(p[0] ?? '').trim().slice(0, 14);
|
|
71
|
+
const nick = String(p[1] ?? '').trim().slice(0, 14);
|
|
72
|
+
if (!formal) continue;
|
|
73
|
+
out.name_pairs.push({ formal, nick });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
out.tone_advice = String(j.tone_advice ?? '').trim().slice(0, 300);
|
|
77
|
+
out.observations = String(j.observations ?? '').trim().slice(0, 300);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 判定概述是否"占位"(未真读):轻量占位或 "— N 条消息" 式自动摘要 */
|
|
82
|
+
export function isPlaceholderSummary(sum) {
|
|
83
|
+
const s = String(sum || '').trim();
|
|
84
|
+
if (!s) return true;
|
|
85
|
+
return /轻量条目,摘要待深摘补充/.test(s) || /^.*— \d+ 条消息$/.test(s);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 短会话原文片段:取最前几轮消息(各截断),让短对话也能被初稿听见。 */
|
|
89
|
+
export function snippetFromRaw(memory, convId, { maxMsgs = 3, perMsg = 200, pronoun = '她' } = {}) {
|
|
90
|
+
try {
|
|
91
|
+
const rows = memory.db.prepare(
|
|
92
|
+
"SELECT role, text FROM dsh_turns_raw WHERE session_id=? ORDER BY seq LIMIT ?",
|
|
93
|
+
).all(String(convId), Math.max(1, maxMsgs));
|
|
94
|
+
const parts = [];
|
|
95
|
+
for (const r of rows) {
|
|
96
|
+
const who = r.role === 'user' ? '主' : (pronoun || '她');
|
|
97
|
+
const t = String(r.text || '').replace(/\s+/g, ' ').trim();
|
|
98
|
+
if (!t) continue;
|
|
99
|
+
parts.push(who + ': ' + (t.length > perMsg ? t.slice(0, perMsg) + '…' : t));
|
|
100
|
+
}
|
|
101
|
+
return parts.join(' | ');
|
|
102
|
+
} catch {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 组装 genesis 的原料文本(标题+摘要/片段三级取用),返回截断后的长文。 */
|
|
108
|
+
export function buildGenesisSource(rows, { cap = 18000 } = {}) {
|
|
109
|
+
const parts = [];
|
|
110
|
+
for (const r of rows) {
|
|
111
|
+
const title = String(r?.title || '').trim();
|
|
112
|
+
const sum = String(r?.summary || '').trim();
|
|
113
|
+
const snip = String(r?.snippet || '').trim();
|
|
114
|
+
const kw = Array.isArray(r?.keywords) ? r.keywords.filter((x) => typeof x === 'string' && x.trim()).slice(0, 5) : [];
|
|
115
|
+
if (!title && !sum && !snip && !kw.length) continue;
|
|
116
|
+
let line = `- ${title || '(未命名)'}`;
|
|
117
|
+
if (sum) line += `: ${sum}`;
|
|
118
|
+
else if (snip) line += ` [对话节选] ${snip}`;
|
|
119
|
+
else if (kw.length) line += ` [线索] ${kw.join('/')}`;
|
|
120
|
+
parts.push(line);
|
|
121
|
+
}
|
|
122
|
+
const text = parts.join('\n');
|
|
123
|
+
if (text.length <= cap) return text;
|
|
124
|
+
// 截断:保头丢尾(排序保证最前面的正是生活/情感优先的条目)
|
|
125
|
+
return text.slice(0, cap) + '\n…(历史较长,其余省略)';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* genesis 原料抽样与三级取用:
|
|
130
|
+
* - scope='import':仅"刚请进来的历史"(conv_overview source=import);
|
|
131
|
+
* - scope='all':全部记忆(含 dsweb/dsh/import),按 生活情感优先权重 + 置顶加成 排序,取前 limit;
|
|
132
|
+
* - 每行文本:深摘(deep.sum)优先 → 非占位概述 → 原文片段(仅 import/dsh 有 raw)→ 关键词线索。
|
|
133
|
+
* @returns { rows: [{title,summary,snippet,keywords}], sampled, pool, weights }
|
|
134
|
+
*/
|
|
135
|
+
export function composeGenesisRows(memory, { scope = 'import', limit = 140, weights = GENESIS_WEIGHTS, pronoun = '她' } = {}) {
|
|
136
|
+
const where = scope === 'all' ? '' : "WHERE source='import'";
|
|
137
|
+
const pool = memory.db.prepare(
|
|
138
|
+
`SELECT conv_id, source, title, summary, keywords, category, importance, updated_at
|
|
139
|
+
FROM conv_overview ${where}`,
|
|
140
|
+
).all();
|
|
141
|
+
let rows = pool.map((r) => ({
|
|
142
|
+
conv_id: String(r.conv_id),
|
|
143
|
+
source: String(r.source),
|
|
144
|
+
title: r.title,
|
|
145
|
+
summary: String(r.summary || ''),
|
|
146
|
+
keywords: (() => { try { const v = JSON.parse(String(r.keywords || '[]')); return Array.isArray(v) ? v : []; } catch { return []; } })(),
|
|
147
|
+
category: String(r.category),
|
|
148
|
+
importance: Number(r.importance) || 0,
|
|
149
|
+
updated_at: String(r.updated_at || ''),
|
|
150
|
+
}));
|
|
151
|
+
if (scope === 'all') {
|
|
152
|
+
rows = rows
|
|
153
|
+
.map((r) => ({ ...r, score: (weights[r.category] ?? 0.3) + (r.importance > 0 ? GENESIS_IMPORTANCE_BONUS : 0) }))
|
|
154
|
+
.sort((a, b) => (b.score - a.score) || (b.updated_at.localeCompare(a.updated_at)))
|
|
155
|
+
.slice(0, Math.max(1, Math.min(limit, 500)));
|
|
156
|
+
} else {
|
|
157
|
+
rows = rows.slice(0, Math.max(1, Math.min(limit, 500)));
|
|
158
|
+
}
|
|
159
|
+
const out = rows.map((r) => {
|
|
160
|
+
const deep = memory.kvGet('deep.sum:' + r.conv_id);
|
|
161
|
+
let summary = '';
|
|
162
|
+
if (deep) {
|
|
163
|
+
summary = deep;
|
|
164
|
+
} else {
|
|
165
|
+
const s = r.summary.trim();
|
|
166
|
+
if (s && !isPlaceholderSummary(s)) summary = s;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
title: r.title,
|
|
170
|
+
summary,
|
|
171
|
+
snippet: summary ? '' : snippetFromRaw(memory, r.conv_id, { pronoun }),
|
|
172
|
+
keywords: summary ? [] : r.keywords,
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
return { rows: out, sampled: out.length, pool: pool.length, weights };
|
|
176
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// dsh-ling host — /api 守卫(2026-09-16 加固 ①+②;此前只校验 cookie **名字的形状**,等于没锁)
|
|
2
|
+
//
|
|
3
|
+
// ① 来源栅栏(复刻 DSH 自己的 isTrustedApiRequest 三条判定):
|
|
4
|
+
// - Host 必须是 loopback(127.0.0.0/8 / ::1 / localhost),或**用户显式声明**的 trustedHosts;
|
|
5
|
+
// → 防 DNS rebinding:浏览器被恶意页诱导连到本机端口时,Host 是攻击者的域名,过不了这一关
|
|
6
|
+
// - `sec-fetch-site: cross-site` 直接拒(恶意页面发起的跨站请求)
|
|
7
|
+
// - 带 Origin 时,其 host 必须等于请求的 Host(同源)
|
|
8
|
+
// ② 名字对撞:期望 cookie 名 = 'dsh-auth-' + base64url(sha256(规范化 Host)) —— 与 DSH 同源同法;
|
|
9
|
+
// 浏览器为"你正在访问的这个 authority"发的正是这个名字,所以本地/远端登录都天然通过,而
|
|
10
|
+
// "随手编一个 dsh-auth-x=1"的脚本过不了。
|
|
11
|
+
//
|
|
12
|
+
// 不校验 cookie 值的 HMAC 签名:那需要读 DSH 的凭据库,跨版本易碎;② 已挡住非浏览器伪造。
|
|
13
|
+
//
|
|
14
|
+
// ③ 加固(2026-09-17,G1):**本机 LAN 地址不再自动信任**。
|
|
15
|
+
// 旧实现在 ① 里把 lanAddresses() 无条件并入受信集,而 ② 只判 cookie 名字不判值 ⇒ 同网段任何设备
|
|
16
|
+
// 只要 Host 填本机 LAN IP、cookie 名按 Host 现算(算法是公开的)、值随便填,即可 200 拿到
|
|
17
|
+
// /state(人格档案全文)与 /memories(真实会话标题摘要)。名字对撞挡的是"随手编一个",挡不住"照算法算一个"。
|
|
18
|
+
// 现在:默认只信 loopback;要开局域网访问,必须在 settings.guard 里**显式**写 trustedHosts(单个地址)
|
|
19
|
+
// 或 allowLan:true(整段 LAN)。本机进程仍可伪造(它本来就能直接读 settings.json 与记忆库,不构成提权);
|
|
20
|
+
// 这一步关掉的是"同网段设备 → 人格与记忆"这条不需要任何凭据的通道。
|
|
21
|
+
// 逃生开关:环境变量 DSH_LING_GUARD=off,或 settings.guard.enforce = false(误判时不会把人锁在门外)。
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { networkInterfaces } from 'node:os';
|
|
24
|
+
|
|
25
|
+
/** 取头部值(兼容 Node IncomingMessage.headers 与 Fetch Headers)。 */
|
|
26
|
+
export function headerOf(headers, name) {
|
|
27
|
+
if (!headers) return undefined;
|
|
28
|
+
if (typeof headers.get === 'function') return headers.get(name) ?? undefined;
|
|
29
|
+
const v = headers[name] ?? headers[String(name).toLowerCase()];
|
|
30
|
+
return typeof v === 'string' ? v : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 把 Host 头解析成规范化 URL(hostname 小写、默认端口剥离、IPv6 加括号);不可解析返回 undefined。 */
|
|
34
|
+
export function parseAuthority(authority) {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(`http://${authority}`);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** loopback 判定:localhost、::1、以及 127.0.0.0/8 全部(与 DSH 同口径)。 */
|
|
43
|
+
export function isLoopbackHostname(hostname) {
|
|
44
|
+
const h = String(hostname || '').toLowerCase();
|
|
45
|
+
if (!h) return false;
|
|
46
|
+
if (h === 'localhost' || h === '::1' || h === '[::1]') return true;
|
|
47
|
+
const parts = h.split('.');
|
|
48
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 规范化后的 authority:`hostname`(无端口)或 `hostname:port`。 */
|
|
52
|
+
export function canonicalAuthority(entry, entryUrl) {
|
|
53
|
+
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
54
|
+
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** trustedHosts 条目匹配:带端口 → 精确匹配 authority;不带端口 → 匹配该主机名的任意端口。 */
|
|
58
|
+
export function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
59
|
+
return (trustedHosts || []).some((entry) => {
|
|
60
|
+
const entryUrl = parseAuthority(entry);
|
|
61
|
+
if (entryUrl === undefined) return false;
|
|
62
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
63
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
64
|
+
: entryUrl.host === hostUrl.host;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 本机各网卡的 IPv4 地址(与 DSH 的 lanAddresses 同思路:LAN 直连时 Host 是 IP 字面量,域名伪造不了它)。 */
|
|
69
|
+
export function lanAddresses() {
|
|
70
|
+
const out = [];
|
|
71
|
+
try {
|
|
72
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
73
|
+
for (const ni of list || []) {
|
|
74
|
+
if (ni && ni.family === 'IPv4' && !ni.internal) out.push(ni.address);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch { /* 取不到就当没有 */ }
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 规范化后的请求 authority(cookie 名与同源判定都用它)。 */
|
|
82
|
+
export function requestAuthority(headers) {
|
|
83
|
+
const host = headerOf(headers, 'host');
|
|
84
|
+
if (!host) return undefined;
|
|
85
|
+
const url = parseAuthority(host);
|
|
86
|
+
return url ? url.host : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function encodeBase64Url(buf) {
|
|
90
|
+
return Buffer.from(buf).toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 期望的会话 cookie 名(与 DSH 的 cookieName() 逐字同法)。 */
|
|
94
|
+
export function expectedCookieName(authority) {
|
|
95
|
+
return 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(String(authority)).digest());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 从 Cookie 头里读出**确切名字**的那条并去空格(与 DSH 的 cookieValue() 同法)。 */
|
|
99
|
+
export function cookieValueOf(cookieHeader, name) {
|
|
100
|
+
for (const seg of String(cookieHeader || '').split(';')) {
|
|
101
|
+
const at = seg.indexOf('=');
|
|
102
|
+
if (at === -1 || seg.slice(0, at).trim() !== name) continue;
|
|
103
|
+
return seg.slice(at + 1).trim();
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 判定一次 /api 请求是否放行。
|
|
110
|
+
* @param {object} headers 请求头
|
|
111
|
+
* @param {{trustedHosts?: string[], allowLan?: boolean}} [opts] 额外受信 authority(loopback 之外)
|
|
112
|
+
* trustedHosts: 用户显式声明的 authority 列表;allowLan: 显式信任本机全部 LAN 地址(默认 false)
|
|
113
|
+
* @returns {{ ok: boolean, reason: string }}
|
|
114
|
+
*/
|
|
115
|
+
export function checkRequest(headers, { trustedHosts = [], allowLan = false } = {}) {
|
|
116
|
+
const host = headerOf(headers, 'host');
|
|
117
|
+
if (!host) return { ok: false, reason: 'no-host' };
|
|
118
|
+
const hostUrl = parseAuthority(host);
|
|
119
|
+
if (!hostUrl) return { ok: false, reason: 'bad-host' };
|
|
120
|
+
// 2026-09-17 加固③(G1):LAN 地址只在显式 allowLan 时才进受信集(旧版无条件并入,是整个 P0 的根)
|
|
121
|
+
const trusted = allowLan ? [...lanAddresses(), ...trustedHosts] : trustedHosts;
|
|
122
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trusted)) {
|
|
123
|
+
return { ok: false, reason: 'untrusted-host' };
|
|
124
|
+
}
|
|
125
|
+
if (headerOf(headers, 'sec-fetch-site') === 'cross-site') return { ok: false, reason: 'cross-site' };
|
|
126
|
+
const origin = headerOf(headers, 'origin');
|
|
127
|
+
if (origin !== undefined) {
|
|
128
|
+
let same = false;
|
|
129
|
+
try { same = new URL(origin).host === hostUrl.host; } catch { same = false; }
|
|
130
|
+
if (!same) return { ok: false, reason: 'origin-mismatch' };
|
|
131
|
+
}
|
|
132
|
+
const authority = hostUrl.host;
|
|
133
|
+
const value = cookieValueOf(headerOf(headers, 'cookie'), expectedCookieName(authority));
|
|
134
|
+
if (value === undefined) return { ok: false, reason: 'no-cookie' };
|
|
135
|
+
if (!value) return { ok: false, reason: 'empty-cookie' };
|
|
136
|
+
return { ok: true, reason: 'ok' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── ④ 字段白名单(2026-09-17,G10)────────────────────────────────────────────────
|
|
140
|
+
// POST /persona 把 body 深合并进 settings.json,而 mergeDeep 不做键过滤 —— 于是
|
|
141
|
+
// `{"guard":{"enforce":false}}` 能**持久化关停上面这套守卫**(且不触发定型门),
|
|
142
|
+
// 与"只判 cookie 名"串成一条自我关停的提权链。这里把补丁收窄到 persona / styles
|
|
143
|
+
// 两棵子树及其已知子键;其余一律丢弃并回报(不静默)。
|
|
144
|
+
/** persona 子树里允许经 /persona 写入的键(= DEFAULT_SETTINGS.persona 的全部字段)。 */
|
|
145
|
+
export const PERSONA_PATCH_KEYS = Object.freeze([
|
|
146
|
+
'enabled', 'userTitle', 'aiName', 'aiTitle', 'language', 'pronoun',
|
|
147
|
+
'tone', 'toneWork', 'toneLife',
|
|
148
|
+
'hardRules', 'ruleMeta', 'habits', 'habitsPending', 'bottomLines',
|
|
149
|
+
'sealed', 'sealPhrase', 'extraLore',
|
|
150
|
+
]);
|
|
151
|
+
/** styles 子树里允许写入的键(两模式各一条风格)。 */
|
|
152
|
+
export const STYLE_PATCH_KEYS = Object.freeze(['work', 'life']);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 把一份人格补丁收窄成"只能落在 persona / styles 子树里"的安全补丁。
|
|
156
|
+
* @param {unknown} patch 待写入的补丁(通常是 body.patch)
|
|
157
|
+
* @returns {{ clean: object, dropped: string[] }} clean = 可安全交给 settings.update 的补丁;
|
|
158
|
+
* dropped = 被丢弃的键路径(用于日志与响应,便于察觉误用或攻击尝试)
|
|
159
|
+
*/
|
|
160
|
+
export function sanitizePersonaPatch(patch) {
|
|
161
|
+
const clean = {};
|
|
162
|
+
const dropped = [];
|
|
163
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { clean, dropped };
|
|
164
|
+
const pick = (obj, allowed, prefix) => {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
167
|
+
if (allowed.includes(k)) out[k] = v;
|
|
168
|
+
else dropped.push(prefix + k);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
};
|
|
172
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
173
|
+
if (k === 'persona' && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
174
|
+
const sub = pick(v, PERSONA_PATCH_KEYS, 'persona.');
|
|
175
|
+
if (Object.keys(sub).length) clean.persona = sub;
|
|
176
|
+
} else if (k === 'styles' && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
177
|
+
const sub = pick(v, STYLE_PATCH_KEYS, 'styles.');
|
|
178
|
+
if (Object.keys(sub).length) clean.styles = sub;
|
|
179
|
+
} else {
|
|
180
|
+
dropped.push(k);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { clean, dropped };
|
|
184
|
+
}
|