@twinsearth/w2m-dsh-plugin 2.1.2 → 2.1.3

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.2"
8
+ "version": "2.1.3"
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.2",
15
+ "version": "2.1.3",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "2.1.2"
29
+ "version": "2.1.3"
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.2",
4
+ "version": "2.1.3",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ 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.3] - 2026-10-31
11
+
12
+ - Remote-view candidate: target selector, view.hello/VK (X25519+HKDF, AES-256-GCM), host decrypt, relay remote.frame broker/route, '加密中继' UI label.
13
+ - Still missing: P1.5 auto-fallback, target consent UI, deployed relay endpoint, real P2P verification. Test candidate.
14
+
10
15
  ## [2.1.2] - 2026-10-31
11
16
 
12
17
  - Remote-view preview: settings toggle, iMac sidebar entry, local 1fps JPEG viewer, target capture module, P2P view frame protocol, host /remote/start|frame|stop routes, encrypted-relay media broker (safety core).
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.0.3", "w2m-mcp"]
86
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.3", "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.0", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.1.3", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,13 @@
1
+ # P2P UDP port
2
+
3
+ Set `p2pPort` in the plugin config (or `--p2p-port` on the agent CLI) to bind a stable UDP port. This is required for a router port-forward: the public candidate, router mapping and local bind must be the same port.
4
+
5
+ ```yaml
6
+ - id: w2m
7
+ config:
8
+ p2pPort: 31559
9
+ ```
10
+
11
+ - Valid range: 1..65535; omitted/null keeps an ephemeral port.
12
+ - Invalid values are reported as `P2P_PORT_INVALID` in `w2m_status` and do not start a P2P node.
13
+ - Forward the same UDP port on the router to this machine, and allow it in the host firewall.
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "2.1.3",
3
+ "case": "RELEASE-2130",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-31T00:27:14.997Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2130-1793406434998",
11
+ "requirement": "v2.1.3 remote-view candidate: p2pPort config, VK/hello frame channel, relay media broker",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "p2p_port": "readConfig normalizes p2pPort and createP2PRuntime passes bindPort",
16
+ "vk": "view.hello/VK + AES-GCM frame 3/3",
17
+ "relay": "remote.frame ciphertext broker/route; relay suite 189/189",
18
+ "preflight": "candidate only; full release gates later"
19
+ }
20
+ }
21
+ }
package/lib/client.js CHANGED
@@ -2254,7 +2254,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
2254
2254
  : h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '本机回环预览(选择目标后点连接)')))),
