@twinsearth/w2m-dsh-plugin 2.1.2 → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/docs/P2P-PORT.md +16 -0
- package/docs/acceptance/2.1.3/RELEASE-2130.json +22 -0
- package/docs/acceptance/2.1.4/RELEASE-2140.json +22 -0
- package/lib/client.js +1 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +19 -5
- package/src/agent/p2p-node.mjs +8 -2
- package/src/agent/view/frame-channel.mjs +64 -42
- package/src/agent/view/relay-target.mjs +77 -0
- package/src/agent/view/session-key.mjs +68 -0
- package/src/plugin/remote-view-route.mjs +90 -21
- package/src/plugin/tools.mjs +14 -6
- package/src/relay/remote-media.mjs +4 -3
- package/src/relay/remote-requests.mjs +55 -0
- package/src/relay/server.mjs +59 -1
|
@@ -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.
|
|
8
|
+
"version": "2.1.4"
|
|
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.
|
|
15
|
+
"version": "2.1.4",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.1.
|
|
29
|
+
"version": "2.1.4"
|
|
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.
|
|
4
|
+
"version": "2.1.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ 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.4] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
- P1.5 encrypted relay fallback: relay /v1/remote/request mailbox, host P2P-failure fallback, target polling + VK + sealed frame POST, host GET/decrypt/cache, '加密中继' UI label.
|
|
13
|
+
- P2P: p2pPublicPort announcement override; p2pPort config normalization.
|
|
14
|
+
- Target relay polling starts only with W2M_VIEW_ALLOW=1.
|
|
15
|
+
- Verification: preflight GREEN; view/p2p/relay tests pass; real relay deployment + two-machine E2E still pending.
|
|
16
|
+
|
|
17
|
+
## [2.1.3] - 2026-10-31
|
|
18
|
+
|
|
19
|
+
- Remote-view candidate: target selector, view.hello/VK (X25519+HKDF, AES-256-GCM), host decrypt, relay remote.frame broker/route, '加密中继' UI label.
|
|
20
|
+
- Still missing: P1.5 auto-fallback, target consent UI, deployed relay endpoint, real P2P verification. Test candidate.
|
|
21
|
+
|
|
10
22
|
## [2.1.2] - 2026-10-31
|
|
11
23
|
|
|
12
24
|
- 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.
|
|
86
|
+
args = ["-y", "@twinsearth/w2m-dsh-plugin@2.1.4", "w2m-mcp"]
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
### 5. 验证过的 release tarball(离线)
|
package/codex/mcp-servers.toml
CHANGED
package/docs/P2P-PORT.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
p2pPublicPort: 31559
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- Valid range: 1..65535; omitted/null keeps an ephemeral port.
|
|
13
|
+
- Invalid values are reported as `P2P_PORT_INVALID` in `w2m_status` and do not start a P2P node.
|
|
14
|
+
- Forward the same UDP port on the router to this machine, and allow it in the host firewall.
|
|
15
|
+
|
|
16
|
+
`p2pPublicPort` overrides the announced public port when the router forwards a fixed port but STUN reports a different outbound mapping. It must match the router external port.
|
|
@@ -0,0 +1,22 @@
|
|
|
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-31T01:23:10.013Z",
|
|
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": "p2pPort/p2pPublicPort normalized; local bind=announced public=31559 verified on ToFuture",
|
|
16
|
+
"vk": "view.hello/VK + AES-GCM frame 3/3",
|
|
17
|
+
"relay": "remote.frame ciphertext broker/route; relay suite 189/189",
|
|
18
|
+
"preflight": "GREEN: 16 pass, 0 fail, 1 host-only UNVERIFIABLE",
|
|
19
|
+
"build": "dist/twinsearth-w2m-dsh-plugin-2.1.3.tgz sha256 3017057f63c68debef3116b4791e2ff937fd2fbb4715b92383c433433f20afcb"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.1.4",
|
|
3
|
+
"case": "RELEASE-2140",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T03:04:58.412Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2140-1793415396132",
|
|
11
|
+
"requirement": "v2.1.4: P1.5 encrypted relay fallback + p2pPublicPort",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"preflight": "GREEN: 16 pass, 0 fail, 1 host-only UNVERIFIABLE",
|
|
16
|
+
"p1_5": "relay request mailbox, host fallback, target polling/encrypt, host decrypt",
|
|
17
|
+
"p2p": "p2pPort + p2pPublicPort, local three-way 31559 verified",
|
|
18
|
+
"e2e": "pending new relay deployment",
|
|
19
|
+
"build": "dist/twinsearth-w2m-dsh-plugin-2.1.4.tgz sha256 2be2c19720b20e7dcf36fb9032170ee13e711eec4e8e52d3ae1a4b6b6eb44ca1"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
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.
|
|
3
|
+
"version": "2.1.4",
|
|
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 test/remote-requests.test.mjs test/relay-target.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.
|
|
4
|
+
"version": "2.1.4",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -72,6 +72,7 @@ import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningK
|
|
|
72
72
|
import { createXferFrameRouter } from './transfer/router.mjs';
|
|
73
73
|
import { captureScreen } from './view/capture.mjs';
|
|
74
74
|
import { createViewTarget } from './view/frame-channel.mjs';
|
|
75
|
+
import { startRelayViewTarget } from './view/relay-target.mjs';
|
|
75
76
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
76
77
|
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
77
78
|
import { createSigner } from '../signing.mjs';
|
|
@@ -1147,6 +1148,7 @@ export function createAgent(options) {
|
|
|
1147
1148
|
const peerKeys = new PeerKeyStore();
|
|
1148
1149
|
/** P0-a: xfer.* frames arriving on a P2P channel are routed here, not to task dispatch. */
|
|
1149
1150
|
const xferRouter = createXferFrameRouter();
|
|
1151
|
+
let viewTargets = null;
|
|
1150
1152
|
/**
|
|
1151
1153
|
* What each task's direct channel actually was, for the result envelope.
|
|
1152
1154
|
*
|
|
@@ -3969,11 +3971,19 @@ let attemptAttached = false;
|
|
|
3969
3971
|
return;
|
|
3970
3972
|
}
|
|
3971
3973
|
if (payload !== null && typeof payload.type === 'string' && payload.type.startsWith('view.')) {
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3974
|
+
// A target owns the VK handshake state, so it must outlive one message:
|
|
3975
|
+
// hello, request and stop for the same session share one instance.
|
|
3976
|
+
viewTargets ??= new Map();
|
|
3977
|
+
const key = String(payload.session_id ?? 'default');
|
|
3978
|
+
let target = viewTargets.get(key);
|
|
3979
|
+
if (target === undefined || payload.type === 'view.hello') {
|
|
3980
|
+
target = createViewTarget({
|
|
3981
|
+
capture: () => captureScreen({}),
|
|
3982
|
+
allow: process.env.W2M_VIEW_ALLOW === '1',
|
|
3983
|
+
send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
|
|
3984
|
+
});
|
|
3985
|
+
viewTargets.set(key, target);
|
|
3986
|
+
}
|
|
3977
3987
|
target.handle(payload);
|
|
3978
3988
|
return;
|
|
3979
3989
|
}
|
|
@@ -4460,6 +4470,10 @@ let attemptAttached = false;
|
|
|
4460
4470
|
// the first heartbeat: "is an agent running here?" is the first question a
|
|
4461
4471
|
// remote operator asks (task-16).
|
|
4462
4472
|
publishState();
|
|
4473
|
+
const viewRelay = process.env.W2M_VIEW_ALLOW === '1'
|
|
4474
|
+
? startRelayViewTarget({ rabbitUrl: baseUrl, token: () => identity.device_token, machineId: identity.machine_id, capture: (options) => captureScreen(options), log })
|
|
4475
|
+
: null;
|
|
4476
|
+
void viewRelay;
|
|
4463
4477
|
if (statePublishIntervalMs > 0) {
|
|
4464
4478
|
statePublishTimer = setInterval(() => publishState(), statePublishIntervalMs);
|
|
4465
4479
|
// Never keep the process alive just to refresh a diagnostics file.
|
package/src/agent/p2p-node.mjs
CHANGED
|
@@ -451,6 +451,7 @@ export class P2PNode extends EventEmitter {
|
|
|
451
451
|
bindUdpSocket = bindUdpSocketDefault,
|
|
452
452
|
bindAddress = '0.0.0.0',
|
|
453
453
|
bindPort = 0,
|
|
454
|
+
publicPort = null,
|
|
454
455
|
deviceKey = null,
|
|
455
456
|
peerKeys = null,
|
|
456
457
|
cryptoTuning = null,
|
|
@@ -465,6 +466,8 @@ export class P2PNode extends EventEmitter {
|
|
|
465
466
|
}
|
|
466
467
|
this.machineId = machineId;
|
|
467
468
|
this.rabbitUrl = rabbitUrl;
|
|
469
|
+
const publicPortResult = normalizeP2PPort(publicPort);
|
|
470
|
+
this.publicPort = publicPortResult.ok ? publicPortResult.port : null;
|
|
468
471
|
|
|
469
472
|
for (const [name, value] of [
|
|
470
473
|
['postJson', postJson],
|
|
@@ -927,7 +930,10 @@ export class P2PNode extends EventEmitter {
|
|
|
927
930
|
? 'unknown'
|
|
928
931
|
: null;
|
|
929
932
|
|
|
930
|
-
|
|
933
|
+
// A port-forward can make an external port reachable while STUN still reports
|
|
934
|
+
// the router's outbound mapping. Announce the operator-declared port instead.
|
|
935
|
+
const announcedReflexive = reflexive !== null && this.publicPort !== null ? { ...reflexive, port: this.publicPort } : reflexive;
|
|
936
|
+
const candidates = this.usableCandidates(announcedReflexive);
|
|
931
937
|
|
|
932
938
|
if (candidates.length === 0) {
|
|
933
939
|
const error = `P2P_NO_CANDIDATES: nothing usable to announce (${discoveryError})`;
|
|
@@ -936,7 +942,7 @@ export class P2PNode extends EventEmitter {
|
|
|
936
942
|
socket,
|
|
937
943
|
candidates: [],
|
|
938
944
|
mapping,
|
|
939
|
-
reflexive,
|
|
945
|
+
reflexive: announcedReflexive,
|
|
940
946
|
ok: false,
|
|
941
947
|
error,
|
|
942
948
|
posted: false,
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* P2P view frame channel (
|
|
2
|
+
* P2P/P1.5 view frame channel (view-only).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
function parseAny(payload) {
|
|
28
39
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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.
|
|
105
|
-
|
|
106
|
-
|
|
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,
|
|
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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1.5 target-side view polling.
|
|
3
|
+
*
|
|
4
|
+
* The target asks the relay for a pending viewer request (1 s cadence); on one,
|
|
5
|
+
* it derives VK from the viewer's ephemeral public key, captures a JPEG, seals
|
|
6
|
+
* it, and posts opaque ciphertext at ≤1 fps. Nothing is written to disk and the
|
|
7
|
+
* relay never sees plaintext.
|
|
8
|
+
*/
|
|
9
|
+
import { createViewSide, sealViewFrame } from './session-key.mjs';
|
|
10
|
+
|
|
11
|
+
export const REMOTE_REQUEST_PATH = '/v1/remote/request';
|
|
12
|
+
export const REMOTE_FRAME_PATH = '/v1/remote/frame';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{rabbitUrl:string, token:()=>string|null, machineId:string, capture:Function,
|
|
16
|
+
* intervalMs?:number, maxBytes?:number, fetchImpl?:Function, log?:Function, maxFrames?:number}} deps
|
|
17
|
+
*/
|
|
18
|
+
export function startRelayViewTarget(deps) {
|
|
19
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
20
|
+
const intervalMs = Number.isInteger(deps.intervalMs) ? deps.intervalMs : 1000;
|
|
21
|
+
const maxBytes = Number.isInteger(deps.maxBytes) ? deps.maxBytes : 96 * 1024;
|
|
22
|
+
const maxFrames = Number.isInteger(deps.maxFrames) ? deps.maxFrames : 60;
|
|
23
|
+
const base = String(deps.rabbitUrl ?? '').replace(/\/+$/, '');
|
|
24
|
+
let stopped = false;
|
|
25
|
+
let timer = null;
|
|
26
|
+
let busy = false;
|
|
27
|
+
|
|
28
|
+
const stop = () => { stopped = true; if (timer !== null) clearInterval(timer); timer = null; };
|
|
29
|
+
|
|
30
|
+
const poll = async () => {
|
|
31
|
+
if (stopped || busy) return;
|
|
32
|
+
const token = deps.token();
|
|
33
|
+
if (typeof token !== 'string' || token === '') return;
|
|
34
|
+
busy = true;
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetchImpl(base + REMOTE_REQUEST_PATH + '?target=' + encodeURIComponent(deps.machineId), {
|
|
37
|
+
headers: { authorization: 'Bearer ' + token },
|
|
38
|
+
});
|
|
39
|
+
if (stopped || !response.ok) { busy = false; return; }
|
|
40
|
+
const body = await response.json().catch(() => null);
|
|
41
|
+
if (body?.ok !== true || typeof body.viewer_public_key !== 'string') { busy = false; return; }
|
|
42
|
+
const side = createViewSide();
|
|
43
|
+
let vk;
|
|
44
|
+
try { vk = side.finish(body.viewer_public_key).key; } catch { busy = false; return; }
|
|
45
|
+
const sessionId = body.session_id;
|
|
46
|
+
let frames = 0;
|
|
47
|
+
const tick = async () => {
|
|
48
|
+
if (stopped || frames >= maxFrames) { busy = false; return; }
|
|
49
|
+
frames += 1;
|
|
50
|
+
const captured = deps.capture({ maxBytes });
|
|
51
|
+
if (captured?.ok !== true) { busy = false; return; }
|
|
52
|
+
let ct;
|
|
53
|
+
try { ct = sealViewFrame(vk, captured.data ?? Buffer.from(captured.base64 ?? '', 'base64')); } catch { busy = false; return; }
|
|
54
|
+
try {
|
|
55
|
+
await fetchImpl(base + REMOTE_FRAME_PATH, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { authorization: 'Bearer ' + deps.token(), 'content-type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ session_id: sessionId, target: deps.machineId, ct, public_key: side.public_key }),
|
|
59
|
+
});
|
|
60
|
+
} catch { /* relay transient; next tick retries */ }
|
|
61
|
+
};
|
|
62
|
+
await tick();
|
|
63
|
+
timer = setInterval(() => { void tick(); }, intervalMs);
|
|
64
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
65
|
+
} finally {
|
|
66
|
+
busy = false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const pollTimer = setInterval(() => { void poll(); }, intervalMs);
|
|
71
|
+
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
72
|
+
void poll();
|
|
73
|
+
return {
|
|
74
|
+
stop() { stopped = true; if (timer !== null) clearInterval(timer); clearInterval(pollTimer); },
|
|
75
|
+
setStop() { stop(); },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,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';
|
|
@@ -60,6 +61,40 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
|
60
61
|
listener = null;
|
|
61
62
|
};
|
|
62
63
|
|
|
64
|
+
const postRelayRequest = async ({ stateDir, machineId, sessionId, publicKey }) => {
|
|
65
|
+
try {
|
|
66
|
+
const device = JSON.parse(readFileSync(join(stateDir, 'device.json'), 'utf8').replace(/^\uFEFF/, ''));
|
|
67
|
+
const base = String(device.rabbit_url ?? device.rabbitUrl ?? '').replace(/\/+$/, '');
|
|
68
|
+
const token = device.device_token ?? device.deviceToken ?? null;
|
|
69
|
+
if (base === '' || typeof token !== 'string' || token === '') return { ok: false, error: 'device.json has no relay url/token' };
|
|
70
|
+
const response = await fetch(base + '/v1/remote/request', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ session_id: sessionId, target: machineId, viewer_public_key: publicKey }),
|
|
74
|
+
});
|
|
75
|
+
const body = await response.json().catch(() => null);
|
|
76
|
+
if (!response.ok || body?.ok !== true) return { ok: false, error: body?.error?.code ?? ('HTTP ' + response.status) };
|
|
77
|
+
return { ok: true, ttl_ms: body.ttl_ms ?? null };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const fetchRelayFrame = async ({ stateDir, sessionId, machineId }) => {
|
|
84
|
+
try {
|
|
85
|
+
const device = JSON.parse(readFileSync(join(stateDir, 'device.json'), 'utf8').replace(/^\uFEFF/, ''));
|
|
86
|
+
const base = String(device.rabbit_url ?? device.rabbitUrl ?? '').replace(/\/+$/, '');
|
|
87
|
+
const token = device.device_token ?? device.deviceToken ?? null;
|
|
88
|
+
if (base === '' || typeof token !== 'string') return { ok: false };
|
|
89
|
+
const url = base + '/v1/remote/frame?session_id=' + encodeURIComponent(sessionId) + '&target=' + encodeURIComponent(machineId);
|
|
90
|
+
const response = await fetch(url, { headers: { authorization: 'Bearer ' + token } });
|
|
91
|
+
if (!response.ok) return { ok: false, waiting: response.status === 404 };
|
|
92
|
+
const body = await response.json().catch(() => null);
|
|
93
|
+
if (body?.ok !== true || typeof body.ct !== 'string') return { ok: false };
|
|
94
|
+
return { ok: true, ct: body.ct, public_key: body.public_key ?? null };
|
|
95
|
+
} catch { return { ok: false }; }
|
|
96
|
+
};
|
|
97
|
+
|
|
63
98
|
ctx.inject(['connection'], (webCtx) => {
|
|
64
99
|
const registry = webCtx?.connection?.fetch;
|
|
65
100
|
if (registry === undefined || typeof registry.register !== 'function') { status.reason = 'the connection service carries no fetch registry'; return; }
|
|
@@ -88,29 +123,50 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
|
88
123
|
try { body = await request.json(); } catch { /* refused below */ }
|
|
89
124
|
const machineId = typeof body?.machine_id === 'string' ? body.machine_id : '';
|
|
90
125
|
if (machineId === '') return Response.json({ ok: false, error: 'a JSON body with {"machine_id": "..."} is required' }, { status: 400, headers: NO_STORE });
|
|
91
|
-
if (p2p?.node === null || p2p?.node === undefined) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: 'no P2P node on this host' }, { status: 503, headers: NO_STORE });
|
|
92
126
|
await stopActive();
|
|
93
|
-
|
|
94
|
-
return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: error instanceof Error ? error.message : String(error) }, { status: 503, headers: NO_STORE });
|
|
95
|
-
}
|
|
96
|
-
const dialled = await p2p.node.dial(machineId, {});
|
|
97
|
-
if (dialled?.ok !== true) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: dialled?.error ?? 'P2P_DIAL_FAILED' }, { status: 502, headers: NO_STORE });
|
|
127
|
+
const side = createViewSide();
|
|
98
128
|
const sessionId = 'view-' + Date.now().toString(36);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
129
|
+
let dialled = null;
|
|
130
|
+
let p2pError = null;
|
|
131
|
+
if (p2p?.node === null || p2p?.node === undefined) p2pError = 'no P2P node on this host';
|
|
132
|
+
else {
|
|
133
|
+
try { await p2p.node.start?.(); dialled = await p2p.node.dial(machineId, {}); } catch (error) { p2pError = error instanceof Error ? error.message : String(error); }
|
|
134
|
+
if (dialled?.ok !== true) p2pError = dialled?.error ?? 'P2P_DIAL_FAILED';
|
|
135
|
+
}
|
|
136
|
+
if (dialled?.ok === true) {
|
|
137
|
+
active = { mode: 'p2p', machineId, sessionId, channel: dialled.channel, side, vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
|
|
138
|
+
listener = (event) => {
|
|
139
|
+
const frame = payloadJson(event?.payload);
|
|
140
|
+
if (frame === null || frame?.session_id !== sessionId) return;
|
|
141
|
+
if (frame.type === 'view.hello_ack') {
|
|
142
|
+
try {
|
|
143
|
+
active.vk = active.side.finish(frame.public_key).key;
|
|
144
|
+
active.encrypted = true;
|
|
145
|
+
void active.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
|
|
146
|
+
} catch { active.error = 'VIEW_VK_BAD'; }
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (frame.type === 'view.frame') {
|
|
150
|
+
if (active.vk === null) { active.error = 'VIEW_VK_REQUIRED'; return; }
|
|
151
|
+
let plaintext;
|
|
152
|
+
try { plaintext = openViewFrame(active.vk, frame.ct); } catch { active.error = 'VIEW_VK_BAD'; return; }
|
|
153
|
+
if (plaintext.length > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
|
|
154
|
+
active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true };
|
|
155
|
+
} else if (frame.type === 'view.error') {
|
|
156
|
+
active.error = frame.code ?? 'VIEW_ERROR';
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
p2p.node.on('message', listener);
|
|
160
|
+
await dialled.channel.send(JSON.stringify({ type: 'view.hello', session_id: sessionId, public_key: active.side.public_key }));
|
|
161
|
+
return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
|
|
162
|
+
}
|
|
163
|
+
// P1.5: P2P failed, so ask the relay to carry an opaque frame later.
|
|
164
|
+
const relayRequest = await postRelayRequest({ stateDir: deps.stateDir, machineId, sessionId, publicKey: side.public_key });
|
|
165
|
+
if (relayRequest.ok !== true) {
|
|
166
|
+
return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: p2pError, relay_error: relayRequest.error ?? null }, { status: 502, headers: NO_STORE });
|
|
167
|
+
}
|
|
168
|
+
active = { mode: 'relay', machineId, sessionId, channel: null, side, vk: null, encrypted: true, latest: null, error: null, startedAt: Date.now() };
|
|
169
|
+
return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: true, channel: 'relay', relay_ttl_ms: relayRequest.ttl_ms ?? null }, { headers: NO_STORE });
|
|
114
170
|
},
|
|
115
171
|
});
|
|
116
172
|
|
|
@@ -126,6 +182,19 @@ export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
|
126
182
|
fetch: async () => {
|
|
127
183
|
if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
|
|
128
184
|
if (active !== null) {
|
|
185
|
+
if (active.mode === 'relay' && active.latest === null) {
|
|
186
|
+
const got = await fetchRelayFrame({ stateDir: deps.stateDir, sessionId: active.sessionId, machineId: active.machineId });
|
|
187
|
+
if (got.ok === true) {
|
|
188
|
+
try {
|
|
189
|
+
if (active.vk === null) {
|
|
190
|
+
if (typeof got.public_key !== 'string') { active.error = 'VIEW_VK_REQUIRED'; return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE }); }
|
|
191
|
+
active.vk = active.side.finish(got.public_key).key;
|
|
192
|
+
}
|
|
193
|
+
const plaintext = openViewFrame(active.vk, got.ct);
|
|
194
|
+
if (plaintext.length <= maxBytes) active.latest = { seq: null, mime: 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true, channel: 'relay' };
|
|
195
|
+
} catch { active.error = 'VIEW_VK_BAD'; }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
129
198
|
if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
|
|
130
199
|
if (active.error !== null) return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE });
|
|
131
200
|
return Response.json({ ok: false, code: 'VIEW_WAITING_FOR_FRAME', machine_id: active.machineId }, { status: 202, headers: NO_STORE });
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
296
|
+
const PLUGIN_VERSION = '2.1.4';
|
|
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,8 @@ 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);
|
|
748
|
+
const p2pPublicPortResult = normalizeP2PPort(config.p2pPublicPort);
|
|
746
749
|
const stun = resolveStunServers(config);
|
|
747
750
|
const stunList = stun.servers;
|
|
748
751
|
|
|
@@ -930,6 +933,9 @@ function readConfig(config = {}) {
|
|
|
930
933
|
? config.bootstrapOperatorKey
|
|
931
934
|
: (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM),
|
|
932
935
|
p2pMode,
|
|
936
|
+
p2pPort: p2pPortResult.ok ? p2pPortResult.port : null,
|
|
937
|
+
p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
|
|
938
|
+
p2pPublicPort: p2pPublicPortResult.ok ? p2pPublicPortResult.port : null,
|
|
933
939
|
stunServers: stunList,
|
|
934
940
|
};
|
|
935
941
|
}
|
|
@@ -2031,6 +2037,8 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
2031
2037
|
getJson: transport.getJson,
|
|
2032
2038
|
stunServers: cfg.stunServers,
|
|
2033
2039
|
log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
|
|
2040
|
+
...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
|
|
2041
|
+
...(Number.isInteger(cfg.p2pPublicPort) ? { publicPort: cfg.p2pPublicPort } : {}),
|
|
2034
2042
|
...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
|
|
2035
2043
|
...block,
|
|
2036
2044
|
});
|
|
@@ -3610,10 +3618,10 @@ export async function apply(ctx, config = {}) {
|
|
|
3610
3618
|
// v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
|
|
3611
3619
|
// renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
|
|
3612
3620
|
// 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);
|
|
3621
|
+
registerCaptureRoutes(ctx);
|
|
3622
|
+
const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
|
|
3623
|
+
if (remoteViewRoutes.mounted !== true) {
|
|
3624
|
+
ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
|
|
3617
3625
|
}
|
|
3618
3626
|
if (capabilityRoutes.mounted !== true) {
|
|
3619
3627
|
ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
|
|
@@ -48,14 +48,14 @@ export function createRemoteMediaBroker(options = {}) {
|
|
|
48
48
|
return { ok: true, code: REMOTE_MEDIA_CODE.OK, session_id: sessionId, target: targetMachineId };
|
|
49
49
|
},
|
|
50
50
|
/** Store one opaque ciphertext frame (latest wins). */
|
|
51
|
-
put(sessionId, targetMachineId, ciphertext) {
|
|
51
|
+
put(sessionId, targetMachineId, ciphertext, publicKey = null) {
|
|
52
52
|
prune();
|
|
53
53
|
const entry = sessions.get(sessionId);
|
|
54
54
|
if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
|
|
55
55
|
if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
56
56
|
if (!(ciphertext instanceof Uint8Array) || ciphertext.byteLength === 0) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
57
57
|
if (ciphertext.byteLength > maxBytes) return { ok: false, code: REMOTE_MEDIA_CODE.TOO_LARGE };
|
|
58
|
-
entry.frame = { ciphertext: Buffer.from(ciphertext), bytes: ciphertext.byteLength, at: now() };
|
|
58
|
+
entry.frame = { ciphertext: Buffer.from(ciphertext), bytes: ciphertext.byteLength, public_key: typeof publicKey === 'string' ? publicKey : null, at: now() };
|
|
59
59
|
entry.expiresAt = now() + ttlMs;
|
|
60
60
|
return { ok: true, code: REMOTE_MEDIA_CODE.OK, bytes: ciphertext.byteLength };
|
|
61
61
|
},
|
|
@@ -81,7 +81,8 @@ export function parseRemoteFrame(body) {
|
|
|
81
81
|
if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
82
82
|
if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
83
83
|
if (typeof body.ct !== 'string' || body.ct === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
84
|
+
if (body.public_key !== undefined && (typeof body.public_key !== 'string' || Buffer.from(body.public_key, 'base64').length !== 32)) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
84
85
|
let ciphertext;
|
|
85
86
|
try { ciphertext = Buffer.from(body.ct, 'base64'); } catch { return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD }; }
|
|
86
|
-
return { ok: true, session_id: body.session_id, target: body.target, ciphertext };
|
|
87
|
+
return { ok: true, session_id: body.session_id, target: body.target, ciphertext, public_key: body.public_key ?? null };
|
|
87
88
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1.5 remote-view request mailbox (relay memory only).
|
|
3
|
+
*
|
|
4
|
+
* A viewer posts one request per target; the target consumes it. No disk, no
|
|
5
|
+
* logs, no plaintext media. Every entry expires, and the newest request wins so
|
|
6
|
+
* a stale viewer cannot pin a target forever.
|
|
7
|
+
*/
|
|
8
|
+
export const REMOTE_REQUEST_CODE = Object.freeze({
|
|
9
|
+
OK: 'REMOTE_REQUEST_OK',
|
|
10
|
+
BAD: 'REMOTE_REQUEST_BAD',
|
|
11
|
+
TOO_LARGE: 'REMOTE_REQUEST_TOO_LARGE',
|
|
12
|
+
NOT_FOUND: 'REMOTE_REQUEST_NOT_FOUND',
|
|
13
|
+
EXPIRED: 'REMOTE_REQUEST_EXPIRED',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_REMOTE_REQUEST_TTL_MS = 15_000;
|
|
17
|
+
|
|
18
|
+
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
19
|
+
|
|
20
|
+
export function createRemoteRequestBroker(options = {}) {
|
|
21
|
+
const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : DEFAULT_REMOTE_REQUEST_TTL_MS;
|
|
22
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
23
|
+
const requests = new Map();
|
|
24
|
+
|
|
25
|
+
const prune = () => { const at = now(); for (const [key, entry] of requests) if (entry.expiresAt <= at) requests.delete(key); };
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
post(body) {
|
|
29
|
+
prune();
|
|
30
|
+
if (!isObject(body)) return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
|
|
31
|
+
if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
|
|
32
|
+
if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
|
|
33
|
+
if (typeof body.viewer_public_key !== 'string' || body.viewer_public_key === '') return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
|
|
34
|
+
const key = Buffer.from(body.viewer_public_key, 'base64');
|
|
35
|
+
if (key.length !== 32) return { ok: false, code: REMOTE_REQUEST_CODE.BAD };
|
|
36
|
+
requests.set(body.target, {
|
|
37
|
+
session_id: body.session_id,
|
|
38
|
+
target: body.target,
|
|
39
|
+
viewer_public_key: body.viewer_public_key,
|
|
40
|
+
expiresAt: now() + ttlMs,
|
|
41
|
+
at: now(),
|
|
42
|
+
});
|
|
43
|
+
return { ok: true, code: REMOTE_REQUEST_CODE.OK, target: body.target, ttl_ms: ttlMs };
|
|
44
|
+
},
|
|
45
|
+
/** Consume the pending request for one target; the target is the only reader. */
|
|
46
|
+
take(target) {
|
|
47
|
+
prune();
|
|
48
|
+
const entry = requests.get(target);
|
|
49
|
+
if (entry === undefined) return { ok: false, code: REMOTE_REQUEST_CODE.NOT_FOUND };
|
|
50
|
+
requests.delete(target);
|
|
51
|
+
return { ok: true, code: REMOTE_REQUEST_CODE.OK, session_id: entry.session_id, target: entry.target, viewer_public_key: entry.viewer_public_key, at: entry.at };
|
|
52
|
+
},
|
|
53
|
+
stats() { prune(); return { targets: requests.size, ttl_ms: ttlMs }; },
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -60,6 +60,8 @@ 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';
|
|
64
|
+
import { createRemoteRequestBroker } from './remote-requests.mjs';
|
|
63
65
|
import { can as mayAct } from '../util/policy-matrix.mjs';
|
|
64
66
|
import { createFleetMemory } from './fleet-memory.mjs';
|
|
65
67
|
import { createJoinChallenges } from './join-challenge.mjs';
|
|
@@ -737,7 +739,10 @@ export class RelayServer {
|
|
|
737
739
|
// received bytes, so no route may consume the stream first and re-serialise.
|
|
738
740
|
/** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
|
|
739
741
|
const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
|
|
740
|
-
|
|
742
|
+
// P1.5: 47 KiB ciphertext is ~63 KiB once base64-encoded inside JSON; give this
|
|
743
|
+
// one opaque route the headroom it needs, and no other JSON route gets it.
|
|
744
|
+
const bodyFrameLimit = method === 'POST' && path === '/v1/remote/frame' ? 70 * 1024 : blobFrameLimit;
|
|
745
|
+
const rawBody = methodHasBody(method) ? await readRawBody(req, bodyFrameLimit) : null;
|
|
741
746
|
const takeJson = () => parseJsonBody(rawBody);
|
|
742
747
|
|
|
743
748
|
/* ---- public endpoints ---- */
|
|
@@ -1447,6 +1452,59 @@ export class RelayServer {
|
|
|
1447
1452
|
this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
|
|
1448
1453
|
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1449
1454
|
|
|
1455
|
+
if (path === '/v1/remote/request') {
|
|
1456
|
+
// P1.5 signalling: one pending request per target, relay memory only.
|
|
1457
|
+
this.remoteRequests ??= createRemoteRequestBroker();
|
|
1458
|
+
if (method === 'POST') {
|
|
1459
|
+
const body = takeJson();
|
|
1460
|
+
const target = typeof body?.target === 'string' ? body.target : '';
|
|
1461
|
+
const targetDevice = target === '' ? null : this.state.devices.get(target);
|
|
1462
|
+
if (targetDevice === undefined || targetDevice === null || targetDevice.fleet_id !== device.fleet_id) {
|
|
1463
|
+
return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'REMOTE_REQUEST_BAD', message: 'target is not in this fleet' } });
|
|
1464
|
+
}
|
|
1465
|
+
const posted = this.remoteRequests.post(body);
|
|
1466
|
+
if (posted.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: posted.code, message: 'remote.request refused' } });
|
|
1467
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, target: posted.target, ttl_ms: posted.ttl_ms });
|
|
1468
|
+
}
|
|
1469
|
+
if (method === 'GET') {
|
|
1470
|
+
const target = url.searchParams.get('target');
|
|
1471
|
+
if (target !== device.machine_id) return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'REMOTE_REQUEST_BAD', message: 'only the target may read its request' } });
|
|
1472
|
+
const taken = this.remoteRequests.take(target);
|
|
1473
|
+
if (taken.ok !== true) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: taken.code, message: 'remote.request unavailable' } });
|
|
1474
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, ...taken });
|
|
1475
|
+
}
|
|
1476
|
+
return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET or POST only' } });
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (path === '/v1/remote/frame') {
|
|
1480
|
+
// P1.5: opaque ciphertext only. The body is never logged, parsed as media
|
|
1481
|
+
// or persisted; the broker keeps one newest frame per session with a TTL.
|
|
1482
|
+
this.remoteMedia ??= createRemoteMediaBroker();
|
|
1483
|
+
if (method === 'POST') {
|
|
1484
|
+
const body = takeJson();
|
|
1485
|
+
const parsed = parseRemoteFrame(body);
|
|
1486
|
+
if (parsed.ok !== true) {
|
|
1487
|
+
return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: parsed.code, message: 'remote.frame refused' } });
|
|
1488
|
+
}
|
|
1489
|
+
if (parsed.target !== device.machine_id) {
|
|
1490
|
+
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' } });
|
|
1491
|
+
}
|
|
1492
|
+
const opened = this.remoteMedia.open(parsed.session_id, parsed.target);
|
|
1493
|
+
if (opened.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: opened.code, message: 'remote.frame refused' } });
|
|
1494
|
+
const stored = this.remoteMedia.put(parsed.session_id, parsed.target, parsed.ciphertext, parsed.public_key);
|
|
1495
|
+
if (stored.ok !== true) return sendJson(res, 413, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: stored.code, message: 'remote.frame refused' } });
|
|
1496
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, bytes: stored.bytes });
|
|
1497
|
+
}
|
|
1498
|
+
if (method === 'GET') {
|
|
1499
|
+
const sessionId = url.searchParams.get('session_id');
|
|
1500
|
+
const target = url.searchParams.get('target');
|
|
1501
|
+
const taken = this.remoteMedia.take(sessionId, target);
|
|
1502
|
+
if (taken.ok !== true) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: taken.code, message: 'remote.frame unavailable' } });
|
|
1503
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, ct: taken.ciphertext.toString('base64'), public_key: taken.public_key ?? null, bytes: taken.bytes, at_ms: taken.at });
|
|
1504
|
+
}
|
|
1505
|
+
return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET or POST only' } });
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1450
1508
|
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1451
1509
|
/**
|
|
1452
1510
|
* 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
|