@twinsearth/w2m-dsh-plugin 2.2.2 → 2.2.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.2.2"
8
+ "version": "2.2.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.2.2",
15
+ "version": "2.2.3",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "2.2.2"
29
+ "version": "2.2.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.2.2",
4
+ "version": "2.2.3",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ 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.2.3] - 2026-11-05
11
+
12
+ - Remote desktop (view-only) now reaches 1 frame per second in a fleet. A machine opts in by
13
+ enabling 远程桌面 in its own settings; the target agent reads that file before consuming a
14
+ request and before every frame, so enabling or disabling takes effect without an agent restart.
15
+ - The target sends repeated frames instead of one: P2P renews the request from the viewer, and the
16
+ relay target runs a bounded encrypted-frame stream while the viewer renews its request. The viewer
17
+ fetches the next relay frame on every 1 Hz poll and re-derives the key when a renewed stream uses a
18
+ new target ephemeral key.
19
+ - Windows capture uses a once-compiled .NET helper (with the PowerShell path as fallback) so a frame
20
+ costs hundreds of milliseconds rather than a PowerShell startup; the frame scheduler compensates
21
+ for capture time to keep the one-second cadence.
22
+ - Tests: target consent (including the localside parent directory), relay streaming at the configured
23
+ interval, P2P frame streaming and stop, viewer relay frame refresh, and a 1 Hz viewer route probe.
24
+
10
25
  ## [2.2.2] - 2026-11-05
11
26
 
12
27
  - DSH 0.2.2-rc.1 compatibility: composer popups (the dispatch-target menu and the Node-execution menu)
