@twinsearth/w2m-dsh-plugin 2.0.10 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/bin/remote-s2-capture.mjs +29 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/P2P-PORT.md +13 -0
- package/docs/acceptance/2.1.3/RELEASE-2130.json +21 -0
- package/lib/client.js +180 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +20 -0
- package/src/agent/view/capture.mjs +111 -0
- package/src/agent/view/frame-channel.mjs +136 -0
- package/src/agent/view/session-key.mjs +68 -0
- package/src/plugin/remote-view-route.mjs +155 -0
- package/src/plugin/tools.mjs +11 -1
- package/src/relay/remote-media.mjs +87 -0
- package/src/relay/server.mjs +34 -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.
|
|
8
|
+
"version": "2.1.3"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.1.3",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "2.
|
|
29
|
+
"version": "2.1.3"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ All notable changes to this project are documented here.
|
|
|
7
7
|
- `w2m_intent`: structured intents mapped onto allow-listed commands; unknown actions, parameters and types
|
|
8
8
|
are refused by name (the fleet does not invent commands).
|
|
9
9
|
|
|
10
|
+
## [2.1.3] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
- Remote-view candidate: target selector, view.hello/VK (X25519+HKDF, AES-256-GCM), host decrypt, relay remote.frame broker/route, '加密中继' UI label.
|
|
13
|
+
- Still missing: P1.5 auto-fallback, target consent UI, deployed relay endpoint, real P2P verification. Test candidate.
|
|
14
|
+
|
|
15
|
+
## [2.1.2] - 2026-10-31
|
|
16
|
+
|
|
17
|
+
- Remote-view preview: settings toggle, iMac sidebar entry, local 1fps JPEG viewer, target capture module, P2P view frame protocol, host /remote/start|frame|stop routes, encrypted-relay media broker (safety core).
|
|
18
|
+
- Known gaps: P1.5 relay media is not wired end-to-end; peerKey/consent UI and real remote-desktop verification are pending. This release is a test candidate, not feature-complete.
|
|
19
|
+
|
|
20
|
+
## [2.1.0] - 2026-10-31
|
|
21
|
+
|
|
22
|
+
- Remote-view planning: REMOTE-DESKTOP-2.1.0.md and S2 capture probe (bin/remote-s2-capture.mjs).
|
|
23
|
+
- Packaging: GitHub Packages artifact published as 2.1.0 for version alignment.
|
|
24
|
+
- Status: the remote-desktop UI (settings toggle, iMac sidebar tab, viewer) is **not implemented yet**; 2.1.0 must not be described as feature-complete.
|
|
25
|
+
|
|
10
26
|
## [2.0.10] - 2026-10-31
|
|
11
27
|
|
|
12
28
|
- remote-view S2: ship scripts/remote-s2-capture.mjs so macOS capture/TCC probes run as a normal script (no node -e, no reparse refusal); measures 1/2 fps capture timing, JPEG size, ffmpeg presence, writes w2m-s2-result.json and supports exit-code summary.
|
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.3", "w2m-mcp"]
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
### 5. 验证过的 release tarball(离线)
|
|
@@ -44,6 +44,25 @@ export function summaryBits(result) {
|
|
|
44
44
|
return bits;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function captureWindows() {
|
|
48
|
+
const script = [
|
|
49
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
|
50
|
+
'$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
|
|
51
|
+
'$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height',
|
|
52
|
+
'$g=[System.Drawing.Graphics]::FromImage($bmp)',
|
|
53
|
+
'$g.CopyFromScreen($b.Left,$b.Top,0,0,$bmp.Size)',
|
|
54
|
+
'$ms=New-Object System.IO.MemoryStream',
|
|
55
|
+
'$bmp.Save($ms,[System.Drawing.Imaging.ImageFormat]::Jpeg)',
|
|
56
|
+
'[Convert]::ToBase64String($ms.ToArray())',
|
|
57
|
+
].join(';');
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const run = spawnSync('powershell.exe', ['-NoProfile','-NonInteractive','-STA','-ExecutionPolicy','Bypass','-Command', script], { timeout: 20000, encoding: 'utf8' });
|
|
60
|
+
const ms = Date.now() - started;
|
|
61
|
+
const base64 = String(run.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
62
|
+
const bytes = /^[A-Za-z0-9+/=]{100,}$/.test(base64) ? Buffer.from(base64, 'base64').length : 0;
|
|
63
|
+
return { ok: run.status === 0 && bytes > 0, status: run.status, ms, bytes, width: null, height: null, error: run.status === 0 ? null : String(run.stderr ?? '').slice(0, 300) };
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
function capture(file, dimensions = true) {
|
|
48
67
|
const started = Date.now();
|
|
49
68
|
const run = spawnSync('screencapture', ['-x', '-t', 'jpg', file], { timeout: 15000, encoding: 'utf8' });
|
|
@@ -81,6 +100,16 @@ function main() {
|
|
|
81
100
|
tcc_guess: 'unknown',
|
|
82
101
|
code: null,
|
|
83
102
|
};
|
|
103
|
+
if (process.platform === 'win32') {
|
|
104
|
+
result.captures.push(captureWindows());
|
|
105
|
+
spawnSync('ping', ['-n', opts.fps === 2 ? '1' : '2', '127.0.0.1'], { timeout: 5000 });
|
|
106
|
+
result.captures.push(captureWindows());
|
|
107
|
+
result.tcc_guess = 'not-applicable-windows';
|
|
108
|
+
result.ok = result.captures.every((entry) => entry.ok === true);
|
|
109
|
+
result.code = result.ok ? 'S2_CAPTURE_OK' : 'S2_CAPTURE_FAILED';
|
|
110
|
+
finish(result, opts, result.ok ? 0 : 3);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
84
113
|
if (process.platform !== 'darwin') {
|
|
85
114
|
result.code = 'S2_UNSUPPORTED_PLATFORM';
|
|
86
115
|
finish(result, opts, 2);
|
package/codex/mcp-servers.toml
CHANGED
package/docs/P2P-PORT.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# P2P UDP port
|
|
2
|
+
|
|
3
|
+
Set `p2pPort` in the plugin config (or `--p2p-port` on the agent CLI) to bind a stable UDP port. This is required for a router port-forward: the public candidate, router mapping and local bind must be the same port.
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
- id: w2m
|
|
7
|
+
config:
|
|
8
|
+
p2pPort: 31559
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Valid range: 1..65535; omitted/null keeps an ephemeral port.
|
|
12
|
+
- Invalid values are reported as `P2P_PORT_INVALID` in `w2m_status` and do not start a P2P node.
|
|
13
|
+
- Forward the same UDP port on the router to this machine, and allow it in the host firewall.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2.1.3",
|
|
3
|
+
"case": "RELEASE-2130",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-31T00:27:14.997Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-2130-1793406434998",
|
|
11
|
+
"requirement": "v2.1.3 remote-view candidate: p2pPort config, VK/hello frame channel, relay media broker",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"p2p_port": "readConfig normalizes p2pPort and createP2PRuntime passes bindPort",
|
|
16
|
+
"vk": "view.hello/VK + AES-GCM frame 3/3",
|
|
17
|
+
"relay": "remote.frame ciphertext broker/route; relay suite 189/189",
|
|
18
|
+
"preflight": "candidate only; full release gates later"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -2115,6 +2115,153 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
|
|
|
2115
2115
|
]);
|
|
2116
2116
|
}
|
|
2117
2117
|
|
|
2118
|
+
/* ------------------------------------------------------------------ */
|
|
2119
|
+
/* v2.1.0 remote-view skeleton (view-only; media not wired yet) */
|
|
2120
|
+
/* ------------------------------------------------------------------ */
|
|
2121
|
+
|
|
2122
|
+
const REMOTE_VIEW_KEY = 'w2m.remoteView.enabled';
|
|
2123
|
+
const REMOTE_CONFIG_URL = '/api/w2m/remote/config';
|
|
2124
|
+
const REMOTE_FRAME_URL = '/api/w2m/remote/frame';
|
|
2125
|
+
const readLocalFlag = () => {
|
|
2126
|
+
try { return window.localStorage.getItem(REMOTE_VIEW_KEY) === '1'; } catch { return false; }
|
|
2127
|
+
};
|
|
2128
|
+
const cacheFlag = (value) => { try { window.localStorage.setItem(REMOTE_VIEW_KEY, value ? '1' : '0'); } catch { /* denied */ } };
|
|
2129
|
+
const announceFlag = () => { try { window.dispatchEvent(new Event('w2m-remote-view-changed')); } catch { /* headless */ } };
|
|
2130
|
+
|
|
2131
|
+
function useRemoteViewFlag() {
|
|
2132
|
+
const [enabled, setEnabledState] = React.useState(readLocalFlag);
|
|
2133
|
+
const [ready, setReady] = React.useState(false);
|
|
2134
|
+
React.useEffect(() => {
|
|
2135
|
+
let alive = true;
|
|
2136
|
+
fetch(REMOTE_CONFIG_URL, { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
2137
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
2138
|
+
.then((body) => {
|
|
2139
|
+
if (!alive) return;
|
|
2140
|
+
const value = body?.enabled === true;
|
|
2141
|
+
cacheFlag(value);
|
|
2142
|
+
setEnabledState(value);
|
|
2143
|
+
setReady(true);
|
|
2144
|
+
})
|
|
2145
|
+
.catch(() => { if (alive) setReady(true); });
|
|
2146
|
+
const sync = () => setEnabledState(readLocalFlag());
|
|
2147
|
+
window.addEventListener('w2m-remote-view-changed', sync);
|
|
2148
|
+
window.addEventListener('storage', sync);
|
|
2149
|
+
return () => {
|
|
2150
|
+
alive = false;
|
|
2151
|
+
window.removeEventListener('w2m-remote-view-changed', sync);
|
|
2152
|
+
window.removeEventListener('storage', sync);
|
|
2153
|
+
};
|
|
2154
|
+
}, []);
|
|
2155
|
+
const setEnabled = (value) => {
|
|
2156
|
+
const next = value === true;
|
|
2157
|
+
cacheFlag(next);
|
|
2158
|
+
setEnabledState(next);
|
|
2159
|
+
announceFlag();
|
|
2160
|
+
fetch(REMOTE_CONFIG_URL, {
|
|
2161
|
+
method: 'POST', credentials: 'same-origin',
|
|
2162
|
+
headers: { 'content-type': 'application/json' },
|
|
2163
|
+
body: JSON.stringify({ enabled: next }),
|
|
2164
|
+
}).catch(() => { /* local flag remains the fallback */ });
|
|
2165
|
+
};
|
|
2166
|
+
return [enabled, setEnabled, ready];
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function RemoteViewSettings() {
|
|
2170
|
+
const [enabled, setEnabled, ready] = useRemoteViewFlag();
|
|
2171
|
+
return h('div', { style: { display: 'grid', gap: '10px', padding: '4px 0' } }, [
|
|
2172
|
+
h('div', { key: 'title', style: { fontWeight: 600 } }, '远程桌面'),
|
|
2173
|
+
h('label', { key: 'toggle', style: { display: 'flex', gap: '8px', alignItems: 'center', cursor: 'pointer', opacity: ready ? 1 : 0.6 } }, [
|
|
2174
|
+
h('input', { key: 'box', type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }),
|
|
2175
|
+
h('span', { key: 'text' }, enabled ? '已开启' : '已关闭'),
|
|
2176
|
+
]),
|
|
2177
|
+
h('div', { key: 'hint', style: { fontSize: '12px', opacity: 0.75, lineHeight: 1.5 } },
|
|
2178
|
+
'仅查看,不发送鼠标/键盘输入。P1 目标为最高 1 帧/秒;实际帧率取决于目标机性能与网络。媒体优先 P2P,relay 只承载信令。'),
|
|
2179
|
+
h('div', { key: 'status', style: { fontSize: '12px', opacity: 0.6 } }, 'P1 当前为本机回环查看;远端采集/P2P 在后续切片接入。'),
|
|
2180
|
+
]);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function RemoteViewSidebarIcon() {
|
|
2184
|
+
const [enabled, , ready] = useRemoteViewFlag();
|
|
2185
|
+
if (!ready || !enabled) return null;
|
|
2186
|
+
return h('span', { title: '远程桌面', style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } }, h('svg', {
|
|
2187
|
+
width: 20, height: 18, viewBox: '0 0 24 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
2188
|
+
}, [
|
|
2189
|
+
h('rect', { key: 'screen', x: 2.5, y: 2, width: 19, height: 12, rx: 1.5 }),
|
|
2190
|
+
h('path', { key: 'stand', d: 'M8.5 18h7M12 14.2V18' }),
|
|
2191
|
+
]));
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function RemoteViewMainPage() {
|
|
2195
|
+
const [enabled] = useRemoteViewFlag();
|
|
2196
|
+
const [machines, setMachines] = React.useState([]);
|
|
2197
|
+
const [target, setTarget] = React.useState('');
|
|
2198
|
+
const [session, setSession] = React.useState(null);
|
|
2199
|
+
const [live, setLive] = React.useState(true);
|
|
2200
|
+
const [frame, setFrame] = React.useState(null);
|
|
2201
|
+
const [stats, setStats] = React.useState(null);
|
|
2202
|
+
const [error, setError] = React.useState(null);
|
|
2203
|
+
React.useEffect(() => {
|
|
2204
|
+
if (!enabled) return undefined;
|
|
2205
|
+
let alive = true;
|
|
2206
|
+
fetch('/api/w2m/target?session_id=__global__', { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
2207
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
2208
|
+
.then((body) => { if (!alive) return; const list = Array.isArray(body?.machines) ? body.machines.filter((m) => m.online === true) : []; setMachines(list); if (list.length > 0) setTarget((current) => current || list[0].machine_id); })
|
|
2209
|
+
.catch(() => { /* roster unavailable */ });
|
|
2210
|
+
return () => { alive = false; };
|
|
2211
|
+
}, [enabled]);
|
|
2212
|
+
React.useEffect(() => {
|
|
2213
|
+
if (!enabled || !live) return undefined;
|
|
2214
|
+
let alive = true;
|
|
2215
|
+
const tick = async () => {
|
|
2216
|
+
try {
|
|
2217
|
+
const response = await fetch(REMOTE_FRAME_URL, { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
2218
|
+
const body = await response.json().catch(() => null);
|
|
2219
|
+
if (!alive) return;
|
|
2220
|
+
if (body?.ok === true) { setFrame(body); setStats({ capture_ms: body.capture_ms, bytes: body.bytes, at: body.at, encrypted: body.encrypted }); setError(null); }
|
|
2221
|
+
else if (body?.code === 'VIEW_WAITING_FOR_FRAME') { setError(null); }
|
|
2222
|
+
else { setError(body?.code ?? ('HTTP ' + response.status)); }
|
|
2223
|
+
} catch (failure) { if (alive) setError(failure instanceof Error ? failure.message : String(failure)); }
|
|
2224
|
+
};
|
|
2225
|
+
tick();
|
|
2226
|
+
const timer = setInterval(tick, 1000);
|
|
2227
|
+
return () => { alive = false; clearInterval(timer); };
|
|
2228
|
+
}, [enabled, live, session]);
|
|
2229
|
+
const connect = async () => {
|
|
2230
|
+
setError(null);
|
|
2231
|
+
try {
|
|
2232
|
+
const response = await fetch('/api/w2m/remote/start', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ machine_id: target }) });
|
|
2233
|
+
const body = await response.json().catch(() => null);
|
|
2234
|
+
if (!response.ok || body?.ok !== true) { setError(body?.code ?? body?.error ?? ('HTTP ' + response.status)); return; }
|
|
2235
|
+
setFrame(null); setSession(body);
|
|
2236
|
+
} catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
|
|
2237
|
+
};
|
|
2238
|
+
const disconnect = async () => {
|
|
2239
|
+
try { await fetch('/api/w2m/remote/stop', { method: 'POST', credentials: 'same-origin' }); } catch { /* best effort */ }
|
|
2240
|
+
setSession(null); setFrame(null); setStats(null);
|
|
2241
|
+
};
|
|
2242
|
+
const ready = enabled && frame?.data_base64;
|
|
2243
|
+
return h('div', { style: { padding: '16px', display: 'grid', gap: '12px', maxWidth: '1100px' } }, [
|
|
2244
|
+
h('div', { key: 'title', style: { fontSize: '16px', fontWeight: 600 } }, '远程桌面 · 仅查看'),
|
|
2245
|
+
h('div', { key: 'state', style: { fontSize: '13px', opacity: 0.8 } }, enabled ? (session ? ('P2P 会话:' + (session.machine_id ?? '') + (session.encrypted ? '(加密)' : '(未加密)')) : '未连接目标') : '插件设置中尚未开启远程桌面。'),
|
|
2246
|
+
h('div', { key: 'controls', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } }, [
|
|
2247
|
+
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)))),
|
|
2248
|
+
h('button', { key: 'connect', type: 'button', disabled: !enabled || target === '' || session !== null, onClick: connect }, '连接'),
|
|
2249
|
+
h('button', { key: 'stop', type: 'button', disabled: session === null, onClick: disconnect }, '停止'),
|
|
2250
|
+
h('button', { key: 'pause', type: 'button', disabled: !enabled, onClick: () => setLive((value) => !value) }, live ? '暂停' : '继续'),
|
|
2251
|
+
]),
|
|
2252
|
+
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
|
|
2253
|
+
? h('img', { src: 'data:' + (frame.mime ?? 'image/jpeg') + ';base64,' + frame.data_base64, alt: '远程桌面画面', style: { maxWidth: '100%', maxHeight: '70vh', display: 'block' } })
|
|
2254
|
+
: h('span', { style: { opacity: 0.6, fontSize: '13px' } }, !enabled ? '尚未开启' : (error ? ('查看失败:' + error) : (session ? '等待远端帧…' : '本机回环预览(选择目标后点连接)')))),
|
|
2255
|
+
h('div', { key: 'statusline', style: { fontSize: '12px', opacity: 0.7, display: 'flex', gap: '14px', flexWrap: 'wrap' } }, [
|
|
2256
|
+
h('span', { key: 'fps' }, '1 fps 上限'),
|
|
2257
|
+
h('span', { key: 'channel' }, '通道:' + (session ? (session.channel === 'relay' ? '加密中继' : 'P2P') : '本机回环')),
|
|
2258
|
+
h('span', { key: 'cap' }, '采集:' + (stats?.capture_ms ?? '—') + 'ms'),
|
|
2259
|
+
h('span', { key: 'bytes' }, '帧:' + (stats?.bytes ?? '—') + 'B'),
|
|
2260
|
+
h('span', { key: 'age' }, '最近帧:' + (stats?.at ?? '—')),
|
|
2261
|
+
]),
|
|
2262
|
+
]);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2118
2265
|
function FleetChat({ sessionId, viewMode }) {
|
|
2119
2266
|
// v1.9.18: chat emotes are local slash commands, never image bytes. The relay only sees text.
|
|
2120
2267
|
const CHAT_COMMANDS = ['/微笑', '/火箭', '/烟花'];
|
|
@@ -2802,6 +2949,39 @@ const CHAT_EMOJIS = CHAT_COMMANDS;
|
|
|
2802
2949
|
error,
|
|
2803
2950
|
);
|
|
2804
2951
|
}
|
|
2952
|
+
// v2.1.0: remote-view skeleton — settings toggle, conditional iMac sidebar entry, viewer page.
|
|
2953
|
+
try {
|
|
2954
|
+
slots.inject('settings.section', () => slots.register({
|
|
2955
|
+
name: 'settings.section',
|
|
2956
|
+
id: 'w2m-remote-view',
|
|
2957
|
+
order: 13,
|
|
2958
|
+
label: () => '远程桌面',
|
|
2959
|
+
inject: () => ({ t }),
|
|
2960
|
+
}, RemoteViewSettings));
|
|
2961
|
+
} catch (error) {
|
|
2962
|
+
console.error('@twinsearth/w2m-dsh-plugin: the remote-view settings section was not mounted', error);
|
|
2963
|
+
}
|
|
2964
|
+
try {
|
|
2965
|
+
slots.inject('sidebar.panellist', () => slots.register({
|
|
2966
|
+
name: 'sidebar.panellist',
|
|
2967
|
+
id: 'w2m-remote-view',
|
|
2968
|
+
order: 5,
|
|
2969
|
+
label: () => '',
|
|
2970
|
+
inject: () => ({}),
|
|
2971
|
+
}, RemoteViewSidebarIcon));
|
|
2972
|
+
} catch (error) {
|
|
2973
|
+
console.error('@twinsearth/w2m-dsh-plugin: the remote-view sidebar entry was not mounted', error);
|
|
2974
|
+
}
|
|
2975
|
+
try {
|
|
2976
|
+
slots.inject('main', () => slots.register({
|
|
2977
|
+
name: 'main',
|
|
2978
|
+
key: 'w2m-remote-view',
|
|
2979
|
+
label: () => '远程桌面',
|
|
2980
|
+
inject: () => ({ t }),
|
|
2981
|
+
}, RemoteViewMainPage));
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
console.error('@twinsearth/w2m-dsh-plugin: the remote-view main page was not mounted', error);
|
|
2984
|
+
}
|
|
2805
2985
|
}
|
|
2806
2986
|
|
|
2807
2987
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"pack": "node scripts/pack.mjs",
|
|
67
67
|
"reachability": "node scripts/reachability-check.mjs",
|
|
68
68
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
69
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs",
|
|
69
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs",
|
|
70
70
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
71
71
|
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
72
72
|
"test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.1.3",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
} from '../util/exec-capability.mjs';
|
|
71
71
|
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningKey } from './crypto.mjs';
|
|
72
72
|
import { createXferFrameRouter } from './transfer/router.mjs';
|
|
73
|
+
import { captureScreen } from './view/capture.mjs';
|
|
74
|
+
import { createViewTarget } from './view/frame-channel.mjs';
|
|
73
75
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
74
76
|
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
75
77
|
import { createSigner } from '../signing.mjs';
|
|
@@ -1145,6 +1147,7 @@ export function createAgent(options) {
|
|
|
1145
1147
|
const peerKeys = new PeerKeyStore();
|
|
1146
1148
|
/** P0-a: xfer.* frames arriving on a P2P channel are routed here, not to task dispatch. */
|
|
1147
1149
|
const xferRouter = createXferFrameRouter();
|
|
1150
|
+
let viewTargets = null;
|
|
1148
1151
|
/**
|
|
1149
1152
|
* What each task's direct channel actually was, for the result envelope.
|
|
1150
1153
|
*
|
|
@@ -3966,6 +3969,23 @@ let attemptAttached = false;
|
|
|
3966
3969
|
xferRouter.handle(payload, { channel: event.channel });
|
|
3967
3970
|
return;
|
|
3968
3971
|
}
|
|
3972
|
+
if (payload !== null && typeof payload.type === 'string' && payload.type.startsWith('view.')) {
|
|
3973
|
+
// A target owns the VK handshake state, so it must outlive one message:
|
|
3974
|
+
// hello, request and stop for the same session share one instance.
|
|
3975
|
+
viewTargets ??= new Map();
|
|
3976
|
+
const key = String(payload.session_id ?? 'default');
|
|
3977
|
+
let target = viewTargets.get(key);
|
|
3978
|
+
if (target === undefined || payload.type === 'view.hello') {
|
|
3979
|
+
target = createViewTarget({
|
|
3980
|
+
capture: () => captureScreen({}),
|
|
3981
|
+
allow: process.env.W2M_VIEW_ALLOW === '1',
|
|
3982
|
+
send: (frame) => { try { event.channel.send(frame); } catch { /* peer gone */ } },
|
|
3983
|
+
});
|
|
3984
|
+
viewTargets.set(key, target);
|
|
3985
|
+
}
|
|
3986
|
+
target.handle(payload);
|
|
3987
|
+
return;
|
|
3988
|
+
}
|
|
3969
3989
|
if (payload === null) {
|
|
3970
3990
|
log('warn', 'ignoring non-JSON frame on a P2P channel', {
|
|
3971
3991
|
peer: event.peer ?? null,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target-side screen capture for remote-view P1.
|
|
3
|
+
*
|
|
4
|
+
* One JPEG per call, bounded by a timeout, injectable `run` so tests never
|
|
5
|
+
* launch PowerShell or screencapture. Errors are named, never "capture failed"
|
|
6
|
+
* without a reason.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const VIEW_CAPTURE_CODE = Object.freeze({
|
|
14
|
+
OK: 'CAPTURE_OK',
|
|
15
|
+
UNSUPPORTED: 'CAPTURE_UNSUPPORTED_PLATFORM',
|
|
16
|
+
DENIED: 'CAPTURE_DENIED',
|
|
17
|
+
BLANK: 'CAPTURE_BLANK',
|
|
18
|
+
FAILED: 'CAPTURE_FAILED',
|
|
19
|
+
TOO_LARGE: 'CAPTURE_TOO_LARGE',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_MAX_FRAME_BYTES = 96 * 1024;
|
|
23
|
+
|
|
24
|
+
const defaultRun = (argv, options = {}) => spawnSync(argv[0], argv.slice(1), {
|
|
25
|
+
timeout: options.timeoutMs ?? 15000,
|
|
26
|
+
encoding: options.encoding ?? 'utf8',
|
|
27
|
+
env: options.env ?? process.env,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function windowsCaptureScript(maxBytes) {
|
|
31
|
+
return [
|
|
32
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
|
33
|
+
'$b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
|
|
34
|
+
'$src=New-Object System.Drawing.Bitmap $b.Width,$b.Height',
|
|
35
|
+
'$g=[System.Drawing.Graphics]::FromImage($src)',
|
|
36
|
+
'$g.CopyFromScreen($b.Left,$b.Top,0,0,$src.Size)',
|
|
37
|
+
'$codec=[System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq "image/jpeg" }',
|
|
38
|
+
'$max=' + String(maxBytes),
|
|
39
|
+
'$best=$null',
|
|
40
|
+
'foreach($w in @(1280,1024,800,640)){',
|
|
41
|
+
' $h=[int]($b.Height*$w/$b.Width)',
|
|
42
|
+
' $dst=New-Object System.Drawing.Bitmap $w,$h',
|
|
43
|
+
' $dg=[System.Drawing.Graphics]::FromImage($dst)',
|
|
44
|
+
' $dg.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',
|
|
45
|
+
' $dg.DrawImage($src,0,0,$w,$h)',
|
|
46
|
+
' foreach($q in @(70,55,40)){',
|
|
47
|
+
' $p=New-Object System.Drawing.Imaging.EncoderParameters(1)',
|
|
48
|
+
' $p.Param[0]=New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality,[long]$q)',
|
|
49
|
+
' $ms=New-Object System.IO.MemoryStream',
|
|
50
|
+
' $dst.Save($ms,$codec,$p)',
|
|
51
|
+
' $bytes=$ms.ToArray()',
|
|
52
|
+
' if($best -eq $null -or $bytes.Length -lt $best.Length){$best=$bytes}',
|
|
53
|
+
' if($bytes.Length -le $max){[Convert]::ToBase64String($bytes); exit 0}',
|
|
54
|
+
' }',
|
|
55
|
+
'}',
|
|
56
|
+
'[Convert]::ToBase64String($best)',
|
|
57
|
+
].join(';');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{platform?: NodeJS.Platform, run?: Function, tempRoot?: string, maxBytes?: number}} [options]
|
|
63
|
+
* @returns {{ok: true, code: string, mime: string, data: Buffer, base64: string, bytes: number, capture_ms: number, width: number|null, height: number|null}
|
|
64
|
+
* | {ok: false, code: string, error: string, capture_ms: number}}
|
|
65
|
+
*/
|
|
66
|
+
export function captureScreen(options = {}) {
|
|
67
|
+
const platform = options.platform ?? process.platform;
|
|
68
|
+
const run = options.run ?? defaultRun;
|
|
69
|
+
const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_FRAME_BYTES;
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
try {
|
|
72
|
+
let base64 = null;
|
|
73
|
+
let width = null;
|
|
74
|
+
let height = null;
|
|
75
|
+
if (platform === 'win32') {
|
|
76
|
+
const result = run(['powershell.exe', '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', windowsCaptureScript(maxBytes)], { timeoutMs: 20000 });
|
|
77
|
+
if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `PowerShell capture failed (exit ${result.code ?? 'null'}${result.signal ? ', signal ' + result.signal : ''}${result.error?.message ? ', ' + result.error.message : ''})`, capture_ms: Date.now() - started };
|
|
78
|
+
base64 = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? '';
|
|
79
|
+
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 };
|
|
80
|
+
} else if (platform === 'darwin') {
|
|
81
|
+
const dir = mkdtempSync(join(options.tempRoot ?? tmpdir(), 'w2m-view-'));
|
|
82
|
+
const file = join(dir, 'frame.jpg');
|
|
83
|
+
try {
|
|
84
|
+
const result = run(['/usr/sbin/screencapture', '-x', '-t', 'jpg', file], { timeoutMs: 15000 });
|
|
85
|
+
if (result.code !== 0) return { ok: false, code: VIEW_CAPTURE_CODE.DENIED, error: `screencapture failed (exit ${result.code})`, capture_ms: Date.now() - started };
|
|
86
|
+
const data = readFileSync(file);
|
|
87
|
+
base64 = data.toString('base64');
|
|
88
|
+
const sips = run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file], { timeoutMs: 5000 });
|
|
89
|
+
const w = /pixelWidth:\s*(\d+)/.exec(sips?.stdout ?? '');
|
|
90
|
+
const h = /pixelHeight:\s*(\d+)/.exec(sips?.stdout ?? '');
|
|
91
|
+
width = w === null ? null : Number(w[1]);
|
|
92
|
+
height = h === null ? null : Number(h[1]);
|
|
93
|
+
} finally {
|
|
94
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
return { ok: false, code: VIEW_CAPTURE_CODE.UNSUPPORTED, error: `screen capture is not implemented for ${platform}`, capture_ms: Date.now() - started };
|
|
98
|
+
}
|
|
99
|
+
const data = Buffer.from(base64, 'base64');
|
|
100
|
+
if (data.length < 1000) return { ok: false, code: VIEW_CAPTURE_CODE.BLANK, error: 'captured JPEG is too small to be a screen', capture_ms: Date.now() - started };
|
|
101
|
+
if (data.length > maxBytes) return { ok: false, code: VIEW_CAPTURE_CODE.TOO_LARGE, error: `frame ${data.length} > ${maxBytes}`, capture_ms: Date.now() - started };
|
|
102
|
+
return { ok: true, code: VIEW_CAPTURE_CODE.OK, mime: 'image/jpeg', data, base64, bytes: data.length, capture_ms: Date.now() - started, width, height };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { ok: false, code: VIEW_CAPTURE_CODE.FAILED, error: error instanceof Error ? error.message : String(error), capture_ms: Date.now() - started };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Test/util helper: file size without reading the whole image. */
|
|
109
|
+
export function frameBytes(file) {
|
|
110
|
+
try { return statSync(file).size; } catch { return 0; }
|
|
111
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P/P1.5 view frame channel (view-only).
|
|
3
|
+
*
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
import { createViewSide, openViewFrame, sealViewFrame } from './session-key.mjs';
|
|
15
|
+
|
|
16
|
+
export const VIEW_FRAME = Object.freeze({
|
|
17
|
+
HELLO: 'view.hello',
|
|
18
|
+
HELLO_ACK: 'view.hello_ack',
|
|
19
|
+
REQUEST: 'view.request',
|
|
20
|
+
FRAME: 'view.frame',
|
|
21
|
+
STOP: 'view.stop',
|
|
22
|
+
ERROR: 'view.error',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const VIEW_CODE = Object.freeze({
|
|
26
|
+
CONSENT_REQUIRED: 'VIEW_CONSENT_REQUIRED',
|
|
27
|
+
VK_REQUIRED: 'VIEW_VK_REQUIRED',
|
|
28
|
+
VK_BAD: 'VIEW_VK_BAD',
|
|
29
|
+
FRAME_TOO_LARGE: 'VIEW_FRAME_TOO_LARGE',
|
|
30
|
+
BAD_FRAME: 'VIEW_BAD_FRAME',
|
|
31
|
+
CAPTURE_FAILED: 'VIEW_CAPTURE_FAILED',
|
|
32
|
+
INPUT_FORBIDDEN: 'VIEW_INPUT_FORBIDDEN',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
36
|
+
const isInputFrame = (type) => /^(mouse|key|pointer|input)\./.test(type);
|
|
37
|
+
|
|
38
|
+
function parseAny(payload) {
|
|
39
|
+
try {
|
|
40
|
+
if (Buffer.isBuffer(payload) || typeof payload === 'string') return JSON.parse(Buffer.from(payload).toString('utf8'));
|
|
41
|
+
return payload;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
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 */
|
|
52
|
+
export function createViewTarget({ capture, allow = false, send, maxBytes = 96 * 1024, fps = 1 } = {}) {
|
|
53
|
+
if (typeof capture !== 'function' || typeof send !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
|
|
54
|
+
let seq = 0;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
let vk = null;
|
|
57
|
+
const reply = (frame) => { try { send(JSON.stringify(frame)); } catch { /* channel gone */ } };
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
handle(payload) {
|
|
61
|
+
const raw = parseAny(payload);
|
|
62
|
+
if (isObject(raw) && typeof raw.type === 'string' && isInputFrame(raw.type)) {
|
|
63
|
+
reply({ type: VIEW_FRAME.ERROR, code: VIEW_CODE.INPUT_FORBIDDEN, session_id: raw.session_id ?? null });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const frame = parseViewFrame(payload);
|
|
67
|
+
if (frame === null) return false;
|
|
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
|
+
}
|
|
78
|
+
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 });
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
stats() { return { seq, stopped, allow: allow === true, fps, max_bytes: maxBytes, encrypted: vk !== null }; },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {{send:Function, onFrame:Function, maxBytes?:number}} deps */
|
|
96
|
+
export function createViewClient({ send, onFrame, maxBytes = 96 * 1024 } = {}) {
|
|
97
|
+
if (typeof send !== 'function' || typeof onFrame !== 'function') return { ok: false, code: VIEW_CODE.BAD_FRAME };
|
|
98
|
+
let sessionId = null;
|
|
99
|
+
let machineId = null;
|
|
100
|
+
let side = null;
|
|
101
|
+
let vk = null;
|
|
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 }));
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
request({ machineId: machine = null, session } = {}) {
|
|
107
|
+
machineId = machine;
|
|
108
|
+
sessionId = session ?? ('view-' + Date.now());
|
|
109
|
+
side = createViewSide();
|
|
110
|
+
send(JSON.stringify({ type: VIEW_FRAME.HELLO, session_id: sessionId, public_key: side.public_key }));
|
|
111
|
+
return sessionId;
|
|
112
|
+
},
|
|
113
|
+
accept(payload) {
|
|
114
|
+
const frame = parseViewFrame(payload);
|
|
115
|
+
if (frame === null) return false;
|
|
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
|
+
}
|
|
122
|
+
if (frame.type !== VIEW_FRAME.FRAME) return true;
|
|
123
|
+
if (sessionId !== null && frame.session_id !== sessionId) return true;
|
|
124
|
+
if (!Number.isInteger(frame.seq) || frame.seq <= lastSeq) return true;
|
|
125
|
+
if (vk === null || typeof frame.ct !== 'string' || frame.ct === '') { onFrame({ ok: false, code: VIEW_CODE.VK_REQUIRED }); return true; }
|
|
126
|
+
let plaintext;
|
|
127
|
+
try { plaintext = openViewFrame(vk, frame.ct); } catch { onFrame({ ok: false, code: VIEW_CODE.VK_BAD }); return true; }
|
|
128
|
+
if (plaintext.length > maxBytes) { onFrame({ ok: false, code: VIEW_CODE.FRAME_TOO_LARGE }); return true; }
|
|
129
|
+
lastSeq = frame.seq;
|
|
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 });
|
|
131
|
+
return true;
|
|
132
|
+
},
|
|
133
|
+
stop() { if (sessionId !== null) send(JSON.stringify({ type: VIEW_FRAME.STOP, session_id: sessionId })); },
|
|
134
|
+
stats() { return { sessionId, lastSeq, encrypted: vk !== null }; },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1.5 view-session keys (VK).
|
|
3
|
+
*
|
|
4
|
+
* Ephemeral X25519 per session; both sides mix the shared secret with the two
|
|
5
|
+
* sorted public keys, so viewer and target derive the same 32-byte key without
|
|
6
|
+
* a static device key being reused for media. Frames are AES-256-GCM sealed;
|
|
7
|
+
* the relay only ever sees the base64 ciphertext.
|
|
8
|
+
*/
|
|
9
|
+
import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
export const VIEW_VK_LABEL = 'w2m-remote-view v1 session';
|
|
12
|
+
export const VIEW_VK_SALT = 'w2m-remote-view v1 salt';
|
|
13
|
+
|
|
14
|
+
const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
15
|
+
|
|
16
|
+
function ephemeral() {
|
|
17
|
+
const { privateKey, publicKey } = generateKeyPairSync('x25519');
|
|
18
|
+
const raw = publicKey.export({ type: 'spki', format: 'der' }).subarray(X25519_SPKI_PREFIX.length);
|
|
19
|
+
return { privateKey, publicKeyB64: raw.toString('base64') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function peerPublic(raw) {
|
|
23
|
+
return createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function deriveKey(shared, publicA, publicB) {
|
|
27
|
+
const [first, second] = [publicA, publicB].sort();
|
|
28
|
+
return Buffer.from(hkdfSync('sha256', Buffer.from(shared), Buffer.from(VIEW_VK_SALT), Buffer.from(VIEW_VK_LABEL + '|' + first + '|' + second), 32));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One side's ephemeral keypair plus the public key to put in the signal. */
|
|
32
|
+
export function createViewSide() {
|
|
33
|
+
const eph = ephemeral();
|
|
34
|
+
const publicKey = eph.publicKeyB64;
|
|
35
|
+
return {
|
|
36
|
+
public_key: publicKey,
|
|
37
|
+
/** @param {string} peerPublicKey base64 X25519 public key from the peer */
|
|
38
|
+
finish(peerPublicKey) {
|
|
39
|
+
if (typeof peerPublicKey !== 'string' || peerPublicKey === '') throw new Error('VIEW_VK_BAD_PEER_KEY');
|
|
40
|
+
const peer = Buffer.from(peerPublicKey, 'base64');
|
|
41
|
+
if (peer.length !== 32) throw new Error('VIEW_VK_BAD_PEER_KEY');
|
|
42
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: peerPublic(peer) });
|
|
43
|
+
return { key: deriveKey(shared, publicKey, peerPublicKey), public_key: publicKey };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sealViewFrame(key, plaintext) {
|
|
49
|
+
if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('VIEW_VK_BAD_KEY');
|
|
50
|
+
const iv = randomBytes(12);
|
|
51
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
52
|
+
cipher.setAAD(Buffer.from('w2m-remote-view/frame'));
|
|
53
|
+
const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
54
|
+
const tag = cipher.getAuthTag();
|
|
55
|
+
return Buffer.concat([iv, tag, body]).toString('base64');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function openViewFrame(key, encoded) {
|
|
59
|
+
if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('VIEW_VK_BAD_KEY');
|
|
60
|
+
const raw = Buffer.from(encoded, 'base64');
|
|
61
|
+
if (raw.length < 28) throw new Error('VIEW_VK_BAD_FRAME');
|
|
62
|
+
const iv = raw.subarray(0, 12);
|
|
63
|
+
const tag = raw.subarray(12, 28);
|
|
64
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
65
|
+
decipher.setAAD(Buffer.from('w2m-remote-view/frame'));
|
|
66
|
+
decipher.setAuthTag(tag);
|
|
67
|
+
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
|
|
68
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.1.x remote-view host routes.
|
|
3
|
+
*
|
|
4
|
+
* local : /frame captures this host (P1 fallback, no target selected)
|
|
5
|
+
* remote : /start dials the target over P2P and asks its agent for view frames;
|
|
6
|
+
* /frame serves the newest cached frame; /stop ends the session.
|
|
7
|
+
*
|
|
8
|
+
* Remote frames never traverse the relay. A P2P failure is P2P_UNAVAILABLE,
|
|
9
|
+
* never satisfied by a relay/plaintext media fallback.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { captureHostScreen } from './capture-route.mjs';
|
|
14
|
+
import { createViewSide, openViewFrame } from '../agent/view/session-key.mjs';
|
|
15
|
+
|
|
16
|
+
export const REMOTE_CONFIG_PATH = '/api/w2m/remote/config';
|
|
17
|
+
export const REMOTE_FRAME_PATH = '/api/w2m/remote/frame';
|
|
18
|
+
export const REMOTE_START_PATH = '/api/w2m/remote/start';
|
|
19
|
+
export const REMOTE_STOP_PATH = '/api/w2m/remote/stop';
|
|
20
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
21
|
+
|
|
22
|
+
function readConfig(file) {
|
|
23
|
+
try { return { ok: true, enabled: JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))?.enabled === true }; }
|
|
24
|
+
catch { return { ok: true, enabled: false }; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeConfig(file, enabled) {
|
|
28
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
29
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
30
|
+
writeFileSync(tmp, JSON.stringify({ enabled: enabled === true, updatedAt: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 });
|
|
31
|
+
renameSync(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function payloadJson(payload) {
|
|
35
|
+
try { return Buffer.isBuffer(payload) || typeof payload === 'string' ? JSON.parse(Buffer.from(payload).toString('utf8')) : payload; }
|
|
36
|
+
catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} ctx Cordis context
|
|
41
|
+
* @param {{stateDir:string, p2p?:object, capture?:Function, platform?:NodeJS.Platform, maxBytes?:number}} deps
|
|
42
|
+
*/
|
|
43
|
+
export function registerRemoteViewRoutes(ctx, deps = {}) {
|
|
44
|
+
const status = { mounted: false, reason: 'the host has no Connection service', configPath: null };
|
|
45
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
46
|
+
if (typeof deps.stateDir !== 'string' || deps.stateDir === '') { status.reason = 'stateDir is not configured'; return status; }
|
|
47
|
+
const file = join(deps.stateDir, 'remote-view.json');
|
|
48
|
+
const capture = deps.capture ?? captureHostScreen;
|
|
49
|
+
const platform = deps.platform ?? process.platform;
|
|
50
|
+
const p2p = deps.p2p ?? null;
|
|
51
|
+
const maxBytes = Number.isInteger(deps.maxBytes) ? deps.maxBytes : 96 * 1024;
|
|
52
|
+
let active = null;
|
|
53
|
+
let listener = null;
|
|
54
|
+
|
|
55
|
+
const stopActive = async () => {
|
|
56
|
+
if (active === null) return;
|
|
57
|
+
try { await active.channel?.send?.(JSON.stringify({ type: 'view.stop', session_id: active.sessionId })); } catch { /* peer gone */ }
|
|
58
|
+
try { active.channel?.close?.(); } catch { /* already closed */ }
|
|
59
|
+
if (p2p?.node && typeof p2p.node.off === 'function' && listener !== null) p2p.node.off('message', listener);
|
|
60
|
+
active = null;
|
|
61
|
+
listener = null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
65
|
+
const registry = webCtx?.connection?.fetch;
|
|
66
|
+
if (registry === undefined || typeof registry.register !== 'function') { status.reason = 'the connection service carries no fetch registry'; return; }
|
|
67
|
+
registry.register({
|
|
68
|
+
path: REMOTE_CONFIG_PATH,
|
|
69
|
+
methods: ['GET', 'POST'],
|
|
70
|
+
requestBody: 'buffered',
|
|
71
|
+
fetch: async (request) => {
|
|
72
|
+
if (request.method === 'GET') return Response.json({ ok: true, ...readConfig(file), session: active === null ? null : { machine_id: active.machineId, encrypted: active.encrypted === true } }, { headers: NO_STORE });
|
|
73
|
+
let body = null;
|
|
74
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
75
|
+
if (body === null || typeof body.enabled !== 'boolean') return Response.json({ ok: false, error: 'a JSON body with {"enabled": true|false} is required' }, { status: 400, headers: NO_STORE });
|
|
76
|
+
if (body.enabled === false) await stopActive();
|
|
77
|
+
writeConfig(file, body.enabled);
|
|
78
|
+
return Response.json({ ok: true, enabled: body.enabled }, { headers: NO_STORE });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
registry.register({
|
|
83
|
+
path: REMOTE_START_PATH,
|
|
84
|
+
methods: ['POST'],
|
|
85
|
+
requestBody: 'buffered',
|
|
86
|
+
fetch: async (request) => {
|
|
87
|
+
if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
|
|
88
|
+
let body = null;
|
|
89
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
90
|
+
const machineId = typeof body?.machine_id === 'string' ? body.machine_id : '';
|
|
91
|
+
if (machineId === '') return Response.json({ ok: false, error: 'a JSON body with {"machine_id": "..."} is required' }, { status: 400, headers: NO_STORE });
|
|
92
|
+
if (p2p?.node === null || p2p?.node === undefined) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: 'no P2P node on this host' }, { status: 503, headers: NO_STORE });
|
|
93
|
+
await stopActive();
|
|
94
|
+
try { await p2p.node.start?.(); } catch (error) {
|
|
95
|
+
return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: error instanceof Error ? error.message : String(error) }, { status: 503, headers: NO_STORE });
|
|
96
|
+
}
|
|
97
|
+
const dialled = await p2p.node.dial(machineId, {});
|
|
98
|
+
if (dialled?.ok !== true) return Response.json({ ok: false, code: 'P2P_UNAVAILABLE', error: dialled?.error ?? 'P2P_DIAL_FAILED' }, { status: 502, headers: NO_STORE });
|
|
99
|
+
const sessionId = 'view-' + Date.now().toString(36);
|
|
100
|
+
active = { machineId, sessionId, channel: dialled.channel, side: createViewSide(), vk: null, encrypted: dialled.channel?.encryption?.encrypted === true, latest: null, error: null, startedAt: Date.now() };
|
|
101
|
+
listener = (event) => {
|
|
102
|
+
const frame = payloadJson(event?.payload);
|
|
103
|
+
if (frame === null || frame?.session_id !== sessionId) return;
|
|
104
|
+
if (frame.type === 'view.hello_ack') {
|
|
105
|
+
try {
|
|
106
|
+
active.vk = active.side.finish(frame.public_key).key;
|
|
107
|
+
active.encrypted = true;
|
|
108
|
+
void active.channel.send(JSON.stringify({ type: 'view.request', session_id: sessionId, machine_id: machineId, fps: 1, max_bytes: maxBytes }));
|
|
109
|
+
} catch { active.error = 'VIEW_VK_BAD'; }
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (frame.type === 'view.frame') {
|
|
113
|
+
if (active.vk === null) { active.error = 'VIEW_VK_REQUIRED'; return; }
|
|
114
|
+
let plaintext;
|
|
115
|
+
try { plaintext = openViewFrame(active.vk, frame.ct); } catch { active.error = 'VIEW_VK_BAD'; return; }
|
|
116
|
+
if (plaintext.length > maxBytes) { active.error = 'VIEW_FRAME_TOO_LARGE'; return; }
|
|
117
|
+
active.latest = { seq: frame.seq ?? null, mime: frame.mime ?? 'image/jpeg', bytes: plaintext.length, data_base64: plaintext.toString('base64'), at: new Date().toISOString(), encrypted: true };
|
|
118
|
+
} else if (frame.type === 'view.error') {
|
|
119
|
+
active.error = frame.code ?? 'VIEW_ERROR';
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
p2p.node.on('message', listener);
|
|
123
|
+
await dialled.channel.send(JSON.stringify({ type: 'view.hello', session_id: sessionId, public_key: active.side.public_key }));
|
|
124
|
+
return Response.json({ ok: true, machine_id: machineId, session_id: sessionId, encrypted: active.encrypted, channel: 'p2p' }, { headers: NO_STORE });
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
registry.register({
|
|
129
|
+
path: REMOTE_STOP_PATH,
|
|
130
|
+
methods: ['POST'],
|
|
131
|
+
fetch: async () => { await stopActive(); return Response.json({ ok: true }, { headers: NO_STORE }); },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
registry.register({
|
|
135
|
+
path: REMOTE_FRAME_PATH,
|
|
136
|
+
methods: ['POST'],
|
|
137
|
+
fetch: async () => {
|
|
138
|
+
if (readConfig(file).enabled !== true) return Response.json({ ok: false, code: 'VIEW_DISABLED' }, { status: 403, headers: NO_STORE });
|
|
139
|
+
if (active !== null) {
|
|
140
|
+
if (active.latest !== null) return Response.json({ ok: true, ...active.latest }, { headers: NO_STORE });
|
|
141
|
+
if (active.error !== null) return Response.json({ ok: false, code: active.error }, { status: 502, headers: NO_STORE });
|
|
142
|
+
return Response.json({ ok: false, code: 'VIEW_WAITING_FOR_FRAME', machine_id: active.machineId }, { status: 202, headers: NO_STORE });
|
|
143
|
+
}
|
|
144
|
+
const started = Date.now();
|
|
145
|
+
const result = await capture({ platform });
|
|
146
|
+
if (result.ok !== true) return Response.json({ ok: false, code: 'CAPTURE_FAILED', error: result.error ?? null }, { status: 502, headers: NO_STORE });
|
|
147
|
+
return Response.json({ ok: true, mime: result.mime ?? 'image/jpeg', data_base64: result.data_base64, bytes: Math.ceil((result.data_base64?.length ?? 0) * 3 / 4), capture_ms: Date.now() - started, at: new Date().toISOString() }, { headers: NO_STORE });
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
status.mounted = true;
|
|
151
|
+
status.reason = null;
|
|
152
|
+
status.configPath = file;
|
|
153
|
+
});
|
|
154
|
+
return status;
|
|
155
|
+
}
|
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
|
|
@@ -199,6 +200,7 @@ 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
202
|
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
203
|
+
import { registerRemoteViewRoutes } from './remote-view-route.mjs';
|
|
202
204
|
import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
|
|
203
205
|
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
204
206
|
import { renderMemoryView } from './memory-view.mjs';
|
|
@@ -291,7 +293,7 @@ const MAX_STAGES = 16;
|
|
|
291
293
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
292
294
|
* overrides it at runtime for tests and packagers.
|
|
293
295
|
*/
|
|
294
|
-
const PLUGIN_VERSION = '2.
|
|
296
|
+
const PLUGIN_VERSION = '2.1.3';
|
|
295
297
|
|
|
296
298
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
297
299
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -742,6 +744,7 @@ function readConfig(config = {}) {
|
|
|
742
744
|
// silent default when a value *is* supplied.
|
|
743
745
|
const p2p = resolveP2PMode(config);
|
|
744
746
|
const p2pMode = p2p.mode;
|
|
747
|
+
const p2pPortResult = normalizeP2PPort(config.p2pPort);
|
|
745
748
|
const stun = resolveStunServers(config);
|
|
746
749
|
const stunList = stun.servers;
|
|
747
750
|
|
|
@@ -929,6 +932,8 @@ function readConfig(config = {}) {
|
|
|
929
932
|
? config.bootstrapOperatorKey
|
|
930
933
|
: (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM),
|
|
931
934
|
p2pMode,
|
|
935
|
+
p2pPort: p2pPortResult.ok ? p2pPortResult.port : null,
|
|
936
|
+
p2pPortError: p2pPortResult.ok ? null : `P2P_PORT_INVALID: ${p2pPortResult.reason}`,
|
|
932
937
|
stunServers: stunList,
|
|
933
938
|
};
|
|
934
939
|
}
|
|
@@ -2030,6 +2035,7 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
2030
2035
|
getJson: transport.getJson,
|
|
2031
2036
|
stunServers: cfg.stunServers,
|
|
2032
2037
|
log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
|
|
2038
|
+
...(Number.isInteger(cfg.p2pPort) ? { bindPort: cfg.p2pPort } : {}),
|
|
2033
2039
|
...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
|
|
2034
2040
|
...block,
|
|
2035
2041
|
});
|
|
@@ -3610,6 +3616,10 @@ export async function apply(ctx, config = {}) {
|
|
|
3610
3616
|
// renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
|
|
3611
3617
|
// depend on those permissions.
|
|
3612
3618
|
registerCaptureRoutes(ctx);
|
|
3619
|
+
const remoteViewRoutes = registerRemoteViewRoutes(ctx, { stateDir: cfg.stateDir ?? '', p2p });
|
|
3620
|
+
if (remoteViewRoutes.mounted !== true) {
|
|
3621
|
+
ctx.logger?.info?.('w2m: remote-view routes inactive — ' + remoteViewRoutes.reason);
|
|
3622
|
+
}
|
|
3613
3623
|
if (capabilityRoutes.mounted !== true) {
|
|
3614
3624
|
ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
|
|
3615
3625
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1.5 encrypted relay media broker (in-memory only).
|
|
3
|
+
*
|
|
4
|
+
* The relay never sees a JPEG or a session key:
|
|
5
|
+
* - payloads are opaque ciphertext, size-capped to fit the relay frame budget;
|
|
6
|
+
* - one newest frame per session (latest-frame-wins), no history;
|
|
7
|
+
* - every entry expires; nothing is written to disk or logged.
|
|
8
|
+
*
|
|
9
|
+
* HTTP/agent wiring is a separate slice; this module is the safety contract.
|
|
10
|
+
*/
|
|
11
|
+
export const REMOTE_MEDIA_CODE = Object.freeze({
|
|
12
|
+
OK: 'REMOTE_MEDIA_OK',
|
|
13
|
+
BAD_SESSION: 'REMOTE_MEDIA_BAD_SESSION',
|
|
14
|
+
BAD_PAYLOAD: 'REMOTE_MEDIA_BAD_PAYLOAD',
|
|
15
|
+
TOO_LARGE: 'REMOTE_MEDIA_TOO_LARGE',
|
|
16
|
+
NOT_FOUND: 'REMOTE_MEDIA_NOT_FOUND',
|
|
17
|
+
EXPIRED: 'REMOTE_MEDIA_EXPIRED',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_REMOTE_MEDIA_MAX_BYTES = 47 * 1024;
|
|
21
|
+
export const DEFAULT_REMOTE_MEDIA_TTL_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{maxBytes?: number, ttlMs?: number, now?: () => number}} [options]
|
|
27
|
+
*/
|
|
28
|
+
export function createRemoteMediaBroker(options = {}) {
|
|
29
|
+
const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : DEFAULT_REMOTE_MEDIA_MAX_BYTES;
|
|
30
|
+
const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : DEFAULT_REMOTE_MEDIA_TTL_MS;
|
|
31
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
32
|
+
const sessions = new Map();
|
|
33
|
+
|
|
34
|
+
const prune = () => {
|
|
35
|
+
const at = now();
|
|
36
|
+
for (const [id, entry] of sessions) if (entry.expiresAt <= at) sessions.delete(id);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
maxBytes,
|
|
41
|
+
ttlMs,
|
|
42
|
+
/** Open or refresh a session addressed to one target machine. */
|
|
43
|
+
open(sessionId, targetMachineId) {
|
|
44
|
+
if (typeof sessionId !== 'string' || sessionId === '' || typeof targetMachineId !== 'string' || targetMachineId === '') {
|
|
45
|
+
return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
46
|
+
}
|
|
47
|
+
sessions.set(sessionId, { target: targetMachineId, frame: null, expiresAt: now() + ttlMs });
|
|
48
|
+
return { ok: true, code: REMOTE_MEDIA_CODE.OK, session_id: sessionId, target: targetMachineId };
|
|
49
|
+
},
|
|
50
|
+
/** Store one opaque ciphertext frame (latest wins). */
|
|
51
|
+
put(sessionId, targetMachineId, ciphertext) {
|
|
52
|
+
prune();
|
|
53
|
+
const entry = sessions.get(sessionId);
|
|
54
|
+
if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
|
|
55
|
+
if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
56
|
+
if (!(ciphertext instanceof Uint8Array) || ciphertext.byteLength === 0) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
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() };
|
|
59
|
+
entry.expiresAt = now() + ttlMs;
|
|
60
|
+
return { ok: true, code: REMOTE_MEDIA_CODE.OK, bytes: ciphertext.byteLength };
|
|
61
|
+
},
|
|
62
|
+
/** Read and consume the newest frame, or report why not. */
|
|
63
|
+
take(sessionId, targetMachineId) {
|
|
64
|
+
prune();
|
|
65
|
+
const entry = sessions.get(sessionId);
|
|
66
|
+
if (entry === undefined) return { ok: false, code: REMOTE_MEDIA_CODE.EXPIRED };
|
|
67
|
+
if (entry.target !== targetMachineId) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
68
|
+
if (entry.frame === null) return { ok: false, code: REMOTE_MEDIA_CODE.NOT_FOUND };
|
|
69
|
+
const frame = entry.frame;
|
|
70
|
+
entry.frame = null;
|
|
71
|
+
return { ok: true, code: REMOTE_MEDIA_CODE.OK, ...frame };
|
|
72
|
+
},
|
|
73
|
+
close(sessionId) { sessions.delete(sessionId); },
|
|
74
|
+
stats() { prune(); return { sessions: sessions.size, maxBytes, ttlMs }; },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Validate the JSON body of a frame write without decoding the ciphertext. */
|
|
79
|
+
export function parseRemoteFrame(body) {
|
|
80
|
+
if (!isObject(body)) return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
81
|
+
if (typeof body.session_id !== 'string' || body.session_id === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
82
|
+
if (typeof body.target !== 'string' || body.target === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_SESSION };
|
|
83
|
+
if (typeof body.ct !== 'string' || body.ct === '') return { ok: false, code: REMOTE_MEDIA_CODE.BAD_PAYLOAD };
|
|
84
|
+
let ciphertext;
|
|
85
|
+
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
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -60,6 +60,7 @@ export const BLOB_FRAME_BYTES = 16 * 1024 * 1024; // 16 MiB per blob frame -- th
|
|
|
60
60
|
// BLOB_UPLOAD_FAILED even though the agent had accepted it. One number for "how large may one artifact be" is the honest shape;
|
|
61
61
|
// the JSON frame limit stays 64 KiB for every other route.
|
|
62
62
|
import { createFleetMessages } from './fleet-messages.mjs';
|
|
63
|
+
import { createRemoteMediaBroker, parseRemoteFrame } from './remote-media.mjs';
|
|
63
64
|
import { can as mayAct } from '../util/policy-matrix.mjs';
|
|
64
65
|
import { createFleetMemory } from './fleet-memory.mjs';
|
|
65
66
|
import { createJoinChallenges } from './join-challenge.mjs';
|
|
@@ -737,7 +738,10 @@ export class RelayServer {
|
|
|
737
738
|
// received bytes, so no route may consume the stream first and re-serialise.
|
|
738
739
|
/** v1.2.7: only the blob channel gets the larger frame cap; every JSON route keeps the 64 KiB frame limit. */
|
|
739
740
|
const blobFrameLimit = method === 'PUT' && path.startsWith('/v1/fleet/blobs/') ? BLOB_FRAME_BYTES : this.frameLimitBytes;
|
|
740
|
-
|
|
741
|
+
// P1.5: 47 KiB ciphertext is ~63 KiB once base64-encoded inside JSON; give this
|
|
742
|
+
// one opaque route the headroom it needs, and no other JSON route gets it.
|
|
743
|
+
const bodyFrameLimit = method === 'POST' && path === '/v1/remote/frame' ? 70 * 1024 : blobFrameLimit;
|
|
744
|
+
const rawBody = methodHasBody(method) ? await readRawBody(req, bodyFrameLimit) : null;
|
|
741
745
|
const takeJson = () => parseJsonBody(rawBody);
|
|
742
746
|
|
|
743
747
|
/* ---- public endpoints ---- */
|
|
@@ -1447,6 +1451,35 @@ export class RelayServer {
|
|
|
1447
1451
|
this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
|
|
1448
1452
|
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1449
1453
|
|
|
1454
|
+
if (path === '/v1/remote/frame') {
|
|
1455
|
+
// P1.5: opaque ciphertext only. The body is never logged, parsed as media
|
|
1456
|
+
// or persisted; the broker keeps one newest frame per session with a TTL.
|
|
1457
|
+
this.remoteMedia ??= createRemoteMediaBroker();
|
|
1458
|
+
if (method === 'POST') {
|
|
1459
|
+
const body = takeJson();
|
|
1460
|
+
const parsed = parseRemoteFrame(body);
|
|
1461
|
+
if (parsed.ok !== true) {
|
|
1462
|
+
return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: parsed.code, message: 'remote.frame refused' } });
|
|
1463
|
+
}
|
|
1464
|
+
if (parsed.target !== device.machine_id) {
|
|
1465
|
+
return sendJson(res, 403, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'REMOTE_MEDIA_BAD_SESSION', message: 'only the target machine may publish its frame' } });
|
|
1466
|
+
}
|
|
1467
|
+
const opened = this.remoteMedia.open(parsed.session_id, parsed.target);
|
|
1468
|
+
if (opened.ok !== true) return sendJson(res, 400, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: opened.code, message: 'remote.frame refused' } });
|
|
1469
|
+
const stored = this.remoteMedia.put(parsed.session_id, parsed.target, parsed.ciphertext);
|
|
1470
|
+
if (stored.ok !== true) return sendJson(res, 413, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: stored.code, message: 'remote.frame refused' } });
|
|
1471
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, bytes: stored.bytes });
|
|
1472
|
+
}
|
|
1473
|
+
if (method === 'GET') {
|
|
1474
|
+
const sessionId = url.searchParams.get('session_id');
|
|
1475
|
+
const target = url.searchParams.get('target');
|
|
1476
|
+
const taken = this.remoteMedia.take(sessionId, target);
|
|
1477
|
+
if (taken.ok !== true) return sendJson(res, 404, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: taken.code, message: 'remote.frame unavailable' } });
|
|
1478
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, ok: true, ct: taken.ciphertext.toString('base64'), bytes: taken.bytes, at_ms: taken.at });
|
|
1479
|
+
}
|
|
1480
|
+
return sendJson(res, 405, { protocol_version: PROTOCOL_VERSION, ok: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'GET or POST only' } });
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1450
1483
|
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1451
1484
|
/**
|
|
1452
1485
|
* v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and
|