omp-mobile 0.4.3 → 0.5.1
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/NOTICE +3 -0
- package/README.md +5 -3
- package/package.json +3 -2
- package/src/omp-extension.mjs +90 -36
- package/src/relay-control.mjs +104 -0
- package/src/relay.mjs +368 -0
- package/src/server.mjs +138 -18
- package/src/state.mjs +36 -0
- package/src/takeover.mjs +15 -3
- package/vendor/LICENSE.dsh-remote +21 -0
- package/vendor/README.md +9 -0
- package/vendor/dsh-remote-host.mjs +20109 -0
package/src/relay.mjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon as a DSH Remote Host: it registers with a relay server (by
|
|
3
|
+
* default the public https://dsh.r2049.cn of ds-harness-remote) through
|
|
4
|
+
* account-less device pairing, and serves its own HTTP + WebSocket API to
|
|
5
|
+
* paired phones through that relay, end-to-end encrypted (Noise IK, pinned
|
|
6
|
+
* keys). The relay only forwards ciphertext; nothing of DSH runs here.
|
|
7
|
+
*
|
|
8
|
+
* Over the encrypted channel the phone speaks a small tunnel (`createTunnelRouter`):
|
|
9
|
+
* rpc `omp.http.open` {id, method, path, headers?, body? (text)} -> {status, headers}
|
|
10
|
+
* rpc `omp.ws.open` {id, path} -> {}
|
|
11
|
+
* rpc `omp.ws.send` {id, text} -> {}
|
|
12
|
+
* rpc `omp.close` {id} -> {}
|
|
13
|
+
* event `omp.data` {id, text? (a text body or WebSocket message part), data? (base64 of a binary body),
|
|
14
|
+
* more? (the WebSocket message continues), done?, error?}
|
|
15
|
+
* Requests go to the daemon's loopback tunnel listener with its token added,
|
|
16
|
+
* so a paired phone gets exactly what a phone on the tailnet gets.
|
|
17
|
+
*/
|
|
18
|
+
import { request as httpRequest } from "node:http";
|
|
19
|
+
import { StringDecoder } from "node:string_decoder";
|
|
20
|
+
import { WebSocket } from "ws";
|
|
21
|
+
import {
|
|
22
|
+
ConnectionController,
|
|
23
|
+
HostPairing,
|
|
24
|
+
HostServerApi,
|
|
25
|
+
HostServerConnection,
|
|
26
|
+
IdentityStore,
|
|
27
|
+
SafeLogger,
|
|
28
|
+
ServerCredentialStore,
|
|
29
|
+
createEvent,
|
|
30
|
+
createRpcError,
|
|
31
|
+
createRpcResponse,
|
|
32
|
+
} from "../vendor/dsh-remote-host.mjs";
|
|
33
|
+
|
|
34
|
+
/** Marks a request that came through the relay tunnel (set by the tunnel, never by the phone). */
|
|
35
|
+
export const RELAY_HEADER = "x-omp-mobile-relay";
|
|
36
|
+
/** Characters per text part and bytes per base64 part (a multiple of 3): well under the 4 MiB secure message cap. */
|
|
37
|
+
const TEXT_CHUNK_CHARS = 1024 * 1024;
|
|
38
|
+
const BINARY_PART_BYTES = 3 * 256 * 1024;
|
|
39
|
+
/** A binary answer (an image) is sent whole: at most this large. */
|
|
40
|
+
const MAX_BINARY_BODY = 32 * 1024 * 1024;
|
|
41
|
+
const MAX_HANDLES = 64;
|
|
42
|
+
const MAX_REQUEST_BODY = 16 * 1024 * 1024;
|
|
43
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
44
|
+
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
45
|
+
/** Headers the phone may pass on; the token and the relay mark are the tunnel's own. */
|
|
46
|
+
const PASSED_HEADERS = new Set(["accept", "content-type", "x-omp-mobile-probe"]);
|
|
47
|
+
|
|
48
|
+
class TunnelError extends Error {
|
|
49
|
+
constructor(code, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A daemon path the phone may ask for: absolute, no scheme or host, and never the local `/relay` control. */
|
|
56
|
+
function tunnelPath(value) {
|
|
57
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.length > 8192 || /[\s\\]/.test(value)) {
|
|
58
|
+
throw new TunnelError("INVALID_MESSAGE", "Invalid path.");
|
|
59
|
+
}
|
|
60
|
+
const url = new URL(value, "http://tunnel");
|
|
61
|
+
if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) throw new TunnelError("METHOD_NOT_ALLOWED", "Relay control is local only.");
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One phone connection's tunnel to the daemon at 127.0.0.1:`port`.
|
|
67
|
+
* @param {{port: number, token: string}} target
|
|
68
|
+
* @param {(message: object) => Promise<void>} send Encrypted delivery to that phone.
|
|
69
|
+
*/
|
|
70
|
+
export function createTunnelRouter(target, send) {
|
|
71
|
+
/** @type {Map<string, {close(): void, socket?: WebSocket}>} */
|
|
72
|
+
const handles = new Map();
|
|
73
|
+
let out = Promise.resolve();
|
|
74
|
+
/** Events go out in order; a failed send (the phone left) ends the handle. */
|
|
75
|
+
const emit = (data) => {
|
|
76
|
+
out = out.then(() => send(createEvent("omp.data", data))).catch(() => handles.get(data.id)?.close());
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
const claim = (id) => {
|
|
80
|
+
if (typeof id !== "string" || !ID.test(id)) throw new TunnelError("INVALID_MESSAGE", "Invalid handle id.");
|
|
81
|
+
if (handles.has(id)) throw new TunnelError("REQUEST_CONFLICT", "Handle already open.");
|
|
82
|
+
if (handles.size >= MAX_HANDLES) throw new TunnelError("RATE_LIMITED", "Too many open requests.");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const openHttp = (params) => {
|
|
86
|
+
claim(params.id);
|
|
87
|
+
const { id } = params;
|
|
88
|
+
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
89
|
+
if (!METHODS.has(method)) throw new TunnelError("INVALID_MESSAGE", "Invalid method.");
|
|
90
|
+
const url = tunnelPath(params.path);
|
|
91
|
+
const headers = { authorization: `Bearer ${target.token}`, [RELAY_HEADER]: "1" };
|
|
92
|
+
for (const [key, value] of Object.entries(params.headers ?? {})) {
|
|
93
|
+
if (PASSED_HEADERS.has(key.toLowerCase()) && typeof value === "string") headers[key.toLowerCase()] = value;
|
|
94
|
+
}
|
|
95
|
+
const body = typeof params.body === "string" ? Buffer.from(params.body, "utf8") : undefined;
|
|
96
|
+
if (body !== undefined && body.length > MAX_REQUEST_BODY) throw new TunnelError("FRAME_TOO_LARGE", "Request body too large.");
|
|
97
|
+
if (body !== undefined) headers["content-length"] = String(body.length);
|
|
98
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
99
|
+
let settled = false;
|
|
100
|
+
const request = httpRequest({ host: "127.0.0.1", port: target.port, method, path: `${url.pathname}${url.search}`, headers, agent: false }, (response) => {
|
|
101
|
+
settled = true;
|
|
102
|
+
const type = String(response.headers["content-type"] ?? "");
|
|
103
|
+
resolveOpen({ status: response.statusCode ?? 502, headers: { "content-type": type } });
|
|
104
|
+
let ended = false;
|
|
105
|
+
// Text (JSON, the `/events` stream) goes as text, whole characters only; anything
|
|
106
|
+
// else (an image from `/blobs`) is collected and sent as base64 in 3-byte-aligned parts,
|
|
107
|
+
// so the phone can join the parts without decoding them.
|
|
108
|
+
const text = /^(text\/|application\/json)/.test(type) ? new StringDecoder("utf8") : undefined;
|
|
109
|
+
const bytes = [];
|
|
110
|
+
let total = 0;
|
|
111
|
+
response.on("data", (chunk) => {
|
|
112
|
+
if (text === undefined) {
|
|
113
|
+
total += chunk.length;
|
|
114
|
+
if (total > MAX_BINARY_BODY) request.destroy();
|
|
115
|
+
else bytes.push(chunk);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const part = text.write(chunk);
|
|
119
|
+
if (part === "") return;
|
|
120
|
+
response.pause();
|
|
121
|
+
let sent = Promise.resolve();
|
|
122
|
+
for (let at = 0; at < part.length; at += TEXT_CHUNK_CHARS) sent = emit({ id, text: part.slice(at, at + TEXT_CHUNK_CHARS) });
|
|
123
|
+
void sent.then(() => response.resume());
|
|
124
|
+
});
|
|
125
|
+
response.on("end", () => {
|
|
126
|
+
ended = true;
|
|
127
|
+
handles.delete(id);
|
|
128
|
+
const rest = text?.end() ?? "";
|
|
129
|
+
if (rest !== "") void emit({ id, text: rest });
|
|
130
|
+
const all = Buffer.concat(bytes);
|
|
131
|
+
for (let at = 0; at < all.length; at += BINARY_PART_BYTES) void emit({ id, data: all.subarray(at, at + BINARY_PART_BYTES).toString("base64") });
|
|
132
|
+
void emit({ id, done: true });
|
|
133
|
+
});
|
|
134
|
+
response.on("close", () => {
|
|
135
|
+
if (ended || !handles.has(id)) return;
|
|
136
|
+
handles.delete(id);
|
|
137
|
+
void emit({ id, done: true, error: "closed" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
handles.set(id, { close: () => { handles.delete(id); request.destroy(); } });
|
|
141
|
+
request.on("error", () => {
|
|
142
|
+
handles.delete(id);
|
|
143
|
+
if (!settled) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", "The daemon did not answer."));
|
|
144
|
+
});
|
|
145
|
+
request.end(body);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const openWs = (params) => {
|
|
150
|
+
claim(params.id);
|
|
151
|
+
const { id } = params;
|
|
152
|
+
const url = tunnelPath(params.path);
|
|
153
|
+
url.searchParams.set("token", target.token);
|
|
154
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
155
|
+
const socket = new WebSocket(`ws://127.0.0.1:${target.port}${url.pathname}${url.search}`, {
|
|
156
|
+
headers: { [RELAY_HEADER]: "1" },
|
|
157
|
+
perMessageDeflate: false,
|
|
158
|
+
maxPayload: 256 * 1024 * 1024,
|
|
159
|
+
});
|
|
160
|
+
let opened = false;
|
|
161
|
+
handles.set(id, { socket, close: () => { handles.delete(id); socket.terminate(); } });
|
|
162
|
+
socket.on("open", () => {
|
|
163
|
+
opened = true;
|
|
164
|
+
resolveOpen({});
|
|
165
|
+
});
|
|
166
|
+
socket.on("message", (data, binary) => {
|
|
167
|
+
if (binary) return;
|
|
168
|
+
const text = data.toString("utf8");
|
|
169
|
+
socket.pause();
|
|
170
|
+
let sent = Promise.resolve();
|
|
171
|
+
const parts = Math.max(1, Math.ceil(text.length / TEXT_CHUNK_CHARS));
|
|
172
|
+
for (let index = 0; index < parts; index += 1) {
|
|
173
|
+
sent = emit({ id, text: text.slice(index * TEXT_CHUNK_CHARS, (index + 1) * TEXT_CHUNK_CHARS), more: index < parts - 1 });
|
|
174
|
+
}
|
|
175
|
+
void sent.then(() => socket.resume());
|
|
176
|
+
});
|
|
177
|
+
socket.on("close", (code) => {
|
|
178
|
+
const known = handles.delete(id);
|
|
179
|
+
if (!opened) rejectOpen(new TunnelError("HARNESS_UNAVAILABLE", `The daemon refused the socket (${code}).`));
|
|
180
|
+
else if (known) void emit({ id, done: true, code });
|
|
181
|
+
});
|
|
182
|
+
socket.on("error", () => undefined);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const call = async (method, params) => {
|
|
187
|
+
if (params === null || typeof params !== "object") throw new TunnelError("INVALID_MESSAGE", "Missing parameters.");
|
|
188
|
+
switch (method) {
|
|
189
|
+
case "omp.http.open":
|
|
190
|
+
return openHttp(params);
|
|
191
|
+
case "omp.ws.open":
|
|
192
|
+
return openWs(params);
|
|
193
|
+
case "omp.ws.send": {
|
|
194
|
+
const socket = handles.get(params.id)?.socket;
|
|
195
|
+
if (socket === undefined || typeof params.text !== "string") throw new TunnelError("CONNECTION_NOT_FOUND", "No such socket.");
|
|
196
|
+
await new Promise((resolveSend, rejectSend) => socket.send(params.text, (error) => (error ? rejectSend(new TunnelError("CONNECTION_FAILED", "Socket closed.")) : resolveSend())));
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
case "omp.close":
|
|
200
|
+
handles.get(params.id)?.close();
|
|
201
|
+
return {};
|
|
202
|
+
default:
|
|
203
|
+
throw new TunnelError("METHOD_NOT_FOUND", `Unknown method ${String(method)}.`);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
async handle(message) {
|
|
209
|
+
if (message.type !== "rpc.request") return createRpcError(message.id, "INVALID_MESSAGE", "Only RPC requests are accepted.");
|
|
210
|
+
const { method, params } = message.payload ?? {};
|
|
211
|
+
try {
|
|
212
|
+
return createRpcResponse(message.id, await call(method, params));
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const code = error instanceof TunnelError ? error.code : "HARNESS_UNAVAILABLE";
|
|
215
|
+
return createRpcError(message.id, code, error instanceof Error ? error.message : String(error));
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
closePeerStreams() {
|
|
219
|
+
for (const handle of [...handles.values()]) handle.close();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* This daemon's Host identity, pairing and relay connection on one server.
|
|
226
|
+
* State (identity key, device tokens, pinned phones, a pairing in progress)
|
|
227
|
+
* lives in `dir`, owner-only.
|
|
228
|
+
*/
|
|
229
|
+
export class RelayHost {
|
|
230
|
+
/**
|
|
231
|
+
* @param {{serverUrl: string, dir: string, name: string, version: string, port: () => number, token: string, log: (line: string) => void}} options
|
|
232
|
+
*/
|
|
233
|
+
constructor(options) {
|
|
234
|
+
this.options = options;
|
|
235
|
+
this.serverUrl = options.serverUrl.replace(/\/+$/, "");
|
|
236
|
+
this.pairing = undefined;
|
|
237
|
+
this.connection = undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async start() {
|
|
241
|
+
const { dir, name, log } = this.options;
|
|
242
|
+
this.identities = new IdentityStore({ directory: dir });
|
|
243
|
+
this.identity = await this.identities.loadOrCreate(name);
|
|
244
|
+
this.api = new HostServerApi(this.serverUrl, new ServerCredentialStore(this.identities.directory));
|
|
245
|
+
this.api.bindIdentity(this.identity);
|
|
246
|
+
const write = (level) => (line) => log(`relay ${level}: ${line}`);
|
|
247
|
+
this.logger = new SafeLogger({ debug() {}, info: write("info"), warn: write("warn"), error: write("error") });
|
|
248
|
+
this.connections = new ConnectionController(
|
|
249
|
+
this.identities,
|
|
250
|
+
(_context, send) => createTunnelRouter({ port: this.options.port(), token: this.options.token }, send),
|
|
251
|
+
this.logger,
|
|
252
|
+
);
|
|
253
|
+
if (await this.api.hasStoredAuthorization()) this.connect();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Go (back) online with the device tokens a pairing left. */
|
|
257
|
+
connect() {
|
|
258
|
+
if (this.connection !== undefined) {
|
|
259
|
+
this.connection.reconnect();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const config = {
|
|
263
|
+
serverUrl: this.serverUrl,
|
|
264
|
+
// No WebRTC on the computer: phone traffic always goes through the relay, still end-to-end encrypted.
|
|
265
|
+
forceRelay: true,
|
|
266
|
+
rotatePinnedPeerKey: false,
|
|
267
|
+
reconnect: { enabled: true, initialDelayMs: 2_000, maxDelayMs: 30_000, jitter: 0.2 },
|
|
268
|
+
};
|
|
269
|
+
this.connection = new HostServerConnection(config, this.identity, this.identities, this.api, this.connections, this.logger);
|
|
270
|
+
this.connection.start();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The pairing workflow; its state survives restarts in `dir`. */
|
|
274
|
+
hostPairing() {
|
|
275
|
+
this.pairing ??= new HostPairing(
|
|
276
|
+
this.serverUrl,
|
|
277
|
+
this.identities.directory,
|
|
278
|
+
this.identity,
|
|
279
|
+
{
|
|
280
|
+
deviceId: this.identity.deviceId,
|
|
281
|
+
name: this.identity.name,
|
|
282
|
+
role: "host",
|
|
283
|
+
platform: process.platform,
|
|
284
|
+
identityKey: this.identity.publicKey,
|
|
285
|
+
clientVersion: `omp-mobile/${this.options.version}`,
|
|
286
|
+
},
|
|
287
|
+
async (credentials) => {
|
|
288
|
+
await this.api.adoptPairing(credentials);
|
|
289
|
+
this.connect();
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
return this.pairing;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Start a pairing: the view carries `qrPayload`, the link the phone scans. */
|
|
296
|
+
async pairStart() {
|
|
297
|
+
// A finished pairing is left on disk, and asking the server about it again can fail
|
|
298
|
+
// (MEMBERSHIP_REQUIRED once its phone was revoked): drop it; an open one `start` cancels itself.
|
|
299
|
+
const previous = await this.hostPairing()
|
|
300
|
+
.status()
|
|
301
|
+
.catch(() => undefined);
|
|
302
|
+
if (previous === undefined || !["created", "claimed", "approved"].includes(previous.state)) {
|
|
303
|
+
await this.pairing.discard();
|
|
304
|
+
this.pairing = undefined;
|
|
305
|
+
}
|
|
306
|
+
return this.hostPairing().start();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Where the pairing stands (a phone claimed it: `mobile` names it); undefined when none. */
|
|
310
|
+
pairStatus() {
|
|
311
|
+
return this.hostPairing().status();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Approve the phone with the 8-digit code it shows. One attempt: a wrong code ends the pairing. */
|
|
315
|
+
pairConfirm(sessionId, code) {
|
|
316
|
+
return this.hostPairing().confirm(sessionId, code);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
pairCancel(sessionId) {
|
|
320
|
+
return this.hostPairing().cancel(sessionId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Paired phones, as the server lists them, and whether each is connected now. */
|
|
324
|
+
async phones() {
|
|
325
|
+
if (!(await this.api.hasStoredAuthorization())) return [];
|
|
326
|
+
const connected = new Set(this.connections.peerDeviceIds());
|
|
327
|
+
// `/devices` is for client devices; a host lists its phones through its memberships.
|
|
328
|
+
const { accessToken } = await this.api.authenticate();
|
|
329
|
+
const response = await fetch(`${this.serverUrl}/api/v1/device-memberships?limit=100`, { headers: { authorization: `Bearer ${accessToken}` } });
|
|
330
|
+
if (!response.ok) throw new Error(`The relay refused the phone list (HTTP ${response.status}).`);
|
|
331
|
+
const { items } = await response.json();
|
|
332
|
+
return (Array.isArray(items) ? items : []).map(({ peer }) => ({
|
|
333
|
+
deviceId: peer.deviceId,
|
|
334
|
+
name: peer.name,
|
|
335
|
+
platform: peer.platform,
|
|
336
|
+
connected: connected.has(peer.deviceId),
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Revoke one phone: the server ends its membership; its connection closes now. */
|
|
341
|
+
async revoke(deviceId) {
|
|
342
|
+
const { accessToken } = await this.api.authenticate();
|
|
343
|
+
const response = await fetch(`${this.serverUrl}/api/v1/device-memberships/${encodeURIComponent(deviceId)}`, {
|
|
344
|
+
method: "DELETE",
|
|
345
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
346
|
+
});
|
|
347
|
+
if (!response.ok && response.status !== 403) throw new Error(`The relay refused the revoke (HTTP ${response.status}).`);
|
|
348
|
+
await this.connections.revoke(deviceId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async status() {
|
|
352
|
+
// Before `start` finished there is no identity yet: not paired, not online.
|
|
353
|
+
return {
|
|
354
|
+
server: this.serverUrl,
|
|
355
|
+
deviceId: this.identity?.deviceId ?? null,
|
|
356
|
+
paired: (await this.api?.hasStoredAuthorization()) ?? false,
|
|
357
|
+
online: this.connection?.isOnline() ?? false,
|
|
358
|
+
connected: this.connections?.connectionCount() ?? 0,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async close() {
|
|
363
|
+
await this.connection?.stop().catch(() => undefined);
|
|
364
|
+
await this.connections?.close().catch(() => undefined);
|
|
365
|
+
this.pairing?.close();
|
|
366
|
+
this.api?.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { AttentionTracker, serveEvents } from "./attention.mjs";
|
|
|
21
21
|
import { listOmpSessions, oneLine, promptTitle, sessionOwners } from "./omp-sessions.mjs";
|
|
22
22
|
import { askOwnerToLeave, forksOf, isOmpCommand, processInfo, sessionBusy, takeoverFarewell, tellTerminal } from "./takeover.mjs";
|
|
23
23
|
import { TranscriptStore, deltaBody, deltaQuery } from "./transcripts.mjs";
|
|
24
|
+
import { RELAY_HEADER, RelayHost } from "./relay.mjs";
|
|
25
|
+
import { DEFAULT_RELAY, runRelayCli } from "./relay-control.mjs";
|
|
24
26
|
|
|
25
27
|
const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
26
28
|
const PROTOCOL = 1;
|
|
@@ -28,6 +30,7 @@ const DEFAULT_PORT = 7390;
|
|
|
28
30
|
const DEFAULT_TOKEN_FILE = join(homedir(), ".omp-mobile", "token");
|
|
29
31
|
const DEFAULT_REGISTRY_FILE = join(homedir(), ".omp-mobile", "sessions.json");
|
|
30
32
|
const DEFAULT_STATE_FILE = join(homedir(), ".omp-mobile", "daemon.json");
|
|
33
|
+
const DEFAULT_RELAY_DIR = join(homedir(), ".omp-mobile", "relay");
|
|
31
34
|
const DEFAULT_OMP_HOME = join(homedir(), ".omp");
|
|
32
35
|
const MAX_BODY = 256 * 1024;
|
|
33
36
|
/** Terminal sessions listed next to the daemon's own, newest first. */
|
|
@@ -58,8 +61,6 @@ const IDLE_SESSION_MS = 30 * 60_000;
|
|
|
58
61
|
const REAP_INTERVAL_MS = 60_000;
|
|
59
62
|
/** Messages a live session keeps in memory; older ones leave its front (its `base` counts them). */
|
|
60
63
|
const MAX_LIVE_MESSAGES = 5_000;
|
|
61
|
-
/** Events that are a session being used: a turn and what it streams. State and setting echoes are not. */
|
|
62
|
-
const ACTIVITY_EVENT = /^(agent_|turn_|message_|tool_execution_|auto_compaction_|auto_retry_)/;
|
|
63
64
|
/**
|
|
64
65
|
* The fields of a tool result's `details` the app reads: an edit's diff, a
|
|
65
66
|
* read's line count, a command's run time, a screenshot's file. The rest
|
|
@@ -148,6 +149,8 @@ function parseArgs(argv) {
|
|
|
148
149
|
tailscale: "tailscale",
|
|
149
150
|
whois: true,
|
|
150
151
|
qr: true,
|
|
152
|
+
relay: "",
|
|
153
|
+
relayDir: DEFAULT_RELAY_DIR,
|
|
151
154
|
};
|
|
152
155
|
for (let i = 0; i < argv.length; i += 1) {
|
|
153
156
|
const flag = argv[i];
|
|
@@ -210,6 +213,19 @@ function parseArgs(argv) {
|
|
|
210
213
|
case "--no-qr":
|
|
211
214
|
options.qr = false;
|
|
212
215
|
break;
|
|
216
|
+
case "--relay":
|
|
217
|
+
// The server is optional: `--relay` alone means the public ds-harness-remote relay.
|
|
218
|
+
if (value !== undefined && !value.startsWith("--")) {
|
|
219
|
+
options.relay = value;
|
|
220
|
+
i += 1;
|
|
221
|
+
} else {
|
|
222
|
+
options.relay = DEFAULT_RELAY;
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case "--relay-dir":
|
|
226
|
+
options.relayDir = resolve(value);
|
|
227
|
+
i += 1;
|
|
228
|
+
break;
|
|
213
229
|
case "--help":
|
|
214
230
|
case "-h":
|
|
215
231
|
printHelp();
|
|
@@ -247,6 +263,14 @@ Admission: on a non-loopback --host every request and WebSocket upgrade must nam
|
|
|
247
263
|
this node in Host (421 otherwise) and come from a tailnet device of this node's
|
|
248
264
|
user (403 otherwise), before the token is checked. Loopback hosts skip admission.
|
|
249
265
|
--no-qr Do not print the pairing QR code at startup
|
|
266
|
+
--relay [url] Also serve paired phones through a ds-harness-remote relay (default ${DEFAULT_RELAY}),
|
|
267
|
+
end-to-end encrypted; pair with \`omp-mobile relay pair\` or /mobile relay
|
|
268
|
+
--relay-dir <dir> Relay identity and device tokens (default ~/.omp-mobile/relay)
|
|
269
|
+
|
|
270
|
+
Relay commands (talk to the running daemon; same --state-file):
|
|
271
|
+
omp-mobile relay pair Show a pairing QR code, then confirm the phone's 8-digit code
|
|
272
|
+
omp-mobile relay status Relay server, connection and paired phones
|
|
273
|
+
omp-mobile relay revoke <phone device id>
|
|
250
274
|
`);
|
|
251
275
|
}
|
|
252
276
|
|
|
@@ -638,8 +662,10 @@ class SessionEntry {
|
|
|
638
662
|
|
|
639
663
|
bind() {
|
|
640
664
|
this.rpc.on("event", (event) => {
|
|
641
|
-
if (typeof event?.type === "string" && ACTIVITY_EVENT.test(event.type)) this.updatedAt = Date.now();
|
|
642
665
|
if (event.type === "message_end" && event.message) {
|
|
666
|
+
// A new message is what makes a session the most recent one; opening it,
|
|
667
|
+
// a lifecycle event from a resumed omp, or a state echo are not.
|
|
668
|
+
this.updatedAt = Date.now();
|
|
643
669
|
this.messages.push(phoneMessage(event.message, this.blobDir));
|
|
644
670
|
if (this.messages.length > MAX_LIVE_MESSAGES) {
|
|
645
671
|
// Indices stay the transcript's: a phone's `since` still lines up after the front is dropped.
|
|
@@ -746,6 +772,29 @@ class Daemon {
|
|
|
746
772
|
// the phone's WebSocket client (OkHttp) asks for permessage-deflate on its own.
|
|
747
773
|
this.wsServer = new WebSocketServer({ noServer: true, perMessageDeflate: { threshold: 1_024 } });
|
|
748
774
|
this.server.on("upgrade", (req, socket, head) => void this.admitUpgrade(req, socket, head));
|
|
775
|
+
/**
|
|
776
|
+
* Phones paired through a relay reach the daemon through this loopback-only
|
|
777
|
+
* listener (`relay.mjs` adds the token): the same API, without the tailnet admission.
|
|
778
|
+
*/
|
|
779
|
+
this.tunnelServer = undefined;
|
|
780
|
+
/** @type {RelayHost | undefined} */
|
|
781
|
+
this.relay = undefined;
|
|
782
|
+
if (options.relay) {
|
|
783
|
+
this.tunnelServer = createServer((req, res) => void this.handleHttp(req, res));
|
|
784
|
+
this.tunnelServer.on("upgrade", (req, socket, head) => {
|
|
785
|
+
socket.on("error", () => socket.destroy());
|
|
786
|
+
this.handleUpgrade(req, socket, head);
|
|
787
|
+
});
|
|
788
|
+
this.relay = new RelayHost({
|
|
789
|
+
serverUrl: options.relay,
|
|
790
|
+
dir: options.relayDir,
|
|
791
|
+
name: `omp-mobile on ${hostname().replace(/\.local$/, "")}`,
|
|
792
|
+
version: VERSION,
|
|
793
|
+
port: () => this.tunnelServer.address().port,
|
|
794
|
+
token: this.token,
|
|
795
|
+
log: (line) => this.log(line),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
749
798
|
}
|
|
750
799
|
|
|
751
800
|
baseUrl() {
|
|
@@ -828,9 +877,14 @@ class Daemon {
|
|
|
828
877
|
liveSessions: this.sessions.size,
|
|
829
878
|
eventStreams: this.streams.size,
|
|
830
879
|
lastPhone: this.lastPhone,
|
|
880
|
+
relay: this.relay === undefined ? null : await this.relay.status(),
|
|
831
881
|
});
|
|
832
882
|
return;
|
|
833
883
|
}
|
|
884
|
+
if (url.pathname === "/relay" || url.pathname.startsWith("/relay/")) {
|
|
885
|
+
await this.handleRelay(req, res, url);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
834
888
|
if (url.pathname === "/events" && req.method === "GET") {
|
|
835
889
|
const end = serveEvents(this.attention, req, res);
|
|
836
890
|
this.streams.add(end);
|
|
@@ -1125,7 +1179,7 @@ class Daemon {
|
|
|
1125
1179
|
const deadline = Date.now() + 3_000;
|
|
1126
1180
|
while ((await this.owners())?.get(id) === pid && Date.now() < deadline) await new Promise((wait) => setTimeout(wait, 100));
|
|
1127
1181
|
// Whoever looks at the terminal sees why omp closed.
|
|
1128
|
-
if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell(device));
|
|
1182
|
+
if (info?.tty !== undefined) tellTerminal(info.tty, takeoverFarewell({ device, sessionFile: stored.sessionFile }));
|
|
1129
1183
|
const forks = forksOf(dirname(stored.sessionFile), id, since);
|
|
1130
1184
|
if (forks.length > 0) this.log(new Error(`taking over ${id} from pid ${pid} left forked session files: ${forks.join(", ")}`));
|
|
1131
1185
|
outcome = { pid, via: left.via, waitedMs: left.waitedMs, busy, forks };
|
|
@@ -1253,6 +1307,8 @@ class Daemon {
|
|
|
1253
1307
|
const text = typeof message.text === "string" ? message.text : "";
|
|
1254
1308
|
if (text.trim() === "" && images.length === 0) throw new Error("empty prompt");
|
|
1255
1309
|
await entry.rpc.prompt(text, images);
|
|
1310
|
+
entry.updatedAt = Date.now();
|
|
1311
|
+
entry.onChange?.(entry);
|
|
1256
1312
|
ws.send(JSON.stringify({ type: "accepted", command: "prompt" }));
|
|
1257
1313
|
return;
|
|
1258
1314
|
}
|
|
@@ -1284,7 +1340,6 @@ class Daemon {
|
|
|
1284
1340
|
}
|
|
1285
1341
|
const state = await entry.rpc.setModel(message.provider, message.modelId);
|
|
1286
1342
|
entry.state = state;
|
|
1287
|
-
entry.updatedAt = Date.now();
|
|
1288
1343
|
entry.onChange?.(entry);
|
|
1289
1344
|
entry.broadcast({ type: "state", state });
|
|
1290
1345
|
ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
|
|
@@ -1301,7 +1356,6 @@ class Daemon {
|
|
|
1301
1356
|
if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
|
|
1302
1357
|
const state = await entry.rpc.setThinkingLevel(message.level);
|
|
1303
1358
|
entry.state = state;
|
|
1304
|
-
entry.updatedAt = Date.now();
|
|
1305
1359
|
entry.broadcast({ type: "state", state });
|
|
1306
1360
|
ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
|
|
1307
1361
|
return;
|
|
@@ -1328,17 +1382,72 @@ class Daemon {
|
|
|
1328
1382
|
*/
|
|
1329
1383
|
noteClient(req) {
|
|
1330
1384
|
if (req.headers["x-omp-mobile-probe"] !== undefined) return;
|
|
1331
|
-
|
|
1385
|
+
// Through the relay every request comes from loopback: name the path instead.
|
|
1386
|
+
const address = req.headers[RELAY_HEADER] !== undefined ? "relay" : String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/i, "");
|
|
1332
1387
|
this.lastPhone = { address, at: Date.now() };
|
|
1333
1388
|
}
|
|
1334
1389
|
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1390
|
+
/**
|
|
1391
|
+
* Relay pairing and phones, for `omp-mobile relay` and `/mobile relay` on this
|
|
1392
|
+
* computer; never through the relay itself (a paired phone cannot pair or revoke).
|
|
1393
|
+
*/
|
|
1394
|
+
async handleRelay(req, res, url) {
|
|
1395
|
+
if (req.headers[RELAY_HEADER] !== undefined) {
|
|
1396
|
+
sendJson(res, 403, { error: "relay control is local only" });
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (this.relay === undefined) {
|
|
1400
|
+
sendJson(res, 409, { error: "the daemon runs without --relay" });
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
if (url.pathname === "/relay" && req.method === "GET") {
|
|
1405
|
+
sendJson(res, 200, { ...(await this.relay.status()), phones: await this.relay.phones() });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (url.pathname === "/relay/pair" && req.method === "POST") {
|
|
1409
|
+
sendJson(res, 200, await this.relay.pairStart());
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (url.pathname === "/relay/pair" && req.method === "GET") {
|
|
1413
|
+
sendJson(res, 200, { pairing: (await this.relay.pairStatus()) ?? null });
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if ((url.pathname === "/relay/pair/confirm" || url.pathname === "/relay/pair/cancel") && req.method === "POST") {
|
|
1417
|
+
const body = await readBody(req);
|
|
1418
|
+
if (typeof body.sessionId !== "string") {
|
|
1419
|
+
sendJson(res, 400, { error: "sessionId is required" });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const confirm = url.pathname.endsWith("/confirm");
|
|
1423
|
+
if (confirm && (typeof body.code !== "string" || !/^\d{8}$/.test(body.code))) {
|
|
1424
|
+
sendJson(res, 400, { error: "the code is 8 digits" });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
sendJson(res, 200, confirm ? await this.relay.pairConfirm(body.sessionId, body.code) : await this.relay.pairCancel(body.sessionId));
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const phone = /^\/relay\/phones\/([^/]+)$/.exec(url.pathname);
|
|
1431
|
+
if (phone !== null && req.method === "DELETE") {
|
|
1432
|
+
await this.relay.revoke(decodeURIComponent(phone[1]));
|
|
1433
|
+
sendJson(res, 200, { ok: true });
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
sendJson(res, 404, { error: "not found" });
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
// The relay's own refusals carry a code (PAIRING_EXPIRED, PAIRING_ATTEMPT_USED, ...): pass it on.
|
|
1439
|
+
const code = typeof error?.code === "string" ? error.code : undefined;
|
|
1440
|
+
sendJson(res, 502, { error: error instanceof Error ? error.message : String(error), ...(code === undefined ? {} : { code }) });
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async listen() {
|
|
1445
|
+
await new Promise((resolveListen) => this.server.listen(this.options.port, this.options.host, () => resolveListen()));
|
|
1446
|
+
if (this.relay !== undefined) {
|
|
1447
|
+
await new Promise((resolveListen) => this.tunnelServer.listen(0, "127.0.0.1", () => resolveListen()));
|
|
1448
|
+
await this.relay.start();
|
|
1449
|
+
}
|
|
1450
|
+
this.startTimers();
|
|
1342
1451
|
}
|
|
1343
1452
|
|
|
1344
1453
|
/** Keepalive pings and the idle-session reaper, once the daemon listens; old stashed images go. */
|
|
@@ -1395,7 +1504,9 @@ class Daemon {
|
|
|
1395
1504
|
const outcomes = await Promise.all(closing);
|
|
1396
1505
|
const stuck = outcomes.filter((outcome) => outcome === "running").length;
|
|
1397
1506
|
if (stuck > 0) console.error(`omp-mobile: ${stuck} omp process(es) ignored SIGINT and SIGTERM and are still running`);
|
|
1507
|
+
await this.relay?.close();
|
|
1398
1508
|
this.wsServer.close();
|
|
1509
|
+
this.tunnelServer?.close();
|
|
1399
1510
|
await new Promise((resolveClose) => this.server.close(() => resolveClose()));
|
|
1400
1511
|
}
|
|
1401
1512
|
}
|
|
@@ -1473,6 +1584,10 @@ function clearState(path) {
|
|
|
1473
1584
|
}
|
|
1474
1585
|
|
|
1475
1586
|
async function main() {
|
|
1587
|
+
if (process.argv[2] === "relay") {
|
|
1588
|
+
await runRelayCli(process.argv.slice(3));
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1476
1591
|
const options = parseArgs(process.argv.slice(2));
|
|
1477
1592
|
if (options.host === "tailscale") options.host = await tailscaleHost(options);
|
|
1478
1593
|
const token = loadToken(options);
|
|
@@ -1481,21 +1596,26 @@ async function main() {
|
|
|
1481
1596
|
await daemon.listen();
|
|
1482
1597
|
const url = daemon.baseUrl();
|
|
1483
1598
|
const pair = `omp-mobile://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
|
|
1484
|
-
writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, startedAt: daemon.startedAt });
|
|
1599
|
+
writeState(options.stateFile, { pid: process.pid, version: VERSION, url, pair, relay: options.relay || null, startedAt: daemon.startedAt });
|
|
1485
1600
|
console.log(`omp-mobile daemon ${VERSION}`);
|
|
1486
1601
|
console.log(` listening ${url}`);
|
|
1487
1602
|
console.log(` cwd ${options.cwd}`);
|
|
1488
1603
|
for (const line of identity.notes) (line.includes("WARNING") ? console.warn : console.log)(line);
|
|
1489
1604
|
console.log(` token ${token}`);
|
|
1490
1605
|
console.log(` pair ${pair}`);
|
|
1491
|
-
if (
|
|
1606
|
+
if (daemon.relay !== undefined) {
|
|
1607
|
+
const relay = await daemon.relay.status();
|
|
1608
|
+
console.log(` relay ${relay.server} · ${relay.paired ? "paired" : "no phone paired yet"} · pair a phone: omp-mobile relay pair`);
|
|
1609
|
+
}
|
|
1610
|
+
const tailnet = isTailnetAddress(options.host);
|
|
1611
|
+
if (options.qr && (tailnet || daemon.relay === undefined)) {
|
|
1492
1612
|
console.log("");
|
|
1493
1613
|
console.log(" Scan with OMP on your phone (Machines > Scan to pair):");
|
|
1494
1614
|
console.log(renderQr(pair));
|
|
1495
1615
|
// The app accepts only tailnet addresses from a code; say so before the user tries.
|
|
1496
|
-
if (!
|
|
1616
|
+
if (!tailnet) {
|
|
1497
1617
|
console.log(` note: ${options.host} is not a Tailscale address, so the app will refuse this code.`);
|
|
1498
|
-
console.log(" Start with --host <this computer's Tailscale IP
|
|
1618
|
+
console.log(" Start with --host <this computer's Tailscale IP>, or --relay, to pair a phone.");
|
|
1499
1619
|
}
|
|
1500
1620
|
}
|
|
1501
1621
|
const shutdown = async () => {
|