2255
2255
  h('div', { key: 'statusline', style: { fontSize: '12px', opacity: 0.7, display: 'flex', gap: '14px', flexWrap: 'wrap' } }, [
2256
2256
  h('span', { key: 'fps' }, '1 fps 上限'),
2257
- h('span', { key: 'channel' }, '通道:' + (session ? 'P2P' : '本机回环')),
2257
+ h('span', { key: 'channel' }, '通道:' + (session ? (session.channel === 'relay' ? '加密中继' : 'P2P') : '本机回环')),
2258
2258
  h('span', { key: 'cap' }, '采集:' + (stats?.capture_ms ?? '—') + 'ms'),
2259
2259
  h('span', { key: 'bytes' }, '帧:' + (stats?.bytes ?? '—') + 'B'),
2260
2260
  h('span', { key: 'age' }, '最近帧:' + (stats?.at ?? '—')),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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": {
@@ -66,7 +66,7 @@
66
66
  "pack": "node scripts/pack.mjs",
67
67
  "reachability": "node scripts/reachability-check.mjs",
68
68
  "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",
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",
70
70
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
71
71
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
72
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.1.2",
4
+ "version": "2.1.3",
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": [
@@ -1147,6 +1147,7 @@ export function createAgent(options) {
1147
1147
  const peerKeys = new PeerKeyStore();
1148
1148
  /** P0-a: xfer.* frames arriving on a P2P channel are routed here, not to task dispatch. */
1149
1149
  const xferRouter = createXferFrameRouter();
1150
+ let viewTargets = null;
1150
1151
  /**
1151
1152
  * What each task's direct channel actually was, for the result envelope.
1152
1153
  *
@@ -3969,11 +3970,19 @@ let attemptAttached = false;
3969
3970
  return;
3970
3971
  }
3971
3972
  if (payload !== null && typeof payload.type === 'string' && payload.type.startsWith('view.')) {
3972
- const target = createViewTarget({
3973
- capture: () => captureScreen({}),
3974
- allow: process.env.W2M_VIEW_ALLOW === '1',
3975
- send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
3976
- });
3973
+ // A target owns the VK handshake state, so it must outlive one message:
3974
+ // hello, request and stop for the same session share one instance.
3975
+ viewTargets ??= new Map();
3976
+ const key = String(payload.session_id ?? 'default');
3977
+ let target = viewTargets.get(key);
3978
+ if (target === undefined || payload.type === 'view.hello') {
3979
+ target = createViewTarget({
3980
+ capture: () => captureScreen({}),
3981
+ allow: process.env.W2M_VIEW_ALLOW === '1',
3982
+ send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
3983
+ });
3984
+ viewTargets.set(key, target);
3985
+ }
3977
3986
  target.handle(payload);
3978
3987
  return;
3979
3988
  }
@@ -1,11 +1,21 @@
1
1
  /**
2
- * P2P view frame channel (P1, view-only).
2
+ * P2P/P1.5 view frame channel (view-only).
3
3
  *
4
- * Frames are JSON on the existing P2PChannel (one JPEG per frame, ≤96 KiB).
5
- * The target defaults to DENY; consent is a separate decision supplied by the
6
- * caller (`allow: true`). Nothing here accepts input events.
4
+ * Handshake:
5
+ * viewer → view.hello { session_id, public_key }
6
+ * target → view.hello_ack { session_id, public_key }
7
+ * both derive VK (X25519 + HKDF), then:
8
+ * viewer → view.request { fps, max_bytes }
9
+ * target → view.frame { ct (AES-256-GCM), bytes, seq }
10
+ *
11
+ * A frame without a prior hello is refused; the target defaults to DENY and
12
+ * never accepts input frames.
7
13
  */
14
+ import { createViewSide, openViewFrame, sealViewFrame } from './session-key.mjs';
15
+
8
16
  export const VIEW_FRAME = Object.freeze({
17
+ HELLO: 'view.hello',
18
+ HELLO_ACK: 'view.hello_ack',
9
19
  REQUEST: 'view.request',
10
20
  FRAME: 'view.frame',
11
21
  STOP: 'view.stop',
@@ -14,7 +24,8 @@ export const VIEW_FRAME = Object.freeze({
14
24
 
15
25
  export const VIEW_CODE = Object.freeze({
16
26
  CONSENT_REQUIRED: 'VIEW_CONSENT_REQUIRED',
17
- DISABLED: 'VIEW_DISABLED',
27
+ VK_REQUIRED: 'VIEW_VK_REQUIRED',
28
+ VK_BAD: 'VIEW_VK_BAD',
18
29
  FRAME_TOO_LARGE: 'VIEW_FRAME_TOO_LARGE',
19
30
  BAD_FRAME: 'VIEW_BAD_FRAME',
20
31
  CAPTURE_FAILED: 'VIEW_CAPTURE_FAILED',
@@ -24,29 +35,30 @@ export const VIEW_CODE = Object.freeze({
24
35
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
25
36
  const isInputFrame = (type) => /^(mouse|key|pointer|input)\./.test(type);
26
37
 
27
- export function parseViewFrame(payload) {
38
+ function parseAny(payload) {
28
39
  try {
29
- const frame = Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload;
30
- if (!isObject(frame) || typeof frame.type !== 'string' || !frame.type.startsWith('view.')) return null;
31
- return frame;
40
+ if (Buffer.isBuffer(payload) || typeof payload === 'string') return JSON.parse(Buffer.from(payload).toString('utf8'));
41
+ return payload;
32
42
  } catch { return null; }
33
43
  }
34
44
 
35
- /**
36
- * Target side: answer view.request with one JPEG frame; refuse by default.
37
- *
38
- * @param {{capture: Function, allow?: boolean, send: Function, maxBytes?: number, fps?: number}} deps
39
- */
45
+ export function parseViewFrame(payload) {
46
+ const frame = parseAny(payload);
47
+ if (!isObject(frame) || typeof frame.type !== 'string' || !frame.type.startsWith('view.')) return null;
48
+ return frame;
49
+ }
50
+
51
+ /** @param {{capture:Function, allow?:boolean, send:Function, maxBytes?:number, fps?:number}} deps */
40
52
  export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1 } = {}) {
41
53
  if (typeof capture !== 'function' || typeof send !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
42
54
  let seq = 0;
43
55
  let stopped = false;
56
+ let vk = null;
44
57
  const reply = (frame) => { try { send(JSON.stringify(frame)); } catch { /* channel gone */ } };
45
58
  return {
46
59
  ok: true,
47
60
  handle(payload) {
48
- let raw = null;
49
- try { raw = Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload; } catch { raw = null; }
61
+ const raw = parseAny(payload);
50
62
  if (isObject(raw) && typeof raw.type === 'string' && isInputFrame(raw.type)) {
51
63
  reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.INPUT_FORBIDDEN, session_id: raw.session_id ?? null });
52
64
  return true;
@@ -54,61 +66,71 @@ export function createViewTarget({ capture, allow = false, send, maxBytes = 96 *
54
66
  const frame = parseViewFrame(payload);
55
67
  if (frame === null) return false;
56
68
  if (frame.type === VIEW_FRAME.STOP) { stopped = true; return true; }
69
+ if (frame.type === VIEW_FRAME.HELLO) {
70
+ if (typeof frame.public_key !== 'string' || frame.public_key === '') { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: frame.session_id ?? null }); return true; }
71
+ try {
72
+ const side = createViewSide();
73
+ vk = side.finish(frame.public_key).key;
74
+ reply({ type: VIEW_FRAME.HELLO_ACK, session_id: frame.session_id ?? null, public_key: side.public_key });
75
+ } catch { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: frame.session_id ?? null }); }
76
+ return true;
77
+ }
57
78
  if (frame.type !== VIEW_FRAME.REQUEST) return true;
58
79
  if (allow !== true) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.CONSENT_REQUIRED, session_id: frame.session_id ?? null }); return true; }
59
80
  if (stopped) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.DISABLED, session_id: frame.session_id ?? null }); return true; }
81
+ if (vk === null) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_REQUIRED, session_id: frame.session_id ?? null }); return true; }
60
82
  const result = capture({ maxBytes });
61
- if (result?.ok !== true) {
62
- reply({ type: VIEW_FRAME.ERROR, code: result?.code ?? VIEW_CODE.CAPTURE_FAILED, session_id: frame.session_id ?? null });
63
- return true;
64
- }
83
+ if (result?.ok !== true) { reply({ type: VIEW_FRAME.ERROR, code: result?.code ?? VIEW_CODE.CAPTURE_FAILED, session_id: frame.session_id ?? null }); return true; }
84
+ let ct;
85
+ try { ct = sealViewFrame(vk, result.data ?? Buffer.from(result.base64 ?? '', 'base64')); }
86
+ catch { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: frame.session_id ?? null }); return true; }
65
87
  seq += 1;
