@twinsearth/w2m-dsh-plugin 1.9.16 → 1.9.19

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -3
  5. package/bin/w2m-joint.mjs +36 -5
  6. package/bin/w2m-scrub.mjs +78 -0
  7. package/codex/mcp-servers.toml +1 -1
  8. package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
  9. package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
  10. package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
  11. package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
  12. package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
  13. package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
  14. package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
  15. package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
  16. package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
  17. package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
  18. package/lib/client.js +474 -368
  19. package/package.json +4 -3
  20. package/plugin.json +1 -1
  21. package/src/agent/agent.mjs +6 -1
  22. package/src/agent/sinks/dir-sink.mjs +59 -13
  23. package/src/plugin/fleet-chat-route.mjs +7 -1
  24. package/src/plugin/joint-dev/audit-file.mjs +70 -37
  25. package/src/plugin/joint-dev/merge-engine.mjs +102 -0
  26. package/src/plugin/joint-dev/plan-store.mjs +117 -32
  27. package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
  28. package/src/plugin/operator-key.mjs +73 -0
  29. package/src/plugin/operator-token-route.mjs +71 -0
  30. package/src/plugin/tools-joint.mjs +97 -0
  31. package/src/plugin/tools.mjs +8 -3
  32. package/src/relay/fleet-messages.mjs +22 -0
  33. package/src/relay/offer-journal.mjs +71 -0
  34. package/src/relay/p0-projection.mjs +10 -0
  35. package/src/relay/report.mjs +40 -2
  36. package/src/relay/scrub.mjs +94 -81
  37. package/src/relay/server.mjs +2043 -2038
  38. package/src/relay/state.mjs +3351 -3300
  39. package/src/util/artifact-ref.mjs +88 -0
  40. package/src/util/redline-core.mjs +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.9.16",
3
+ "version": "1.9.19",
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 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 test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.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 test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.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",
@@ -208,7 +208,8 @@
208
208
  "w2m-hidden-task": "bin/w2m-hidden-task.mjs",
209
209
  "w2m-self-update": "bin/w2m-self-update.mjs",
210
210
  "w2m-joint": "bin/w2m-joint.mjs",
211
- "w2m-operator": "bin/w2m-operator.mjs"
211
+ "w2m-operator": "bin/w2m-operator.mjs",
212
+ "w2m-scrub": "bin/w2m-scrub.mjs"
212
213
  },
