@twinsearth/w2m-dsh-plugin 2.0.7 → 2.0.9

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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -5
  4. package/TEST-CASES-JOINT-FLEET-2.0.8.md +119 -0
  5. package/TEST-PLAN-JOINT-FLEET-2.0.8.md +99 -0
  6. package/codex/mcp-servers.toml +1 -1
  7. package/docs/DSH-UPGRADE-2.0.8.md +129 -0
  8. package/docs/TRANSFER-LAYER.md +162 -0
  9. package/docs/acceptance/2.0.8/RELEASE-2080.json +21 -0
  10. package/docs/acceptance/2.0.9/RELEASE-2090.json +24 -0
  11. package/docs/adr/0002-p2p-transport.md +67 -0
  12. package/docs/plans/IM2A-2.2.2-PLAN.md +293 -0
  13. package/lib/client.js +30 -4
  14. package/package.json +5 -3
  15. package/plugin.json +1 -1
  16. package/src/agent/agent.mjs +7 -0
  17. package/src/agent/chacha.mjs +93 -0
  18. package/src/agent/crypto.mjs +64 -29
  19. package/src/agent/discovery.mjs +81 -0
  20. package/src/agent/iroh-sidecar.mjs +149 -0
  21. package/src/agent/p2p-node.mjs +11 -1
  22. package/src/agent/p2p-race.mjs +105 -0
  23. package/src/agent/p2p.mjs +105 -18
  24. package/src/agent/path-scheduler.mjs +106 -0
  25. package/src/agent/transfer/artifact-ref-v3.mjs +93 -0
  26. package/src/agent/transfer/ferry.mjs +206 -0
  27. package/src/agent/transfer/index.mjs +4 -0
  28. package/src/agent/transfer/manifest.mjs +92 -0
  29. package/src/agent/transfer/object-store.mjs +144 -0
  30. package/src/agent/transfer/publish.mjs +76 -0
  31. package/src/agent/transfer/reference.mjs +97 -0
  32. package/src/agent/transfer/router.mjs +80 -0
  33. package/src/agent/transfer/want-block.mjs +135 -0
  34. package/src/agent/transport.mjs +146 -0
  35. package/src/game/xiangqi.mjs +217 -0
  36. package/src/plugin/fleet-binding.mjs +105 -0
  37. package/src/plugin/tools.mjs +34 -2
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 文件分块清单(C1 的最小可信底座)。
3
+ *
4
+ * 纯函数,无 I/O:给定文件大小与块大小,产出一份可独立校验的 manifest。
5
+ * root = SHA256("w2m-xfer-root-v1" || size || chunkBytes || 逐块 SHA256),域分离避免
6
+ * 叶哈希/根哈希/不同切法互相混淆;manifest 本身不再包含明文内容。
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+
10
+ export const XFER_VERSION = 1;
11
+ export const DEFAULT_CHUNK_BYTES = 256 * 1024;
12
+ export const MIN_CHUNK_BYTES = 16 * 1024;
13
+ export const MAX_CHUNK_BYTES = 2 * 1024 * 1024;
14
+ export const MAX_CHUNKS = 65536;
15
+
16
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
17
+
18
+ export function chunkLengths(sizeBytes, chunkBytes = DEFAULT_CHUNK_BYTES) {
19
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0) return { ok: false, code: 'XFER_SIZE_INVALID' };
20
+ if (!Number.isSafeInteger(chunkBytes) || chunkBytes < MIN_CHUNK_BYTES || chunkBytes > MAX_CHUNK_BYTES) return { ok: false, code: 'XFER_CHUNK_SIZE_INVALID' };
21
+ if (sizeBytes === 0) return { ok: true, count: 0, chunkBytes, lastChunkBytes: 0 };
22
+ const count = Math.ceil(sizeBytes / chunkBytes);
23
+ if (count > MAX_CHUNKS) return { ok: false, code: 'XFER_TOO_MANY_CHUNKS', detail: { count, max: MAX_CHUNKS } };
24
+ return { ok: true, count, chunkBytes, lastChunkBytes: sizeBytes % chunkBytes === 0 ? chunkBytes : sizeBytes % chunkBytes };
25
+ }
26
+
27
+ export function chunkSha256(bytes) {
28
+ return sha256(bytes);
29
+ }
30
+
31
+ /**
32
+ * @param {{sizeBytes:number, chunkBytes?:number, chunkHashes:string[], objectId:string, taskId?:string|null,
33
+ * machineId?:string|null, kind?:string, sinkKind?:string, relPath:string, cipherBytes?:number}} input
34
+ */
35
+ export function createManifest(input) {
36
+ const plan = chunkLengths(input.sizeBytes, input.chunkBytes ?? DEFAULT_CHUNK_BYTES);
37
+ if (plan.ok !== true) return plan;
38
+ const hashes = Array.isArray(input.chunkHashes) ? input.chunkHashes : [];
39
+ if (hashes.length !== plan.count) return { ok: false, code: 'XFER_CHUNK_COUNT_MISMATCH', detail: { expected: plan.count, got: hashes.length } };
40
+ for (const hash of hashes) if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) return { ok: false, code: 'XFER_CHUNK_HASH_INVALID' };
41
+ const root = createHash('sha256')
42
+ .update('w2m-xfer-root-v1\0')
43
+ .update(String(input.sizeBytes) + '\0' + String(plan.chunkBytes) + '\0')
44
+ .update(hashes.join('\0'))
45
+ .digest('hex');
46
+ return {
47
+ ok: true,
48
+ manifest: {
49
+ v: XFER_VERSION,
50
+ object_id: input.objectId,
51
+ task_id: input.taskId ?? null,
52
+ machine_id: input.machineId ?? null,
53
+ kind: typeof input.kind === 'string' ? input.kind : 'stdout',
54
+ sink: { kind: typeof input.sinkKind === 'string' ? input.sinkKind : 'cache', rel_path: input.relPath },
55
+ size_bytes: input.sizeBytes,
56
+ chunk_bytes: plan.chunkBytes,
57
+ chunks: plan.count,
58
+ last_chunk_bytes: plan.lastChunkBytes,
59
+ chunk_hashes: hashes.slice(),
60
+ root_sha256: root,
61
+ },
62
+ };
63
+ }
64
+
65
+ export function verifyManifest(manifest) {
66
+ if (manifest === null || typeof manifest !== 'object') return { ok: false, code: 'XFER_MANIFEST_INVALID' };
67
+ if (manifest.v !== XFER_VERSION) return { ok: false, code: 'XFER_MANIFEST_VERSION' };
68
+ if (typeof manifest.object_id !== 'string' || !/^[0-9a-f]{32,64}$/.test(manifest.object_id)) return { ok: false, code: 'XFER_OBJECT_ID_INVALID' };
69
+ if (!Number.isSafeInteger(manifest.size_bytes) || manifest.size_bytes < 0) return { ok: false, code: 'XFER_SIZE_INVALID' };
70
+ const rebuilt = createManifest({
71
+ sizeBytes: manifest.size_bytes,
72
+ chunkBytes: manifest.chunk_bytes,
73
+ chunkHashes: manifest.chunk_hashes,
74
+ objectId: manifest.object_id,
75
+ taskId: manifest.task_id,
76
+ machineId: manifest.machine_id,
77
+ kind: manifest.kind,
78
+ sinkKind: manifest.sink?.kind,
79
+ relPath: manifest.sink?.rel_path,
80
+ });
81
+ if (rebuilt.ok !== true) return rebuilt;
82
+ if (rebuilt.manifest.root_sha256 !== manifest.root_sha256) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
83
+ return { ok: true };
84
+ }
85
+
86
+ /** 某一块在文件中的字节偏移与长度(最后一块较短)。 */
87
+ export function chunkRange(manifest, index) {
88
+ if (!Number.isInteger(index) || index < 0 || index >= manifest.chunks) return null;
89
+ const offset = index * manifest.chunk_bytes;
90
+ const length = index === manifest.chunks - 1 ? manifest.last_chunk_bytes : manifest.chunk_bytes;
91
+ return { offset, length };
92
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 分块落盘 / 读取端的最小实现(C1 + C3 共用)。
3
+ *
4
+ * 一个对象一个目录:data.part(稀疏、按 offset 写)、meta.json(原子更新)。
5
+ * 提交前必须:全部块到齐、逐块哈希匹配、根哈希重算一致 → fsync → rename 为 <object_id>.bin。
6
+ * 失败保留 data.part 以便续传;根哈希不符则隔离,不产生“半份成品”。
7
+ */
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import {
10
+ closeSync, existsSync, fsyncSync, ftruncateSync, mkdirSync, openSync, readFileSync, readSync,
11
+ renameSync, unlinkSync, writeFileSync, writeSync,
12
+ } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ import { createManifest, chunkLengths, chunkRange, XFER_VERSION } from './manifest.mjs';
16
+
17
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
18
+ const readJson = (file) => JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
19
+
20
+ export function createObjectStore({ root }) {
21
+ if (typeof root !== 'string' || root === '') throw new TypeError('XFER_STORE_ROOT_REQUIRED');
22
+ const dirOf = (objectId) => join(root, objectId);
23
+ const metaPath = (objectId) => join(dirOf(objectId), 'meta.json');
24
+ const partPath = (objectId) => join(dirOf(objectId), 'data.part');
25
+ const finalPath = (objectId) => join(root, objectId + '.bin');
26
+
27
+ const saveMeta = (meta) => {
28
+ const file = metaPath(meta.object_id);
29
+ const tmp = file + '.tmp-' + process.pid + '-' + randomBytes(4).toString('hex');
30
+ writeFileSync(tmp, JSON.stringify(meta), { encoding: 'utf8', mode: 0o600 });
31
+ renameSync(tmp, file);
32
+ };
33
+
34
+ return {
35
+ root,
36
+
37
+ /** 开始/恢复一个入站对象。同一 objectId 且根一致 = 续传;否则拒绝。 */
38
+ begin({ objectId, totalBytes, chunkBytes, rootSha256, taskId = null, machineId = null, kind = 'stdout', sinkKind = 'cache', relPath = null }) {
39
+ if (typeof objectId !== 'string' || !/^[0-9a-f]{32,64}$/.test(objectId)) return { ok: false, code: 'XFER_OBJECT_ID_INVALID' };
40
+ if (typeof rootSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(rootSha256)) return { ok: false, code: 'XFER_ROOT_INVALID' };
41
+ const dir = dirOf(objectId);
42
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ // A completed object is immutable and needs no new session.
44
+ if (existsSync(finalPath(objectId))) return { ok: true, resumed: false, complete: true, objectId };
45
+ if (existsSync(metaPath(objectId))) {
46
+ const meta = readJson(metaPath(objectId));
47
+ if (meta.root_sha256 !== rootSha256 || meta.size_bytes !== totalBytes) return { ok: false, code: 'XFER_OBJECT_CONFLICT' };
48
+ meta.resumed = true;
49
+ saveMeta(meta);
50
+ return { ok: true, resumed: true, complete: meta.chunk_hashes.every((h) => h !== null), objectId };
51
+ }
52
+ const plan = chunkLengths(totalBytes, chunkBytes);
53
+ if (plan.ok !== true) return plan;
54
+ const fd = openSync(partPath(objectId), 'w+', 0o600);
55
+ try { ftruncateSync(fd, totalBytes); } finally { closeSync(fd); }
56
+ const emptyRoot = createManifest({ sizeBytes: 0, chunkBytes, chunkHashes: [], objectId, taskId, machineId, kind, sinkKind, relPath: relPath ?? objectId + '.bin' }).manifest.root_sha256;
57
+ const meta = {
58
+ v: XFER_VERSION,
59
+ object_id: objectId,
60
+ task_id: taskId,
61
+ machine_id: machineId,
62
+ kind,
63
+ sink: { kind: sinkKind, rel_path: relPath ?? objectId + '.bin' },
64
+ size_bytes: totalBytes,
65
+ chunk_bytes: plan.chunkBytes,
66
+ chunks: plan.count,
67
+ last_chunk_bytes: plan.lastChunkBytes,
68
+ root_sha256: totalBytes === 0 ? emptyRoot : rootSha256,
69
+ root_expected: rootSha256,
70
+ chunk_hashes: Array(plan.count).fill(null),
71
+ created_at: new Date().toISOString(),
72
+ };
73
+ // 空对象直接可提交:根必须与调用方声明的根一致。
74
+ if (totalBytes === 0 && rootSha256 !== emptyRoot) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
75
+ saveMeta(meta);
76
+ return { ok: true, resumed: false, complete: totalBytes === 0, objectId };
77
+ },
78
+
79
+ /** 写一块。乱序允许;同 index 同哈希幂等;冲突即终态错误。 */
80
+ writeChunk({ objectId, index, bytes, chunkSha256 = null }) {
81
+ const meta = readJson(metaPath(objectId));
82
+ const range = chunkRange(meta, index);
83
+ if (range === null) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
84
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? '');
85
+ if (buf.length !== range.length) return { ok: false, code: 'XFER_CHUNK_LENGTH_INVALID', detail: { expected: range.length, got: buf.length } };
86
+ const digest = sha256(buf);
87
+ if (chunkSha256 !== null && chunkSha256 !== digest) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
88
+ if (meta.chunk_hashes[index] !== null && meta.chunk_hashes[index] !== digest) return { ok: false, code: 'XFER_CHUNK_CONFLICT' };
89
+ if (meta.chunk_hashes[index] === digest) return { ok: true, duplicate: true, index, sha256: digest };
90
+ const fd = openSync(partPath(objectId), 'r+');
91
+ try { writeSync(fd, buf, 0, buf.length, range.offset); } finally { closeSync(fd); }
92
+ meta.chunk_hashes[index] = digest;
93
+ saveMeta(meta);
94
+ return { ok: true, duplicate: false, index, sha256: digest };
95
+ },
96
+
97
+ /** 已完成的块索引;续传握手用。 */
98
+ have(objectId) {
99
+ if (!existsSync(metaPath(objectId))) return { ok: false, code: 'XFER_SESSION_NOT_FOUND' };
100
+ const meta = readJson(metaPath(objectId));
101
+ const have = [];
102
+ meta.chunk_hashes.forEach((hash, index) => { if (hash !== null) have.push(index); });
103
+ return { ok: true, objectId, have, complete: meta.chunk_hashes.every((h) => h !== null), chunks: meta.chunks, root_sha256: meta.root_expected };
104
+ },
105
+
106
+ /** 全量校验 + 提交。根不符 → 移到 quarantine 并报错(不产生成品)。 */
107
+ commit(objectId) {
108
+ const meta = readJson(metaPath(objectId));
109
+ if (meta.chunk_hashes.some((hash) => hash === null)) return { ok: false, code: 'XFER_CHUNKS_MISSING', detail: { missing: meta.chunk_hashes.filter((h) => h === null).length } };
110
+ const rebuilt = createManifest({ sizeBytes: meta.size_bytes, chunkBytes: meta.chunk_bytes, chunkHashes: meta.chunk_hashes, objectId: meta.object_id, taskId: meta.task_id, machineId: meta.machine_id, kind: meta.kind, sinkKind: meta.sink.kind, relPath: meta.sink.rel_path });
111
+ if (rebuilt.ok !== true) return rebuilt;
112
+ if (rebuilt.manifest.root_sha256 !== meta.root_expected) return { ok: false, code: 'XFER_ROOT_MISMATCH', detail: { expected: meta.root_expected, actual: rebuilt.manifest.root_sha256 } };
113
+ const fd = openSync(partPath(objectId), 'r+');
114
+ try { fsyncSync(fd); } finally { closeSync(fd); }
115
+ renameSync(partPath(objectId), finalPath(objectId));
116
+ const manifest = { ...rebuilt.manifest, committed_at: new Date().toISOString() };
117
+ const file = join(root, objectId + '.ref.json');
118
+ writeFileSync(file, JSON.stringify(manifest), { encoding: 'utf8', mode: 0o600 });
119
+ try { unlinkSync(metaPath(objectId)); } catch { /* already gone */ }
120
+ try { unlinkSync(dirOf(objectId)); } catch { /* directory removal is best effort */ }
121
+ return { ok: true, objectId, bytes: meta.size_bytes, sha256: rebuilt.manifest.root_sha256, path: finalPath(objectId), manifest };
122
+ },
123
+
124
+ /** 读一块并校验;损坏返回 XFER_CHUNK_HASH_MISMATCH,绝不返回坏字节。 */
125
+ readChunk({ objectId, index }) {
126
+ const manifestPath = join(root, objectId + '.ref.json');
127
+ if (!existsSync(manifestPath)) return { ok: false, code: 'XFER_OBJECT_NOT_FOUND' };
128
+ const manifest = readJson(manifestPath);
129
+ const range = chunkRange(manifest, index);
130
+ if (range === null) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
131
+ const buf = Buffer.alloc(range.length);
132
+ const fd = openSync(finalPath(objectId), 'r');
133
+ try { const read = readSync(fd, buf, 0, range.length, range.offset); if (read !== range.length) return { ok: false, code: 'XFER_READ_SHORT' }; } finally { closeSync(fd); }
134
+ if (sha256(buf) !== manifest.chunk_hashes[index]) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
135
+ return { ok: true, bytes: buf, index, sha256: manifest.chunk_hashes[index] };
136
+ },
137
+
138
+ has(objectId) {
139
+ if (existsSync(join(root, objectId + '.ref.json'))) return { ok: true, complete: true, bytes: readJson(join(root, objectId + '.ref.json')).size_bytes };
140
+ if (existsSync(metaPath(objectId))) { const have = this.have(objectId); return { ok: true, complete: false, received: have.have ?? [], chunks: have.chunks ?? null }; }
141
+ return { ok: false, code: 'XFER_OBJECT_NOT_FOUND' };
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * C2:ArtifactRef 的控制面发布(复用 relay 现有项目决策通道,不新增存储、不传字节)。
3
+ *
4
+ * 发布体 = { title:"artifact.available", kind:"artifact_available", text: JSON(ref) };
5
+ * relay 只记录这条元数据,字节永远在本机对象库/sink。读取端按 title 过滤并校验 ref。
6
+ * 该通道在 relay 2.0.5 已存在(POST/GET /v1/projects/:id/decisions),无需新路由即可先跑通。
7
+ */
8
+ import { verifyRefV2 } from './ferry.mjs';
9
+
10
+ const TITLE = 'artifact.available';
11
+ const KIND = 'artifact_available';
12
+
13
+ function baseOf(url) {
14
+ if (typeof url !== 'string' || url.trim() === '') return null;
15
+ return url.trim().replace(/\/+$/, '');
16
+ }
17
+
18
+ /**
19
+ * @param {{relayUrl:string, token:string, projectId?:string, ref:object, fetchImpl?:Function, signal?:AbortSignal}} input
20
+ */
21
+ export async function publishRef(input) {
22
+ const base = baseOf(input?.relayUrl);
23
+ if (base === null) return { ok: false, code: 'RELAY_URL_REQUIRED' };
24
+ if (typeof input?.token !== 'string' || input.token === '') return { ok: false, code: 'OPERATOR_TOKEN_REQUIRED' };
25
+ const checked = verifyRefV2(input?.ref);
26
+ if (checked.ok !== true) return checked;
27
+ const projectId = typeof input.projectId === 'string' && input.projectId !== '' ? input.projectId : 'default';
28
+ const fetchImpl = typeof input.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
29
+ const body = { title: TITLE, kind: KIND, text: JSON.stringify(checked.ok ? input.ref : null), refs: { rootCid: input.ref.rootCid, originPeer: input.ref.originPeer } };
30
+ let response = null;
31
+ try {
32
+ response = await fetchImpl(base + '/v1/projects/' + encodeURIComponent(projectId) + '/decisions', {
33
+ method: 'POST',
34
+ headers: { authorization: 'Bearer ' + input.token, 'content-type': 'application/json', accept: 'application/json' },
35
+ body: JSON.stringify(body),
36
+ signal: input.signal,
37
+ });
38
+ } catch {
39
+ return { ok: false, code: 'RELAY_UNREACHABLE' };
40
+ }
41
+ let json = null;
42
+ try { json = await response.json(); } catch { json = null; }
43
+ if (response.ok !== true) return { ok: false, code: 'RELAY_REFUSED', detail: { status: response.status, error: json?.error?.code ?? null } };
44
+ if (json?.ok !== true) return { ok: false, code: 'RELAY_REFUSED', detail: { status: response.status } };
45
+ return { ok: true, decision: json.decision ?? null, ref: input.ref };
46
+ }
47
+
48
+ /**
49
+ * @param {{relayUrl:string, token:string, projectId?:string, fetchImpl?:Function, signal?:AbortSignal}} input
50
+ */
51
+ export async function listPublishedRefs(input) {
52
+ const base = baseOf(input?.relayUrl);
53
+ if (base === null) return { ok: false, code: 'RELAY_URL_REQUIRED' };
54
+ if (typeof input?.token !== 'string' || input.token === '') return { ok: false, code: 'DEVICE_TOKEN_REQUIRED' };
55
+ const projectId = typeof input.projectId === 'string' && input.projectId !== '' ? input.projectId : 'default';
56
+ const fetchImpl = typeof input.fetchImpl === 'function' ? input.fetchImpl : globalThis.fetch;
57
+ let response = null;
58
+ try {
59
+ response = await fetchImpl(base + '/v1/projects/' + encodeURIComponent(projectId) + '/decisions', { headers: { authorization: 'Bearer ' + input.token, accept: 'application/json' }, signal: input.signal });
60
+ } catch {
61
+ return { ok: false, code: 'RELAY_UNREACHABLE' };
62
+ }
63
+ let json = null;
64
+ try { json = await response.json(); } catch { json = null; }
65
+ if (response.ok !== true) return { ok: false, code: 'RELAY_REFUSED', detail: { status: response.status } };
66
+ const decisions = Array.isArray(json?.decisions) ? json.decisions : [];
67
+ const refs = [];
68
+ for (const decision of decisions) {
69
+ if (decision?.title !== TITLE || decision?.kind !== KIND) continue;
70
+ let ref = null;
71
+ try { ref = JSON.parse(String(decision.text ?? '')); } catch { continue; }
72
+ if (verifyRefV2(ref).ok !== true) continue;
73
+ refs.push(ref);
74
+ }
75
+ return { ok: true, refs };
76
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ArtifactRef v2 + 本地引用登记(C2 的基础形状)与解析阶梯(C3 的本地分支)。
3
+ *
4
+ * 控制面只承载元数据:object_id / task / machine / kind / sink / 大小 / 块数 / 根哈希 / TTL。
5
+ * 字节永远在本机对象库或用户 sink 里。resolver 不回落 relay:本地没有就明确
6
+ * TRANSFER_UNREACHABLE(远端 P2P 拉取是下一阶段接线)。
7
+ */
8
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const HEX64 = /^[0-9a-f]{64}$/;
12
+ const OBJECT = /^[0-9a-f]{32,64}$/;
13
+
14
+ export function createRef(manifest, { ttlMs = 24 * 3600 * 1000, now = Date.now } = {}) {
15
+ if (manifest === null || typeof manifest !== 'object') return { ok: false, code: 'XFER_MANIFEST_INVALID' };
16
+ if (!OBJECT.test(String(manifest.object_id ?? ''))) return { ok: false, code: 'XFER_OBJECT_ID_INVALID' };
17
+ if (!HEX64.test(String(manifest.root_sha256 ?? ''))) return { ok: false, code: 'XFER_ROOT_INVALID' };
18
+ if (!Number.isSafeInteger(manifest.size_bytes) || !Number.isSafeInteger(manifest.chunks)) return { ok: false, code: 'XFER_MANIFEST_INVALID' };
19
+ return {
20
+ ok: true,
21
+ ref: {
22
+ v: 2,
23
+ object_id: manifest.object_id,
24
+ task_id: manifest.task_id ?? null,
25
+ machine_id: manifest.machine_id ?? null,
26
+ kind: manifest.kind ?? 'stdout',
27
+ sink: { kind: manifest.sink?.kind ?? 'cache', rel_path: manifest.sink?.rel_path ?? manifest.object_id + '.bin' },
28
+ size_bytes: manifest.size_bytes,
29
+ chunk_bytes: manifest.chunk_bytes,
30
+ chunks: manifest.chunks,
31
+ root_sha256: manifest.root_sha256,
32
+ created_at: new Date(now()).toISOString(),
33
+ expires_at: new Date(now() + ttlMs).toISOString(),
34
+ },
35
+ };
36
+ }
37
+
38
+ export function verifyRef(ref, { now = Date.now } = {}) {
39
+ const nowMs = typeof now === 'function' ? now() : now;
40
+ if (ref === null || typeof ref !== 'object' || ref.v !== 2) return { ok: false, code: 'DELIVERY_REF_INVALID' };
41
+ if (!OBJECT.test(String(ref.object_id ?? ''))) return { ok: false, code: 'DELIVERY_REF_INVALID' };
42
+ if (!HEX64.test(String(ref.root_sha256 ?? ''))) return { ok: false, code: 'DELIVERY_REF_INVALID' };
43
+ if (!Number.isSafeInteger(ref.size_bytes) || !Number.isSafeInteger(ref.chunks) || ref.chunks < 0) return { ok: false, code: 'DELIVERY_REF_INVALID' };
44
+ if (typeof ref.expires_at === 'string' && Date.parse(ref.expires_at) <= nowMs) return { ok: false, code: 'DELIVERY_STALE' };
45
+ return { ok: true };
46
+ }
47
+
48
+ export function createRefRegistry({ root }) {
49
+ if (typeof root !== 'string' || root === '') throw new TypeError('XFER_REF_ROOT_REQUIRED');
50
+ const dir = join(root, 'refs');
51
+ const fileOf = (objectId) => join(dir, objectId + '.json');
52
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ return {
54
+ publish(ref) {
55
+ const checked = verifyRef(ref);
56
+ if (checked.ok !== true) return checked;
57
+ const file = fileOf(ref.object_id);
58
+ writeFileSync(file, JSON.stringify(ref), { encoding: 'utf8', mode: 0o600 });
59
+ return { ok: true, ref, path: file };
60
+ },
61
+ get(objectId) {
62
+ try {
63
+ const ref = JSON.parse(readFileSync(fileOf(objectId), 'utf8').replace(/^\uFEFF/, ''));
64
+ const checked = verifyRef(ref);
65
+ return checked.ok === true ? { ok: true, ref } : checked;
66
+ } catch {
67
+ return { ok: false, code: 'DELIVERY_REF_NOT_FOUND' };
68
+ }
69
+ },
70
+ list() {
71
+ const refs = [];
72
+ let names = [];
73
+ try { names = readdirSync(dir); } catch { return { ok: true, refs }; }
74
+ for (const name of names) {
75
+ if (!name.endsWith('.json')) continue;
76
+ try { const ref = JSON.parse(readFileSync(join(dir, name), 'utf8')); if (verifyRef(ref).ok === true) refs.push(ref); } catch { /* skip corrupt */ }
77
+ }
78
+ return { ok: true, refs };
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * 本地解析:对象已完整且根哈希匹配 → 返回路径;否则显式失败(不回落 relay)。
85
+ * 远端 P2P 拉取(C3 的 xfer 分支)在下一阶段接入,这里保留明确的失败语义。
86
+ */
87
+ export function resolveLocal(ref, { store }) {
88
+ const checked = verifyRef(ref);
89
+ if (checked.ok !== true) return checked;
90
+ const local = store.has(ref.object_id);
91
+ if (local.ok !== true) return { ok: false, code: 'TRANSFER_UNREACHABLE', detail: { object_id: ref.object_id, reason: 'not in local object store; remote P2P fetch not wired yet' } };
92
+ if (local.complete !== true) return { ok: false, code: 'TRANSFER_INCOMPLETE', detail: { object_id: ref.object_id } };
93
+ const manifestPath = join(store.root, ref.object_id + '.ref.json');
94
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, ''));
95
+ if (manifest.root_sha256 !== ref.root_sha256) return { ok: false, code: 'DELIVERY_ROOT_MISMATCH' };
96
+ return { ok: true, path: join(store.root, ref.object_id + '.bin'), bytes: manifest.size_bytes, object_id: ref.object_id };
97
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * P0-a: route `xfer.*` frames that arrive on a real P2PChannel.
3
+ *
4
+ * The router owns no transport: it is handed the frame and a channel, and it
5
+ * uses the existing chunk source/client primitives. Registering a source or a
6
+ * receiver is explicit, so an unregistered frame is dropped (never guessed).
7
+ * One listener per channel is the caller's job; this router never attaches one.
8
+ */
9
+ import { createChunkSource, createWantClient } from './want-block.mjs';
10
+
11
+ export function createXferFrameRouter() {
12
+ const sources = new Map(); // rootCid -> { chunked, filePath, transferId, source }
13
+ const receivers = new Map(); // transferId -> { receiver, client }
14
+
15
+ const parse = (payload) => {
16
+ if (Buffer.isBuffer(payload) || typeof payload === 'string') {
17
+ try { return JSON.parse(Buffer.from(payload).toString('utf8')); } catch { return null; }
18
+ }
19
+ return payload !== null && typeof payload === 'object' ? payload : null;
20
+ };
21
+
22
+ return {
23
+ registerSource(rootCid, { chunked, filePath, transferId = null } = {}) {
24
+ if (typeof rootCid !== 'string' || rootCid === '') return { ok: false, code: 'XFER_ROOT_INVALID' };
25
+ sources.set(rootCid, { chunked, filePath, transferId, source: null, sourceTransferId: null });
26
+ return { ok: true };
27
+ },
28
+ registerReceiver(transferId, receiver, rootCid) {
29
+ if (typeof transferId !== 'string' || transferId === '') return { ok: false, code: 'XFER_TRANSFER_INVALID' };
30
+ const client = createWantClient({ transferId, rootCid, receiver });
31
+ if (client.ok !== true) return client;
32
+ receivers.set(transferId, { receiver, client });
33
+ return { ok: true };
34
+ },
35
+ unregister(rootCid) { sources.delete(rootCid); },
36
+ has(rootCid) { return sources.has(rootCid); },
37
+ /** Send the next want window for a registered receiver over a channel. */
38
+ request(transferId, channel) {
39
+ const entry = receivers.get(transferId);
40
+ if (entry === undefined || typeof channel?.send !== 'function') return { ok: false, code: 'XFER_RECEIVER_UNKNOWN' };
41
+ const missing = entry.client.missing();
42
+ if (missing.length === 0) return { ok: true, requested: 0 };
43
+ const frames = entry.client.requestFrames(missing);
44
+ for (const frame of frames) channel.send(JSON.stringify(frame));
45
+ return { ok: true, requested: missing.length };
46
+ },
47
+ /**
48
+ * @returns {boolean} true when the frame belonged to this layer (handled or refused)
49
+ */
50
+ handle(payload, { channel } = {}) {
51
+ const frame = parse(payload);
52
+ if (frame === null || typeof frame.type !== 'string' || !frame.type.startsWith('xfer.')) return false;
53
+ if (typeof channel?.send !== 'function') return true;
54
+ if (frame.type === 'xfer.want') {
55
+ const entry = sources.get(frame.rootCid) ?? sources.get(frame.transferId);
56
+ if (entry === undefined) return true; // unknown source: drop, do not leak why
57
+ if (entry.source === null || entry.sourceTransferId !== frame.transferId) {
58
+ entry.source = createChunkSource({ chunked: entry.chunked, filePath: entry.filePath, transferId: frame.transferId });
59
+ entry.sourceTransferId = frame.transferId;
60
+ }
61
+ const served = entry.source.handle(frame);
62
+ if (served.ok !== true) { channel.send(JSON.stringify({ type: 'xfer.error', transferId: frame.transferId, code: served.code })); return true; }
63
+ for (const outgoing of served.frames) channel.send(JSON.stringify(outgoing));
64
+ return true;
65
+ }
66
+ if (frame.type === 'xfer.chunk') {
67
+ const entry = receivers.get(frame.transferId);
68
+ if (entry === undefined) return true;
69
+ entry.receiver.writeChunk(frame.index, Buffer.from(String(frame.data), 'base64'), frame.sha256);
70
+ return true;
71
+ }
72
+ if (frame.type === 'xfer.error') {
73
+ const entry = receivers.get(frame.transferId);
74
+ if (entry !== undefined) entry.lastError = frame.code ?? 'XFER_PEER_ERROR';
75
+ return true;
76
+ }
77
+ return true;
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * P2P want-block 协议(传输无关的最小实现)。
3
+ *
4
+ * 帧只在两台机器之间流动,绝不经过 relay:
5
+ * xfer.want { transferId, rootCid, indices }
6
+ * xfer.chunk { transferId, index, sha256, size, data(base64) }
7
+ * xfer.error { transferId, code, index? }
8
+ * 接收端把 chunk 交给 createReceiver().writeChunk;全部到齐后 finalize。
9
+ * 传输由调用方注入(P2PChannel / 测试用 duplex),本模块不发起 HTTP。
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { readSync, openSync, closeSync } from 'node:fs';
13
+
14
+ export const WANT = 'xfer.want';
15
+ export const CHUNK = 'xfer.chunk';
16
+ export const ERROR = 'xfer.error';
17
+ export const MAX_WINDOW = 8;
18
+ export const MAX_CHUNK_B64 = 3 * 1024 * 1024;
19
+
20
+ const isHex64 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
21
+
22
+ /** 发送端:按请求读取文件块并编码为帧。 */
23
+ export function createChunkSource({ chunked, filePath, transferId = null }) {
24
+ if (chunked === null || typeof chunked !== 'object' || !Array.isArray(chunked.chunks)) return { ok: false, code: 'XFER_SOURCE_INVALID' };
25
+ const tid = transferId ?? chunked.rootCid;
26
+ return {
27
+ ok: true,
28
+ handle(frame) {
29
+ if (frame?.type !== WANT) return { ok: false, code: 'XFER_FRAME_UNEXPECTED' };
30
+ if (frame.transferId !== tid) return { ok: false, code: 'XFER_TRANSFER_MISMATCH' };
31
+ if (frame.rootCid !== chunked.rootCid) return { ok: false, code: 'XFER_ROOT_MISMATCH' };
32
+ const indices = Array.isArray(frame.indices) ? frame.indices : [];
33
+ if (indices.length === 0 || indices.length > MAX_WINDOW) return { ok: false, code: 'XFER_WINDOW_INVALID' };
34
+ const out = [];
35
+ const fd = openSync(filePath, 'r');
36
+ try {
37
+ for (const index of indices) {
38
+ const chunk = chunked.chunks[index];
39
+ if (!Number.isInteger(index) || chunk === undefined) { out.push({ type: ERROR, transferId: tid, code: 'XFER_CHUNK_INDEX_INVALID', index }); continue; }
40
+ const buf = Buffer.alloc(chunk.size);
41
+ const read = readSync(fd, buf, 0, chunk.size, index * chunked.chunkBytes);
42
+ if (read !== chunk.size) { out.push({ type: ERROR, transferId: tid, code: 'XFER_READ_SHORT', index }); continue; }
43
+ out.push({ type: CHUNK, transferId: tid, index, sha256: chunk.sha256, size: chunk.size, data: buf.toString('base64') });
44
+ }
45
+ } finally { closeSync(fd); }
46
+ return { ok: true, frames: out };
47
+ },
48
+ };
49
+ }
50
+
51
+ /** 接收端:把帧写入 createReceiver;缺失块由 receiver 的位图反推。 */
52
+ export function createWantClient({ transferId, rootCid, receiver }) {
53
+ if (typeof transferId !== 'string' || !isHex64(rootCid?.replace(/^sha256:/, ''))) return { ok: false, code: 'XFER_ROOT_INVALID' };
54
+ return {
55
+ ok: true,
56
+ missing() {
57
+ const have = receiver.have();
58
+ const total = receiver.progress().total;
59
+ const out = [];
60
+ for (let index = 0; index < total; index += 1) if (!have.has(index)) out.push(index);
61
+ return out;
62
+ },
63
+ requestFrames(indices) {
64
+ return [{ type: WANT, transferId, rootCid, indices: indices.slice(0, MAX_WINDOW) }];
65
+ },
66
+ accept(frame) {
67
+ if (frame?.type === ERROR) return { ok: false, code: frame.code ?? 'XFER_PEER_ERROR', index: frame.index ?? null };
68
+ if (frame?.type !== CHUNK) return { ok: false, code: 'XFER_FRAME_UNEXPECTED' };
69
+ if (frame.transferId !== transferId) return { ok: false, code: 'XFER_TRANSFER_MISMATCH' };
70
+ if (String(frame.data ?? '').length > MAX_CHUNK_B64) return { ok: false, code: 'XFER_CHUNK_TOO_LARGE' };
71
+ const data = Buffer.from(String(frame.data), 'base64');
72
+ const written = receiver.writeChunk(frame.index, data, frame.sha256);
73
+ if (written.ok !== true) return written;
74
+ return { ok: true, index: frame.index, complete: receiver.progress().complete };
75
+ },
76
+ };
77
+ }
78
+
79
+ /** 两帧之间的纯函数检查:块哈希与大小必须与声明一致(不依赖 receiver)。 */
80
+ export function verifyChunkFrame(frame) {
81
+ if (frame?.type !== CHUNK) return { ok: false, code: 'XFER_FRAME_UNEXPECTED' };
82
+ if (!Number.isInteger(frame.index) || frame.index < 0) return { ok: false, code: 'XFER_CHUNK_INDEX_INVALID' };
83
+ if (!isHex64(frame.sha256)) return { ok: false, code: 'XFER_CHUNK_HASH_INVALID' };
84
+ const data = Buffer.from(String(frame.data ?? ''), 'base64');
85
+ if (data.length !== frame.size) return { ok: false, code: 'XFER_CHUNK_LENGTH_INVALID' };
86
+ const digest = createHash('sha256').update(data).digest('hex');
87
+ if (digest !== frame.sha256) return { ok: false, code: 'XFER_CHUNK_HASH_MISMATCH' };
88
+ return { ok: true, data };
89
+ }
90
+
91
+
92
+ /**
93
+ * P0-a: run the chunk source over a MessageChannel (P2PChannel / iroh sidecar)
94
+ * instead of a bare dgram socket. Frames stay JSON for wire compatibility; the
95
+ * channel supplies encryption, ACK and fragmentation.
96
+ */
97
+ export function attachChunkSource(channel, { chunked, filePath, transferId = null }) {
98
+ const source = createChunkSource({ chunked, filePath, transferId });
99
+ if (source.ok !== true) return source;
100
+ const handler = (payload) => {
101
+ let frame = null;
102
+ try { frame = JSON.parse(Buffer.from(payload).toString('utf8')); } catch { return; }
103
+ const served = source.handle(frame);
104
+ if (served.ok !== true) { channel.send(JSON.stringify({ type: ERROR, transferId: frame?.transferId ?? null, code: served.code })); return; }
105
+ for (const outgoing of served.frames) channel.send(JSON.stringify(outgoing));
106
+ };
107
+ channel.on('message', handler);
108
+ return { ok: true, close: () => channel.off?.('message', handler) };
109
+ }
110
+
111
+ /**
112
+ * P0-a: receiver side over a MessageChannel. request() sends the next window;
113
+ * inbound chunk frames are validated and written exactly as in the UDP path.
114
+ */
115
+ export function attachWantClient(channel, { transferId, rootCid, receiver }) {
116
+ const client = createWantClient({ transferId, rootCid, receiver });
117
+ if (client.ok !== true) return client;
118
+ const handler = (payload) => {
119
+ let frame = null;
120
+ try { frame = JSON.parse(Buffer.from(payload).toString('utf8')); } catch { return; }
121
+ client.accept(frame);
122
+ };
123
+ channel.on('message', handler);
124
+ return {
125
+ ok: true,
126
+ client,
127
+ request() {
128
+ const missing = client.missing();
129
+ if (missing.length === 0) return 0;
130
+ channel.send(JSON.stringify(client.requestFrames(missing)[0]));
131
+ return missing.length;
132
+ },
133
+ close: () => channel.off?.('message', handler),
134
+ };
135
+ }