package/README.md CHANGED
@@ -37,7 +37,8 @@ English | [中文说明](#中文说明)
37
37
  ### 功能 6 · 全局文件夹读写黑白名单:保护系统盘 & 个人/工作文件夹
38
38
  DSH 默认的权限模型是“限制 or 全开”,一旦放开就难以精细控制。本插件在官方沙箱之上追加全局文件夹读写黑白名单,为系统盘(`C:\Windows`、`Program Files`、`/etc`、`/usr` 等)和个人/工作文件夹建立明确的读/写边界,避免 AI 或跨机任务误触不该动的路径。这一设计来自实际重装系统后的教训:粗粒度的“全开”代价太高,黑白名单把“绝对不允许”与“经常要放行”的路径变成可配置的硬规则。v2.0.4 起提供两种策略:**小白模式(默认)**只保留黑名单强制拒绝(内置 Windows/Program Files 等默认保护文件夹,路径操作每 3 秒最多自动放行一次),其余全部放行;**高级模式**需手动切换,保留白名单预算、待审批队列与 10 秒无人应答自动放行。
39
39
 
40
- ### 功能 7 ·远程桌面(开发中)
40
+ ### 功能 7 · 远程桌面(仅查看,1 帧/秒)
41
+ 在设置里打开「远程桌面」后,可在远程桌面页选择在线目标并连接。目标机也必须打开同一开关,表示同意被编队内其他机器查看;只传输画面,不发送鼠标/键盘输入。目标以 1 帧/秒采集并加密:优先 P2P 直连,P2P 不通时走 relay 的加密帧通道(relay 只看到密文,看不到 JPEG)。查看端每秒拉取最新一帧,因此最低可用帧率是 1 帧/秒;网络和采集耗时会决定实际帧率。目标关闭开关后,下一次采集立即停止。
41
42
 
42
43
  ## 安装
43
44
 
@@ -92,7 +93,7 @@ w2m-mcp --help
92
93
  # 复制进你自己的 codex/mcp-servers.toml
93
94
  [mcp_servers.w2m]
94
95
  command = "npx"
95
- args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.2", "w2m-mcp"]
96
+ args = ["-y", "@twinsearth/w2m-dsh-plugin@2.2.3", "w2m-mcp"]
96
97
  ```
97
98
 
98
99
  ### 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.2.2", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@2.2.3", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,32 @@
1
+ {
2
+ "version": "2.2.3",
3
+ "case": "RELEASE-2230",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-11-05T02:16:57.551Z",
9
+ "executor": "lead",
10
+ "task_id": "pre-release-2230-1793845017571",
11
+ "requirement": "v2.2.3: a fleet target that enables remote desktop serves repeated encrypted view frames to an online viewer at a one-second cadence, over P2P when available and the encrypted relay frame channel otherwise",
12
+ "commands": [
13
+ ["node", "--test", "--test-force-exit", "test/view-capture.test.mjs", "test/view-frame-channel.test.mjs", "test/view-consent.test.mjs"],
14
+ ["node", "--test", "--test-force-exit", "test/relay-target.test.mjs", "test/remote-view-route.test.mjs"],
15
+ ["node", "--test", "--test-force-exit", "test/client-bundle.test.mjs"],
16
+ ["node", "--test", "--test-force-exit", "test/loader-entry.test.mjs"]
17
+ ],
18
+ "result": {
19
+ "outcome": "ok",
20
+ "assertions": {
21
+ "capture": "test/view-capture.test.mjs 4/4: unsupported platform, macOS capture, spawnSync status, and the transport size refusal",
22
+ "p2p": "test/view-frame-channel.test.mjs 4/4: consent denial, handshake, repeated frames at the configured interval, and input-frame refusal",
23
+ "consent": "test/view-consent.test.mjs 4/4: default deny, local enable, localside/ parent fallback, and W2M_VIEW_ALLOW overrides",
24
+ "relay": "test/relay-target.test.mjs 3/3: first sealed frame, repeated frames at the interval, and no request consumption while consent is off",
25
+ "viewer": "test/remote-view-route.test.mjs 3/3: P2P session, P2P refusal, and a fresh relay frame on each 1 Hz poll",
26
+ "client": "test/client-bundle.test.mjs 36/36: the client bundle still loads with the remote-view settings and page copy",
27
+ "loader": "test/loader-entry.test.mjs 10/10 against the packed 2.2.3 tarball in the real DSH 0.2.2-rc.1 Loader and Edge page",
28
+ "capture_timing": "local Windows real-capture relay stream produced 3 frames at 1.067 s, 2.087 s and 3.123 s (1.02-1.06 s cadence); the fast helper itself measured 0.23-0.40 s after compilation",
29
+ "scope": "local code and tests only; no installed profile was edited by the tests; release is published separately"
30
+ }
31
+ }
32
+ }
package/lib/client.js CHANGED
@@ -2626,8 +2626,8 @@ function UpdateSettingsSection() {
2626
2626
  h('span', { key: 'text' }, enabled ? '已开启' : '已关闭'),
2627
2627
  ]),
2628
2628
  h('div', { key: 'hint', style: { fontSize: '12px', opacity: 0.75, lineHeight: 1.5 } },
2629
- '仅查看,不发送鼠标/键盘输入。P1 目标为最高 1 帧/秒;实际帧率取决于目标机性能与网络。媒体优先 P2P,relay 只承载信令。'),
2630
- h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, 'P1 当前为本机回环查看;远端采集/P2P 在后续切片接入。'),
2629
+ '开启后可查看其他机器;本机也会允许编队内其他机器查看,仅画面,不发送鼠标/键盘输入。目标帧率为 1 帧/秒;实际帧率取决于目标机性能与网络。媒体优先 P2P,P2P 不通时走加密 relay 帧通道。'),
2630
+ h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, '要查看某台机器,那台机器也需要开启此开关。选择目标后点「连接」;远端画面每秒刷新一次。'),
2631
2631
  ]);
2632
2632
  }
2633
2633
 
@@ -2693,7 +2693,7 @@ function UpdateSettingsSection() {
2693
2693
  const ready = enabled && frame?.data_base64;
2694
2694
  return h('div', { style: { padding: '16px', display: 'grid', gap: '12px', maxWidth: '1100px' } }, [
2695
2695
  h('div', { key: 'title', style: { fontSize: '16px', fontWeight: 600 } }, '远程桌面 · 仅查看'),
2696
- h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('P2P 会话:' + (session.machine_id ?? '') + (session.encrypted ? '(加密)' : '(未加密)')) : '未连接目标') : '插件设置中尚未开启远程桌面。'),
2696
+ h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('远程会话:' + (session.machine_id ?? '') + (session.encrypted ? '(加密)' : '(未加密)') + ' · ' + (session.channel === 'relay' ? '加密 relay' : 'P2P')) : '未连接目标') : '插件设置中尚未开启远程桌面。'),
2697
2697
  h('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } }, [
2698
2698
  h('select', { key: 'target', value: target, disabled: !enabled, onChange: (event) => setTarget(event.target.value), style: { minWidth: '180px' } }, machines.length === 0 ? h('option', { value: '' }, '无在线机器') : machines.map((machine) => h('option', { key: machine.machine_id, value: machine.machine_id }, (machine.name ?? machine.machine_id)))),
2699
2699
  h('button', { key: 'connect', type: 'button', disabled: !enabled || target === '' || session !== null, onClick: connect }, '连接'),
@@ -2702,10 +2702,10 @@ function UpdateSettingsSection() {
2702
2702
  ]),
2703
2703
  h('div', { key: 'canvas', style: { minHeight: '320px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-sm)', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--dsw-alias-bg-layer-1)' } }, ready
2704
2704
  ? h('img', { src: 'data:' + (frame.mime ?? 'image/jpeg') + ';base64,' + frame.data_base64, alt: '远程桌面画面', style: { maxWidth: '100%', maxHeight: '70vh', display: 'block' } })
2705
- : h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '本机回环预览(选择目标后点连接)')))),
2705
+ : h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '选择在线目标后点「连接」')))),
2706
2706
  h('div', { key: 'statusline', style: { fontSize: '12px', opacity: 0.7, display: 'flex', gap: '14px', flexWrap: 'wrap' } }, [
2707
2707
  h('span', { key: 'fps' }, '1 fps 上限'),
2708
- h('span', { key: 'channel' }, '通道:' + (session ? (session.channel === 'relay' ? '加密中继' : 'P2P') : '本机回环')),
2708
+ h('span', { key: 'channel' }, '通道:' + (session ? (session.channel === 'relay' ? '加密中继' : 'P2P') : '未连接')),
2709
2709
  h('span', { key: 'cap' }, '采集:' + (stats?.capture_ms ?? '—') + 'ms'),
2710
2710
  h('span', { key: 'bytes' }, '帧:' + (stats?.bytes ?? '—') + 'B'),
2711
2711
  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.2.2",
3
+ "version": "2.2.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": {
@@ -71,7 +71,7 @@
71
71
  "pack": "node scripts/pack.mjs",
72
72
  "reachability": "node scripts/reachability-check.mjs",
73
73
  "reachability:classify": "node scripts/reachability-classify.mjs",
74
- "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/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.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",
74
+ "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/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.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/view-consent.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",
75
75
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
76
76
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
77
77
  "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.2.2",
4
+ "version": "2.2.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": [
@@ -73,6 +73,7 @@ import { createXferFrameRouter } from './transfer/router.mjs';
73
73
  import { captureScreen } from './view/capture.mjs';
74
74
  import { createViewTarget } from './view/frame-channel.mjs';
75
75
  import { startRelayViewTarget } from './view/relay-target.mjs';
76
+ import { viewAllowed } from './view/consent.mjs';
76
77
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
77
78
  import { punchSession, reverseDialSession } from './p2p.mjs';
78
79
  import { createSigner } from '../signing.mjs';
@@ -1149,6 +1150,7 @@ export function createAgent(options) {
1149
1150
  /** P0-a: xfer.* frames arriving on a P2P channel are routed here, not to task dispatch. */
1150
1151
  const xferRouter = createXferFrameRouter();
1151
1152
  let viewTargets = null;
1153
+ let viewRelayRunner = null;
1152
1154
  /**
1153
1155
  * What each task's direct channel actually was, for the result envelope.
1154
1156
  *
@@ -3978,8 +3980,8 @@ let attemptAttached = false;
3978
3980
  let target = viewTargets.get(key);
3979
3981
  if (target === undefined || payload.type === 'view.hello') {
3980
3982
  target = createViewTarget({
3981
- capture: () => captureScreen({}),
3982
- allow: process.env.W2M_VIEW_ALLOW === '1',
3983
+ capture: () => captureScreen({ fast: true }),
3984
+ allow: () => viewAllowed(stateDir),
3983
3985
  send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
3984
3986
  });
3985
3987
  viewTargets.set(key, target);
@@ -4434,6 +4436,10 @@ let attemptAttached = false;
4434
4436
  if (idleHeartbeatTimer) clearInterval(idleHeartbeatTimer);
4435
4437
  idleHeartbeatTimer = null;
4436
4438
  heartbeat?.stop();
4439
+ viewRelayRunner?.stop();
4440
+ viewRelayRunner = null;
4441
+ for (const target of viewTargets?.values() ?? []) target?.stop?.();
4442
+ viewTargets = null;
4437
4443
  runAbort?.abort();
4438
4444
  streamAbort?.abort();
4439
4445
  // Final snapshot: a reader must be able to tell "stopped" from "crashed
@@ -4470,10 +4476,17 @@ let attemptAttached = false;
4470
4476
  // the first heartbeat: "is an agent running here?" is the first question a
4471
4477
  // remote operator asks (task-16).
4472
4478
  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;
4479
+ // The poller always runs; the consent file is checked before a request is consumed and before
4480
+ // every frame, so enabling remote viewing in the settings page takes effect without an agent
4481
+ // restart. A disabled machine never consumes a viewer request.
4482
+ viewRelayRunner = startRelayViewTarget({
4483
+ rabbitUrl: baseUrl,
4484
+ token: () => identity.device_token,
4485
+ machineId: identity.machine_id,
4486
+ capture: (options) => captureScreen({ ...(options ?? {}), fast: true }),
4487
+ allow: () => viewAllowed(stateDir),
4488
+ log,
4489
+ });
4477
4490
  if (statePublishIntervalMs > 0) {
4478
4491
  statePublishTimer = setInterval(() => publishState(), statePublishIntervalMs);
4479
4492
  // Never keep the process alive just to refresh a diagnostics file.
@@ -6,7 +6,7 @@
6
6
  * without a reason.
7
7
  */
8
8
  import { spawnSync } from 'node:child_process';
9
- import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
 
@@ -59,7 +59,93 @@ function windowsCaptureScript(maxBytes) {
59
59
 
60
60
 
61
61
  /**
62
- * @param {{platform?: NodeJS.Platform, run?: Function, tempRoot?: string, maxBytes?: number}} [options]
62
+ * A tiny compiled Windows helper for 1 fps capture.
63
+ *
64
+ * Starting PowerShell costs 1.5-3 s per frame on this host, so a 1 fps stream
65
+ * written as "spawn PowerShell every second" is not 1 fps. The helper is compiled
66
+ * once with the in-box .NET Framework compiler and then invoked like any small
67
+ * executable; the fallback keeps working on machines without that compiler.
68
+ */
69
+ const WINDOWS_HELPER_SOURCE = `using System;
70
+ using System.Drawing;
71
+ using System.Drawing.Imaging;
72
+ using System.IO;
73
+ using System.Windows.Forms;
74
+ class W2MCapture {
75
+ static void Main(string[] args) {
76
+ int max = int.Parse(args[1]);
77
+ var b = Screen.PrimaryScreen.Bounds;
78
+ using (var src = new Bitmap(b.Width, b.Height)) {
79
+ using (var g = Graphics.FromImage(src)) g.CopyFromScreen(b.Left, b.Top, 0, 0, src.Size);
80
+ int[] widths = new int[] { 960, 800, 640 };
81
+ var codec = Array.Find(ImageCodecInfo.GetImageEncoders(), c => c.MimeType == "image/jpeg");
82
+ foreach (int w in widths) {
83
+ int h = Math.Max(1, (int)Math.Round(b.Height * (double)w / b.Width));
84
+ using (var dst = new Bitmap(w, h)) {
85
+ using (var dg = Graphics.FromImage(dst)) {
86
+ dg.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
87
+ dg.DrawImage(src, 0, 0, w, h);
88
+ }
89
+ foreach (int q in new int[] { 40, 30 }) {
90
+ using (var ms = new MemoryStream()) {
91
+ var ep = new EncoderParameters(1);
92
+ ep.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, (long)q);
93
+ dst.Save(ms, codec, ep);
94
+ var bytes = ms.ToArray();
95
+ if (bytes.Length <= max) {
96
+ File.WriteAllBytes(args[0], bytes);
97
+ return;
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }`;
106
+
107
+ let windowsHelperState;
108
+
109
+ /** Resolve, compiling once, the fast Windows capture helper. */
110
+ function windowsFastHelper() {
111
+ if (windowsHelperState !== undefined) return windowsHelperState;
112
+ try {
113
+ const dir = join(tmpdir(), 'w2m-view-fast');
114
+ mkdirSync(dir, { recursive: true });
115
+ const exe = join(dir, 'w2m-capture-v1.exe');
116
+ if (existsSync(exe)) { windowsHelperState = exe; return exe; }
117
+ const source = join(dir, 'w2m-capture-v1.cs');
118
+ writeFileSync(source, WINDOWS_HELPER_SOURCE, 'utf8');
119
+ const cscCandidates = [
120
+ join(process.env.WINDIR ?? 'C:\\Windows', 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
121
+ join(process.env.WINDIR ?? 'C:\\Windows', 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'),
122
+ ];
123
+ const csc = cscCandidates.find(candidate => existsSync(candidate));
124
+ if (csc !== undefined) {
125
+ spawnSync(csc, [
126
+ '/nologo', '/target:exe', `/out:${exe}`,
127
+ '/r:System.Drawing.dll', '/r:System.Windows.Forms.dll', source,
128
+ ], { timeout: 60_000, windowsHide: true, encoding: 'utf8' });
129
+ }
130
+ if (!existsSync(exe)) {
131
+ // Some hosts deny csc.exe while allowing PowerShell. Add-Type compiles the same source
132
+ // once and writes the executable, after which every frame is a small process spawn.
133
+ const psQuote = value => `'${value.replace(/'/g, "''")}'`;
134
+ const command = `Add-Type -Path ${psQuote(source)} -OutputAssembly ${psQuote(exe)} -OutputType ConsoleApplication -ReferencedAssemblies System.Drawing.dll,System.Windows.Forms.dll`;
135
+ spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
136
+ timeout: 60_000, windowsHide: true, encoding: 'utf8',
137
+ });
138
+ }
139
+ windowsHelperState = existsSync(exe) ? exe : null;
140
+ return windowsHelperState;
141
+ } catch {
142
+ windowsHelperState = null;
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * @param {{platform?: NodeJS.Platform, run?: Function, tempRoot?: string, maxBytes?: number, fast?: boolean}} [options]
63
149
  * @returns {{ok: true, code: string, mime: string, data: Buffer, base64: string, bytes: number, capture_ms: number, width: number|null, height: number|null}
64
150
  * | {ok: false, code: string, error: string, capture_ms: number}}
65
151
  */
@@ -73,12 +159,32 @@ export function captureScreen(options = {}) {
73
159
  let width = null;
74
160
  let height = null;
75
161
  if (platform === 'win32') {
76
- const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
77
- // `spawnSync` reports the exit code as `status`; `code` is the execFile-shaped field
78
- // and is undefined here, which made every successful capture look like a denial.
79
- if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.status ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
80
- base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
81
- if (!/^[A-Za-z0-9+/=]{100,}$/.test(base64)) return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: 'PowerShell returned no JPEG', capture_ms: Date.now() - started };
162
+ if (options.fast === true) {
163
+ const helper = windowsFastHelper();
164
+ if (helper !== null) {
165
+ const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-fast-'));
166
+ const file = join(dir, 'frame.jpg');
167
+ try {
168
+ const result = run([helper, file, String(maxBytes)], { timeoutMs: 5000, encoding: 'utf8' });
169
+ if (result.status === 0 && existsSync(file)) {
170
+ const data = readFileSync(file);
171
+ if (data.length >= 1000) base64 = data.toString('base64');
172
+ }
173
+ } catch {
174
+ // Fall through to the PowerShell capture path.
175
+ } finally {
176
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
177
+ }
178
+ }
179
+ }
180
+ if (base64 === null) {
181
+ const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
182
+ // `spawnSync` reports the exit code as `status`; `code` is the execFile-shaped field
183
+ // and is undefined here, which made every successful capture look like a denial.
184
+ if (result.status !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.status ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
185
+ base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
186
+ if (!/^[A-Za-z0-9+/=]{100,}$/.test(base64)) return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: 'PowerShell returned no JPEG', capture_ms: Date.now() - started };
187
+ }
82
188
  } else if (platform === 'darwin') {
83
189
  const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-'));
84
190
  const file = join(dir, 'frame.jpg');
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Target-side remote-view consent.
3
+ *
4
+ * Turning on "远程桌面" on a machine is an explicit statement that the machine may be viewed by
5
+ * the fleet; `W2M_VIEW_ALLOW=0` remains an operator override that denies even an enabled file.
6
+ * The file is written by the host settings route under the same state directory the agent uses.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ /** The per-machine settings file shared with `src/plugin/remote-view-route.mjs`. */
12
+ export const REMOTE_VIEW_CONFIG_FILE = 'remote-view.json';
13
+
14
+ /**
15
+ * Whether this machine may serve view frames.
16
+ *
17
+ * @param {string} stateDir - agent state directory.
18
+ * @param {NodeJS.ProcessEnv} [env] - process environment for the operator override.
19
+ * @returns {boolean}
20
+ */
21
+ export function viewAllowed(stateDir, env = process.env) {
22
+ if (env.W2M_VIEW_ALLOW === '0') return false;
23
+ if (env.W2M_VIEW_ALLOW === '1') return true;
24
+ // The agent's `--state` is the `localside/` child of the plugin's state directory, while the
25
+ // settings route writes beside `device.json` one level up. Check the agent directory first (an
26
+ // explicit deployment may keep the file there), then the plugin root.
27
+ for (const file of [join(stateDir, REMOTE_VIEW_CONFIG_FILE), join(dirname(stateDir), REMOTE_VIEW_CONFIG_FILE)]) {
28
+ try {
29
+ const value = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
30
+ if (value?.enabled === true || value?.allow === true) return true;
31
+ } catch {
32
+ // Try the next location.
33
+ }
34
+ }
35
+ return false;
36
+ }
@@ -48,13 +48,44 @@ export function parseViewFrame(payload) {
48
48
  return frame;
49
49
  }
50
50
 
51
- /** @param {{capture:Function, allow?:boolean, send:Function, maxBytes?:number, fps?:number}} deps */
52
- export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1 } = {}) {
51
+ /** @param {{capture:Function, allow?:boolean|Function, send:Function, maxBytes?:number, fps?:number, intervalMs?:number, maxFrames?:number}} deps */
52
+ export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1, intervalMs, maxFrames = 300 } = {}) {
53
53
  if (typeof capture !== 'function' || typeof send !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
54
54
  let seq = 0;
55
+ let frames = 0;
55
56
  let stopped = false;
56
57
  let vk = null;
58
+ let timer = null;
59
+ let sessionId = null;
60
+ const delay = Number.isInteger(intervalMs) && intervalMs > 0 ? intervalMs : Math.max(100, Math.round(1000 / Math.max(1, fps)));
61
+ const allowed = () => (typeof allow === 'function' ? allow() === true : allow === true);
57
62
  const reply = (frame) => { try { send(JSON.stringify(frame)); } catch { /* channel gone */ } };
63
+ const stopTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
64
+ const schedule = (waitMs) => {
65
+ if (stopped || vk === null) return;
66
+ timer = setTimeout(() => { timer = null; tick(); }, Math.max(0, waitMs));
67
+ if (typeof timer.unref === 'function') timer.unref();
68
+ };
69
+ const fail = (code) => {
70
+ stopped = true;
71
+ stopTimer();
72
+ reply({ type: VIEW_FRAME.ERROR, code, session_id: sessionId });
73
+ };
74
+ const tick = () => {
75
+ if (stopped || vk === null || sessionId === null) { stopTimer(); return; }
76
+ if (!allowed()) { fail(VIEW_CODE.CONSENT_REQUIRED); return; }
77
+ if (maxFrames > 0 && frames >= maxFrames) { stopTimer(); return; }
78
+ const started = Date.now();
79
+ const result = capture({ maxBytes });
80
+ if (result?.ok !== true) { fail(result?.code ?? VIEW_CODE.CAPTURE_FAILED); return; }
81
+ let ct;
82
+ try { ct = sealViewFrame(vk, result.data ?? Buffer.from(result.base64 ?? '', 'base64')); }
83
+ catch { fail(VIEW_CODE.VK_BAD); return; }
84
+ seq += 1;
85
+ frames += 1;
86
+ reply({ type: VIEW_FRAME.FRAME, session_id: sessionId, seq, ts_ms: Date.now(), mime: result.mime ?? 'image/jpeg', bytes: result.bytes, enc: 'aes-256-gcm', ct });
87
+ if (maxFrames <= 0 || frames < maxFrames) schedule(delay - (Date.now() - started));
88
+ };
58
89
  return {
59
90
  ok: true,
60
91
  handle(payload) {
@@ -65,30 +96,34 @@ export function createViewTarget({ capture, allow = false, send, maxBytes = 96 *
65
96
  }
66
97
  const frame = parseViewFrame(payload);
67
98
  if (frame === null) return false;
68
- if (frame.type === VIEW_FRAME.STOP) { stopped = true; return true; }
99
+ if (frame.type === VIEW_FRAME.STOP) { stopped = true; stopTimer(); return true; }
69
100
  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; }
101
+ stopTimer();
102
+ stopped = false;
103
+ frames = 0;
104
+ sessionId = typeof frame.session_id === 'string' ? frame.session_id : null;
105
+ if (typeof frame.public_key !== 'string' || frame.public_key === '') { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: sessionId }); return true; }
71
106
  try {
72
107
  const side = createViewSide();
73
108
  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 }); }