213
214
  "disclosure": {
214
215
  "cloud": true,
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.16",
4
+ "version": "1.9.19",
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": [
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { Readable } from 'node:stream';
25
25
  import { access, constants as fsConstants } from 'node:fs/promises';
26
- import { existsSync, mkdirSync, mkdtempSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
26
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, join } from 'node:path';
28
28
  import { commitTaskChanges, createWorktree, listChangedFiles, producePatch, removeWorktree } from './worktree.mjs';
29
29
  import { describePatchArtifact } from './patch-artifact.mjs';
@@ -31,6 +31,7 @@ import { ferryMaintenance, maintenancePaths } from './maintenance-ferry.mjs';
31
31
  import { authorizeWithGrant } from './grant-gate.mjs';
32
32
  import { tmpdir } from 'node:os';
33
33
  import { randomUUID , sign as signBytes } from 'node:crypto';
34
+ import { fileURLToPath } from 'node:url';
34
35
 
35
36
  import {
36
37
  EMPTY_SHA256,
@@ -2122,6 +2123,10 @@ export function createAgent(options) {
2122
2123
  python: caps.python ?? null,
2123
2124
  npm: caps.npm ?? null,
2124
2125
  git,
2126
+ plugin_version: (() => {
2127
+ try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')).version ?? null; } catch { return null; }
2128
+ })(),
2129
+ joint_dev: 'v2',
2125
2130
  };
2126
2131
  return toolchainCache;
2127
2132
  }
@@ -1,33 +1,79 @@
1
1
  /**
2
- * 目录 sink(本地/局域网共享盘):密封对象落盘、按内容哈希取回。
3
- * 数据面适配器的最小实现;git/ssh 适配器同契约,后续补。
4
- * 对象已经是 sealObject() 的密文,sink 不接触明文与密钥。
2
+ * 目录 sink(本地/局域网共享盘):ArtifactRef v1 的对象落盘与取回。
3
+ *
4
+ * 对象已经是调用方封装好的密文字节,sink 不接触明文与密钥。写入是原子的
5
+ * (tmp → fsync → rename,0600),取回时校验对象 id 形状与期望的 cipher sha256。
5
6
  */
6
7
  import { createHash } from 'node:crypto';
7
- import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from 'node:fs';
8
9
  import { join } from 'node:path';
9
10
 
11
+ const OBJECT_ID = /^[0-9a-f]{32,64}$/;
12
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
13
+
10
14
  export function createDirSink({ root }) {
11
15
  if (typeof root !== 'string' || root === '') throw new Error('SINK_ROOT_REQUIRED');
12
16
  mkdirSync(root, { recursive: true });
17
+
18
+ const pathOf = (objectId) => join(root, objectId + '.json');
19
+
20
+ /** Write the sealed object bytes atomically under `<objectId>.json`. */
21
+ async function writeObject({ objectId, body, expectSha256 = null }) {
22
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
23
+ const bytes = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
24
+ const actual = sha256(bytes);
25
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
26
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
27
+ }
28
+ const path = pathOf(objectId);
29
+ const tmp = path + '.tmp-' + process.pid + '-' + Date.now().toString(36);
30
+ const fd = openSync(tmp, 'w', 0o600);
31
+ try {
32
+ writeSync(fd, bytes);
33
+ fsyncSync(fd);
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ renameSync(tmp, path);
38
+ return { ok: true, objectId, path, rel_path: objectId + '.json', sha256: actual, bytes: bytes.length };
39
+ }
40
+
41
+ /** Read the object bytes back and verify the expected cipher sha256. */
42
+ async function readObject({ objectId, expectSha256 = null }) {
43
+ if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
44
+ const path = pathOf(objectId);
45
+ if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
46
+ let bytes;
47
+ try { bytes = readFileSync(path); } catch { return { ok: false, code: 'OBJECT_UNREADABLE' }; }
48
+ const actual = sha256(bytes);
49
+ if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
50
+ return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
51
+ }
52
+ return { ok: true, objectId, path, sha256: actual, bytes };
53
+ }
54
+
13
55
  return {
14
56
  kind: 'dir',
57
+ writeObject,
58
+ readObject,
59
+ // Backwards-compatible adapters for the pre-v2 callers: the object id is derived from the body.
15
60
  async pushObject({ sealed }) {
16
61
  if (sealed === null || typeof sealed !== 'object') return { ok: false, code: 'OBJECT_INVALID' };
17
62
  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') };
63
+ const id = createHash('sha256').update(body, 'utf8').digest('hex').slice(0, 32);
64
+ const written = await writeObject({ objectId: id, body });
65
+ return written.ok === true ? { ok: true, objectId: id, path: written.path, bytes: written.bytes } : written;
22
66
  },
23
67
  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 };
68
+ const read = await readObject({ objectId });
69
+ if (read.ok !== true) return read;
70
+ try { return { ok: true, sealed: JSON.parse(read.bytes.toString('utf8')), path: read.path }; } catch { return { ok: false, code: 'OBJECT_UNREADABLE' }; }
28
71
  },
29
72
  async listObjects() {
30
- return readdirSync(root).filter((name) => name.endsWith('.json')).map((name) => name.replace(/\.json$/, ''));
73
+ return readdirSync(root).filter((name) => name.endsWith('.json') && OBJECT_ID.test(name.slice(0, -5))).map((name) => name.slice(0, -5));
74
+ },
75
+ removeObject(objectId) {
76
+ try { unlinkSync(pathOf(objectId)); return { ok: true }; } catch { return { ok: false, code: 'OBJECT_NOT_FOUND' }; }
31
77
  },
32
78
  };
33
79
  }
