@twinsearth/w2m-dsh-plugin 1.9.0 → 1.9.3
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +15 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.1/OPS-191.json +20 -0
- package/docs/acceptance/1.9.3/RELEASE-193.json +21 -0
- package/lib/client.js +122 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/sinks/dir-sink.mjs +33 -0
- package/src/ops/plugin-adapter.mjs +19 -2
- package/src/ops/upload-scheduler.mjs +9 -0
- package/src/plugin/folder-policy.mjs +10 -18
- package/src/plugin/gpw-policy-adapter.mjs +30 -0
- package/src/plugin/joint-dev/audit-file.mjs +83 -0
- package/src/plugin/joint-dev/dispatch.mjs +154 -0
- package/src/plugin/joint-dev/loop.mjs +73 -0
- package/src/plugin/joint-dev/plan-store.mjs +66 -0
- package/src/plugin/joint-dev/redline.mjs +16 -0
- package/src/plugin/npm-update.mjs +18 -12
- package/src/plugin/path-policy.mjs +214 -0
- package/src/plugin/target-policy.mjs +90 -0
- package/src/plugin/tools.mjs +8231 -8174
- package/src/relay/guarded-dispatch.mjs +43 -0
- package/src/relay/p0-projection.mjs +94 -0
- package/src/relay/redline-guard.mjs +72 -0
- package/src/relay/scrub.mjs +91 -0
- package/src/relay/server.mjs +21 -1
- package/src/relay/state.mjs +6 -1
- package/src/util/data-plane.mjs +180 -0
- package/src/util/redline-core.mjs +216 -0
- package/src/util/sealed-object.mjs +141 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit 1 的可测试接线层:把红线守卫包在现有 handler 外层。
|
|
3
|
+
* server.mjs 只需 `const handle = createGuardedDispatch({ handle: rawHandle })` 即可接入,
|
|
4
|
+
* 无需把守卫散落到各路由内部。
|
|
5
|
+
*/
|
|
6
|
+
import { guardResultEnvelope, guardTaskBody } from './redline-guard.mjs';
|
|
7
|
+
import { blobRefusal } from './p0-projection.mjs';
|
|
8
|
+
|
|
9
|
+
const BLOB_PREFIXES = ['/v1/fleet/blob-sessions', '/v1/fleet/blobs', '/v1/fleet/blob-latest'];
|
|
10
|
+
|
|
11
|
+
function refusal(status, body) {
|
|
12
|
+
return { status, body, headers: { 'cache-control': 'no-store' } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{handle: Function}} options
|
|
17
|
+
* @returns {Function} (request) => {status, body, headers};request: {method, path, body}
|
|
18
|
+
*/
|
|
19
|
+
export function createGuardedDispatch({ handle }) {
|
|
20
|
+
if (typeof handle !== 'function') throw new Error('HANDLE_REQUIRED');
|
|
21
|
+
return function guarded(request) {
|
|
22
|
+
const method = String(request?.method ?? 'GET').toUpperCase();
|
|
23
|
+
const path = String(request?.path ?? '/');
|
|
24
|
+
|
|
25
|
+
for (const prefix of BLOB_PREFIXES) {
|
|
26
|
+
if (path === prefix || path.startsWith(prefix + '/') || path.startsWith(prefix + '?')) {
|
|
27
|
+
const refusalResult = blobRefusal(method);
|
|
28
|
+
return refusal(refusalResult.status, refusalResult.body);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (method === 'POST' && path === '/v1/task') {
|
|
33
|
+
const guard = guardTaskBody(request?.body);
|
|
34
|
+
if (guard.ok !== true) return refusal(guard.status, guard.body);
|
|
35
|
+
}
|
|
36
|
+
if (method === 'POST' && path === '/v1/result') {
|
|
37
|
+
const guard = guardResultEnvelope(request?.body);
|
|
38
|
+
if (guard.ok !== true) return refusal(guard.status, guard.body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return handle(request);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relay P0 投影:写入磁盘/ledger/报表前的唯一投影点。
|
|
3
|
+
* V5/V6/V7:去 stdout/stderr 头尾、argv→hash、路径→count+hash。
|
|
4
|
+
* V8:报表去 stdout_head,blob_sha256 恒 null。
|
|
5
|
+
* V12:blob 端点统一拒绝。
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
10
|
+
const CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material'];
|
|
11
|
+
|
|
12
|
+
function projectPaths(list) {
|
|
13
|
+
if (!Array.isArray(list)) return { count: 0, paths_sha256: sha256('') };
|
|
14
|
+
const normalized = list.map((entry) => String(entry).replace(/\\/g, '/')).sort();
|
|
15
|
+
return { count: normalized.length, paths_sha256: sha256(normalized.join('\n')) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 投影结果信封(原地副本,不改入参)。
|
|
20
|
+
* @returns {{projected: object, dropped: string[], argv_hash: string|null}}
|
|
21
|
+
*/
|
|
22
|
+
export function projectResultEnvelope(envelope) {
|
|
23
|
+
if (envelope === null || typeof envelope !== 'object') return { projected: envelope, dropped: [], argv_hash: null };
|
|
24
|
+
const projected = JSON.parse(JSON.stringify(envelope));
|
|
25
|
+
const dropped = [];
|
|
26
|
+
let argvHash = null;
|
|
27
|
+
for (const field of CONTENT_FIELDS) {
|
|
28
|
+
if (field in projected) {
|
|
29
|
+
if (field === 'command_argv') argvHash = sha256(JSON.stringify(projected[field]));
|
|
30
|
+
delete projected[field];
|
|
31
|
+
dropped.push(field);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (argvHash !== null) projected.command_argv_sha256 = argvHash;
|
|
35
|
+
if ('blob_sha256' in projected) { projected.blob_sha256 = null; dropped.push('blob_sha256'); }
|
|
36
|
+
if ('output_mode' in projected) { delete projected.output_mode; dropped.push('output_mode'); }
|
|
37
|
+
if (projected.workspace !== null && typeof projected.workspace === 'object') {
|
|
38
|
+
if ('patch_sha256' in projected.workspace) projected.workspace.patch_sha256 = null;
|
|
39
|
+
if ('changed_files' in projected.workspace) {
|
|
40
|
+
projected.workspace.files = projectPaths(projected.workspace.changed_files);
|
|
41
|
+
delete projected.workspace.changed_files;
|
|
42
|
+
dropped.push('workspace.changed_files');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if ('changed_files' in projected) {
|
|
46
|
+
projected.files = projectPaths(projected.changed_files);
|
|
47
|
+
delete projected.changed_files;
|
|
48
|
+
dropped.push('changed_files');
|
|
49
|
+
}
|
|
50
|
+
// V7:其余本地派生路径字段同样只保留 count + paths_sha256
|
|
51
|
+
for (const field of ['untracked', 'untracked_files', 'diff_numstat', 'renames', 'deleted_files', 'deleted']) {
|
|
52
|
+
if (field in projected) {
|
|
53
|
+
projected[field + '_paths'] = projectPaths(Array.isArray(projected[field]) ? projected[field] : []);
|
|
54
|
+
delete projected[field];
|
|
55
|
+
dropped.push(field);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { projected, dropped, argv_hash: argvHash };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 投影 ledger 行(result.stored 等):只保留元数据。 */
|
|
62
|
+
export function projectLedgerLine(line) {
|
|
63
|
+
let record;
|
|
64
|
+
try { record = JSON.parse(line); } catch { return { changed: false, line }; }
|
|
65
|
+
const target = record?.result ?? record?.envelope;
|
|
66
|
+
if (target === null || typeof target !== 'object') return { changed: false, line };
|
|
67
|
+
const { projected, dropped } = projectResultEnvelope(target);
|
|
68
|
+
if (dropped.length === 0) return { changed: false, line };
|
|
69
|
+
if (record.result !== undefined) record.result = projected; else record.envelope = projected;
|
|
70
|
+
record.projection = { dropped, at: new Date().toISOString() };
|
|
71
|
+
return { changed: true, line: JSON.stringify(record) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** V8:报表聚合投影。 */
|
|
75
|
+
export function stripReportAggregate(aggregate) {
|
|
76
|
+
if (aggregate === null || typeof aggregate !== 'object') return aggregate;
|
|
77
|
+
const out = JSON.parse(JSON.stringify(aggregate));
|
|
78
|
+
const machines = Array.isArray(out.machines) ? out.machines : [];
|
|
79
|
+
for (const machine of machines) {
|
|
80
|
+
if ('stdout_head' in machine) delete machine.stdout_head;
|
|
81
|
+
if ('blob_sha256' in machine) machine.blob_sha256 = null;
|
|
82
|
+
}
|
|
83
|
+
if ('stdout_head' in out) delete out.stdout_head;
|
|
84
|
+
if ('blob_sha256' in out) out.blob_sha256 = null;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** V12:blob 端点统一拒绝。 */
|
|
89
|
+
export function blobRefusal(method) {
|
|
90
|
+
return {
|
|
91
|
+
status: 403,
|
|
92
|
+
body: { ok: false, code: 'DATA_PLANE_REFUSED', detail: { method, reason: 'relay never stores data-plane objects' } },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relay 红线守卫(relay 专用;字段表唯一来源 util/redline-core)。
|
|
3
|
+
* C7:深层递归扫描 + 16KiB 语义上限 + 协议错误形状(protocol_version/error.code)。
|
|
4
|
+
*/
|
|
5
|
+
import { FORBIDDEN_DATA_KEYS } from '../util/redline-core.mjs';
|
|
6
|
+
|
|
7
|
+
export const MAX_CONTROL_BYTES = 16 * 1024;
|
|
8
|
+
const PROTOCOL_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
const MESSAGES = {
|
|
11
|
+
DATA_PLANE_FORBIDDEN: 'the payload carries data-plane content; relay stores metadata only',
|
|
12
|
+
CONTROL_TOO_LARGE: 'the control message exceeds the 16 KiB limit',
|
|
13
|
+
BAD_REQUEST: 'the request body is not a JSON object',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function refuse(status, code, detail = {}) {
|
|
17
|
+
return {
|
|
18
|
+
status,
|
|
19
|
+
body: {
|
|
20
|
+
protocol_version: PROTOCOL_VERSION,
|
|
21
|
+
ok: false,
|
|
22
|
+
code,
|
|
23
|
+
reason: code,
|
|
24
|
+
detail,
|
|
25
|
+
error: { code, message: MESSAGES[code] ?? code, detail },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function scan(value, path, hits) {
|
|
31
|
+
if (value === null || typeof value !== 'object') return;
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
value.forEach((entry, index) => scan(entry, path + '[' + index + ']', hits));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const [key, child] of Object.entries(value)) {
|
|
37
|
+
const keyPath = path === '' ? key : path + '.' + key;
|
|
38
|
+
if (FORBIDDEN_DATA_KEYS.includes(key.toLowerCase())) hits.push(keyPath);
|
|
39
|
+
scan(child, keyPath, hits);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function guardTaskBody(body) {
|
|
44
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return refuse(400, 'BAD_REQUEST');
|
|
45
|
+
const bytes = Buffer.byteLength(JSON.stringify(body), 'utf8');
|
|
46
|
+
if (bytes > MAX_CONTROL_BYTES) return refuse(413, 'CONTROL_TOO_LARGE', { bytes, limit_bytes: MAX_CONTROL_BYTES });
|
|
47
|
+
const hits = [];
|
|
48
|
+
scan(body, '', hits);
|
|
49
|
+
if (hits.length > 0) return refuse(422, 'DATA_PLANE_FORBIDDEN', { fields: hits.filter((hit) => !hit.includes('.')), paths: hits.slice(0, 8) });
|
|
50
|
+
return { ok: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function guardResultEnvelope(envelope) {
|
|
54
|
+
if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) return refuse(400, 'BAD_REQUEST');
|
|
55
|
+
const explicit = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'blob_sha256', 'output_mode'].filter((key) => key in envelope);
|
|
56
|
+
const hits = [];
|
|
57
|
+
scan(envelope, '', hits);
|
|
58
|
+
if (explicit.length > 0 || hits.length > 0) return refuse(422, 'DATA_PLANE_FORBIDDEN', { fields: [...explicit, ...hits.filter((hit) => !hit.includes('.'))], paths: hits.slice(0, 8) });
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function guardBlobRoute(method) {
|
|
63
|
+
return {
|
|
64
|
+
status: 403,
|
|
65
|
+
body: {
|
|
66
|
+
protocol_version: PROTOCOL_VERSION,
|
|
67
|
+
ok: false,
|
|
68
|
+
code: 'DATA_PLANE_REFUSED',
|
|
69
|
+
error: { code: 'DATA_PLANE_REFUSED', message: 'relay never stores data-plane objects', detail: { method } },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* relay 状态清理(P0):旧 blob 清除 + ledger 内容投影重建。
|
|
3
|
+
* 只处理已落盘的数据,不联网;dry-run 先报清单,apply 先备份再原子替换。
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
copyFileSync, existsSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const LEDGER_CONTENT_FIELDS = ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'command_argv', 'pipeline_hash_material', 'changed_files'];
|
|
12
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
13
|
+
|
|
14
|
+
function projectLedgerLine(line) {
|
|
15
|
+
let record;
|
|
16
|
+
try { record = JSON.parse(line); } catch { return { changed: false, line }; }
|
|
17
|
+
const result = record?.result ?? record?.envelope ?? null;
|
|
18
|
+
if (result === null || typeof result !== 'object') return { changed: false, line };
|
|
19
|
+
let changed = false;
|
|
20
|
+
for (const field of LEDGER_CONTENT_FIELDS) {
|
|
21
|
+
if (field in result) { delete result[field]; changed = true; }
|
|
22
|
+
}
|
|
23
|
+
if ('patch_sha256' in result) { result.patch_sha256 = null; changed = true; }
|
|
24
|
+
if ('workspace' in result && typeof result.workspace === 'object' && result.workspace !== null) {
|
|
25
|
+
if ('changed_files' in result.workspace) { delete result.workspace.changed_files; changed = true; }
|
|
26
|
+
if ('patch_sha256' in result.workspace) { result.workspace.patch_sha256 = null; changed = true; }
|
|
27
|
+
}
|
|
28
|
+
return { changed, line: changed ? JSON.stringify(record) : line };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 清理 ledger:默认 dry-run;apply 时备份 .bak-<ts> 后原子替换。 */
|
|
32
|
+
export function scrubLedger(ledgerPath, { dryRun = true, now = Date.now() } = {}) {
|
|
33
|
+
if (!existsSync(ledgerPath)) return { ok: true, exists: false, changed: 0, lines: 0, backup: null };
|
|
34
|
+
const text = readFileSync(ledgerPath, 'utf8');
|
|
35
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
36
|
+
let changed = 0;
|
|
37
|
+
const rewritten = lines.map((line) => {
|
|
38
|
+
const projected = projectLedgerLine(line);
|
|
39
|
+
if (projected.changed) changed += 1;
|
|
40
|
+
return projected.line;
|
|
41
|
+
});
|
|
42
|
+
if (dryRun || changed === 0) return { ok: true, exists: true, changed, lines: lines.length, backup: null, dryRun };
|
|
43
|
+
const backup = ledgerPath + '.bak-' + now;
|
|
44
|
+
copyFileSync(ledgerPath, backup);
|
|
45
|
+
const tmp = ledgerPath + '.tmp-' + process.pid;
|
|
46
|
+
writeFileSync(tmp, rewritten.join('\n') + '\n', 'utf8');
|
|
47
|
+
renameSync(tmp, ledgerPath);
|
|
48
|
+
return { ok: true, exists: true, changed, lines: lines.length, backup, dryRun: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 清点/删除 relay blob 目录(内容寻址文件)。 */
|
|
52
|
+
export function scrubBlobs(blobDir, { dryRun = true, now = Date.now() } = {}) {
|
|
53
|
+
if (!existsSync(blobDir)) return { ok: true, exists: false, files: [], bytes: 0, deleted: 0, manifest: null };
|
|
54
|
+
const files = [];
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
for (const name of readdirSync(blobDir)) {
|
|
57
|
+
const path = join(blobDir, name);
|
|
58
|
+
if (!statSync(path).isFile()) continue;
|
|
59
|
+
files.push(name);
|
|
60
|
+
bytes += statSync(path).size;
|
|
61
|
+
}
|
|
62
|
+
const manifest = {
|
|
63
|
+
at: new Date(now).toISOString(),
|
|
64
|
+
files: files.map((name) => ({ name, sha256: sha256(readFileSync(join(blobDir, name))) })),
|
|
65
|
+
};
|
|
66
|
+
if (dryRun) return { ok: true, exists: true, files, bytes, deleted: 0, manifest, dryRun };
|
|
67
|
+
for (const name of files) unlinkSync(join(blobDir, name));
|
|
68
|
+
return { ok: true, exists: true, files, bytes, deleted: files.length, manifest, dryRun: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 验收:扫描目录中是否仍存在内容字段/数据面 marker。 */
|
|
72
|
+
export function scanForContent(rootDir, markers = []) {
|
|
73
|
+
const hits = [];
|
|
74
|
+
const walk = (dir) => {
|
|
75
|
+
for (const name of readdirSync(dir)) {
|
|
76
|
+
const path = join(dir, name);
|
|
77
|
+
const stat = statSync(path);
|
|
78
|
+
if (stat.isDirectory()) { walk(path); continue; }
|
|
79
|
+
if (stat.size > 32 * 1024 * 1024) continue;
|
|
80
|
+
const text = readFileSync(path, 'utf8');
|
|
81
|
+
for (const field of LEDGER_CONTENT_FIELDS) {
|
|
82
|
+
if (text.includes('"' + field + '"')) hits.push({ file: path, marker: field });
|
|
83
|
+
}
|
|
84
|
+
for (const marker of markers) {
|
|
85
|
+
if (marker !== '' && text.includes(marker)) hits.push({ file: path, marker });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (existsSync(rootDir)) walk(rootDir);
|
|
90
|
+
return { ok: hits.length === 0, hits };
|
|
91
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { guardBlobRoute } from './redline-guard.mjs';
|
|
2
|
+
import { guardResultEnvelope, guardTaskBody } from './redline-guard.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* W2M Rabbit relay — node:http / node:https server.
|
|
3
5
|
*
|
|
@@ -695,6 +697,13 @@ export class RelayServer {
|
|
|
695
697
|
});
|
|
696
698
|
}
|
|
697
699
|
const path = normalizePath(stripped);
|
|
700
|
+
// B3/V12:basePath 剥离与 URL 规范化之后立即拒绝数据面 blob 路由,
|
|
701
|
+
// 覆盖 `..`、`%2e%2e`、basePath 前缀等变形;在读取 body 之前返回。
|
|
702
|
+
if (path === '/v1/fleet/blob-sessions' || path.startsWith('/v1/fleet/blob-sessions/')
|
|
703
|
+
|| path === '/v1/fleet/blob-latest' || path.startsWith('/v1/fleet/blobs')) {
|
|
704
|
+
const refusal = guardBlobRoute(method);
|
|
705
|
+
return sendJson(res, refusal.status, refusal.body);
|
|
706
|
+
}
|
|
698
707
|
const signedPath = `${stripped}${url.search}`;
|
|
699
708
|
|
|
700
709
|
// v0.3.0 §9: read the body ONCE, before routing. An HMAC covers the exact
|
|
@@ -785,6 +794,10 @@ export class RelayServer {
|
|
|
785
794
|
this.enforceSignature(req, { method, signedPath, body: rawBody });
|
|
786
795
|
const body = takeJson();
|
|
787
796
|
if (principal.machine_id && !body.created_by) body.created_by = principal.machine_id;
|
|
797
|
+
const taskGuard = guardTaskBody(body);
|
|
798
|
+
if (taskGuard.ok !== true) {
|
|
799
|
+
return sendJson(res, taskGuard.status, taskGuard.body);
|
|
800
|
+
}
|
|
788
801
|
const result = this.state.createTask(body);
|
|
789
802
|
return sendJson(res, 200, result);
|
|
790
803
|
}
|
|
@@ -1210,7 +1223,14 @@ export class RelayServer {
|
|
|
1210
1223
|
}
|
|
1211
1224
|
if (method === 'POST' && path === '/v1/result') {
|
|
1212
1225
|
const body = takeJson();
|
|
1213
|
-
|
|
1226
|
+
// B1 方案 A:window 模式接收旧信封(含 argv/head)并投影后存储;enforce 才硬拒。
|
|
1227
|
+
if (String(process.env.W2M_DATA_PLANE_GUARD ?? 'window') === 'enforce') {
|
|
1228
|
+
const resultGuard = guardResultEnvelope(body);
|
|
1229
|
+
if (resultGuard.ok !== true) {
|
|
1230
|
+
return sendJson(res, resultGuard.status, resultGuard.body);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
const result = this.state.submitResult(body);
|
|
1214
1234
|
const { _instanceKey, ...clean } = result;
|
|
1215
1235
|
return sendJson(res, 200, clean);
|
|
1216
1236
|
}
|
package/src/relay/state.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectResultEnvelope } from './p0-projection.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* W2M Rabbit relay — in-memory state machine.
|
|
3
4
|
*
|
|
@@ -3155,8 +3156,12 @@ export class RabbitState {
|
|
|
3155
3156
|
envelopeShaOk = sha256Hex(jcs(rest)) === envelope.envelope_sha256;
|
|
3156
3157
|
}
|
|
3157
3158
|
|
|
3159
|
+
// B2/V5/V6/V7:先校验(上面已完成),再投影后存储;原信封哈希单独保留用于审计。
|
|
3160
|
+
const { projected, dropped } = projectResultEnvelope(envelope);
|
|
3158
3161
|
const record = {
|
|
3159
|
-
envelope,
|
|
3162
|
+
envelope: projected,
|
|
3163
|
+
envelope_sha256_received: typeof envelope.envelope_sha256 === 'string' ? envelope.envelope_sha256 : null,
|
|
3164
|
+
...(dropped.length > 0 ? { projection: { dropped } } : {}),
|
|
3160
3165
|
machine_id: machineId,
|
|
3161
3166
|
dedupe_key: dedupeKey,
|
|
3162
3167
|
attempt,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S0 — 数据面端口(DataPlaneSink)。P0 止血的地基。
|
|
3
|
+
*
|
|
4
|
+
* 红线(2026-10-27 冻结):
|
|
5
|
+
* · 用户数据只允许去用户自己选择的第三方(GitHub / 局域网 Git / SSH 云盘等);
|
|
6
|
+
* · 我们的 Relay 只承载类型化元数据(指令/状态/hash/size/count/Ref ≤4KiB);
|
|
7
|
+
* · 任何 push/fetch 目标解析后指向 relay → 一律拒绝;
|
|
8
|
+
* · 无 sink 时统一返回 ARTIFACT_NOT_DELIVERED,绝不回退 relay。
|
|
9
|
+
*
|
|
10
|
+
* 本模块无 I/O:只做 Ref 校验、sink 目标判定与端口分发。真实适配器(git/ssh/dir)
|
|
11
|
+
* 与 NullSink 由调用方注入,这样单测不碰网络与磁盘。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const REF_MAX_BYTES = 4096;
|
|
15
|
+
export const REF_FIELDS = Object.freeze([
|
|
16
|
+
'v', 'sink', 'locator', 'kind', 'task_id', 'machine_id',
|
|
17
|
+
'plain', 'cipher', 'enc', 'zip', 'sig',
|
|
18
|
+
]);
|
|
19
|
+
export const REF_KINDS = Object.freeze(['stdout', 'stderr', 'patch', 'memory', 'artifact', 'reduce-input']);
|
|
20
|
+
export const SINK_KINDS = Object.freeze(['github', 'lan-git', 'ssh', 'dir']);
|
|
21
|
+
export const REF_HEX = /^[0-9a-f]{64}$/;
|
|
22
|
+
|
|
23
|
+
const DATA_FIELDS = new Set([
|
|
24
|
+
'data', 'content', 'contents', 'patch', 'patch_text', 'stdout', 'stderr',
|
|
25
|
+
'text', 'blob', 'base64', 'body', 'raw',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function utf8Bytes(value) {
|
|
29
|
+
return Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseHost(endpoint) {
|
|
33
|
+
if (typeof endpoint !== 'string' || endpoint.trim() === '') return null;
|
|
34
|
+
const scpLike = /^[^@\s]+@([^:\s]+):/.exec(endpoint.trim());
|
|
35
|
+
if (scpLike !== null) return { host: scpLike[1].toLowerCase(), port: '', href: endpoint, scheme: 'ssh' };
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(endpoint);
|
|
38
|
+
return { host: url.hostname.toLowerCase(), port: url.port, href: url.href, scheme: url.protocol.replace(':', '') };
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPrivateOrLocal(host) {
|
|
45
|
+
return /^10\./.test(host)
|
|
46
|
+
|| /^192\.168\./.test(host)
|
|
47
|
+
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|
|
48
|
+
|| host === 'localhost'
|
|
49
|
+
|| host.endsWith('.local')
|
|
50
|
+
|| /^\[fd[0-9a-f]{2}:/i.test(host)
|
|
51
|
+
|| /^\[fe80:/i.test(host);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 判断一个数据面目标是否获准(只做字符串/URL 级判定;DNS 解析后校验由适配器负责)。
|
|
56
|
+
* @returns {{ok: true, sink: string} | {ok: false, code: string, detail: object}}
|
|
57
|
+
*/
|
|
58
|
+
export function assertDataPlaneTarget({ endpoint, relayBase }) {
|
|
59
|
+
const target = parseHost(endpoint);
|
|
60
|
+
if (target === null) return { ok: false, code: 'DATA_PLANE_ENDPOINT_INVALID', detail: {} };
|
|
61
|
+
if (typeof relayBase === 'string' && relayBase !== '') {
|
|
62
|
+
const relay = parseHost(relayBase);
|
|
63
|
+
if (relay !== null && target.host === relay.host) {
|
|
64
|
+
return { ok: false, code: 'DATA_PLANE_POINTS_AT_RELAY', detail: { endpoint, relay: relay.host } };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (target.host === 'github.com' || target.host.endsWith('.github.com')) {
|
|
68
|
+
return { ok: true, sink: 'github' };
|
|
69
|
+
}
|
|
70
|
+
if (target.scheme === 'ssh' || target.scheme === 'git') {
|
|
71
|
+
return { ok: true, sink: isPrivateOrLocal(target.host) ? 'lan-git' : 'ssh' };
|
|
72
|
+
}
|
|
73
|
+
if (target.scheme === 'http' || target.scheme === 'https') {
|
|
74
|
+
if (isPrivateOrLocal(target.host)) return { ok: true, sink: 'lan-git' };
|
|
75
|
+
return { ok: false, code: 'DATA_PLANE_SCHEME_FORBIDDEN', detail: { scheme: target.scheme, host: target.host } };
|
|
76
|
+
}
|
|
77
|
+
return { ok: false, code: 'DATA_PLANE_SCHEME_FORBIDDEN', detail: { scheme: target.scheme } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function scanForContent(value, path, hits) {
|
|
81
|
+
if (value === null || typeof value !== 'object') return;
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
value.forEach((entry, index) => scanForContent(entry, path + '[' + index + ']', hits));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const [key, child] of Object.entries(value)) {
|
|
87
|
+
const keyPath = path === '' ? key : path + '.' + key;
|
|
88
|
+
if (DATA_FIELDS.has(key.toLowerCase())) hits.push(keyPath);
|
|
89
|
+
scanForContent(child, keyPath, hits);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 校验进入控制面的 Ref:纯元数据、≤4KiB、无内容字段、哈希形状正确。
|
|
95
|
+
* @returns {{ok: true} | {ok: false, code: string, detail: object}}
|
|
96
|
+
*/
|
|
97
|
+
export function inspectRef(ref) {
|
|
98
|
+
if (ref === null || typeof ref !== 'object' || Array.isArray(ref)) {
|
|
99
|
+
return { ok: false, code: 'REF_NOT_OBJECT', detail: {} };
|
|
100
|
+
}
|
|
101
|
+
const bytes = utf8Bytes(ref);
|
|
102
|
+
if (bytes > REF_MAX_BYTES) {
|
|
103
|
+
return { ok: false, code: 'REF_TOO_LARGE', detail: { bytes, limit_bytes: REF_MAX_BYTES } };
|
|
104
|
+
}
|
|
105
|
+
const hits = [];
|
|
106
|
+
scanForContent(ref, '', hits);
|
|
107
|
+
if (hits.length > 0) return { ok: false, code: 'DATA_PLANE_FORBIDDEN', detail: { paths: hits.slice(0, 8) } };
|
|
108
|
+
const unknown = Object.keys(ref).filter((key) => !REF_FIELDS.includes(key));
|
|
109
|
+
if (unknown.length > 0) {
|
|
110
|
+
return { ok: false, code: 'REF_FIELD_UNKNOWN', detail: { unknown, allowed: REF_FIELDS.slice() } };
|
|
111
|
+
}
|
|
112
|
+
if (ref.v !== 1) return { ok: false, code: 'REF_VERSION', detail: { expected: 1, got: ref.v ?? null } };
|
|
113
|
+
if (!SINK_KINDS.includes(ref.sink)) {
|
|
114
|
+
return { ok: false, code: 'REF_SINK_UNKNOWN', detail: { allowed: SINK_KINDS.slice(), got: ref.sink ?? null } };
|
|
115
|
+
}
|
|
116
|
+
if (!REF_KINDS.includes(ref.kind)) {
|
|
117
|
+
return { ok: false, code: 'REF_KIND_UNKNOWN', detail: { allowed: REF_KINDS.slice(), got: ref.kind ?? null } };
|
|
118
|
+
}
|
|
119
|
+
if (typeof ref.locator !== 'string' || ref.locator.trim() === '' || ref.locator.length > 512) {
|
|
120
|
+
return { ok: false, code: 'REF_LOCATOR_INVALID', detail: {} };
|
|
121
|
+
}
|
|
122
|
+
if (ref.plain !== undefined) {
|
|
123
|
+
if (typeof ref.plain !== 'object' || !REF_HEX.test(String(ref.plain.sha256 ?? ''))) {
|
|
124
|
+
return { ok: false, code: 'REF_PLAIN_INVALID', detail: {} };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (ref.cipher !== undefined) {
|
|
128
|
+
if (typeof ref.cipher !== 'object' || !REF_HEX.test(String(ref.cipher.sha256 ?? ''))) {
|
|
129
|
+
return { ok: false, code: 'REF_CIPHER_INVALID', detail: {} };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { ok: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* NullSink:永不上传,统一降级码。用于"无数据面"的合规默认值。
|
|
137
|
+
*/
|
|
138
|
+
export function createNullSink() {
|
|
139
|
+
return {
|
|
140
|
+
kind: 'null',
|
|
141
|
+
async pushObject() {
|
|
142
|
+
return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', detail: { reason: 'no data plane sink configured' } };
|
|
143
|
+
},
|
|
144
|
+
async fetchObject() {
|
|
145
|
+
return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', detail: { reason: 'no data plane sink configured' } };
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 端口分发:调用方注入 sinks(github/lan-git/ssh/dir),未命中一律 NullSink。
|
|
152
|
+
* @param {{sinks?: Record<string, object>, relayBase?: string}} [options]
|
|
153
|
+
*/
|
|
154
|
+
export function createDataPlane(options = {}) {
|
|
155
|
+
const sinks = options.sinks ?? {};
|
|
156
|
+
const relayBase = options.relayBase ?? '';
|
|
157
|
+
function pick(endpoint) {
|
|
158
|
+
const target = assertDataPlaneTarget({ endpoint, relayBase });
|
|
159
|
+
if (target.ok !== true) return { ok: false, result: target };
|
|
160
|
+
const sink = sinks[target.sink];
|
|
161
|
+
if (sink === undefined) return { ok: true, sink: createNullSink() };
|
|
162
|
+
return { ok: true, sink };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
async pushObject({ endpoint, ref, ...rest }) {
|
|
166
|
+
const refCheck = inspectRef(ref);
|
|
167
|
+
if (refCheck.ok !== true) return refCheck;
|
|
168
|
+
const chosen = pick(endpoint);
|
|
169
|
+
if (chosen.ok !== true) return chosen.result;
|
|
170
|
+
return chosen.sink.pushObject({ ref, ...rest });
|
|
171
|
+
},
|
|
172
|
+
async fetchObject({ endpoint, ref, ...rest }) {
|
|
173
|
+
const refCheck = inspectRef(ref);
|
|
174
|
+
if (refCheck.ok !== true) return refCheck;
|
|
175
|
+
const chosen = pick(endpoint);
|
|
176
|
+
if (chosen.ok !== true) return chosen.result;
|
|
177
|
+
return chosen.sink.fetchObject({ ref, ...rest });
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|