109
+ reply({ type: VIEW_FRAME.HELLO_ACK, session_id: sessionId, public_key: side.public_key });
110
+ } catch { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.VK_BAD, session_id: sessionId }); }
76
111
  return true;
77
112
  }
78
113
  if (frame.type !== VIEW_FRAME.REQUEST) return true;
79
- if (allow !== true) { reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.CONSENT_REQUIRED, session_id: frame.session_id ?? null }); return true; }
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; }
82
- const result = capture({ maxBytes });
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; }
87
- seq += 1;
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 });
114
+ if (!allowed()) { fail(VIEW_CODE.CONSENT_REQUIRED); return true; }
115
+ if (stopped) { fail(VIEW_CODE.DISABLED); return true; }
116
+ if (vk === null) { fail(VIEW_CODE.VK_REQUIRED); return true; }
117
+ if (typeof frame.session_id === 'string' && frame.session_id !== '') sessionId = frame.session_id;
118
+ // A renewal is a fresh viewer request; its budget starts over so an actively polling viewer
119
+ // keeps the stream alive, while a viewer that disappears leaves the target to expire its budget.
120
+ frames = 0;
121
+ stopTimer();
122
+ tick();
89
123
  return true;
90
124
  },
91
- stats() { return { seq, stopped, allow: allow === true, fps, max_bytes: maxBytes, encrypted: vk !== null }; },
125
+ stop() { stopped = true; stopTimer(); vk = null; sessionId = null; },
126
+ stats() { return { seq, frames, stopped, allow: allowed(), fps, intervalMs: delay, max_bytes: maxBytes, encrypted: vk !== null }; },
92
127
  };