@@ -156,6 +156,12 @@ export function registerChatRoutes(ctx, deps) {
156
156
  * The session files are never written and nothing is injected into DSH: the transcript is read, the user's messages
157
157
  * are extracted, and only the new ones are published. A failure here must not disturb the panel or the route.
158
158
  */
159
+ const MIRROR_MAX_BYTES = 3500;
160
+ const truncateUtf8 = (value, max) => {
161
+ const bytes = Buffer.from(value, 'utf8');
162
+ if (bytes.length <= max) return value;
163
+ return bytes.subarray(0, max).toString('utf8').replace(/\uFFFD+$/, '') + ' …(已截断)';
164
+ };
159
165
  const mirrorOnce = async () => {
160
166
  const identity = await identityOf();
161
167
  if (identity.conversationKey === null) return;
@@ -165,7 +171,7 @@ export function registerChatRoutes(ctx, deps) {
165
171
  try { messages = extractUserMessages(decodeTranscript(readFileSync(file))); } catch { return; }
166
172
  for (const payload of sync.pending(messages)) {
167
173
  await postToRelay(identity, {
168
- text: payload.text,
174
+ text: truncateUtf8(payload.text, MIRROR_MAX_BYTES),
169
175
  machine_id: identity.machineId,
170
176
  machine_name: identity.machineName,
171
177
  origin: 'session',
@@ -2,11 +2,42 @@
2
2
  * 红线审计落盘:JSONL + fsync + 哈希链 + 校验。
3
3
  * 只接受元数据字段(内容字段直接拒绝),链断裂/篡改可被定位。
4
4
  */
5
- import { closeSync, fsyncSync, openSync, readFileSync, writeSync } from 'node:fs';
5
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from 'node:fs';
6
6
  import { createHash } from 'node:crypto';
7
7
 
8
8
  export const AUDIT_FORBIDDEN = new Set(['content', 'data', 'file', 'files', 'patch', 'stdout', 'stderr', 'diff', 'blob', 'bytes', 'text', 'base64']);
9
9
 
10
+ /** Recursively collect forbidden content keys: a nested payload must not slip past the top level. */
11
+ function contentKeysDeep(value, path = '', hits = []) {
12
+ if (value === null || typeof value !== 'object') return hits;
13
+ if (Array.isArray(value)) { value.forEach((entry, index) => contentKeysDeep(entry, path + '[' + index + ']', hits)); return hits; }
14
+ for (const [key, child] of Object.entries(value)) {
15
+ const keyPath = path === '' ? key : path + '.' + key;
16
+ if (AUDIT_FORBIDDEN.has(key.toLowerCase())) hits.push(keyPath);
17
+ else contentKeysDeep(child, keyPath, hits);
18
+ }
19
+ return hits;
20
+ }
21
+
22
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
+
24
+ function withLock(file, fn) {
25
+ const lock = file + '.lock';
26
+ for (let attempt = 0; attempt < 50; attempt += 1) {
27
+ try {
28
+ const fd = openSync(lock, 'wx', 0o600);
29
+ try { writeSync(fd, String(process.pid)); } finally { closeSync(fd); }
30
+ try { return fn(); } finally { try { unlinkSync(lock); } catch { /* already gone */ } }
31
+ } catch (error) {
32
+ if (error?.code !== 'EEXIST') throw error;
33
+ try { if (Date.now() - statSync(lock).mtimeMs > 10_000) { unlinkSync(lock); continue; } } catch { /* raced */ }
34
+ sleep(20);
35
+ }
36
+ }
37
+ return { ok: false, code: 'AUDIT_BUSY' };
38
+ }
39
+
40
+
10
41
  function stable(value) {
11
42
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
12
43
  if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']';
@@ -20,44 +51,46 @@ export function appendAudit(file, event) {
20
51
  if (event === null || typeof event !== 'object' || Array.isArray(event)) {
21
52
  return { ok: false, code: 'AUDIT_EVENT_INVALID' };
22
53
  }
23
- const bad = Object.keys(event).filter((key) => AUDIT_FORBIDDEN.has(key.toLowerCase()));
54
+ const bad = contentKeysDeep(event);
24
55
  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;
56
+ return withLock(file, () => {
57
+ let previous = null;
58
+ let seq = 1;
59
+ if (existsSync(file)) {
60
+ const lines = readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
61
+ if (lines.length > 0) {
62
+ // A corrupt tail is refused, never treated as "a new file": resetting the chain hides tampering.
63
+ let last;
64
+ try { last = JSON.parse(lines[lines.length - 1]); } catch { return { ok: false, code: 'AUDIT_TAIL_CORRUPT', detail: { line: lines.length } }; }
65
+ previous = last.record_sha256 ?? null;
66
+ seq = Number(last.seq ?? lines.length) + 1;
67
+ }
34
68
  }
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 };
69
+ const body = {
70
+ v: 1,
71
+ seq,
72
+ at: event.at ?? new Date().toISOString(),
73
+ event: typeof event.event === 'string' ? event.event : 'joint.event',
74
+ plan_sha256: event.plan_sha256 ?? null,
75
+ step_id: event.step_id ?? null,
76
+ task_id: event.task_id ?? null,
77
+ machine_id: event.machine_id ?? null,
78
+ outcome: event.outcome ?? null,
79
+ reason_code: event.reason_code ?? null,
80
+ control_bytes: Number.isInteger(event.control_bytes) ? event.control_bytes : null,
81
+ control_sha256: typeof event.control_sha256 === 'string' ? event.control_sha256 : null,
82
+ prev_sha256: previous,
83
+ };
84
+ const record = { ...body, record_sha256: sha256(stable(body)) };
85
+ const fd = openSync(file, 'a');
86
+ try {
87
+ writeSync(fd, JSON.stringify(record) + '\n');
88
+ fsyncSync(fd);
89
+ } finally {
90
+ closeSync(fd);
91
+ }
92
+ return { ok: true, record };
93
+ });
61
94
  }
62
95
 
63
96
  /** 校验整条链;返回第一处问题的 seq。 */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * S5 MergeEngine: apply machine patches in order inside an integration worktree.
3
+ *
4
+ * Local git only. The engine never touches the target ref: `advanceTarget` is a separate,
5
+ * compare-and-swap step the operator runs only after the user approves the merged head. A conflict
6
+ * is a first-class refusal (`MERGE_CONFLICT`) — patches are not auto-resolved and force is never
7
+ * passed to git.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const defaultRunGit = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
15
+
16
+ function assertBaseCommit(repoDir, baseCommit, runGit) {
17
+ try { runGit(['cat-file', '-e', `${baseCommit}^{commit}`], repoDir); return true; } catch { return false; }
18
+ }
19
+
20
+ /** @returns {{ok: true, worktree: string, integration_head: string, applied: object[]} | {ok: false, code: string, detail?: object}} */
21
+ export function mergePatches({ repoDir, baseCommit, patches, runGit = defaultRunGit }) {
22
+ if (typeof repoDir !== 'string' || repoDir === '') return { ok: false, code: 'MERGE_REPO_REQUIRED' };
23
+ if (typeof baseCommit !== 'string' || !/^[0-9a-f]{7,40}$/.test(baseCommit)) return { ok: false, code: 'MERGE_BASE_INVALID' };
24
+ if (!Array.isArray(patches) || patches.length === 0) return { ok: false, code: 'MERGE_PATCHES_REQUIRED' };
25
+ if (assertBaseCommit(repoDir, baseCommit, runGit) !== true) return { ok: false, code: 'MERGE_BASE_MISMATCH', detail: { base_commit: baseCommit } };
26
+
27
+ const worktree = mkdtempSync(join(tmpdir(), 'w2m-merge-'));
28
+ const cleanup = () => {
29
+ try { runGit(['worktree', 'remove', '--force', worktree], repoDir); } catch { /* already gone */ }
30
+ try { rmSync(worktree, { recursive: true, force: true }); } catch { /* best effort */ }
31
+ };
32
+ try {
33
+ runGit(['worktree', 'add', '--detach', worktree, baseCommit], repoDir);
34
+ } catch (error) {
35
+ cleanup();
36
+ return { ok: false, code: 'MERGE_WORKTREE_FAILED', detail: { message: error instanceof Error ? error.message : String(error) } };
37
+ }
38
+
39
+ const applied = [];
40
+ try {
41
+ for (const patch of patches) {
42
+ if (typeof patch?.step_id !== 'string' || typeof patch?.patch !== 'string' || patch.patch === '') {
43
+ cleanup();
44
+ return { ok: false, code: 'MERGE_PATCH_INVALID', detail: { step_id: patch?.step_id ?? null } };
45
+ }
46
+ const file = join(worktree, '.w2m-patch-' + patch.step_id.replace(/[^A-Za-z0-9_-]/g, '_') + '.diff');
47
+ writeFileSync(file, patch.patch, 'utf8');
48
+ try {
49
+ runGit(['apply', '--3way', '--check', file], worktree);
50
+ } catch {
51
+ const files = (() => {
52
+ try { return runGit(['apply', '--check', '--numstat', file], worktree).split('\n').map((line) => line.split('\t').pop()).filter(Boolean); } catch { return []; }
53
+ })();
54
+ cleanup();
55
+ return { ok: false, code: 'MERGE_CONFLICT', detail: { step_id: patch.step_id, machine_id: patch.machine_id ?? null, files } };
56
+ }
57
+ try {
58
+ runGit(['apply', '--3way', file], worktree);
59
+ } catch {
60
+ const applyFailed = { step_id: patch.step_id, machine_id: patch.machine_id ?? null };
61
+ cleanup();
62
+ return { ok: false, code: 'MERGE_CONFLICT', detail: applyFailed };
63
+ }
64
+ runGit(['add', '-A'], worktree);
65
+ runGit(['commit', '-m', `w2m: ${patch.step_id} (${patch.machine_id ?? 'unknown'})`, '--no-verify'], worktree);
66
+ applied.push({ step_id: patch.step_id, machine_id: patch.machine_id ?? null });
67
+ }
68
+ const integrationHead = runGit(['rev-parse', 'HEAD'], worktree);
69
+ return { ok: true, worktree, integration_head: integrationHead, applied };
70
+ } catch (error) {
71
+ cleanup();
72
+ return { ok: false, code: 'MERGE_APPLY_FAILED', detail: { message: error instanceof Error ? error.message : String(error) } };
73
+ }
74
+ }
75
+
76
+ /** Compare-and-swap the target ref; force is impossible here because update-ref takes the old value. */
77
+ export function advanceTarget({ repoDir, targetRef, expectedOld, newCommit, rollbackRef, runGit = defaultRunGit }) {
78
+ if (typeof targetRef !== 'string' || !/^refs\/heads\/[\w./-]+$/.test(targetRef)) return { ok: false, code: 'MERGE_TARGET_INVALID' };
79
+ if (typeof newCommit !== 'string' || !/^[0-9a-f]{7,40}$/.test(newCommit)) return { ok: false, code: 'MERGE_COMMIT_INVALID' };
80
+ if (typeof expectedOld !== 'string' || !/^[0-9a-f]{7,40}$/.test(expectedOld)) return { ok: false, code: 'MERGE_OLD_REQUIRED' };
81
+ const current = (() => { try { return runGit(['rev-parse', targetRef], repoDir); } catch { return null; } })();
82
+ if (current !== expectedOld) return { ok: false, code: 'MERGE_BASE_MISMATCH', detail: { expected: expectedOld, current } };
83
+ if (typeof rollbackRef === 'string' && rollbackRef !== '') {
84
+ if (!/^refs\/w2m\/rollback\/[\w./-]+$/.test(rollbackRef)) return { ok: false, code: 'MERGE_ROLLBACK_REF_INVALID' };
85
+ runGit(['update-ref', rollbackRef, expectedOld], repoDir);
86
+ }
87
+ try {
88
+ runGit(['update-ref', targetRef, newCommit, expectedOld], repoDir);
89
+ } catch (error) {
90
+ return { ok: false, code: 'MERGE_CAS_FAILED', detail: { message: error instanceof Error ? error.message : String(error) } };
91
+ }
92
+ return { ok: true, target_ref: targetRef, head: newCommit, rollback_ref: typeof rollbackRef === 'string' && rollbackRef !== '' ? rollbackRef : null };
93
+ }
94
+
95
+ /** Remove an integration worktree (after a merge, a refusal or an abort). */
96
+ export function abortMerge({ repoDir, worktree, runGit = defaultRunGit }) {
97
+ if (typeof worktree === 'string' && worktree !== '') {
98
+ try { runGit(['worktree', 'remove', '--force', worktree], repoDir); } catch { /* already gone */ }
99
+ try { rmSync(worktree, { recursive: true, force: true }); } catch { /* best effort */ }
100
+ }
101
+ return { ok: true };
102
+ }