@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +21 -0
- package/README.md +3 -3
- package/bin/w2m-joint.mjs +36 -5
- package/bin/w2m-scrub.mjs +78 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
- package/docs/acceptance/1.9.19/RELEASE-1919.json +21 -0
- package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
- package/docs/acceptance-ops/2026-10-28/AG-SMOKE.json +24 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
- package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
- package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
- package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
- package/lib/client.js +474 -368
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +6 -1
- package/src/agent/sinks/dir-sink.mjs +59 -13
- package/src/plugin/fleet-chat-route.mjs +7 -1
- package/src/plugin/joint-dev/audit-file.mjs +70 -37
- package/src/plugin/joint-dev/merge-engine.mjs +102 -0
- package/src/plugin/joint-dev/plan-store.mjs +117 -32
- package/src/plugin/joint-dev/relay-dispatch.mjs +171 -0
- package/src/plugin/operator-key.mjs +73 -0
- package/src/plugin/operator-token-route.mjs +71 -0
- package/src/plugin/tools-joint.mjs +97 -0
- package/src/plugin/tools.mjs +8 -3
- package/src/relay/fleet-messages.mjs +22 -0
- package/src/relay/offer-journal.mjs +71 -0
- package/src/relay/p0-projection.mjs +10 -0
- package/src/relay/report.mjs +40 -2
- package/src/relay/scrub.mjs +94 -81
- package/src/relay/server.mjs +2043 -2038
- package/src/relay/state.mjs +3351 -3300
- package/src/util/artifact-ref.mjs +88 -0
- 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.
|
|
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.
|
|
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": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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 =
|
|
54
|
+
const bad = contentKeysDeep(event);
|
|
24
55
|
if (bad.length > 0) return { ok: false, code: 'AUDIT_CONTENT_FORBIDDEN', detail: { keys: bad } };
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|