66
- reply({
67
- type: VIEW_FRAME.FRAME,
68
- session_id: frame.session_id ?? null,
69
- seq,
70
- ts_ms: Date.now(),
71
- mime: result.mime ?? 'image/jpeg',
72
- bytes: result.bytes,
73
- data_b64: result.base64,
74
- });
88
+ reply({ type: VIEW_FRAME.FRAME, session_id: frame.session_id ?? null, seq, ts_ms: Date.now(), mime: result.mime ?? 'image/jpeg', bytes: result.bytes, enc: 'aes-256-gcm', ct });
75
89
  return true;
76
90
  },
77
- stats() { return { seq, stopped, allow: allow === true, fps, max_bytes: maxBytes }; },
91
+ stats() { return { seq, stopped, allow: allow === true, fps, max_bytes: maxBytes, encrypted: vk !== null }; },
78
92
  };
79
93
  }
80
94
 
81
- /**
82
- * Viewer side: request a frame and accept/validate the response.
83
- *
84
- * @param {{send: Function, onFrame: Function, maxBytes?: number}} deps
85
- */
95
+ /** @param {{send:Function, onFrame:Function, maxBytes?:number}} deps */
86
96
  export function createViewClient({ send, onFrame, maxBytes = 96 * 1024 } = {}) {
87
97
  if (typeof send !== 'function' || typeof onFrame !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
88
98
  let sessionId = null;
99
+ let machineId = null;
100
+ let side = null;
101
+ let vk = null;
89
102
  let lastSeq = 0;
103
+ const sendRequest = () => send(JSON.stringify({ type: VIEW_FRAME.REQUEST, session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
90
104
  return {
91
105
  ok: true,
92
- request({ machineId, session }) {
106
+ request({ machineId: machine = null, session } = {}) {
107
+ machineId = machine;
93
108
  sessionId = session ?? ('view-' + Date.now());
94
- send(JSON.stringify({ type: VIEW_FRAME.REQUEST, session_id: sessionId, machine_id: machineId ?? null, fps: 1, max_bytes: maxBytes }));
109
+ side = createViewSide();
110
+ send(JSON.stringify({ type: VIEW_FRAME.HELLO, session_id: sessionId, public_key: side.public_key }));
95
111
  return sessionId;
96
112
  },
97
113
  accept(payload) {
98
114
  const frame = parseViewFrame(payload);
99
115
  if (frame === null) return false;
100
116
  if (frame.type === VIEW_FRAME.ERROR) { onFrame({ ok: false, code: frame.code ?? VIEW_CODE.CAPTURE_FAILED }); return true; }
117
+ if (frame.type === VIEW_FRAME.HELLO_ACK) {
118
+ try { vk = side.finish(frame.public_key).key; sendRequest(); }
119
+ catch { onFrame({ ok: false, code: VIEW_CODE.VK_BAD }); }
120
+ return true;
121
+ }
101
122
  if (frame.type !== VIEW_FRAME.FRAME) return true;
102
123
  if (sessionId !== null && frame.session_id !== sessionId) return true;
103
124
  if (!Number.isInteger(frame.seq) || frame.seq <= lastSeq) return true;
104
- if (typeof frame.data_b64 !== 'string' || frame.data_b64 === '') { onFrame({ ok: false, code: VIEW_CODE.BAD_FRAME }); return true; }
105
- const bytes = Buffer.from(frame.data_b64, 'base64').length;
106
- if (bytes > maxBytes) { onFrame({ ok: false, code: VIEW_CODE.FRAME_TOO_LARGE }); return true; }
125
+ if (vk === null || typeof frame.ct !== 'string' || frame.ct === '') { onFrame({ ok: false, code: VIEW_CODE.VK_REQUIRED }); return true; }
126
+ let plaintext;
127
+ try { plaintext = openViewFrame(vk, frame.ct); } catch { onFrame({ ok: false, code: VIEW_CODE.VK_BAD }); return true; }
128
+ if (plaintext.length > maxBytes) { onFrame({ ok: false, code: VIEW_CODE.FRAME_TOO_LARGE }); return true; }
107
129
  lastSeq = frame.seq;
108
- onFrame({ ok: true, seq: frame.seq, ts_ms: frame.ts_ms, mime: frame.mime ?? 'image/jpeg', bytes, data_b64: frame.data_b64 });
130
+ onFrame({ ok: true, seq: frame.seq, ts_ms: frame.ts_ms, mime: frame.mime ?? 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), encrypted: true });
109
131
  return true;
110
132
  },
111
133
  stop() { if (sessionId !== null) send(JSON.stringify({ type: VIEW_FRAME.STOP, session_id: sessionId })); },
112
- stats() { return { sessionId, lastSeq }; },
134
+ stats() { return { sessionId, lastSeq, encrypted: vk !== null }; },
113
135
  };
114
136
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * P1.5 view-session keys (VK).
3
+ *
4
+ * Ephemeral X25519 per session; both sides mix the shared secret with the two
5
+ * sorted public keys, so viewer and target derive the same 32-byte key without
6
+ * a static device key being reused for media. Frames are AES-256-GCM sealed;
7
+ * the relay only ever sees the base64 ciphertext.
8
+ */
9
+ import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto';
10
+
11
+ export const VIEW_VK_LABEL = 'w2m-remote-view v1 session';
12
+ export const VIEW_VK_SALT = 'w2m-remote-view v1 salt';
13
+
14
+ const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
15
+
16
+ function ephemeral() {
17
+ const { privateKey, publicKey } = generateKeyPairSync('x25519');
18
+ const raw = publicKey.export({ type: 'spki', format: 'der' }).subarray(X25519_SPKI_PREFIX.length);
19
+ return { privateKey, publicKeyB64: raw.toString('base64') };
20
+ }
21
+
22
+ function peerPublic(raw) {
23
+ return createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
24
+ }
25
+
26
+ function deriveKey(shared, publicA, publicB) {
27
+ const [first, second] = [publicA, publicB].sort();
28
+ return Buffer.from(hkdfSync('sha256', Buffer.from(shared), Buffer.from(VIEW_VK_SALT), Buffer.from(VIEW_VK_LABEL + '|' + first + '|' + second), 32));
29
+ }
30
+
31
+ /** One side's ephemeral keypair plus the public key to put in the signal. */
32
+ export function createViewSide() {
33
+ const eph = ephemeral();
34
+ const publicKey = eph.publicKeyB64;
35
+ return {
36
+ public_key: publicKey,
37
+ /** @param {string} peerPublicKey base64 X25519 public key from the peer */
38
+ finish(peerPublicKey) {
39
+ if (typeof peerPublicKey !== 'string' || peerPublicKey === '') throw new Error('VIEW_VK_BAD_PEER_KEY');
40
+ const peer = Buffer.from(peerPublicKey, 'base64');
41
+ if (peer.length !== 32) throw new Error('VIEW_VK_BAD_PEER_KEY');
42
+ const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: peerPublic(peer) });
43
+ return { key: deriveKey(shared, publicKey, peerPublicKey), public_key: publicKey };
44
+ },
45
+ };
46
+ }
47
+
48
+ export function sealViewFrame(key, plaintext) {
49
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('VIEW_VK_BAD_KEY');
50
+ const iv = randomBytes(12);
51
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
52
+ cipher.setAAD(Buffer.from('w2m-remote-view/frame'));
53
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
54
+ const tag = cipher.getAuthTag();
55
+ return Buffer.concat([iv, tag, body]).toString('base64');
56
+ }
57
+
58
+ export function openViewFrame(key, encoded) {
59
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('VIEW_VK_BAD_KEY');
60
+ const raw = Buffer.from(encoded, 'base64');
61
+ if (raw.length < 28) throw new Error('VIEW_VK_BAD_FRAME');
62
+ const iv = raw.subarray(0, 12);
63
+ const tag = raw.subarray(12, 28);
64
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
65
+ decipher.setAAD(Buffer.from('w2m-remote-view/frame'));
66
+ decipher.setAuthTag(tag);
67
+ return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
68
+ }
@@ -11,6 +11,7 @@
11
11
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { captureHostScreen } from './capture-route.mjs';
14
+ import { createViewSide, openViewFrame } from '../agent/view/session-key.mjs';
14
15
 
15
16
  export const REMOTE_CONFIG_PATH = '/api/w2m/remote/config';
16
17
  export const REMOTE_FRAME_PATH = '/api/w2m/remote/frame';
@@ -96,20 +97,30 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
96
97
  const dialled = await p2p.node.dial(machineId, {});
97
98
  if (dialled?.ok !== true) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: dialled?.error ?? 'P2P_DIAL_FAILED' }, { status: 502, headers: NO_STORE });
98
99
  const sessionId = 'view-' + Date.now().toString(36);
99
- active = { machineId, sessionId, channel: dialled.channel, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
100
+ active = { machineId, sessionId, channel: dialled.channel, side: createViewSide(), vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
100
101
  listener = (event) => {
101
102
  const frame = payloadJson(event?.payload);
102
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
+ }
103
112
  if (frame.type === 'view.frame') {
104
- const bytes = typeof frame.data_b64 === 'string' ? Buffer.from(frame.data_b64, 'base64').length : 0;
105
- if (bytes > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
106
- active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes, data_base64: frame.data_b64, at: new Date().toISOString(), encrypted: active.encrypted };
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 };
107
118
  } else if (frame.type === 'view.error') {
108
119
  active.error = frame.code ?? 'VIEW_ERROR';
109
120
  }
110
121
  };
111
122
  p2p.node.on('message', listener);
112
- await dialled.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
123
+ await dialled.channel.send(JSON.stringify({ type: 'view.hello', session_id: sessionId, public_key: active.side.public_key }));
113
124
  return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
114
125
  },