93
128
  }
94
129
 
@@ -5,6 +5,11 @@
5
5
  * it derives VK from the viewer's ephemeral public key, captures a JPEG, seals
6
6
  * it, and posts opaque ciphertext at ≤1 fps. Nothing is written to disk and the
7
7
  * relay never sees plaintext.
8
+ *
9
+ * The poller is deliberately always running: the operator may enable remote
10
+ * viewing after the agent started, and `allow` is consulted before every request
11
+ * and every frame. A disabled machine never consumes a viewer request, so a
12
+ * later enable does not lose the request to an earlier no-op.
8
13
  */
9
14
  import { createViewSide, sealViewFrame } from './session-key.mjs';
10
15
 
@@ -13,65 +18,104 @@ export const REMOTE_FRAME_PATH = '/v1/remote/frame';
13
18
 
14
19
  /**
15
20
  * @param {{rabbitUrl:string, token:()=>string|null, machineId:string, capture:Function,
16
- * intervalMs?:number, maxBytes?:number, fetchImpl?:Function, log?:Function, maxFrames?:number}} deps
21
+ * allow?:()=>boolean, intervalMs?:number, maxBytes?:number, maxFrames?:number,
22
+ * fetchImpl?:Function, log?:Function}} deps
17
23
  */
18
24
  export function startRelayViewTarget(deps) {
19
25
  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;
26
+ const intervalMs = Number.isInteger(deps.intervalMs) && deps.intervalMs > 0 ? deps.intervalMs : 1000;
27
+ // Stay below the relay broker's 47 KiB ciphertext cap after base64 framing.
28
+ const maxBytes = Number.isInteger(deps.maxBytes) && deps.maxBytes > 0 ? deps.maxBytes : 40 * 1024;
29
+ const maxFrames = Number.isInteger(deps.maxFrames) && deps.maxFrames > 0 ? deps.maxFrames : 60;
30
+ const allow = typeof deps.allow === 'function' ? deps.allow : () => process.env.W2M_VIEW_ALLOW === '1';
23
31
  const base = String(deps.rabbitUrl ?? '').replace(/\/+$/, '');
24
32
  let stopped = false;
25
- let timer = null;
26
- let busy = false;
33
+ let pollTimer = null;
34
+ let frameTimer = null;
35
+ let active = null;
36
+
37
+ const endStream = () => {
38
+ if (frameTimer !== null) clearTimeout(frameTimer);
39
+ frameTimer = null;
40
+ active = null;
41
+ };
42
+ const scheduleFrame = (waitMs) => {
43
+ if (stopped || active === null) return;
44
+ frameTimer = setTimeout(() => { frameTimer = null; void tick(); }, Math.max(0, waitMs));
45
+ if (typeof frameTimer.unref === 'function') frameTimer.unref();
46
+ };
27
47
 
28
- const stop = () => { stopped = true; if (timer !== null) clearInterval(timer); timer = null; };
48
+ const tick = async () => {
49
+ if (stopped || active === null || active.inFlight === true) return;
50
+ if (!allow()) { endStream(); return; }
51
+ if (active.frames >= maxFrames) { endStream(); return; }
52
+ active.inFlight = true;
53
+ const current = active;
54
+ const started = Date.now();
55
+ try {
56
+ const captured = deps.capture({ maxBytes });
57
+ if (captured?.ok !== true) { endStream(); return; }
58
+ let ct;
59
+ try { ct = sealViewFrame(current.vk, captured.data ?? Buffer.from(captured.base64 ?? '', 'base64')); }
60
+ catch { endStream(); return; }
61
+ try {
62
+ const response = await fetchImpl(base + REMOTE_FRAME_PATH, {
63
+ method: 'POST',
64
+ headers: { authorization: 'Bearer ' + deps.token(), 'content-type': 'application/json' },
65
+ body: JSON.stringify({ session_id: current.sessionId, target: deps.machineId, ct, public_key: current.side.public_key }),
66
+ });
67
+ if (response.ok !== true) { endStream(); return; }
68
+ current.frames += 1;
69
+ if (current.frames >= maxFrames) endStream();
70
+ else scheduleFrame(intervalMs - (Date.now() - started));
71
+ } catch {
72
+ // The next poll may establish a fresh session; one transient relay failure
73
+ // ends this stream rather than emitting a burst of stale frames.
74
+ endStream();
75
+ }
76
+ } finally {
77
+ current.inFlight = false;
78
+ }
79
+ };
29
80
 
30
81
  const poll = async () => {
31
- if (stopped || busy) return;
82
+ if (stopped || active !== null || !allow()) return;
32
83
  const token = deps.token();
33
84
  if (typeof token !== 'string' || token === '') return;
34
- busy = true;
85
+ let response;
35
86
  try {
36
- const response = await fetchImpl(base + REMOTE_REQUEST_PATH + '?target=' + encodeURIComponent(deps.machineId), {
87
+ response = await fetchImpl(base + REMOTE_REQUEST_PATH + '?target=' + encodeURIComponent(deps.machineId), {
37
88
  headers: { authorization: 'Bearer ' + token },
38
89
  });
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
- }
90
+ } catch { return; }
91
+ if (stopped || !response.ok) return;
92
+ const body = await response.json().catch(() => null);
93
+ if (body?.ok !== true || typeof body.viewer_public_key !== 'string' || typeof body.session_id !== 'string') return;
94
+ const side = createViewSide();
95
+ let vk;
96
+ try { vk = side.finish(body.viewer_public_key).key; } catch { return; }
97
+ active = { sessionId: body.session_id, side, vk, frames: 0, inFlight: false };
98
+ await tick();
68
99
  };
69
100
 
70
- const pollTimer = setInterval(() => { void poll(); }, intervalMs);
101
+ pollTimer = setInterval(() => { void poll(); }, intervalMs);
71
102
  if (typeof pollTimer.unref === 'function') pollTimer.unref();
72
103
  void poll();
73
104
  return {
74
- stop() { stopped = true; if (timer !== null) clearInterval(timer); clearInterval(pollTimer); },
75
- setStop() { stop(); },
105
+ stop() {
106
+ stopped = true;
107
+ if (pollTimer !== null) clearInterval(pollTimer);
108
+ pollTimer = null;
109
+ endStream();
110
+ },
111
+ stats() {
112
+ return {
113
+ stopped,
114
+ active: active === null ? null : { session_id: active.sessionId, frames: active.frames },
115
+ interval_ms: intervalMs,
116
+ max_bytes: maxBytes,
117
+ max_frames: maxFrames,
118
+ };
119
+ },
76
120
  };
77
121
  }
@@ -134,7 +134,7 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
134
134
  if (dialled?.ok !== true) p2pError = dialled?.error ?? 'P2P_DIAL_FAILED';
135
135
  }
