@twinsearth/w2m-dsh-plugin 1.9.1 → 1.9.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.9.1",
3
+ "version": "1.9.5",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.9.1",
4
+ "version": "1.9.5",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 目录 sink(本地/局域网共享盘):密封对象落盘、按内容哈希取回。
3
+ * 数据面适配器的最小实现;git/ssh 适配器同契约,后续补。
4
+ * 对象已经是 sealObject() 的密文,sink 不接触明文与密钥。
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ export function createDirSink({ root }) {
11
+ if (typeof root !== 'string' || root === '') throw new Error('SINK_ROOT_REQUIRED');
12
+ mkdirSync(root, { recursive: true });
13
+ return {
14
+ kind: 'dir',
15
+ async pushObject({ sealed }) {
16
+ if (sealed === null || typeof sealed !== 'object') return { ok: false, code: 'OBJECT_INVALID' };
17
+ const body = JSON.stringify(sealed);
18
+ const id = createHash('sha256').update(body, 'utf8').digest('hex');
19
+ const path = join(root, id + '.json');
20
+ writeFileSync(path, body, { encoding: 'utf8', mode: 0o600 });
21
+ return { ok: true, objectId: id, path, bytes: Buffer.byteLength(body, 'utf8') };
22
+ },
23
+ async fetchObject({ objectId }) {
24
+ if (typeof objectId !== 'string' || !/^[0-9a-f]{64}$/.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
25
+ const path = join(root, objectId + '.json');
26
+ if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
27
+ return { ok: true, sealed: JSON.parse(readFileSync(path, 'utf8')), path };
28
+ },
29
+ async listObjects() {
30
+ return readdirSync(root).filter((name) => name.endsWith('.json')).map((name) => name.replace(/\.json$/, ''));
31
+ },
32
+ };
33
+ }
@@ -1,3 +1,4 @@
1
+ import { decidePath } from './path-policy.mjs';
1
2
  /**
2
3
  * GPW v2 — folder policy for the approval gate (v1.7.5-beta.1).
3
4
  *
@@ -71,24 +72,15 @@ export function classifyPath(input) {
71
72
  const base = input.base ?? process.cwd();
72
73
  const target = normalizeFolder(input.target, { base, platform });
73
74
  const normalise = (list) => (Array.isArray(list) ? list : []).map((f) => normalizeFolder(f, { base, platform }));
74
-
75
- for (const folder of normalise(input.blacklist)) {
76
- if (isInside(target, folder, { platform })) {
77
- return { decision: 'deny', target, folder, reason: `the folder "${folder}" is on the blacklist` };
78
- }
79
- }
80
- for (const folder of normalise(input.whitelist)) {
81
- if (isInside(target, folder, { platform })) {
82
- return { decision: 'allow', target, folder, reason: `the folder "${folder}" is on the whitelist` };
83
- }
84
- }
85
- if (typeof input.workspace === 'string' && input.workspace !== '') {
86
- const workspace = normalizeFolder(input.workspace, { base, platform });
87
- if (isInside(target, workspace, { platform })) {
88
- return { decision: 'allow', target, folder: workspace, reason: 'the target is inside the workspace' };
89
- }
90
- }
91
- return { decision: 'pass', target, folder: null, reason: 'no list mentions this target, so GPW asks the operator' };
75
+ const rules = [
76
+ ...normalise(input.blacklist).map((path) => ({ path, effect: 'deny', source: 'panel' })),
77
+ ...normalise(input.whitelist).map((path) => ({ path, effect: 'allow', source: 'panel' })),
78
+ ];
79
+ const verdict = decidePath({ target, rules, exceptions: [], workspace: input.workspace ?? null, platform });
80
+ // 兼容旧返回形状:approve 视为 pass(需要人工),判定仍由 decidePath 单一实现。
81
+ const decision = verdict.decision === 'approve' ? 'pass' : verdict.decision;
82
+ const folder = verdict.rule?.path ?? (verdict.source === 'workspace' ? normalizeFolder(input.workspace, { base, platform }) : null);
83
+ return { decision, target, folder, reason: verdict.reason };
92
84
  }
93
85
 
94
86
  /** Fields that carry a single filesystem path in the tool schemas this plugin sees. */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * P0-1 / P0-2 适配层:gpw-colors 的唯一调用入口。
3
+ *
4
+ * 规则:白名单 = allow 规则,黑名单 = deny 规则;判定只走 path-policy.decideMany
5
+ * (分段比较、deny 优先、最长前缀、例外 approve)。颜色由 colorOf 渲染,
6
+ * 不参与判定;白名单/工作区全部命中时 skipBudget=true(不进 10 次/分预算)。
7
+ */
8
+ import { DECISION, colorOf, decideMany } from './path-policy.mjs';
9
+
10
+ export function evaluateCall(input) {
11
+ const platform = input?.platform ?? process.platform;
12
+ const paths = Array.isArray(input?.paths) ? input.paths : [];
13
+ const rules = [];
14
+ for (const path of input?.whitelist ?? []) rules.push({ path, effect: 'allow', source: 'panel' });
15
+ for (const path of input?.blacklist ?? []) rules.push({ path, effect: 'deny', source: 'panel' });
16
+ const verdict = decideMany({
17
+ targets: paths,
18
+ rules,
19
+ exceptions: input?.exceptions ?? [],
20
+ workspace: input?.workspace ?? null,
21
+ platform,
22
+ });
23
+ return {
24
+ decision: verdict.decision,
25
+ color: colorOf(verdict.decision),
26
+ skipBudget: verdict.decision === DECISION.ALLOW,
27
+ reason: verdict.reason,
28
+ results: verdict.results ?? [],
29
+ };
30
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 红线审计落盘:JSONL + fsync + 哈希链 + 校验。
3
+ * 只接受元数据字段(内容字段直接拒绝),链断裂/篡改可被定位。
4
+ */
5
+ import { closeSync, fsyncSync, openSync, readFileSync, writeSync } from 'node:fs';
6
+ import { createHash } from 'node:crypto';
7
+
8
+ export const AUDIT_FORBIDDEN = new Set(['content', 'data', 'file', 'files', 'patch', 'stdout', 'stderr', 'diff', 'blob', 'bytes', 'text', 'base64']);
9
+
10
+ function stable(value) {
11
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
12
+ if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']';
13
+ return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stable(value[key])).join(',') + '}';
14
+ }
15
+
16
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
17
+
18
+ /** 追加一条审计;写盘后 fsync。返回写入的记录。 */
19
+ export function appendAudit(file, event) {
20
+ if (event === null || typeof event !== 'object' || Array.isArray(event)) {
21
+ return { ok: false, code: 'AUDIT_EVENT_INVALID' };
22
+ }
23
+ const bad = Object.keys(event).filter((key) => AUDIT_FORBIDDEN.has(key.toLowerCase()));
24
+ if (bad.length > 0) return { ok: false, code: 'AUDIT_CONTENT_FORBIDDEN', detail: { keys: bad } };
25
+
26
+ let previous = null;
27
+ let seq = 1;
28
+ try {
29
+ const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
30
+ if (lines.length > 0) {
31
+ const last = JSON.parse(lines[lines.length - 1]);
32
+ previous = last.record_sha256 ?? null;
33
+ seq = Number(last.seq ?? lines.length) + 1;
34
+ }
35
+ } catch { /* 新文件 */ }
36
+
37
+ const body = {
38
+ v: 1,
39
+ seq,
40
+ at: event.at ?? new Date().toISOString(),
41
+ event: typeof event.event === 'string' ? event.event : 'joint.event',
42
+ plan_sha256: event.plan_sha256 ?? null,
43
+ step_id: event.step_id ?? null,
44
+ task_id: event.task_id ?? null,
45
+ machine_id: event.machine_id ?? null,
46
+ outcome: event.outcome ?? null,
47
+ reason_code: event.reason_code ?? null,
48
+ control_bytes: Number.isInteger(event.control_bytes) ? event.control_bytes : null,
49
+ control_sha256: typeof event.control_sha256 === 'string' ? event.control_sha256 : null,
50
+ prev_sha256: previous,
51
+ };
52
+ const record = { ...body, record_sha256: sha256(stable(body)) };
53
+ const fd = openSync(file, 'a');
54
+ try {
55
+ writeSync(fd, JSON.stringify(record) + '\n');
56
+ fsyncSync(fd);
57
+ } finally {
58
+ closeSync(fd);
59
+ }
60
+ return { ok: true, record };
61
+ }
62
+
63
+ /** 校验整条链;返回第一处问题的 seq。 */
64
+ export function verifyAuditFile(file) {
65
+ let lines;
66
+ try {
67
+ lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
68
+ } catch {
69
+ return { ok: true, count: 0, head: null };
70
+ }
71
+ let expectedPrev = null;
72
+ let head = null;
73
+ lines.forEach((line, index) => {
74
+ let record;
75
+ try { record = JSON.parse(line); } catch { throw new Error('AUDIT_JSON_BROKEN at line ' + (index + 1)); }
76
+ const { record_sha256: stored, ...body } = record;
77
+ if (body.prev_sha256 !== expectedPrev) throw new Error('AUDIT_CHAIN_BROKEN at seq ' + body.seq);
78
+ if (sha256(stable(body)) !== stored) throw new Error('AUDIT_ENTRY_TAMPERED at seq ' + body.seq);
79
+ expectedPrev = stored;
80
+ head = stored;
81
+ });
82
+ return { ok: true, count: lines.length, head };
83
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 联合开发:计划 → 控制面信封 → 分发(R1–R4 的生产接线点)。
3
+ *
4
+ * 只构造"任务指令 + 元数据":任务 ID、机器、步骤、命令 hash、base commit、写范围。
5
+ * 任何用户代码/文件/输出字节都不允许出现在这里;发送前逐条过 redline.inspectControlMessage,
6
+ * 失败即拒绝并写审计(只记元数据)。本模块不做网络 I/O,sendFn 由调用方注入。
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+
10
+ import { appendAudit } from './audit-file.mjs';
11
+ import { createAudit, inspectControlMessage } from './redline.mjs';
12
+
13
+ export const MAX_STEPS = 16;
14
+ const DATA_KEYS = new Set(['data', 'file', 'files', 'content', 'patch', 'diff', 'blob', 'bytes', 'stdout', 'stderr', 'base64', 'text']);
15
+
16
+ const sha256 = (value) => createHash('sha256').update(String(value), 'utf8').digest('hex');
17
+
18
+ function scanData(value, path, hits) {
19
+ if (value === null || typeof value !== 'object') return;
20
+ if (Array.isArray(value)) { value.forEach((entry, index) => scanData(entry, path + '[' + index + ']', hits)); return; }
21
+ for (const [key, child] of Object.entries(value)) {
22
+ const keyPath = path === '' ? key : path + '.' + key;
23
+ if (DATA_KEYS.has(key.toLowerCase())) hits.push(keyPath);
24
+ scanData(child, keyPath, hits);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * 校验计划:步骤唯一、依赖存在、无环、无数据字段、机器 ID 合法。
30
+ * @returns {{ok: true, plan: object} | {ok: false, code: string, detail: object}}
31
+ */
32
+ export function buildPlan(input) {
33
+ if (input === null || typeof input !== 'object') return { ok: false, code: 'PLAN_INVALID', detail: {} };
34
+ const steps = Array.isArray(input.steps) ? input.steps : null;
35
+ if (steps === null || steps.length === 0 || steps.length > MAX_STEPS) {
36
+ return { ok: false, code: 'PLAN_STEPS_INVALID', detail: { count: steps === null ? null : steps.length } };
37
+ }
38
+ const hits = [];
39
+ scanData(input, '', hits);
40
+ if (hits.length > 0) return { ok: false, code: 'DATA_PLANE_FORBIDDEN', detail: { paths: hits.slice(0, 8) } };
41
+
42
+ const ids = new Set();
43
+ for (const step of steps) {
44
+ if (typeof step?.step_id !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(step.step_id)) {
45
+ return { ok: false, code: 'PLAN_STEP_ID_INVALID', detail: { step_id: step?.step_id ?? null } };
46
+ }
47
+ if (ids.has(step.step_id)) return { ok: false, code: 'PLAN_STEP_DUPLICATE', detail: { step_id: step.step_id } };
48
+ ids.add(step.step_id);
49
+ if (typeof step.machine_id !== 'string' || step.machine_id === '') {
50
+ return { ok: false, code: 'PLAN_MACHINE_INVALID', detail: { step_id: step.step_id } };
51
+ }
52
+ if (!Array.isArray(step.command_argv) || step.command_argv.length === 0) {
53
+ return { ok: false, code: 'PLAN_ARGV_INVALID', detail: { step_id: step.step_id } };
54
+ }
55
+ }
56
+ const graph = new Map(steps.map((step) => [step.step_id, Array.isArray(step.depends_on) ? step.depends_on : []]));
57
+ for (const [id, deps] of graph) {
58
+ for (const dep of deps) {
59
+ if (!ids.has(dep)) return { ok: false, code: 'PLAN_DEP_UNKNOWN', detail: { step_id: id, dep } };
60
+ }
61
+ }
62
+ const visiting = new Set();
63
+ const done = new Set();
64
+ const visit = (id) => {
65
+ if (done.has(id)) return true;
66
+ if (visiting.has(id)) return false;
67
+ visiting.add(id);
68
+ for (const dep of graph.get(id) ?? []) if (!visit(dep)) return false;
69
+ visiting.delete(id);
70
+ done.add(id);
71
+ return true;
72
+ };
73
+ for (const id of ids) if (!visit(id)) return { ok: false, code: 'PLAN_CYCLE', detail: { step_id: id } };
74
+
75
+ const canonical = {
76
+ v: 1,
77
+ title: typeof input.title === 'string' ? input.title.slice(0, 200) : '',
78
+ base_commit: typeof input.base_commit === 'string' ? input.base_commit : null,
79
+ steps: steps.map((step) => ({
80
+ step_id: step.step_id,
81
+ machine_id: step.machine_id,
82
+ command_argv: step.command_argv.slice(0, 32),
83
+ command_hash: sha256(JSON.stringify(step.command_argv)),
84
+ write_scope: Array.isArray(step.write_scope) ? step.write_scope.slice(0, 8) : [],
85
+ depends_on: Array.isArray(step.depends_on) ? step.depends_on.slice(0, 8) : [],
86
+ timeout_ms: Number.isInteger(step.timeout_ms) ? step.timeout_ms : null,
87
+ })),
88
+ };
89
+ return { ok: true, plan: { ...canonical, plan_sha256: sha256(JSON.stringify(canonical)) } };
90
+ }
91
+
92
+ /** 计划 → 控制面信封(每个步骤一条 joint.dispatch,只含元数据)。 */
93
+ export function buildControlEnvelopes(plan) {
94
+ return plan.steps.map((step) => ({
95
+ v: 1,
96
+ type: 'joint.dispatch',
97
+ body: {
98
+ plan_sha256: plan.plan_sha256,
99
+ step_id: step.step_id,
100
+ machine_id: step.machine_id,
101
+ base_commit: plan.base_commit,
102
+ command_argv: step.command_argv,
103
+ command_hash: step.command_hash,
104
+ write_scope: step.write_scope,
105
+ depends_on: step.depends_on,
106
+ ...(step.timeout_ms === null ? {} : { timeout_ms: step.timeout_ms }),
107
+ },
108
+ }));
109
+ }
110
+
111
+ /**
112
+ * 分发:逐条过红线 → 审计 → sendFn。失败即停,返回已完成与失败原因。
113
+ * @param {{plan: object, sendFn: Function, dryRun?: boolean, audit?: object}} input
114
+ */
115
+ export async function dispatchPlan({ plan, sendFn, dryRun = false, audit = null, auditFile = null }) {
116
+ if (plan === null || typeof plan !== 'object' || typeof plan.plan_sha256 !== 'string') {
117
+ return { ok: false, code: 'PLAN_NOT_BUILT', sent: [], audits: [] };
118
+ }
119
+ const auditLog = audit ?? createAudit();
120
+ const envelopes = buildControlEnvelopes(plan);
121
+ const sent = [];
122
+ const audits = [];
123
+ for (const envelope of envelopes) {
124
+ const check = inspectControlMessage(envelope);
125
+ const bytes = Buffer.byteLength(JSON.stringify(envelope), 'utf8');
126
+ if (check.ok !== true) {
127
+ const record = { event: 'joint.dispatch.refused', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, outcome: 'denied', reason_code: check.code, control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) };
128
+ auditLog.append(record);
129
+ audits.push(record);
130
+ return { ok: false, code: check.code, detail: check.detail, sent, audits };
131
+ }
132
+ if (dryRun) { sent.push({ step_id: envelope.body.step_id, bytes, dispatched: false }); continue; }
133
+ if (typeof auditFile === 'string') {
134
+ let written;
135
+ try { written = appendAudit(auditFile, { event: 'joint.dispatch.sending', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) }); } catch (error) { written = { ok: false, detail: String(error) }; }
136
+ if (written.ok !== true) return { ok: false, code: 'AUDIT_UNAVAILABLE', detail: written, sent, audits };
137
+ }
138
+ let response;
139
+ try {
140
+ response = await sendFn(envelope, { stepId: envelope.body.step_id, machineId: envelope.body.machine_id, bytes });
141
+ } catch (error) {
142
+ const record = { event: 'joint.dispatch.failed', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, outcome: 'error', reason_code: 'SEND_FAILED', control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) };
143
+ auditLog.append(record);
144
+ audits.push(record);
145
+ return { ok: false, code: 'SEND_FAILED', detail: { step_id: envelope.body.step_id, error: String(error).slice(0, 120) }, sent, audits };
146
+ }
147
+ const record = { event: 'joint.dispatch.sent', plan_sha256: plan.plan_sha256, step_id: envelope.body.step_id, machine_id: envelope.body.machine_id, outcome: response?.ok === true ? 'ok' : 'rejected', reason_code: response?.code ?? null, control_bytes: bytes, control_sha256: sha256(JSON.stringify(envelope)) };
148
+ auditLog.append(record);
149
+ audits.push(record);
150
+ sent.push({ step_id: envelope.body.step_id, bytes, dispatched: true });
151
+ if (response?.ok !== true) return { ok: false, code: response?.code ?? 'DISPATCH_REJECTED', detail: { step_id: envelope.body.step_id }, sent, audits };
152
+ }
153
+ return { ok: true, sent, audits, auditHead: auditLog.head(), dryRun };
154
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 联合开发闭环(纯函数 + 注入传输):collect → merge → verify。
3
+ * 数据字节只经直连 fetchFn;relay 只出现在控制面(dispatch.mjs)。
4
+ */
5
+
6
+ /** 收集:按 ref 直连拉取,校验 sha256;缺失/不匹配即记为问题。 */
7
+ export async function collectArtifacts({ refs, fetchFn, crypto }) {
8
+ const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
9
+ const artifacts = [];
10
+ const problems = [];
11
+ for (const ref of refs) {
12
+ if (ref === null || typeof ref !== 'object' || typeof ref.machine !== 'string') {
13
+ problems.push({ machine: ref?.machine ?? null, file: ref?.file ?? null, reason: 'REF_INVALID' });
14
+ continue;
15
+ }
16
+ let bytes;
17
+ try {
18
+ bytes = await fetchFn(ref);
19
+ } catch (error) {
20
+ problems.push({ machine: ref.machine, file: ref.file ?? null, reason: 'FETCH_FAILED', detail: String(error).slice(0, 80) });
21
+ continue;
22
+ }
23
+ if (bytes === undefined || bytes === null) {
24
+ problems.push({ machine: ref.machine, file: ref.file ?? null, reason: 'REF_MISSING' });
25
+ continue;
26
+ }
27
+ const hash = digest(Buffer.from(bytes));
28
+ if (ref.sha256 !== hash) {
29
+ problems.push({ machine: ref.machine, file: ref.file ?? null, reason: 'HASH_MISMATCH', expected: ref.sha256, actual: hash });
30
+ continue;
31
+ }
32
+ artifacts.push({ machine: ref.machine, file: ref.file, path: ref.path ?? ref.file, bytes: Buffer.from(bytes), sha256: hash });
33
+ }
34
+ return { ok: problems.length === 0, artifacts, problems };
35
+ }
36
+
37
+ /** 合并:按 (path) 检测同文件双改冲突;冲突时 head 不动,返回冲突明细。 */
38
+ export function mergeArtifacts({ artifacts }) {
39
+ const byPath = new Map();
40
+ const conflicts = [];
41
+ for (const artifact of artifacts) {
42
+ const path = artifact.path ?? artifact.file;
43
+ const key = String(path).toLowerCase();
44
+ const existing = byPath.get(key);
45
+ if (existing === undefined) {
46
+ byPath.set(key, { path, machines: [artifact.machine], bytes: artifact.bytes, sha256: artifact.sha256 });
47
+ continue;
48
+ }
49
+ if (existing.sha256 === artifact.sha256) {
50
+ if (!existing.machines.includes(artifact.machine)) existing.machines.push(artifact.machine);
51
+ continue;
52
+ }
53
+ conflicts.push({ path, machines: [...new Set([...existing.machines, artifact.machine])], reason: 'CONTENT_CONFLICT', left: existing.sha256, right: artifact.sha256 });
54
+ }
55
+ if (conflicts.length > 0) return { ok: false, code: 'MERGE_CONFLICT', conflicts, files: [] };
56
+ const files = [...byPath.values()].map((entry) => ({ path: entry.path, sha256: entry.sha256, bytes: entry.bytes, machines: entry.machines }));
57
+ return { ok: true, files, conflicts: [] };
58
+ }
59
+
60
+ /** 复核:对合并结果逐机比对期望文件;只回结论与差异摘要。 */
61
+ export function verifyReview({ merged, expectations }) {
62
+ const present = new Map(merged.files.map((file) => [String(file.path).toLowerCase(), file.sha256]));
63
+ const results = [];
64
+ for (const expectation of expectations) {
65
+ const actual = present.get(String(expectation.file).toLowerCase()) ?? null;
66
+ const pass = actual !== null && (expectation.sha256 === undefined || actual === expectation.sha256);
67
+ results.push(pass
68
+ ? { machine: expectation.machine, file: expectation.file, status: 'PASS' }
69
+ : { machine: expectation.machine, file: expectation.file, status: 'FAIL', reason: actual === null ? 'FILE_MISSING' : 'HASH_MISMATCH', expected: expectation.sha256 ?? null, actual });
70
+ }
71
+ const failed = results.filter((entry) => entry.status === 'FAIL');
72
+ return { ok: failed.length === 0, verdict: failed.length === 0 ? 'PASS' : 'FAIL', results, failed };
73
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * joint-dev 计划持久化与人工审批门(本地文件,0600)。
3
+ * 未审批的计划禁止 dispatch;审批绑定 plan_sha256 与候选人。
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
10
+ const planDir = (stateDir) => join(stateDir, 'joint-dev', 'plans');
11
+ const approvalDir = (stateDir) => join(stateDir, 'joint-dev', 'approvals');
12
+
13
+ export function savePlan({ stateDir, plan, now = Date.now() }) {
14
+ if (typeof plan?.plan_sha256 !== 'string' || plan.plan_sha256.length !== 64) return { ok: false, code: 'PLAN_INVALID' };
15
+ mkdirSync(planDir(stateDir), { recursive: true });
16
+ const path = join(planDir(stateDir), plan.plan_sha256 + '.json');
17
+ if (existsSync(path)) return { ok: true, path, reused: true };
18
+ writeFileSync(path, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 });
19
+ return { ok: true, path, reused: false };
20
+ }
21
+
22
+ export function loadPlan({ stateDir, planSha }) {
23
+ const path = join(planDir(stateDir), planSha + '.json');
24
+ if (!existsSync(path)) return { ok: false, code: 'PLAN_NOT_FOUND' };
25
+ return { ok: true, ...JSON.parse(readFileSync(path, 'utf8')), path };
26
+ }
27
+
28
+ /**
29
+ * 人工审批。authority 建议 ≥70;审批只由操作员 CLI/UI 写入,不暴露给 agent 工具。
30
+ */
31
+ export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now() }) {
32
+ if (typeof planSha !== 'string' || planSha.length !== 64) return { ok: false, code: 'PLAN_SHA_INVALID' };
33
+ if (typeof approver !== 'string' || approver === '') return { ok: false, code: 'APPROVER_REQUIRED' };
34
+ if (!Number.isInteger(authority) || authority < 70) return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority } };
35
+ if (!existsSync(join(planDir(stateDir), planSha + '.json'))) return { ok: false, code: 'PLAN_NOT_FOUND' };
36
+ mkdirSync(approvalDir(stateDir), { recursive: true });
37
+ const body = {
38
+ v: 1,
39
+ plan_sha256: planSha,
40
+ approver,
41
+ authority,
42
+ note: String(note).slice(0, 200),
43
+ approved_at: new Date(now).toISOString(),
44
+ };
45
+ const record = { ...body, approval_sha256: sha256(JSON.stringify(body)) };
46
+ const path = join(approvalDir(stateDir), planSha + '.json');
47
+ writeFileSync(path, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
48
+ return { ok: true, approval: record, path };
49
+ }
50
+
51
+ export function loadApproval({ stateDir, planSha }) {
52
+ const path = join(approvalDir(stateDir), planSha + '.json');
53
+ if (!existsSync(path)) return { ok: false, code: 'APPROVAL_REQUIRED' };
54
+ const record = JSON.parse(readFileSync(path, 'utf8'));
55
+ if (record.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
56
+ return { ok: true, approval: record };
57
+ }
58
+
59
+ /** dispatch 前置门:计划必须存在且已审批。 */
60
+ export function assertDispatchAllowed({ stateDir, planSha }) {
61
+ const plan = loadPlan({ stateDir, planSha });
62
+ if (plan.ok !== true) return { ok: false, code: 'PLAN_NOT_FOUND' };
63
+ const approval = loadApproval({ stateDir, planSha });
64
+ if (approval.ok !== true) return { ok: false, code: 'APPROVAL_REQUIRED' };
65
+ return { ok: true, plan: plan.plan, approval: approval.approval };
66
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 兼容入口:红线判据的唯一实现在 src/util/redline-core.mjs(relay 与插件共用)。
3
+ * 保留此文件是为了既有引用(joint-dev/*、测试)不发生破坏性改动。
4
+ */
5
+ export {
6
+ MAX_CONTROL_BYTES,
7
+ CONTROL_VERSION,
8
+ CONTROL_TYPES,
9
+ DATA_PLANES,
10
+ CONTROL_ALLOWED_KEYS,
11
+ BODY_ALLOWED_KEYS,
12
+ FORBIDDEN_DATA_KEYS,
13
+ inspectControlMessage,
14
+ assertPlane,
15
+ createAudit,
16
+ } from '../../util/redline-core.mjs';
@@ -14,6 +14,7 @@ import { compareVersions } from './update-source.mjs';
14
14
  export const PLUGIN_PACKAGE_NAME = '@twinsearth/w2m-dsh-plugin';
15
15
  /** npm mirrors are fine here: the answer is only "which version is latest", and the tarball is verified separately. */
16
16
  export const DEFAULT_NPM_LATEST_URL = `https://registry.npmmirror.com/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}/latest`;
17
+ export const DEFAULT_NPM_LATEST_FALLBACK_URL = `https://registry.npmjs.org/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}/latest`;
17
18
  export const NPM_CHECK_TIMEOUT_MS = 15_000;
18
19
 
19
20
  /**
@@ -61,19 +62,24 @@ export function decideNpmUpdate({ latest, currentVersion }) {
61
62
  export async function checkNpmUpdate(input) {
62
63
  const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
63
64
  const url = input?.url ?? DEFAULT_NPM_LATEST_URL;
65
+ const urls = input?.url !== undefined ? [url] : [url, DEFAULT_NPM_LATEST_FALLBACK_URL];
64
66
  const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : NPM_CHECK_TIMEOUT_MS;
65
- let doc;
66
- try {
67
- const response = await fetchImpl(url, {
68
- headers: { accept: 'application/json' },
69
- signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
70
- });
71
- if (response?.ok !== true) {
72
- return decideNpmUpdate({ latest: { ok: false, error: `npm registry answered HTTP ${response?.status ?? '?'}` }, currentVersion: input?.currentVersion ?? null });
67
+ let last = { ok: false, error: 'npm latest could not be read' };
68
+ for (const candidate of urls) {
69
+ try {
70
+ const response = await fetchImpl(candidate, {
71
+ headers: { accept: 'application/json' },
72
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
73
+ });
74
+ if (response?.ok !== true) { last = { ok: false, error: `npm registry answered HTTP ${response?.status ?? '?'}` }; continue; }
75
+ const doc = await response.json();
76
+ const parsed = parseNpmLatest(doc);
77
+ if (parsed.ok === true) return decideNpmUpdate({ latest: parsed, currentVersion: input?.currentVersion ?? null });
78
+ last = parsed;
79
+ } catch (error) {
80
+ last = { ok: false, error: error instanceof Error ? error.message : String(error) };
73
81
  }
74
- doc = await response.json();
75
- } catch (error) {
76
- return decideNpmUpdate({ latest: { ok: false, error: error instanceof Error ? error.message : String(error) }, currentVersion: input?.currentVersion ?? null });
77
82
  }
78
- return decideNpmUpdate({ latest: parseNpmLatest(doc), currentVersion: input?.currentVersion ?? null });
83
+ return decideNpmUpdate({ latest: last, currentVersion: input?.currentVersion ?? null });
79
84
  }
85
+