@twinsearth/w2m-dsh-plugin 2.0.8 → 2.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -501,6 +501,11 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
501
501
  '.w2mChat_emoji{padding:2px 4px;border:0;background:transparent;font-size:16px;line-height:1;cursor:pointer}',
502
502
  '.w2mFleetStatus{display:flex;gap:16px;align-items:center;justify-content:center;width:100%;padding:2px 12px;font-size:11px;opacity:.8;white-space:nowrap}',
503
503
  '.w2mFleetStatusItem{white-space:nowrap}',
504
+ '.w2mRelayItem{display:inline-flex;align-items:center;gap:4px}',
505
+ '.w2mRelayDot{width:8px;height:8px;border-radius:50%;display:inline-block;background:var(--dsw-alias-state-idle-primary)}',
506
+ '.w2mRelayDot[data-state="green"]{background:var(--dsw-alias-state-success-primary)}',
507
+ '.w2mRelayDot[data-state="yellow"]{background:var(--dsw-alias-state-warn-primary)}',
508
+ '.w2mRelayDot[data-state="red"]{background:var(--dsw-alias-state-danger-primary)}',
504
509
  '.w2mSearchToggle{height:26px;width:26px;border:0;border-radius:6px;background:transparent;color:inherit;font-size:14px;cursor:pointer}',
505
510
  '.w2mSearchToggle:hover{background:rgba(127,127,127,.14)}',
506
511
  '.w2mSearchBox{display:flex;align-items:center;gap:4px;padding:2px 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-background-secondary)}',
@@ -2080,12 +2085,33 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2080
2085
  return () => { alive = false; clearInterval(timer); };
2081
2086
  }, []);
2082
2087
  const fleet = snapshot?.fleet ?? null;
2083
- const server = snapshot?.server ?? null;
2088
+ const relays = snapshot?.relays ?? null;
2089
+ const server = relays?.server ?? null;
2084
2090
  const fleetText = fleet && Number.isFinite(fleet.online) && Number.isFinite(fleet.total) ? fleet.online + '/' + fleet.total : '—';
2085
2091
  const serverText = server && Number.isFinite(server.devices_online) && Number.isFinite(server.devices_total) ? server.devices_online + '/' + server.devices_total : '—';
2086
- return h('div', { className: 'w2mFleetStatus', title: '编队与服务器在线数量' }, [
2092
+ const relayItems = Array.isArray(relays?.shown) ? relays.shown.slice(0, 3) : [];
2093
+ const relayLabel = (index) => (['主', '备1', '备2'][index] ?? ('备' + index));
2094
+ const relayDot = (relay) => {
2095
+ if (relay?.ok !== true) return 'red';
2096
+ if (Number.isFinite(relay.rtt_ms) && relay.rtt_ms > 500) return 'yellow';
2097
+ return 'green';
2098
+ };
2099
+ const relayTitle = (relay) => {
2100
+ if (relay?.ok !== true) return (relay?.url ?? 'relay') + ':离线' + (relay?.error ? '(' + relay.error + ')' : '');
2101
+ return (relay?.url ?? 'relay') + ':在线,' + (Number.isFinite(relay?.rtt_ms) ? relay.rtt_ms + 'ms' : '延迟未知');
2102
+ };
2103
+ return h('div', { className: 'w2mFleetStatus', title: '编队、全服与 Relay 在线状态' }, [
2087
2104
  h('span', { key: 'f', className: 'w2mFleetStatusItem' }, '本编队在线 ' + fleetText),
2088
- h('span', { key: 's', className: 'w2mFleetStatusItem' }, '全部机器在线 ' + serverText),
2105
+ h('span', { key: 's', className: 'w2mFleetStatusItem' }, '全服机器在线 ' + serverText),
2106
+ h('span', { key: 'r', className: 'w2mFleetStatusItem' }, [
2107
+ h('span', { key: 'label' }, '在线Relay '),
2108
+ ...(relayItems.length === 0
2109
+ ? [h('span', { key: 'none' }, '—')]
2110
+ : relayItems.map((relay, index) => h('span', { key: 'relay-' + index, className: 'w2mRelayItem', title: relayTitle(relay) }, [
2111
+ h('span', { key: 'dot', className: 'w2mRelayDot', 'data-state': relayDot(relay) }),
2112
+ h('span', { key: 'name' }, relayLabel(index)),
2113
+ ]))),
2114
+ ]),
2089
2115
  ]);
2090
2116
  }
2091
2117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
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": {
@@ -48,7 +48,9 @@
48
48
  "SECURITY.md",
49
49
  "CONTRIBUTING.md",
50
50
  ".claude-plugin",
51
- "codex"
51
+ "codex",
52
+ "TEST-PLAN-JOINT-FLEET-2.0.8.md",
53
+ "TEST-CASES-JOINT-FLEET-2.0.8.md"
52
54
  ],