136
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() };
137
+ active = { mode: 'p2p', machineId, sessionId, channel: dialled.channel, side, vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, p2pRenewAt: Date.now() + 10_000, startedAt: Date.now() };
138
138
  listener = (event) => {
139
139
  const frame = payloadJson(event?.payload);
140
140
  if (frame === null || frame?.session_id !== sessionId) return;
@@ -165,7 +165,7 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
165
165
  if (relayRequest.ok !== true) {
166
166
  return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: p2pError, relay_error: relayRequest.error ?? null }, { status: 502, headers: NO_STORE });
167
167
  }
168
- active = { mode: 'relay', machineId, sessionId, channel: null, side, vk: null, encrypted: true, latest: null, error: null, startedAt: Date.now() };
168
+ active = { mode: 'relay', machineId, sessionId, channel: null, side, vk: null, encrypted: true, latest: null, error: null, lastRelayAt: 0, relayRenewAt: Date.now() + 10_000, startedAt: Date.now() };
169
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 });
170
170
  },
171
171
  });
@@ -182,17 +182,41 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
182
182
  fetch: async () => {
183
183
  if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
184
184
  if (active !== null) {
185
- if (active.mode === 'relay' && active.latest === null) {
185
+ // A P2P target stops after its frame budget. The viewer's 1 Hz poll renews the request
186
+ // every ten seconds so an open page keeps the stream alive; closing the page stops the
187
+ // renewals and the target's bounded budget expires on its own.
188
+ if (active.mode === 'p2p' && Date.now() >= active.p2pRenewAt) {
189
+ active.p2pRenewAt = Date.now() + 10_000;
190
+ try {
191
+ void active.channel.send(JSON.stringify({ type: 'view.request', session_id: active.sessionId, machine_id: active.machineId, fps: 1, max_bytes: maxBytes }));
192
+ } catch { /* peer gone */ }
193
+ }
194
+ // The viewer polls at 1 Hz. Relay media is latest-frame-wins, so each poll asks the relay
195
+ // for the next frame instead of serving the first frame forever.
196
+ if (active.mode === 'relay' && (active.latest === null || Date.now() - active.lastRelayAt >= 900)) {
197
+ active.lastRelayAt = Date.now();
198
+ // The target's relay stream is bounded (60 frames). A renewed request sits in the
199
+ // mailbox and is consumed by the next poll after that stream ends, so the viewer keeps
200
+ // receiving frames without reconnect churn.
201
+ if (Date.now() >= active.relayRenewAt) {
202
+ active.relayRenewAt = Date.now() + 10_000;
203
+ void postRelayRequest({ stateDir: deps.stateDir, machineId: active.machineId, sessionId: active.sessionId, publicKey: active.side.public_key });
204
+ }
186
205
  const got = await fetchRelayFrame({ stateDir: deps.stateDir, sessionId: active.sessionId, machineId: active.machineId });
187
206
  if (got.ok === true) {
188
207
  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
- }
208
+ // A renewed relay stream creates a new target ephemeral keypair; re-derive the
209
+ // viewer side whenever the target publishes a different public key.
210
+ if (typeof got.public_key === 'string') active.vk = active.side.finish(got.public_key).key;
211
+ if (active.vk === null) { active.error = 'VIEW_VK_REQUIRED'; return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE }); }
193
212
  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' };
213
+ if (plaintext.length <= maxBytes) {
214
+ active.latest = { seq: null, mime: 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true, channel: 'relay' };
215
+ active.error = null;
216
+ }
195
217
  } catch { active.error = 'VIEW_VK_BAD'; }
218
+ } else if (got.waiting !== true) {
219
+ active.error = 'VIEW_RELAY_UNAVAILABLE';
196
220
  }
197
221
  }
198
222
  if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
@@ -296,7 +296,7 @@ const MAX_STAGES = 16;
296
296
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
297
297
  * overrides it at runtime for tests and packagers.
298
298
  */
299
- const PLUGIN_VERSION = '2.2.2';
299
+ const PLUGIN_VERSION = '2.2.3';
300
300
 
301
301
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
302
302
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;