@twinsearth/w2m-dsh-plugin 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "2.1.3"
8
+ "version": "2.1.5"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "2.1.3",
15
+ "version": "2.1.5",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "2.1.3"
29
+ "version": "2.1.5"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "2.1.3",
4
+ "version": "2.1.5",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ All notable changes to this project are documented here.
7
7
  - `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
8
8
  are refused by name (the fleet does not invent commands).
9
9
 
10
+ ## [2.1.5] - 2026-10-31
11
+
12
+ - Packaging: desktop subplugin entry `@twinsearth/w2m-dsh-plugin/desktop` (host routes) and `/GPW` alias; root client bundle keeps the UI, same model as GPW.
13
+ - Idempotent route registration guard so root + /desktop do not mount remote-view twice.
14
+ - No behavior change to the main plugin.
15
+
16
+ ## [2.1.4] - 2026-10-31
17
+
18
+ - P1.5 encrypted relay fallback: relay /v1/remote/request mailbox, host P2P-failure fallback, target polling + VK + sealed frame POST, host GET/decrypt/cache, '加密中继' UI label.
19
+ - P2P: p2pPublicPort announcement override; p2pPort config normalization.
20
+ - Target relay polling starts only with W2M_VIEW_ALLOW=1.
21
+ - Verification: preflight GREEN; view/p2p/relay tests pass; real relay deployment + two-machine E2E still pending.
22
+
10
23
  ## [2.1.3] - 2026-10-31
11
24
 
12
25
  - Remote-view candidate: target selector, view.hello/VK (X25519+HKDF, AES-256-GCM), host decrypt, relay remote.frame broker/route, '加密中继' UI label.
package/README.md CHANGED
@@ -83,7 +83,7 @@ w2m-mcp --help
83
83
  # 复制进你自己的 codex/mcp-servers.toml
84
84
  [mcp_servers.w2m]
85
85
  command = "npx"
86
- args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.3", "w2m-mcp"]
86
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.5", "w2m-mcp"]
87
87
  ```
88
88
 
89
89
  ### 5. 验证过的 release tarball(离线)
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.3", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.5", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/docs/P2P-PORT.md CHANGED
@@ -6,8 +6,11 @@ Set `p2pPort` in the plugin config (or `--p2p-port` on the agent CLI) to bind a
6
6
  - id: w2m
7
7
  config:
8
8
  p2pPort: 31559