53
55
  "engines": {
54
56
  "node": ">=20.19.0"
@@ -64,7 +66,7 @@
64
66
  "pack": "node scripts/pack.mjs",
65
67
  "reachability": "node scripts/reachability-check.mjs",
66
68
  "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/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 test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs",
69
+ "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/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 test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs",
68
70
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
71
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
72
  "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": "2.0.8",
4
+ "version": "2.0.9",
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": [
@@ -69,6 +69,7 @@ import {
69
69
  readExecCapability,
70
70
  } from '../util/exec-capability.mjs';
71
71
  import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningKey } from './crypto.mjs';
72
+ import { createXferFrameRouter } from './transfer/router.mjs';
72
73
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
73
74
  import { punchSession, reverseDialSession } from './p2p.mjs';
74
75
  import { createSigner } from '../signing.mjs';
@@ -1142,6 +1143,8 @@ export function createAgent(options) {
1142
1143
  * @type {PeerKeyStore}
1143
1144
  */
1144
1145
  const peerKeys = new PeerKeyStore();
1146
+ /** P0-a: xfer.* frames arriving on a P2P channel are routed here, not to task dispatch. */
1147
+ const xferRouter = createXferFrameRouter();
1145
1148
  /**
1146
1149
  * What each task's direct channel actually was, for the result envelope.
1147
1150
  *
@@ -3959,6 +3962,10 @@ let attemptAttached = false;
3959
3962
  */
3960
3963
  function handleP2PMessage(event) {
3961
3964
  const payload = parseP2PFrame(event.payload);
3965
+ if (payload !== null && typeof payload.type === 'string' && payload.type.startsWith('xfer.')) {
3966
+ xferRouter.handle(payload, { channel: event.channel });
3967
+ return;
3968
+ }
3962
3969
  if (payload === null) {
3963
3970
  log('warn', 'ignoring non-JSON frame on a P2P channel', {
3964
3971
  peer: event.peer ?? null,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * W3 discovery seam: providers only produce candidate addresses.
3
+ *
4
+ * Current order is relay -> mDNS -> DHT. A candidate is never an authorisation:
5
+ * the dial path still verifies the peer's static key and the task identity.
6
+ * Adding a DHT sidecar later means implementing the same `discover(machineId)`
7
+ * shape; it does not change the transport or the relay.
8
+ */
9
+
10
+ export const DISCOVERY_SOURCE = Object.freeze({
11
+ RELAY: 'relay',
12
+ MDNS: 'mdns',
13
+ DHT: 'dht',
14
+ });
15
+
16
+ /** Order used only to break duplicate-address ties, not to trust a source. */
17
+ const SOURCE_RANK = Object.freeze({
18
+ [DISCOVERY_SOURCE.RELAY]: 0,
19
+ [DISCOVERY_SOURCE.MDNS]: 1,
20
+ [DISCOVERY_SOURCE.DHT]: 2,
21
+ });
22
+
23
+ const usable = (candidate) => candidate !== null
24
+ && typeof candidate === 'object'
25
+ && typeof candidate.address === 'string'
26
+ && candidate.address !== ''
27
+ && candidate.address !== '0.0.0.0'
28
+ && candidate.address !== '::'
29
+ && Number.isInteger(candidate.port)
30
+ && candidate.port > 0
31
+ && candidate.port <= 65535;
32
+
33
+ /**
34
+ * Merge candidates from every provider, deduplicating by address:port.
35
+ * The first source in rank order wins the label; the address itself is kept
36
+ * once. No provider can inject a non-address payload into this list.
37
+ */
38
+ export function mergeCandidates(lists, { limit = 8 } = {}) {
39
+ const merged = [];
40
+ const seen = new Set();
41
+ const sorted = [...lists].sort((a, b) => (SOURCE_RANK[a.source] ?? 9) - (SOURCE_RANK[b.source] ?? 9));
42
+ for (const list of sorted) {
43
+ for (const candidate of Array.isArray(list.candidates) ? list.candidates : []) {
44
+ if (!usable(candidate)) continue;
45
+ const key = candidate.address + ':' + candidate.port;
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ merged.push({ address: candidate.address, port: candidate.port, source: list.source ?? candidate.source ?? 'unknown' });
49
+ if (merged.length >= limit) return merged;
50
+ }
51
+ }
52
+ return merged;
53
+ }
54
+
55
+ /** Relay discovery, built on the existing authenticated /v1/peer route. */
56
+ export function createRelayDiscovery({ getJson } = {}) {
57
+ if (typeof getJson !== 'function') return { ok: false, code: 'DISCOVERY_NO_GET_JSON' };
58
+ return {
59
+ ok: true,
60
+ source: DISCOVERY_SOURCE.RELAY,
61
+ async discover(machineId) {
62
+ if (typeof machineId !== 'string' || machineId === '') return { ok: false, code: 'DISCOVERY_BAD_MACHINE_ID', candidates: [] };
63
+ const answer = await getJson('/v1/peer/' + encodeURIComponent(machineId));
64
+ if (answer?.ok !== true || answer?.status === 404) return { ok: false, code: 'DISCOVERY_RELAY_MISS', candidates: [] };
65
+ const candidates = Array.isArray(answer?.json?.peer?.candidates) ? answer.json.peer.candidates : [];
66
+ return { ok: true, source: DISCOVERY_SOURCE.RELAY, candidates, staticKey: answer?.json?.peer?.static_key ?? null };
67
+ },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * A provider for tests and for an injected mDNS implementation. It carries no
73
+ * network logic, so the caller decides how candidates were observed.
74
+ */
75
+ export function createStaticDiscovery({ source, candidates = [] } = {}) {
76
+ return {
77
+ ok: true,
78
+ source: source ?? 'static',
79
+ async discover() { return { ok: true, source: source ?? 'static', candidates: candidates.slice() }; },
80
+ };
81
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * W1 spike: iroh sidecar transport (JSON over length-prefixed stdio).
3
+ *
4
+ * The real binary is intentionally not bundled or auto-downloaded: the sidecar
5
+ * command/args come from configuration and are verified by the caller. This
6
+ * module owns only the process lifecycle and the frame contract, so a fake
7
+ * sidecar can exercise every failure path in CI without Rust or a TLS relay.
8
+ *
9
+ * Wire (parent -> child and child -> parent): 4-byte big-endian length + UTF-8 JSON.
10
+ * { id, method, params } request
11
+ * { id, result } / { id, error } response
12
+ * { type: 'ready' } child is listening
13
+ * { type: 'message', peer, data } inbound payload (base64)
14
+ * { type: 'status', paths, ... } diagnostics
15
+ */
16
+ import { spawn as nodeSpawn } from 'node:child_process';
17
+
18
+ export const IROH_TRANSPORT = 'iroh-v1';
19
+ export const IROH_CODE = Object.freeze({
20
+ START_FAILED: 'IROH_SIDECAR_START_FAILED',
21
+ TIMEOUT: 'IROH_TIMEOUT',
22
+ EXITED: 'IROH_SIDECAR_EXITED',
23
+ CLOSED: 'IROH_CLOSED',
24
+ BAD_FRAME: 'IROH_BAD_FRAME',
25
+ });
26
+
27
+ function encode(value) {
28
+ const body = Buffer.from(JSON.stringify(value), 'utf8');
29
+ const header = Buffer.alloc(4);
30
+ header.writeUInt32BE(body.length);
31
+ return Buffer.concat([header, body]);
32
+ }
33
+
34
+ export function createIrohSidecarTransport({ command, args = [], env = {}, timeoutMs = 2500, spawnImpl = nodeSpawn } = {}) {
35
+ if (typeof command !== 'string' || command === '') {
36
+ return { ok: false, code: IROH_CODE.START_FAILED, detail: 'iroh sidecar command is required' };
37
+ }
38
+ const handlers = new Set();
39
+ const pending = new Map();
40
+ let child = null;
41
+ let buffer = Buffer.alloc(0);
42
+ let nextId = 1;
43
+ let ready = null;
44
+ let closed = false;
45
+ let lastStatus = { paths: [] };
46
+
47
+ const failPending = (code, detail) => {
48
+ for (const [id, entry] of pending) {
49
+ clearTimeout(entry.timer);
50
+ entry.reject(Object.assign(new Error(detail ?? code), { code }));
51
+ pending.delete(id);
52
+ }
53
+ };
54
+
55
+ const onFrame = (message) => {
56
+ if (message?.type === 'ready') { ready?.(); ready = null; return; }
57
+ if (message?.type === 'message') {
58
+ const payload = Buffer.from(String(message.data ?? ''), 'base64');
59
+ for (const handler of handlers) handler({ peer: message.peer ?? null, payload });
60
+ return;
61
+ }
62
+ if (message?.type === 'status') { lastStatus = { paths: Array.isArray(message.paths) ? message.paths : [] }; return; }
63
+ if (typeof message?.id === 'number' && pending.has(message.id)) {
64
+ const entry = pending.get(message.id);
65
+ clearTimeout(entry.timer);
66
+ pending.delete(message.id);
67
+ if (message.error) entry.reject(Object.assign(new Error(String(message.error.message ?? message.error)), { code: message.error.code ?? IROH_CODE.EXITED }));
68
+ else entry.resolve(message.result ?? null);
69
+ }
70
+ };
71
+
72
+ const onData = (chunk) => {
73
+ buffer = Buffer.concat([buffer, chunk]);
74
+ while (buffer.length >= 4) {
75
+ const length = buffer.readUInt32BE(0);
76
+ if (buffer.length < 4 + length) return;
77
+ const body = buffer.subarray(4, 4 + length);
78
+ buffer = buffer.subarray(4 + length);
79
+ try { onFrame(JSON.parse(body.toString('utf8'))); } catch { /* a bad frame is dropped, not fatal */ }
80
+ }
81
+ };
82
+
83
+ const rpc = (method, params = {}) => new Promise((resolve, reject) => {
84
+ if (closed || child === null) { reject(Object.assign(new Error(IROH_CODE.CLOSED), { code: IROH_CODE.CLOSED })); return; }
85
+ const id = nextId;
86
+ nextId += 1;
87
+ const timer = setTimeout(() => {
88
+ pending.delete(id);
89
+ reject(Object.assign(new Error(IROH_CODE.TIMEOUT), { code: IROH_CODE.TIMEOUT }));
90
+ }, timeoutMs);
91
+ timer.unref?.();
92
+ pending.set(id, { resolve, reject, timer });
93
+ child.stdin.write(encode({ id, method, params }));
94
+ });
95
+
96
+ const transport = {
97
+ name: IROH_TRANSPORT,
98
+ async start() {
99
+ return new Promise((resolve) => {
100
+ try {
101
+ child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: { ...process.env, ...env } });
102
+ } catch (error) {
103
+ resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: error instanceof Error ? error.message : String(error) });
104
+ return;
105
+ }
106
+ const timer = setTimeout(() => { resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: IROH_CODE.TIMEOUT }); }, timeoutMs);
107
+ ready = () => { clearTimeout(timer); resolve({ ok: true, name: IROH_TRANSPORT }); };
108
+ child.stdout.on('data', onData);
109
+ child.stderr.on('data', () => {});
110
+ child.on('exit', () => {
111
+ closed = true;
112
+ clearTimeout(timer);
113
+ failPending(IROH_CODE.EXITED, 'iroh sidecar exited');
114
+ if (ready !== null) { ready = null; resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: 'sidecar exited before ready' }); }
115
+ });
116
+ child.on('error', (error) => {
117
+ closed = true;
118
+ clearTimeout(timer);
119
+ resolve({ ok: false, code: IROH_CODE.START_FAILED, detail: error.message });
120
+ });
121
+ });
122
+ },
123
+ async send(peer, payload) {
124
+ if (closed || child === null) return { ok: false, code: IROH_CODE.CLOSED };
125
+ try {
126
+ await rpc('send', { peer, data: Buffer.from(payload).toString('base64') });
127
+ return { ok: true, peer };
128
+ } catch (error) {
129
+ return { ok: false, code: error.code ?? IROH_CODE.EXITED, detail: error.message };
130
+ }
131
+ },
132
+ onMessage(handler) {
133
+ if (typeof handler !== 'function') return () => {};
134
+ handlers.add(handler);
135
+ return () => handlers.delete(handler);
136
+ },
137
+ paths() { return lastStatus.paths.map((path) => ({ ...path, transport: IROH_TRANSPORT })); },
138
+ diagnostics() { return { name: IROH_TRANSPORT, closed, pending: pending.size, paths: lastStatus.paths.length }; },
139
+ async stop() {
140
+ if (closed) return { ok: true };
141
+ closed = true;
142
+ failPending(IROH_CODE.CLOSED, 'transport stopped');
143
+ try { child?.stdin.end(); child?.kill(); } catch { /* already gone */ }
144
+ child = null;
145
+ return { ok: true };
146
+ },
147
+ };
148
+ return { ok: true, transport };
149
+ }
@@ -1232,7 +1232,7 @@ export class P2PNode extends EventEmitter {
1232
1232
  * that publishes no key is genuinely an older build and `PLAINTEXT_PEER` is honest; a peer that
1233
1233
  * publishes one we had not learned is *our* gap, and after this it is not a gap at all.
1234
1234
  */
1235
- let peerKey = this.peerKeys.get(machineId);
1235
+ let peerKey = typeof options.peerKey === 'string' && options.peerKey !== '' ? options.peerKey : this.peerKeys.get(machineId);
1236
1236
  if (peerKey === null && announcedStaticKey !== null) {
1237
1237
  this.peerKeys.set(machineId, announcedStaticKey, 'relay');
1238
1238
  peerKey = announcedStaticKey;
@@ -1497,6 +1497,16 @@ export class P2PNode extends EventEmitter {
1497
1497
  * @param {import('./p2p.mjs').P2PChannel} channel
1498
1498
  * @param {{from:string|null, session:number}} details
1499
1499
  */
1500
+ /** Close and forget a cached channel so a fresh dial can arm a new handshake. */
1501
+ dropChannel(session) {
1502
+ const channel = this.channels.get(session);
1503
+ if (channel === undefined) return false;
1504
+ this.channels.delete(session);
1505
+ this.claimed.delete(session);
1506
+ try { channel.close('replaced-by-rehandshake', { silent: true }); } catch { /* already gone */ }
1507
+ return true;
1508
+ }
1509
+
1500
1510
  adoptChannel(channel, details) {
1501
1511
  const previous = this.channels.get(details.session);
1502
1512
  if (previous !== undefined && previous !== channel) {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Multi-transport race: capability negotiation + first-usable-path wins.
3
+ *
4
+ * The control plane never waits for this. A dispatch goes through the relay
5
+ * immediately; this race only decides which data-channel carries results.
6
+ * There is deliberately no relay fallback here: if every transport loses, the
7
+ * caller gets ALL_TRANSPORTS_FAILED and the task result stays on the control
8
+ * plane, which is a different (and already-working) path.
9
+ */
10
+
11
+ export const TRANSPORT_PRIORITY = Object.freeze(['iroh', 'udp']);
12
+
13
+ /**
14
+ * Intersect the local and remote capability lists.
15
+ * A peer that declares nothing is an older build and gets the UDP path only.
16
+ *
17
+ * @param {{local?: string[], remote?: string[]|null, priority?: string[]}} input
18
+ * @returns {string[]} ordered transport names both ends can attempt
19
+ */
20
+ export function negotiateTransports({ local = ['udp'], remote = null, priority = TRANSPORT_PRIORITY } = {}) {
21
+ const localSet = new Set(local.filter((name) => typeof name === 'string'));
22
+ const remoteSet = remote === null ? new Set(['udp']) : new Set(remote.filter((name) => typeof name === 'string'));
23
+ return priority.filter((name) => localSet.has(name) && remoteSet.has(name));
24
+ }
25
+
26
+ /**
27
+ * Run every candidate's start() concurrently and resolve with the first channel.
28
+ * The losers are aborted as soon as a winner exists (or the total budget expires).
29
+ *
30
+ * @param {{
31
+ * candidates: Array<{name: string, timeoutMs?: number, start: (ctx: {signal: AbortSignal}) => Promise<any>}>,
32
+ * budgetMs?: number,
33
+ * now?: () => number,
34
+ * }} input
35
+ * @returns {Promise<{ok: true, name: string, channel: any, attempts: object[], elapsedMs: number}
36
+ * | {ok: false, code: 'ALL_TRANSPORTS_FAILED'|'NO_TRANSPORTS', attempts: object[], elapsedMs: number}>}
37
+ */
38
+ export async function raceTransports({ candidates, budgetMs = 3000, now = Date.now } = {}) {
39
+ if (!Array.isArray(candidates) || candidates.length === 0) {
40
+ return { ok: false, code: 'NO_TRANSPORTS', attempts: [], elapsedMs: 0 };
41
+ }
42
+ const startedAt = now();
43
+ const attempts = [];
44
+ const controllers = new Map();
45
+ let settled = false;
46
+ let resolveRace = null;
47
+ const race = new Promise((resolve) => { resolveRace = resolve; });
48
+
49
+ const finish = (result) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ for (const [name, controller] of controllers.entries()) {
53
+ if (result.ok === true && result.name === name) continue; // the winner keeps its channel
54
+ controller.abort();
55
+ }
56
+ resolveRace({ ...result, elapsedMs: now() - startedAt });
57
+ };
58
+
59
+ const budgetTimer = setTimeout(() => {
60
+ finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
61
+ }, budgetMs);
62
+ budgetTimer.unref?.();
63
+
64
+ for (const candidate of candidates) {
65
+ if (typeof candidate?.start !== 'function') continue;
66
+ const controller = new AbortController();
67
+ controllers.set(candidate.name, controller);
68
+ const record = { name: candidate.name, ok: false, code: null, elapsedMs: null };
69
+ attempts.push(record);
70
+ const started = now();
71
+ const timer = setTimeout(() => {
72
+ record.code = 'TRANSPORT_TIMEOUT';
73
+ record.elapsedMs = now() - started;
74
+ controller.abort();
75
+ if (attempts.every((attempt) => attempt.elapsedMs !== null) && attempts.every((attempt) => attempt.ok !== true)) {
76
+ finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
77
+ }
78
+ }, Number.isFinite(candidate.timeoutMs) ? candidate.timeoutMs : budgetMs);
79
+ timer.unref?.();
80
+ Promise.resolve()
81
+ .then(() => candidate.start({ signal: controller.signal }))
82
+ .then((channel) => {
83
+ clearTimeout(timer);
84
+ if (settled) return;
85
+ record.ok = true;
86
+ record.elapsedMs = now() - started;
87
+ finish({ ok: true, name: candidate.name, channel, attempts });
88
+ })
89
+ .catch((error) => {
90
+ clearTimeout(timer);
91
+ if (settled) return;
92
+ record.code = error?.code ?? 'TRANSPORT_FAILED';
93
+ record.message = error instanceof Error ? error.message : String(error);
94
+ record.elapsedMs = now() - started;
95
+ if (attempts.every((attempt) => attempt.ok !== true && attempt.elapsedMs !== null)) {
96
+ finish({ ok: false, code: 'ALL_TRANSPORTS_FAILED', attempts });
97
+ }
98
+ });
99
+ }
100
+
101
+ const result = await race;
102
+ clearTimeout(budgetTimer);
103
+ if (result.ok !== true) return result;
104
+ return result;
105
+ }