115
126
  });
@@ -137,6 +137,7 @@ import {
137
137
  P2PNode,
138
138
  SHARED_SERVER,
139
139
  normalizeP2PMode,
140
+ normalizeP2PPort,
140
141
  stunServersWithShared,
141
142
  } from '../agent/p2p-node.mjs';
142
143
  // The punch identity both ends have to agree on (v0.4.x). Imported from the transport that will use
@@ -198,7 +199,7 @@ import { registerProjectStreamRoute } from './project-stream-route.mjs';
198
199
  import { planProgress } from './joint-dev/progress.mjs';
199
200
  import { registerJointTools } from './tools-joint.mjs';
200
201
  import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
201
- import { registerCaptureRoutes } from './capture-route.mjs';
202
+ import { registerCaptureRoutes } from './capture-route.mjs';
202
203
  import { registerRemoteViewRoutes } from './remote-view-route.mjs';
203
204
  import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
204
205
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
@@ -292,7 +293,7 @@ const MAX_STAGES = 16;
292
293
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
293
294
  * overrides it at runtime for tests and packagers.
294
295
  */
295
- const PLUGIN_VERSION = '2.1.2';
296
+ const PLUGIN_VERSION = '2.1.3';
296
297
 
297
298
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
298
299
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -743,6 +744,7 @@ function readConfig(config = {}) {
743
744
  // silent default when a value *is* supplied.
744
745
  const p2p = resolveP2PMode(config);
745
746
  const p2pMode = p2p.mode;
747
+ const p2pPortResult = normalizeP2PPort(config.p2pPort);
746
748
  const stun = resolveStunServers(config);
747
749
  const stunList = stun.servers;
748
750
 
@@ -930,6 +932,8 @@ function readConfig(config = {}) {
930
932
  ? config.bootstrapOperatorKey
931
933
  : (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),
932
934
  p2pMode,
935
+ p2pPort: p2pPortResult.ok ? p2pPortResult.port : null,
936
+ p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
933
937
  stunServers: stunList,
934
938
  };
935
939
  }