9
+ p2pPublicPort: 31559
9
10
  ```
10
11
 
11
12
  - Valid range: 1..65535; omitted/null keeps an ephemeral port.
12
13
  - Invalid values are reported as `P2P_PORT_INVALID` in `w2m_status` and do not start a P2P node.
13
14
  - Forward the same UDP port on the router to this machine, and allow it in the host firewall.
15
+
16
+ `p2pPublicPort` overrides the announced public port when the router forwards a fixed port but STUN reports a different outbound mapping. It must match the router external port.
@@ -5,17 +5,18 @@
5
5
  "machines": [
6
6
  "b53a37f4-1464-4512-9259-33a7df091e3e"
7
7
  ],
8
- "executed_at": "2026-10-31T00:27:14.997Z",
8
+ "executed_at": "2026-10-31T01:23:10.013Z",
9
9
  "executor": "lead",
10
10
  "task_id": "pre-release-2130-1793406434998",
11
11
  "requirement": "v2.1.3 remote-view candidate: p2pPort config, VK/hello frame channel, relay media broker",
12
12
  "result": {
13
13
  "outcome": "ok",
14
14
  "assertions": {
15
- "p2p_port": "readConfig normalizes p2pPort and createP2PRuntime passes bindPort",
15
+ "p2p_port": "p2pPort/p2pPublicPort normalized; local bind=announced public=31559 verified on ToFuture",
16
16
  "vk": "view.hello/VK + AES-GCM frame 3/3",
17
17
  "relay": "remote.frame ciphertext broker/route; relay suite 189/189",
18
- "preflight": "candidate only; full release gates later"
18
+ "preflight": "GREEN: 16 pass, 0 fail, 1 host-only UNVERIFIABLE",
19
+ "build": "dist/twinsearth-w2m-dsh-plugin-2.1.3.tgz sha256 3017057f63c68debef3116b4791e2ff937fd2fbb4715b92383c433433f20afcb"
19
20
  }
20
21
  }
21
22
  }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "2.1.4",
3
+ "case": "RELEASE-2140",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-31T03:04:58.412Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2140-1793415396132",
11
+ "requirement": "v2.1.4: P1.5 encrypted relay fallback + p2pPublicPort",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "preflight": "GREEN: 16 pass, 0 fail, 1 host-only UNVERIFIABLE",
16
+ "p1_5": "relay request mailbox, host fallback, target polling/encrypt, host decrypt",
17
+ "p2p": "p2pPort + p2pPublicPort, local three-way 31559 verified",
18
+ "e2e": "pending new relay deployment",
19
+ "build": "dist/twinsearth-w2m-dsh-plugin-2.1.4.tgz sha256 2be2c19720b20e7dcf36fb9032170ee13e711eec4e8e52d3ae1a4b6b6eb44ca1"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "2.1.5",
3
+ "case": "RELEASE-2150",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-31T04:33:51.426Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2150-1793420646683",
11
+ "requirement": "v2.1.5: package desktop subplugin entry + /GPW alias; root client owns UI",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "exports": "./GPW -> lib/gpw.js; ./desktop -> lib/desktop.js",
16
+ "idempotency": "WeakSet guard prevents double route registration",
17
+ "preflight": "GREEN: 16 pass, 0 fail, 1 host-only UNVERIFIABLE",
18
+ "build": "dist/twinsearth-w2m-dsh-plugin-2.1.5.tgz sha256 251765fdae33a8bde4e3f84fd23a844d5705a8b36a62fd2258ca86022b69429c"
19
+ }
20
+ }
21
+ }
package/lib/client.js CHANGED
@@ -2966,7 +2966,7 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
2966
2966
  name: 'sidebar.panellist',
2967
2967
  id: 'w2m-remote-view',
2968
2968
  order: 5,
2969
- label: () => '',
2969
+ label: () => '远程桌面',
2970
2970
  inject: () => ({}),
2971
2971
  }, RemoteViewSidebarIcon));
2972
2972
  } catch (error) {
package/lib/desktop.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Package-facing entry for the desktop subplugin:
3
+ * @twinsearth/w2m-dsh-plugin/desktop
4
+ *
5
+ * Host half only; the root package's lib/client.js owns the UI (same shape as GPW).
6
+ */
7
+ export { apply, inject, name, registerDesktop } from '../src/plugin/desktop.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,9 @@
11
11
  "./localside": "./bin/w2m-localside.mjs",
12
12
  "./stun": "./bin/w2m-stun.mjs",
13
13
  "./package.json": "./package.json",
14
- "./gpw": "./lib/gpw.js"
14
+ "./gpw": "./lib/gpw.js",
15
+ "./GPW": "./lib/gpw.js",
16
+ "./desktop": "./lib/desktop.js"
15
17
  },
16
18
  "dsh": {
17
19
  "plugin": true,
@@ -66,7 +68,7 @@
66
68
  "pack": "node scripts/pack.mjs",
67
69
  "reachability": "node scripts/reachability-check.mjs",
68
70
  "reachability:classify": "node scripts/reachability-classify.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 test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs",
71
+ "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 test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
70
72
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
71
73
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
72
74
  "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.1.3",
4
+ "version": "2.1.5",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -72,6 +72,7 @@ import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningK
72
72
  import { createXferFrameRouter } from './transfer/router.mjs';
73
73
  import { captureScreen } from './view/capture.mjs';
74
74
  import { createViewTarget } from './view/frame-channel.mjs';
75
+ import { startRelayViewTarget } from './view/relay-target.mjs';
75
76
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
76
77
  import { punchSession, reverseDialSession } from './p2p.mjs';
77
78
  import { createSigner } from '../signing.mjs';
@@ -4469,6 +4470,10 @@ let attemptAttached = false;
4469
4470
  // the first heartbeat: "is an agent running here?" is the first question a
4470
4471
  // remote operator asks (task-16).
4471
4472
  publishState();
4473
+ const viewRelay = process.env.W2M_VIEW_ALLOW === '1'
4474
+ ? startRelayViewTarget({ rabbitUrl: baseUrl, token: () => identity.device_token, machineId: identity.machine_id, capture: (options) => captureScreen(options), log })
4475
+ : null;
4476
+ void viewRelay;
4472
4477
  if (statePublishIntervalMs > 0) {
4473
4478
  statePublishTimer = setInterval(() => publishState(), statePublishIntervalMs);
4474
4479
  // Never keep the process alive just to refresh a diagnostics file.
@@ -451,6 +451,7 @@ export class P2PNode extends EventEmitter {
451
451
  bindUdpSocket = bindUdpSocketDefault,
452
452
  bindAddress = '0.0.0.0',
453
453
  bindPort = 0,
454
+ publicPort = null,
454
455
  deviceKey = null,
455
456
  peerKeys = null,
456
457
  cryptoTuning = null,
@@ -465,6 +466,8 @@ export class P2PNode extends EventEmitter {
465
466
  }
466
467
  this.machineId = machineId;
467
468
  this.rabbitUrl = rabbitUrl;
469
+ const publicPortResult = normalizeP2PPort(publicPort);
470
+ this.publicPort = publicPortResult.ok ? publicPortResult.port : null;
468
471
 
469
472
  for (const [name, value] of [
470
473
  ['postJson', postJson],
@@ -927,7 +930,10 @@ export class P2PNode extends EventEmitter {
927
930
  ? 'unknown'
928
931
  : null;
929
932
 
930
- const candidates = this.usableCandidates(reflexive);
933
+ // A port-forward can make an external port reachable while STUN still reports
934
+ // the router's outbound mapping. Announce the operator-declared port instead.
935
+ const announcedReflexive = reflexive !== null && this.publicPort !== null ? { ...reflexive, port: this.publicPort } : reflexive;
936
+ const candidates = this.usableCandidates(announcedReflexive);
931
937
 
932
938
  if (candidates.length === 0) {
933
939
  const error = `P2P_NO_CANDIDATES: nothing usable to announce (${discoveryError})`;
@@ -936,7 +942,7 @@ export class P2PNode extends EventEmitter {
936
942
  socket,
937
943
  candidates: [],
938
944
  mapping,
939
- reflexive,
945
+ reflexive: announcedReflexive,
940
946
  ok: false,
941
947
  error,
942
948
  posted: false,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * P1.5 target-side view polling.
3
+ *
4
+ * The target asks the relay for a pending viewer request (1 s cadence); on one,
5
+ * it derives VK from the viewer's ephemeral public key, captures a JPEG, seals
6
+ * it, and posts opaque ciphertext at ≤1 fps. Nothing is written to disk and the
7
+ * relay never sees plaintext.
8
+ */
9
+ import { createViewSide, sealViewFrame } from './session-key.mjs';
10
+
11
+ export const REMOTE_REQUEST_PATH = '/v1/remote/request';
12
+ export const REMOTE_FRAME_PATH = '/v1/remote/frame';
13
+
14
+ /**
15
+ * @param {{rabbitUrl:string, token:()=>string|null, machineId:string, capture:Function,
16
+ * intervalMs?:number, maxBytes?:number, fetchImpl?:Function, log?:Function, maxFrames?:number}} deps
17
+ */
18
+ export function startRelayViewTarget(deps) {
19
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
20
+ const intervalMs = Number.isInteger(deps.intervalMs) ? deps.intervalMs : 1000;
21
+ const maxBytes = Number.isInteger(deps.maxBytes) ? deps.maxBytes : 96 * 1024;
22
+ const maxFrames = Number.isInteger(deps.maxFrames) ? deps.maxFrames : 60;
23
+ const base = String(deps.rabbitUrl ?? '').replace(/\/+$/, '');
24
+ let stopped = false;
25
+ let timer = null;
26
+ let busy = false;
27
+
28
+ const stop = () => { stopped = true; if (timer !== null) clearInterval(timer); timer = null; };
29
+
30
+ const poll = async () => {
31
+ if (stopped || busy) return;
32
+ const token = deps.token();
33
+ if (typeof token !== 'string' || token === '') return;
34
+ busy = true;
35
+ try {
36
+ const response = await fetchImpl(base + REMOTE_REQUEST_PATH + '?target=' + encodeURIComponent(deps.machineId), {
37
+ headers: { authorization: 'Bearer ' + token },
38
+ });
39
+ if (stopped || !response.ok) { busy = false; return; }
40
+ const body = await response.json().catch(() => null);
41
+ if (body?.ok !== true || typeof body.viewer_public_key !== 'string') { busy = false; return; }
42
+ const side = createViewSide();
43
+ let vk;
44
+ try { vk = side.finish(body.viewer_public_key).key; } catch { busy = false; return; }
45
+ const sessionId = body.session_id;
46
+ let frames = 0;
47
+ const tick = async () => {
48
+ if (stopped || frames >= maxFrames) { busy = false; return; }
49
+ frames += 1;
50
+ const captured = deps.capture({ maxBytes });
51
+ if (captured?.ok !== true) { busy = false; return; }
52
+ let ct;
53
+ try { ct = sealViewFrame(vk, captured.data ?? Buffer.from(captured.base64 ?? '', 'base64')); } catch { busy = false; return; }
54
+ try {
55
+ await fetchImpl(base + REMOTE_FRAME_PATH, {
56
+ method: 'POST',
57
+ headers: { authorization: 'Bearer ' + deps.token(), 'content-type': 'application/json' },
58
+ body: JSON.stringify({ session_id: sessionId, target: deps.machineId, ct, public_key: side.public_key }),
59
+ });
60
+ } catch { /* relay transient; next tick retries */ }
61
+ };
62
+ await tick();
63
+ timer = setInterval(() => { void tick(); }, intervalMs);
64
+ if (typeof timer.unref === 'function') timer.unref();
65
+ } finally {
66
+ busy = false;
67
+ }
68
+ };
69
+
70
+ const pollTimer = setInterval(() => { void poll(); }, intervalMs);
71
+ if (typeof pollTimer.unref === 'function') pollTimer.unref();
72
+ void poll();
73
+ return {
74
+ stop() { stopped = true; if (timer !== null) clearInterval(timer); clearInterval(pollTimer); },
75
+ setStop() { stop(); },
76
+ };
77
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * desktop subplugin entry.
3
+ *
4
+ * Structure mirrors GPW: the host half lives here (routes), the client half is
5
+ * the root package's `lib/client.js`. Mounting both the root plugin and
6
+ * `/desktop` must not register the routes twice; a module-level WeakSet is the
7
+ * idempotency guard because both entries share this module instance.
8
+ */
9
+ import { registerRemoteViewRoutes } from './remote-view-route.mjs';
10
+
11
+ export const name = 'dsh-w2m-desktop';
12
+ export const inject = ['connection'];
13
+
14
+ const registered = new WeakSet();
15
+
16
+ /**
17
+ * @param {object} ctx Cordis context
18
+ * @param {{stateDir?:string, p2p?:object|null}} [deps]
19
+ */
20
+ export function registerDesktop(ctx, deps = {}) {
21
+ if (ctx === null || typeof ctx !== 'object') return { mounted: false, reason: 'no ctx' };
22
+ if (registered.has(ctx)) return { mounted: false, reason: 'already registered' };
23
+ registered.add(ctx);
24
+ return registerRemoteViewRoutes(ctx, {
25
+ stateDir: typeof deps.stateDir === 'string' ? deps.stateDir : '',
26
+ p2p: deps.p2p ?? null,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Profile row `@twinsearth/w2m-dsh-plugin/desktop` mounts this. The root bundle
32
+ * owns the UI; this half only registers the host routes.
33
+ */
34
+ export function apply(ctx) {
35
+ const stateDir = typeof ctx?.config?.stateDir === 'string' ? ctx.config.stateDir : '';
36
+ return registerDesktop(ctx, { stateDir, p2p: null });
37
+ }
@@ -61,6 +61,40 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
61
61
  listener = null;
62
62
  };
63
63
 
64
+ const postRelayRequest = async ({ stateDir, machineId, sessionId, publicKey }) => {
65
+ try {
66
+ const device = JSON.parse(readFileSync(join(stateDir, 'device.json'), 'utf8').replace(/^\uFEFF/, ''));
67
+ const base = String(device.rabbit_url ?? device.rabbitUrl ?? '').replace(/\/+$/, '');
68
+ const token = device.device_token ?? device.deviceToken ?? null;
69
+ if (base === '' || typeof token !== 'string' || token === '') return { ok: false, error: 'device.json has no relay url/token' };
70
+ const response = await fetch(base + '/v1/remote/request', {
71
+ method: 'POST',
72
+ headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
73
+ body: JSON.stringify({ session_id: sessionId, target: machineId, viewer_public_key: publicKey }),
74
+ });
75
+ const body = await response.json().catch(() => null);
76
+ if (!response.ok || body?.ok !== true) return { ok: false, error: body?.error?.code ?? ('HTTP ' + response.status) };
77
+ return { ok: true, ttl_ms: body.ttl_ms ?? null };
78
+ } catch (error) {
79
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ };
82
+
83
+ const fetchRelayFrame = async ({ stateDir, sessionId, machineId }) => {
84
+ try {
85
+ const device = JSON.parse(readFileSync(join(stateDir, 'device.json'), 'utf8').replace(/^\uFEFF/, ''));
86
+ const base = String(device.rabbit_url ?? device.rabbitUrl ?? '').replace(/\/+$/, '');
87
+ const token = device.device_token ?? device.deviceToken ?? null;
88
+ if (base === '' || typeof token !== 'string') return { ok: false };
89
+ const url = base + '/v1/remote/frame?session_id=' + encodeURIComponent(sessionId) + '&target=' + encodeURIComponent(machineId);
90
+ const response = await fetch(url, { headers: { authorization: 'Bearer ' + token } });
91
+ if (!response.ok) return { ok: false, waiting: response.status === 404 };
92
+ const body = await response.json().catch(() => null);
93
+ if (body?.ok !== true || typeof body.ct !== 'string') return { ok: false };
94
+ return { ok: true, ct: body.ct, public_key: body.public_key ?? null };
95
+ } catch { return { ok: false }; }
96
+ };
97
+
64
98
  ctx.inject(['connection'], (webCtx) => {
65
99
  const registry = webCtx?.connection?.fetch;
66
100
  if (registry === undefined || typeof registry.register !== 'function') { status.reason = 'the connection service carries no fetch registry'; return; }
@@ -89,39 +123,50 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
89
123
  try { body = await request.json(); } catch { /* refused below */ }
90
124
  const machineId = typeof body?.machine_id === 'string' ? body.machine_id : '';
91
125
  if (machineId === '') return Response.json({ ok: false, error: 'a JSON body with {"machine_id": "..."} is required' }, { status: 400, headers: NO_STORE });
92
- if (p2p?.node === null || p2p?.node === undefined) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: 'no P2P node on this host' }, { status: 503, headers: NO_STORE });
93
126
  await stopActive();
94
- try { await p2p.node.start?.(); } catch (error) {
95
- return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: error instanceof Error ? error.message : String(error) }, { status: 503, headers: NO_STORE });
96
- }
97
- const dialled = await p2p.node.dial(machineId, {});
98
- if (dialled?.ok !== true) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: dialled?.error ?? 'P2P_DIAL_FAILED' }, { status: 502, headers: NO_STORE });
127
+ const side = createViewSide();
99
128
  const sessionId = 'view-' + Date.now().toString(36);
100
- active = { machineId, sessionId, channel: dialled.channel, side: createViewSide(), vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
101
- listener = (event) => {
102
- const frame = payloadJson(event?.payload);
103
- if (frame === null || frame?.session_id !== sessionId) return;
104
- if (frame.type === 'view.hello_ack') {
105
- try {
106
- active.vk = active.side.finish(frame.public_key).key;
107
- active.encrypted = true;
108
- void active.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
109
- } catch { active.error = 'VIEW_VK_BAD'; }
110
- return;
111
- }
112
- if (frame.type === 'view.frame') {
113
- if (active.vk === null) { active.error = 'VIEW_VK_REQUIRED'; return; }
114
- let plaintext;
115
- try { plaintext = openViewFrame(active.vk, frame.ct); } catch { active.error = 'VIEW_VK_BAD'; return; }
116
- if (plaintext.length > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
117
- active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true };
118
- } else if (frame.type === 'view.error') {
119
- active.error = frame.code ?? 'VIEW_ERROR';
120
- }
121
- };
122
- p2p.node.on('message', listener);
123
- await dialled.channel.send(JSON.stringify({ type: 'view.hello', session_id: sessionId, public_key: active.side.public_key }));
124
- return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
129
+ let dialled = null;
130
+ let p2pError = null;
131
+ if (p2p?.node === null || p2p?.node === undefined) p2pError = 'no P2P node on this host';
132
+ else {
133
+ try { await p2p.node.start?.(); dialled = await p2p.node.dial(machineId, {}); } catch (error) { p2pError = error instanceof Error ? error.message : String(error); }
134
+ if (dialled?.ok !== true) p2pError = dialled?.error ?? 'P2P_DIAL_FAILED';
135
+ }
136
+ if (dialled?.ok === true) {
137
+ active = { mode: 'p2p', machineId, sessionId, channel: dialled.channel, side, vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
138
+ listener = (event) => {
139
+ const frame = payloadJson(event?.payload);
140
+ if (frame === null || frame?.session_id !== sessionId) return;
141
+ if (frame.type === 'view.hello_ack') {
142
+ try {
143
+ active.vk = active.side.finish(frame.public_key).key;
144
+ active.encrypted = true;
145
+ void active.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
146
+ } catch { active.error = 'VIEW_VK_BAD'; }
147
+ return;
148
+ }
149
+ if (frame.type === 'view.frame') {
150
+ if (active.vk === null) { active.error = 'VIEW_VK_REQUIRED'; return; }
151
+ let plaintext;
152
+ try { plaintext = openViewFrame(active.vk, frame.ct); } catch { active.error = 'VIEW_VK_BAD'; return; }
153
+ if (plaintext.length > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
154
+ active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true };
155
+ } else if (frame.type === 'view.error') {
156
+ active.error = frame.code ?? 'VIEW_ERROR';
157
+ }
158
+ };
159
+ p2p.node.on('message', listener);
160
+ await dialled.channel.send(JSON.stringify({ type: 'view.hello', session_id: sessionId, public_key: active.side.public_key }));
161
+ return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
162
+ }
163
+ // P1.5: P2P failed, so ask the relay to carry an opaque frame later.
164
+ const relayRequest = await postRelayRequest({ stateDir: deps.stateDir, machineId, sessionId, publicKey: side.public_key });
165
+ if (relayRequest.ok !== true) {
166
+ return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: p2pError, relay_error: relayRequest.error ?? null }, { status: 502, headers: NO_STORE });
167
+ }
168
+ active = { mode: 'relay', machineId, sessionId, channel: null, side, vk: null, encrypted: true, latest: null, error: null, startedAt: Date.now() };
169
+ return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: true, channel: 'relay', relay_ttl_ms: relayRequest.ttl_ms ?? null }, { headers: NO_STORE });
125
170
  },
126
171
  });
127
172
 
@@ -137,6 +182,19 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
137
182
  fetch: async () => {
138
183
  if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
139
184
  if (active !== null) {
185
+ if (active.mode === 'relay' && active.latest === null) {
186
+ const got = await fetchRelayFrame({ stateDir: deps.stateDir, sessionId: active.sessionId, machineId: active.machineId });
187
+ if (got.ok === true) {
188
+ try {
189
+ if (active.vk === null) {
190
+ if (typeof got.public_key !== 'string') { active.error = 'VIEW_VK_REQUIRED'; return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE }); }
191
+ active.vk = active.side.finish(got.public_key).key;
192
+ }
193
+ const plaintext = openViewFrame(active.vk, got.ct);
194
+ if (plaintext.length <= maxBytes) active.latest = { seq: null, mime: 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true, channel: 'relay' };
195
+ } catch { active.error = 'VIEW_VK_BAD'; }
196
+ }
197
+ }
140
198
  if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
141
199
  if (active.error !== null) return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE });
142
200
  return Response.json({ ok: false, code: 'VIEW_WAITING_FOR_FRAME', machine_id: active.machineId }, { status: 202, headers: NO_STORE });
@@ -200,7 +200,7 @@ import { planProgress } from './joint-dev/progress.mjs';
200
200
  import { registerJointTools } from './tools-joint.mjs';
201
201
  import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
202
202
  import { registerCaptureRoutes } from './capture-route.mjs';
203
- import { registerRemoteViewRoutes } from './remote-view-route.mjs';
203
+ import { registerDesktop } from './desktop.mjs';
204
204
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
205
205
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
206
206
  import { renderMemoryView } from './memory-view.mjs';
@@ -293,7 +293,7 @@ const MAX_STAGES = 16;
293
293
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
294
294
  * overrides it at runtime for tests and packagers.
295
295
  */
296
- const PLUGIN_VERSION = '2.1.3';
296
+ const PLUGIN_VERSION = '2.1.5';
297
297
 
298
298
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
299
299
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -744,7 +744,8 @@ function readConfig(config = {}) {
744
744
  // silent default when a value *is* supplied.
745
745
  const p2p = resolveP2PMode(config);
746
746
  const p2pMode = p2p.mode;
747
- const p2pPortResult = normalizeP2PPort(config.p2pPort);
747
+ const p2pPortResult = normalizeP2PPort(config.p2pPort);
748
+ const p2pPublicPortResult = normalizeP2PPort(config.p2pPublicPort);
748
749
  const stun = resolveStunServers(config);
749
750
  const stunList = stun.servers;
750
751
 
@@ -933,7 +934,8 @@ function readConfig(config = {}) {
933
934
  : (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM),
934
935
  p2pMode,
935
936
  p2pPort: p2pPortResult.ok ? p2pPortResult.port : null,
936
- p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
937
+ p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
938
+ p2pPublicPort: p2pPublicPortResult.ok ? p2pPublicPortResult.port : null,
937
939
  stunServers: stunList,
938
940
  };
939
941
  }
@@ -2035,7 +2037,8 @@ async function initP2P(ctx, cfg, runtime, block) {
2035
2037
  getJson: transport.getJson,
2036
2038
  stunServers: cfg.stunServers,
2037
2039
  log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
2038
- ...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
2040
+ ...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
2041
+ ...(Number.isInteger(cfg.p2pPublicPort) ? { publicPort: cfg.p2pPublicPort } : {}),
2039
2042
  ...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
2040
2043
  ...block,
2041
2044
  });
@@ -3616,7 +3619,7 @@ export async function apply(ctx, config = {}) {
3616
3619
  // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
3617
3620
  // depend on those permissions.
3618
3621
  registerCaptureRoutes(ctx);
3619
- const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
3622
+ const remoteViewRoutes = registerDesktop(ctx, { stateDir: cfg.stateDir ?? '', p2p });
3620
3623
  if (remoteViewRoutes.mounted !== true) {
3621
3624
  ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
3622
3625
  }
@@ -48,14 +48,14 @@ export function createRemoteMediaBroker(options = {}) {
48
48
  return { ok: true, code: REMOTE_MEDIA_CODE.OK, session_id: sessionId, target: targetMachineId };
49
49
  },
50
50
  /** Store one opaque ciphertext frame (latest wins). */
51
- put(sessionId, targetMachineId, ciphertext) {
51
+ put(sessionId, targetMachineId, ciphertext, publicKey = null) {
52
52
  prune();
53
53
  const entry = sessions.get(sessionId);
54
54
  if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
55
55
  if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
56
56
  if (!(ciphertext instanceof Uint8Array) || ciphertext.byteLength === 0) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
57
57
  if (ciphertext.byteLength > maxBytes) return { ok: false, code: REMOTE_MEDIA_CODE.TOO_LARGE };
58
- entry.frame = { ciphertext: Buffer.from(ciphertext), bytes: ciphertext.byteLength, at: now() };
58
+ entry.frame = { ciphertext: Buffer.from(ciphertext), bytes: ciphertext.byteLength, public_key: typeof publicKey === 'string' ? publicKey : null, at: now() };
59
59
  entry.expiresAt = now() + ttlMs;
60
60
  return { ok: true, code: REMOTE_MEDIA_CODE.OK, bytes: ciphertext.byteLength };
61
61
  },
@@ -81,7 +81,8 @@ export function parseRemoteFrame(body) {
81
81
  if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
82
82
  if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
83
83
  if (typeof body.ct !== 'string' || body.ct === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
84
+ if (body.public_key !== undefined && (typeof body.public_key !== 'string' || Buffer.from(body.public_key, 'base64').length !== 32)) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
84
85
  let ciphertext;
85
86
  try { ciphertext = Buffer.from(body.ct, 'base64'); } catch { return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD }; }
86
- return { ok: true, session_id: body.session_id, target: body.target, ciphertext };
87
+ return { ok: true, session_id: body.session_id, target: body.target, ciphertext, public_key: body.public_key ?? null };
87
88
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * P1.5 remote-view request mailbox (relay memory only).
3
+ *
4
+ * A viewer posts one request per target; the target consumes it. No disk, no
5
+ * logs, no plaintext media. Every entry expires, and the newest request wins so
6
+ * a stale viewer cannot pin a target forever.
7
+ */
8
+ export const REMOTE_REQUEST_CODE = Object.freeze({
9
+ OK: 'REMOTE_REQUEST_OK',
10
+ BAD: 'REMOTE_REQUEST_BAD',
11
+ TOO_LARGE: 'REMOTE_REQUEST_TOO_LARGE',
12
+ NOT_FOUND: 'REMOTE_REQUEST_NOT_FOUND',
13
+ EXPIRED: 'REMOTE_REQUEST_EXPIRED',
14
+ });
15
+
16
+ export const DEFAULT_REMOTE_REQUEST_TTL_MS = 15_000;
17
+
18
+ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
19
+
20
+ export function createRemoteRequestBroker(options = {}) {
21
+ const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : DEFAULT_REMOTE_REQUEST_TTL_MS;
22
+ const now = typeof options.now === 'function' ? options.now : Date.now;
23
+ const requests = new Map();
24
+
25
+ const prune = () => { const at = now(); for (const [key, entry] of requests) if (entry.expiresAt <= at) requests.delete(key); };
26
+
27
+ return {
28
+ post(body) {
29
+ prune();
30
+ if (!isObject(body)) return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
31
+ if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
32
+ if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
33
+ if (typeof body.viewer_public_key !== 'string' || body.viewer_public_key === '') return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
34
+ const key = Buffer.from(body.viewer_public_key, 'base64');
35
+ if (key.length !== 32) return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
36
+ requests.set(body.target, {
37
+ session_id: body.session_id,
38
+ target: body.target,
39
+ viewer_public_key: body.viewer_public_key,
40
+ expiresAt: now() + ttlMs,
41
+ at: now(),
42
+ });
43
+ return { ok: true, code: REMOTE_REQUEST_CODE.OK, target: body.target, ttl_ms: ttlMs };
44
+ },
45
+ /** Consume the pending request for one target; the target is the only reader. */
46
+ take(target) {
47
+ prune();
48
+ const entry = requests.get(target);
49
+ if (entry === undefined) return { ok: false, code: REMOTE_REQUEST_CODE.NOT_FOUND };
50
+ requests.delete(target);
51
+ return { ok: true, code: REMOTE_REQUEST_CODE.OK, session_id: entry.session_id, target: entry.target, viewer_public_key: entry.viewer_public_key, at: entry.at };
52
+ },
53
+ stats() { prune(); return { targets: requests.size, ttl_ms: ttlMs }; },
54
+ };
55
+ }
@@ -34,6 +34,7 @@ export const RELAY_FEATURES = Object.freeze([
34
34
  'relay-version-v1',
35
35
  'chat-text-only-v1',
36
36
  'chat-enc-v2',
37
+ 'remote-view-v1',
37
38
  ]);
38
39
  import fs from 'node:fs';
39
40
  import os from 'node:os';
@@ -61,6 +62,7 @@ export const BLOB_FRAME_BYTES = 16 * 1024 * 1024; // 16 MiB per blob frame -- th
61
62
  // the JSON frame limit stays 64 KiB for every other route.
62
63
  import { createFleetMessages } from './fleet-messages.mjs';
63
64
  import { createRemoteMediaBroker, parseRemoteFrame } from './remote-media.mjs';
65
+ import { createRemoteRequestBroker } from './remote-requests.mjs';
64
66
  import { can as mayAct } from '../util/policy-matrix.mjs';
65
67
  import { createFleetMemory } from './fleet-memory.mjs';
66
68
  import { createJoinChallenges } from './join-challenge.mjs';
@@ -1451,6 +1453,30 @@ export class RelayServer {
1451
1453
  this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1452
1454
  this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1453
1455
 
1456
+ if (path === '/v1/remote/request') {
1457
+ // P1.5 signalling: one pending request per target, relay memory only.
1458
+ this.remoteRequests ??= createRemoteRequestBroker();
1459
+ if (method === 'POST') {
1460
+ const body = takeJson();
1461
+ const target = typeof body?.target === 'string' ? body.target : '';
1462
+ const targetDevice = target === '' ? null : this.state.devices.get(target);
1463
+ if (targetDevice === undefined || targetDevice === null || targetDevice.fleet_id !== device.fleet_id) {
1464
+ return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'REMOTE_REQUEST_BAD', message: 'target is not in this fleet' } });
1465
+ }
1466
+ const posted = this.remoteRequests.post(body);
1467
+ if (posted.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: posted.code, message: 'remote.request refused' } });
1468
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, target: posted.target, ttl_ms: posted.ttl_ms });
1469
+ }
1470
+ if (method === 'GET') {
1471
+ const target = url.searchParams.get('target');
1472
+ if (target !== device.machine_id) return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'REMOTE_REQUEST_BAD', message: 'only the target may read its request' } });
1473
+ const taken = this.remoteRequests.take(target);
1474
+ if (taken.ok !== true) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: taken.code, message: 'remote.request unavailable' } });
1475
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, ...taken });
1476
+ }
1477
+ return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET or POST only' } });
1478
+ }
1479
+
1454
1480
  if (path === '/v1/remote/frame') {
1455
1481
  // P1.5: opaque ciphertext only. The body is never logged, parsed as media
1456
1482
  // or persisted; the broker keeps one newest frame per session with a TTL.
@@ -1466,7 +1492,7 @@ export class RelayServer {
1466
1492
  }
1467
1493
  const opened = this.remoteMedia.open(parsed.session_id, parsed.target);
1468
1494
  if (opened.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: opened.code, message: 'remote.frame refused' } });
1469
- const stored = this.remoteMedia.put(parsed.session_id, parsed.target, parsed.ciphertext);
1495
+ const stored = this.remoteMedia.put(parsed.session_id, parsed.target, parsed.ciphertext, parsed.public_key);
1470
1496
  if (stored.ok !== true) return sendJson(res, 413, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: stored.code, message: 'remote.frame refused' } });
1471
1497
  return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, bytes: stored.bytes });
1472
1498
  }
@@ -1475,7 +1501,7 @@ export class RelayServer {
1475
1501
  const target = url.searchParams.get('target');
1476
1502
  const taken = this.remoteMedia.take(sessionId, target);
1477
1503
  if (taken.ok !== true) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: taken.code, message: 'remote.frame unavailable' } });
1478
- return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, ct: taken.ciphertext.toString('base64'), bytes: taken.bytes, at_ms: taken.at });
1504
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, ct: taken.ciphertext.toString('base64'), public_key: taken.public_key ?? null, bytes: taken.bytes, at_ms: taken.at });
1479
1505
  }
1480
1506
  return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET or POST only' } });
1481
1507
  }