@@ -2031,6 +2035,7 @@ async function initP2P(ctx, cfg, runtime, block) {
2031
2035
  getJson: transport.getJson,
2032
2036
  stunServers: cfg.stunServers,
2033
2037
  log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
2038
+ ...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
2034
2039
  ...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
2035
2040
  ...block,
2036
2041
  });
@@ -3610,10 +3615,10 @@ export async function apply(ctx, config = {}) {
3610
3615
  // v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
3611
3616
  // renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
3612
3617
  // depend on those permissions.
3613
- registerCaptureRoutes(ctx);
3614
- const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
3615
- if (remoteViewRoutes.mounted !== true) {
3616
- ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
3618
+ registerCaptureRoutes(ctx);
3619
+ const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
3620
+ if (remoteViewRoutes.mounted !== true) {
3621
+ ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
3617
3622
  }
3618
3623
  if (capabilityRoutes.mounted !== true) {
3619
3624
  ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
@@ -60,6 +60,7 @@ export const BLOB_FRAME_BYTES = 16 * 1024 * 1024; // 16 MiB per blob frame -- th
60
60
  // BLOB_UPLOAD_FAILED even though the agent had accepted it. One number for "how large may one artifact be" is the honest shape;
61
61
  // the JSON frame limit stays 64 KiB for every other route.
62
62
  import { createFleetMessages } from './fleet-messages.mjs';
63
+ import { createRemoteMediaBroker, parseRemoteFrame } from './remote-media.mjs';
63
64
  import { can as mayAct } from '../util/policy-matrix.mjs';
64
65
  import { createFleetMemory } from './fleet-memory.mjs';
65
66
  import { createJoinChallenges } from './join-challenge.mjs';
@@ -737,7 +738,10 @@ export class RelayServer {
737
738
  // received bytes, so no route may consume the stream first and re-serialise.
738
739
  /** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
739
740
  const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
740
- const rawBody = methodHasBody(method) ? await readRawBody(req, blobFrameLimit) : null;
741
+ // P1.5: 47 KiB ciphertext is ~63 KiB once base64-encoded inside JSON; give this
742
+ // one opaque route the headroom it needs, and no other JSON route gets it.
743
+ const bodyFrameLimit = method === 'POST' && path === '/v1/remote/frame' ? 70 * 1024 : blobFrameLimit;
744
+ const rawBody = methodHasBody(method) ? await readRawBody(req, bodyFrameLimit) : null;
741
745
  const takeJson = () => parseJsonBody(rawBody);
742
746
 
743
747
  /* ---- public endpoints ---- */
@@ -1447,6 +1451,35 @@ export class RelayServer {
1447
1451
  this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1448
1452
  this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
1449
1453
 
1454
+ if (path === '/v1/remote/frame') {
1455
+ // P1.5: opaque ciphertext only. The body is never logged, parsed as media
1456
+ // or persisted; the broker keeps one newest frame per session with a TTL.
1457
+ this.remoteMedia ??= createRemoteMediaBroker();
1458
+ if (method === 'POST') {
1459
+ const body = takeJson();
1460
+ const parsed = parseRemoteFrame(body);
1461
+ if (parsed.ok !== true) {
1462
+ return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: parsed.code, message: 'remote.frame refused' } });
1463
+ }
1464
+ if (parsed.target !== device.machine_id) {
1465
+ return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'REMOTE_MEDIA_BAD_SESSION', message: 'only the target machine may publish its frame' } });
1466
+ }
1467
+ const opened = this.remoteMedia.open(parsed.session_id, parsed.target);
1468
+ 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);
1470
+ if (stored.ok !== true) return sendJson(res, 413, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: stored.code, message: 'remote.frame refused' } });
1471
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, bytes: stored.bytes });
1472
+ }
1473
+ if (method === 'GET') {
1474
+ const sessionId = url.searchParams.get('session_id');
1475
+ const target = url.searchParams.get('target');
1476
+ const taken = this.remoteMedia.take(sessionId, target);
1477
+ 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 });
1479
+ }
1480
+ return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET or POST only' } });
1481
+ }
1482
+
1450
1483
  if (method === 'POST' && path === '/v1/fleet/messages') {
1451
1484
  /**
1452
1485
  * v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and