tinker-agent 2.9.0 → 2.10.0
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/CHANGELOG.md +41 -1
- package/README.md +17 -1
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/tools/bash-task.ts +26 -16
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +139 -154
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/event-store.ts +15 -2
- package/src/tui/remote-app.tsx +210 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { readFile, chmod, writeFile, rename, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { WebSocketOptions } from "bun";
|
|
5
|
+
import type {
|
|
6
|
+
RemoteHistoryPage,
|
|
7
|
+
RemoteMessage,
|
|
8
|
+
} from "../session/remote-history-reader";
|
|
9
|
+
import {
|
|
10
|
+
requireObject,
|
|
11
|
+
requireText,
|
|
12
|
+
type RemoteOperationInput,
|
|
13
|
+
type RemoteView,
|
|
14
|
+
type RemoteFrame,
|
|
15
|
+
type OperationReceipt,
|
|
16
|
+
type RemoteSessionInfo,
|
|
17
|
+
} from "./protocol";
|
|
18
|
+
|
|
19
|
+
export type RemoteClientConfig = {
|
|
20
|
+
url: string;
|
|
21
|
+
token: string;
|
|
22
|
+
ca?: string;
|
|
23
|
+
statePath: string;
|
|
24
|
+
};
|
|
25
|
+
export async function loadRemoteClientConfig(
|
|
26
|
+
filename: string,
|
|
27
|
+
): Promise<RemoteClientConfig> {
|
|
28
|
+
const raw = requireObject(JSON.parse(await readFile(filename, "utf8")));
|
|
29
|
+
const url = new URL(requireText(raw.url, "url", 4096));
|
|
30
|
+
if (
|
|
31
|
+
url.protocol !== "https:" ||
|
|
32
|
+
url.username ||
|
|
33
|
+
url.password ||
|
|
34
|
+
url.search ||
|
|
35
|
+
url.hash ||
|
|
36
|
+
url.pathname !== "/"
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Pairing URL must be an HTTPS origin.");
|
|
39
|
+
const token = requireText(raw.token, "token", 128);
|
|
40
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(token))
|
|
41
|
+
throw new Error("Pairing token is invalid.");
|
|
42
|
+
const ca =
|
|
43
|
+
raw.caFile === undefined
|
|
44
|
+
? undefined
|
|
45
|
+
: await readFile(
|
|
46
|
+
path.resolve(path.dirname(filename), requireText(raw.caFile, "caFile", 4096)),
|
|
47
|
+
"utf8",
|
|
48
|
+
);
|
|
49
|
+
return { url: url.origin, token, ca, statePath: `${filename}.state.json` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ClientState = {
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
workspaceId?: string;
|
|
55
|
+
outbox: RemoteOperationInput[];
|
|
56
|
+
failures: { requestId: string; message: string }[];
|
|
57
|
+
};
|
|
58
|
+
export type ClientSnapshot = {
|
|
59
|
+
connection: "connecting" | "online" | "offline" | "closed";
|
|
60
|
+
view?: RemoteView;
|
|
61
|
+
pending: number;
|
|
62
|
+
error?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function mergeRemoteMessages(
|
|
66
|
+
before: readonly RemoteMessage[],
|
|
67
|
+
after: readonly RemoteMessage[],
|
|
68
|
+
): RemoteMessage[] {
|
|
69
|
+
const all = new Map(before.map((message) => [message.id, message]));
|
|
70
|
+
for (const message of after) all.set(message.id, message);
|
|
71
|
+
return [...all.values()].sort((a, b) => a.ordinal - b.ordinal);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyRemoteFrame(
|
|
75
|
+
current: RemoteFrame | undefined,
|
|
76
|
+
frame: RemoteFrame,
|
|
77
|
+
): RemoteFrame {
|
|
78
|
+
if (frame.version !== 1) throw new Error("Unsupported remote protocol version.");
|
|
79
|
+
if (frame.type === "snapshot") return frame;
|
|
80
|
+
if (!current || current.type !== "snapshot" || frame.epoch !== current.epoch)
|
|
81
|
+
throw new Error("A full snapshot is required.");
|
|
82
|
+
if (frame.sequence <= current.sequence) return current;
|
|
83
|
+
if (frame.sequence !== current.sequence + 1)
|
|
84
|
+
throw new Error("Missing event; a full snapshot is required.");
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
type: "snapshot",
|
|
88
|
+
epoch: frame.epoch,
|
|
89
|
+
sequence: frame.sequence,
|
|
90
|
+
view: {
|
|
91
|
+
...frame.change.activity,
|
|
92
|
+
history: {
|
|
93
|
+
...current.view.history,
|
|
94
|
+
messages: mergeRemoteMessages(
|
|
95
|
+
current.view.history.messages,
|
|
96
|
+
frame.change.messages,
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Optional terminal transport. Disk outbox IDs survive a lost HTTP response/restart. */
|
|
104
|
+
export class RemoteClient {
|
|
105
|
+
private state: ClientState = { outbox: [], failures: [] };
|
|
106
|
+
private frame?: RemoteFrame;
|
|
107
|
+
private socket?: WebSocket;
|
|
108
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
109
|
+
private retryDelay = 500;
|
|
110
|
+
private closed = false;
|
|
111
|
+
private flushing = false;
|
|
112
|
+
private diskTail: Promise<void> = Promise.resolve();
|
|
113
|
+
private snapshot: ClientSnapshot = { connection: "connecting", pending: 0 };
|
|
114
|
+
private readonly listeners = new Set<() => void>();
|
|
115
|
+
constructor(readonly config: RemoteClientConfig) {}
|
|
116
|
+
async initialize(): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
this.state = JSON.parse(
|
|
119
|
+
await readFile(this.config.statePath, "utf8"),
|
|
120
|
+
) as ClientState;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
123
|
+
}
|
|
124
|
+
this.emit({ pending: this.state.outbox.length });
|
|
125
|
+
if (this.state.sessionId) this.watch(this.state.sessionId);
|
|
126
|
+
void this.flush();
|
|
127
|
+
}
|
|
128
|
+
getSnapshot = (): ClientSnapshot => this.snapshot;
|
|
129
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
130
|
+
this.listeners.add(listener);
|
|
131
|
+
return () => this.listeners.delete(listener);
|
|
132
|
+
};
|
|
133
|
+
get sessionId(): string | undefined {
|
|
134
|
+
return this.state.sessionId;
|
|
135
|
+
}
|
|
136
|
+
get workspaceId(): string | undefined {
|
|
137
|
+
return this.state.workspaceId;
|
|
138
|
+
}
|
|
139
|
+
workspaces(): Promise<{ workspaces: { id: string; name: string }[] }> {
|
|
140
|
+
return this.request("/v1/workspaces");
|
|
141
|
+
}
|
|
142
|
+
sessions(workspaceId: string): Promise<{ sessions: RemoteSessionInfo[] }> {
|
|
143
|
+
return this.request(`/v1/workspaces/${workspaceId}/sessions`);
|
|
144
|
+
}
|
|
145
|
+
operation(id: string): Promise<OperationReceipt> {
|
|
146
|
+
return this.request(`/v1/operations/${id}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async select(sessionId: string, workspaceId: string): Promise<void> {
|
|
150
|
+
this.state.sessionId = sessionId;
|
|
151
|
+
this.state.workspaceId = workspaceId;
|
|
152
|
+
await this.persist();
|
|
153
|
+
this.frame = undefined;
|
|
154
|
+
this.emit({ view: undefined });
|
|
155
|
+
this.watch(sessionId);
|
|
156
|
+
}
|
|
157
|
+
async submit(
|
|
158
|
+
input: Omit<RemoteOperationInput, "requestId"> & Record<string, unknown>,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const request = { ...input, requestId: randomUUID() } as RemoteOperationInput;
|
|
161
|
+
this.state.outbox.push(request);
|
|
162
|
+
await this.persist();
|
|
163
|
+
this.emit({ pending: this.state.outbox.length });
|
|
164
|
+
void this.flush();
|
|
165
|
+
return request.requestId;
|
|
166
|
+
}
|
|
167
|
+
async loadOlderHistory(): Promise<void> {
|
|
168
|
+
if (this.frame?.type !== "snapshot") return;
|
|
169
|
+
const id = this.state.sessionId;
|
|
170
|
+
const before = this.frame.view.history.beforeOrdinal;
|
|
171
|
+
if (!id || !before) return;
|
|
172
|
+
const page = await this.request<RemoteHistoryPage>(
|
|
173
|
+
`/v1/sessions/${id}/history?before=${before}`,
|
|
174
|
+
);
|
|
175
|
+
if (id !== this.state.sessionId || this.frame?.type !== "snapshot") return;
|
|
176
|
+
this.frame = {
|
|
177
|
+
...this.frame,
|
|
178
|
+
view: {
|
|
179
|
+
...this.frame.view,
|
|
180
|
+
history: {
|
|
181
|
+
...page,
|
|
182
|
+
messages: mergeRemoteMessages(
|
|
183
|
+
page.messages,
|
|
184
|
+
this.frame.view.history.messages,
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
this.emit({ view: this.frame.view });
|
|
190
|
+
}
|
|
191
|
+
private async flush(): Promise<void> {
|
|
192
|
+
if (this.flushing || this.closed) return;
|
|
193
|
+
this.flushing = true;
|
|
194
|
+
try {
|
|
195
|
+
while (this.state.outbox.length && !this.closed) {
|
|
196
|
+
const input = this.state.outbox[0];
|
|
197
|
+
try {
|
|
198
|
+
const receipt = await this.request<OperationReceipt>("/v1/operations", input);
|
|
199
|
+
this.state.outbox.shift();
|
|
200
|
+
await this.persist();
|
|
201
|
+
if (input.kind === "create" || input.kind === "adopt")
|
|
202
|
+
await this.select(receipt.sessionId, input.workspaceId);
|
|
203
|
+
this.emit({ pending: this.state.outbox.length, error: undefined });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.emit({
|
|
206
|
+
error: error instanceof Error ? error.message : String(error),
|
|
207
|
+
connection: "offline",
|
|
208
|
+
});
|
|
209
|
+
if (
|
|
210
|
+
error instanceof ClientHttpError &&
|
|
211
|
+
error.status >= 400 &&
|
|
212
|
+
error.status < 500 &&
|
|
213
|
+
error.status !== 429
|
|
214
|
+
) {
|
|
215
|
+
this.state.outbox.shift();
|
|
216
|
+
this.state.failures.push({
|
|
217
|
+
requestId: input.requestId,
|
|
218
|
+
message: error.message,
|
|
219
|
+
});
|
|
220
|
+
this.state.failures = this.state.failures.slice(-20);
|
|
221
|
+
await this.persist();
|
|
222
|
+
this.emit({ pending: this.state.outbox.length });
|
|
223
|
+
} else {
|
|
224
|
+
this.reconnect();
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
this.flushing = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
private watch(id: string): void {
|
|
234
|
+
this.socket?.close();
|
|
235
|
+
if (this.closed) return;
|
|
236
|
+
const url = new URL(`/v1/sessions/${id}/events`, this.config.url);
|
|
237
|
+
url.protocol = "wss:";
|
|
238
|
+
if (this.frame) {
|
|
239
|
+
url.searchParams.set("epoch", this.frame.epoch);
|
|
240
|
+
url.searchParams.set("after", String(this.frame.sequence));
|
|
241
|
+
}
|
|
242
|
+
this.emit({ connection: "connecting" });
|
|
243
|
+
const BunWebSocket = WebSocket as unknown as {
|
|
244
|
+
new (url: URL, options: WebSocketOptions): WebSocket;
|
|
245
|
+
};
|
|
246
|
+
const socket = new BunWebSocket(url, {
|
|
247
|
+
headers: { Authorization: `Bearer ${this.config.token}` },
|
|
248
|
+
...(this.config.ca
|
|
249
|
+
? { tls: { ca: this.config.ca, rejectUnauthorized: true } }
|
|
250
|
+
: {}),
|
|
251
|
+
});
|
|
252
|
+
this.socket = socket;
|
|
253
|
+
socket.onmessage = (event) => {
|
|
254
|
+
if (socket !== this.socket || this.closed) return;
|
|
255
|
+
try {
|
|
256
|
+
this.frame = applyRemoteFrame(
|
|
257
|
+
this.frame,
|
|
258
|
+
JSON.parse(String(event.data)) as RemoteFrame,
|
|
259
|
+
);
|
|
260
|
+
this.retryDelay = 500;
|
|
261
|
+
this.emit({
|
|
262
|
+
connection: "online",
|
|
263
|
+
view: this.frame.type === "snapshot" ? this.frame.view : undefined,
|
|
264
|
+
error: undefined,
|
|
265
|
+
});
|
|
266
|
+
void this.flush();
|
|
267
|
+
} catch {
|
|
268
|
+
this.frame = undefined;
|
|
269
|
+
socket.close();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
socket.onclose = () => {
|
|
273
|
+
if (socket === this.socket && !this.closed) {
|
|
274
|
+
this.emit({ connection: "offline" });
|
|
275
|
+
this.reconnect();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
socket.onerror = () => {
|
|
279
|
+
if (socket === this.socket && !this.closed) {
|
|
280
|
+
this.emit({ connection: "offline" });
|
|
281
|
+
socket.close();
|
|
282
|
+
this.reconnect();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
private reconnect(): void {
|
|
287
|
+
if (this.closed || this.reconnectTimer) return;
|
|
288
|
+
this.reconnectTimer = setTimeout(() => {
|
|
289
|
+
this.reconnectTimer = undefined;
|
|
290
|
+
if (this.state.sessionId) this.watch(this.state.sessionId);
|
|
291
|
+
void this.flush();
|
|
292
|
+
}, this.retryDelay);
|
|
293
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 10000);
|
|
294
|
+
}
|
|
295
|
+
async request<T>(route: string, input?: unknown): Promise<T> {
|
|
296
|
+
const response = await fetch(new URL(route, this.config.url), {
|
|
297
|
+
method: input === undefined ? "GET" : "POST",
|
|
298
|
+
redirect: "error",
|
|
299
|
+
headers: {
|
|
300
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
301
|
+
"Content-Type": "application/json",
|
|
302
|
+
},
|
|
303
|
+
...(input === undefined ? {} : { body: JSON.stringify(input) }),
|
|
304
|
+
...(this.config.ca
|
|
305
|
+
? { tls: { ca: this.config.ca, rejectUnauthorized: true } }
|
|
306
|
+
: {}),
|
|
307
|
+
signal: AbortSignal.timeout(15000),
|
|
308
|
+
});
|
|
309
|
+
const result = (await response.json()) as T & { error?: { message: string } };
|
|
310
|
+
if (!response.ok)
|
|
311
|
+
throw new ClientHttpError(
|
|
312
|
+
response.status,
|
|
313
|
+
result.error?.message ?? `HTTP ${response.status}`,
|
|
314
|
+
);
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
private persist(): Promise<void> {
|
|
318
|
+
const data = JSON.stringify(this.state);
|
|
319
|
+
this.diskTail = this.diskTail.then(async () => {
|
|
320
|
+
await mkdir(path.dirname(this.config.statePath), {
|
|
321
|
+
recursive: true,
|
|
322
|
+
mode: 0o700,
|
|
323
|
+
});
|
|
324
|
+
const temp = `${this.config.statePath}.${process.pid}.tmp`;
|
|
325
|
+
await writeFile(temp, data, { mode: 0o600 });
|
|
326
|
+
await chmod(temp, 0o600);
|
|
327
|
+
await rename(temp, this.config.statePath);
|
|
328
|
+
});
|
|
329
|
+
return this.diskTail;
|
|
330
|
+
}
|
|
331
|
+
private emit(patch: Partial<ClientSnapshot>): void {
|
|
332
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
333
|
+
for (const listener of this.listeners) listener();
|
|
334
|
+
}
|
|
335
|
+
async close(): Promise<void> {
|
|
336
|
+
this.closed = true;
|
|
337
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
338
|
+
this.socket?.close();
|
|
339
|
+
await this.diskTail;
|
|
340
|
+
this.emit({ connection: "closed" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
class ClientHttpError extends Error {
|
|
344
|
+
constructor(
|
|
345
|
+
readonly status: number,
|
|
346
|
+
message: string,
|
|
347
|
+
) {
|
|
348
|
+
super(message);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { requireId, requireObject, requireText } from "./protocol";
|
|
5
|
+
|
|
6
|
+
export type RemoteWorkspaceConfig = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
profile?: string;
|
|
11
|
+
};
|
|
12
|
+
export type RemoteServiceConfig = {
|
|
13
|
+
stateDirectory: string;
|
|
14
|
+
hostname: string;
|
|
15
|
+
port: number;
|
|
16
|
+
tls: { certFile: string; keyFile: string };
|
|
17
|
+
devices: { id: string; name: string; tokenSha256: string }[];
|
|
18
|
+
workspaces: RemoteWorkspaceConfig[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfig> {
|
|
22
|
+
const absolute = path.resolve(file);
|
|
23
|
+
const raw = requireObject(JSON.parse(await readFile(absolute, "utf8")));
|
|
24
|
+
if (raw.version !== 1)
|
|
25
|
+
throw new Error("Remote service configuration version must be 1.");
|
|
26
|
+
const resolve = (value: unknown, name: string) =>
|
|
27
|
+
path.resolve(path.dirname(absolute), requireText(value, name, 4096));
|
|
28
|
+
const tls = requireObject(raw.tls);
|
|
29
|
+
const port = raw.port ?? 9443;
|
|
30
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
31
|
+
throw new Error("Invalid service port.");
|
|
32
|
+
if (!Array.isArray(raw.devices) || raw.devices.length === 0)
|
|
33
|
+
throw new Error("At least one paired device is required.");
|
|
34
|
+
if (!Array.isArray(raw.workspaces) || raw.workspaces.length === 0)
|
|
35
|
+
throw new Error("At least one workspace is required.");
|
|
36
|
+
const devices = raw.devices.map((entry) => {
|
|
37
|
+
const device = requireObject(entry);
|
|
38
|
+
const tokenSha256 = requireText(device.tokenSha256, "tokenSha256", 64);
|
|
39
|
+
if (!/^[0-9a-f]{64}$/.test(tokenSha256))
|
|
40
|
+
throw new Error("Device tokenSha256 must be a lowercase SHA-256 digest.");
|
|
41
|
+
return {
|
|
42
|
+
id: requireId(device.id, "device.id"),
|
|
43
|
+
name: requireText(device.name, "device.name", 240),
|
|
44
|
+
tokenSha256,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
const workspaces: RemoteWorkspaceConfig[] = [];
|
|
48
|
+
for (const entry of raw.workspaces) {
|
|
49
|
+
const workspace = requireObject(entry);
|
|
50
|
+
const root = await realpath(resolve(workspace.path, "workspace.path"));
|
|
51
|
+
if (!(await stat(root)).isDirectory())
|
|
52
|
+
throw new Error("Workspace must be a directory.");
|
|
53
|
+
workspaces.push({
|
|
54
|
+
id: requireId(workspace.id, "workspace.id"),
|
|
55
|
+
name: requireText(workspace.name, "workspace.name", 240),
|
|
56
|
+
path: root,
|
|
57
|
+
...(workspace.profile === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { profile: requireText(workspace.profile, "workspace.profile", 240) }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
new Set(devices.map((d) => d.id)).size !== devices.length ||
|
|
64
|
+
new Set(workspaces.map((w) => w.id)).size !== workspaces.length ||
|
|
65
|
+
new Set(workspaces.map((w) => w.path)).size !== workspaces.length
|
|
66
|
+
)
|
|
67
|
+
throw new Error("Duplicate device/workspace identity.");
|
|
68
|
+
const hostname = raw.hostname ?? "127.0.0.1";
|
|
69
|
+
if (hostname !== "127.0.0.1" && hostname !== "::1")
|
|
70
|
+
throw new Error("Bind the service to loopback; expose only the relay TCP port.");
|
|
71
|
+
return {
|
|
72
|
+
stateDirectory: resolve(raw.stateDirectory, "stateDirectory"),
|
|
73
|
+
hostname,
|
|
74
|
+
port,
|
|
75
|
+
tls: {
|
|
76
|
+
certFile: resolve(tls.certFile, "tls.certFile"),
|
|
77
|
+
keyFile: resolve(tls.keyFile, "tls.keyFile"),
|
|
78
|
+
},
|
|
79
|
+
devices,
|
|
80
|
+
workspaces,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function authenticateDevice(
|
|
85
|
+
header: string | null,
|
|
86
|
+
devices: RemoteServiceConfig["devices"],
|
|
87
|
+
): string | undefined {
|
|
88
|
+
if (!header?.startsWith("Bearer ") || header.length > 256) return undefined;
|
|
89
|
+
const token = header.slice(7);
|
|
90
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(token)) return undefined;
|
|
91
|
+
const digest = createHash("sha256").update(token).digest();
|
|
92
|
+
return devices.find((device) =>
|
|
93
|
+
timingSafeEqual(digest, Buffer.from(device.tokenSha256, "hex")),
|
|
94
|
+
)?.id;
|
|
95
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { authenticateDevice, type RemoteServiceConfig } from "./config";
|
|
3
|
+
import {
|
|
4
|
+
RemoteError,
|
|
5
|
+
parseOperation,
|
|
6
|
+
requireId,
|
|
7
|
+
type RemoteCursor,
|
|
8
|
+
type RemoteFrame,
|
|
9
|
+
} from "./protocol";
|
|
10
|
+
import { RemoteService } from "./service";
|
|
11
|
+
import type { HostedSession } from "../agent/runtime-hosted-session";
|
|
12
|
+
|
|
13
|
+
type SocketData = {
|
|
14
|
+
session: HostedSession;
|
|
15
|
+
cursor?: RemoteCursor;
|
|
16
|
+
unsubscribe?: () => void;
|
|
17
|
+
device: string;
|
|
18
|
+
};
|
|
19
|
+
const SOCKET_BUFFER_LIMIT = 2 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export function startRemoteHttpServer(
|
|
22
|
+
service: RemoteService,
|
|
23
|
+
config: RemoteServiceConfig,
|
|
24
|
+
) {
|
|
25
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
26
|
+
const server = Bun.serve<SocketData>({
|
|
27
|
+
hostname: config.hostname,
|
|
28
|
+
port: config.port,
|
|
29
|
+
tls: {
|
|
30
|
+
cert: Bun.file(config.tls.certFile),
|
|
31
|
+
key: Bun.file(config.tls.keyFile),
|
|
32
|
+
},
|
|
33
|
+
maxRequestBodySize: 72 * 1024,
|
|
34
|
+
idleTimeout: 30,
|
|
35
|
+
async fetch(request, server) {
|
|
36
|
+
try {
|
|
37
|
+
// Native clients authenticate in headers. Browser-origin requests are not supported.
|
|
38
|
+
if (request.headers.has("origin"))
|
|
39
|
+
throw new RemoteError(
|
|
40
|
+
403,
|
|
41
|
+
"ORIGIN_REJECTED",
|
|
42
|
+
"Browser origins are not enabled.",
|
|
43
|
+
);
|
|
44
|
+
const device = authenticateDevice(
|
|
45
|
+
request.headers.get("authorization"),
|
|
46
|
+
config.devices,
|
|
47
|
+
);
|
|
48
|
+
if (!device)
|
|
49
|
+
throw new RemoteError(
|
|
50
|
+
401,
|
|
51
|
+
"AUTH_REQUIRED",
|
|
52
|
+
"Pair this device with the Mac before connecting.",
|
|
53
|
+
);
|
|
54
|
+
const url = new URL(request.url);
|
|
55
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
56
|
+
if (parts[0] !== "v1")
|
|
57
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown API version or route.");
|
|
58
|
+
if (request.method === "GET" && url.pathname === "/v1/workspaces") {
|
|
59
|
+
return json({
|
|
60
|
+
version: 1,
|
|
61
|
+
workspaces: service.workspaces.map(({ id, name }) => ({
|
|
62
|
+
id,
|
|
63
|
+
name,
|
|
64
|
+
})),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
request.method === "GET" &&
|
|
69
|
+
parts.length === 4 &&
|
|
70
|
+
parts[1] === "workspaces" &&
|
|
71
|
+
parts[3] === "sessions"
|
|
72
|
+
) {
|
|
73
|
+
return json({
|
|
74
|
+
sessions: await service.listSessions(requireId(parts[2], "workspaceId")),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (parts[1] === "operations") {
|
|
78
|
+
if (request.method === "POST" && parts.length === 2) {
|
|
79
|
+
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
80
|
+
throw new RemoteError(415, "JSON_REQUIRED", "Send application/json.");
|
|
81
|
+
const input = parseOperation(await request.json());
|
|
82
|
+
// Never use request.signal as a runtime cancellation signal.
|
|
83
|
+
return json(await service.submit(input, device), 202);
|
|
84
|
+
}
|
|
85
|
+
if (request.method === "GET" && parts.length === 3)
|
|
86
|
+
return json(service.store.get(requireId(parts[2], "requestId", true)));
|
|
87
|
+
}
|
|
88
|
+
if (request.method === "GET" && parts.length === 4 && parts[1] === "sessions") {
|
|
89
|
+
const session = service.session(requireId(parts[2], "sessionId", true));
|
|
90
|
+
await session.open();
|
|
91
|
+
if (parts[3] === "snapshot") return json(session.hub.snapshot());
|
|
92
|
+
if (parts[3] === "history") {
|
|
93
|
+
return json(
|
|
94
|
+
session.history(
|
|
95
|
+
optionalInteger(
|
|
96
|
+
url.searchParams.get("before"),
|
|
97
|
+
1,
|
|
98
|
+
Number.MAX_SAFE_INTEGER,
|
|
99
|
+
),
|
|
100
|
+
optionalInteger(url.searchParams.get("limit"), 1, 100),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (parts[3] === "events") {
|
|
105
|
+
if (sockets.size >= 64)
|
|
106
|
+
throw new RemoteError(
|
|
107
|
+
429,
|
|
108
|
+
"CONNECTION_LIMIT",
|
|
109
|
+
"Too many connected clients.",
|
|
110
|
+
);
|
|
111
|
+
const epoch = url.searchParams.get("epoch");
|
|
112
|
+
const sequence = optionalInteger(
|
|
113
|
+
url.searchParams.get("after"),
|
|
114
|
+
0,
|
|
115
|
+
Number.MAX_SAFE_INTEGER,
|
|
116
|
+
);
|
|
117
|
+
const cursor =
|
|
118
|
+
epoch && sequence !== undefined ? { epoch, sequence } : undefined;
|
|
119
|
+
if (server.upgrade(request, { data: { session, cursor, device } })) return;
|
|
120
|
+
throw new RemoteError(
|
|
121
|
+
400,
|
|
122
|
+
"WEBSOCKET_REQUIRED",
|
|
123
|
+
"Upgrade this request to WebSocket.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown API route.");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof RemoteError)
|
|
130
|
+
return json(
|
|
131
|
+
{ error: { code: error.code, message: error.message } },
|
|
132
|
+
error.status,
|
|
133
|
+
);
|
|
134
|
+
if (error instanceof SyntaxError)
|
|
135
|
+
return json(
|
|
136
|
+
{
|
|
137
|
+
error: {
|
|
138
|
+
code: "INVALID_JSON",
|
|
139
|
+
message: "Request body is not valid JSON.",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
400,
|
|
143
|
+
);
|
|
144
|
+
return json(
|
|
145
|
+
{
|
|
146
|
+
error: {
|
|
147
|
+
code: "LOCAL_SERVICE_ERROR",
|
|
148
|
+
message:
|
|
149
|
+
error instanceof Error ? error.message : "The local operation failed.",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
500,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
websocket: {
|
|
157
|
+
maxPayloadLength: 1024,
|
|
158
|
+
backpressureLimit: SOCKET_BUFFER_LIMIT,
|
|
159
|
+
closeOnBackpressureLimit: true,
|
|
160
|
+
idleTimeout: 60,
|
|
161
|
+
sendPings: true,
|
|
162
|
+
open(socket) {
|
|
163
|
+
sockets.add(socket);
|
|
164
|
+
const send = (frame: RemoteFrame) => {
|
|
165
|
+
try {
|
|
166
|
+
if (socket.getBufferedAmount() > SOCKET_BUFFER_LIMIT) {
|
|
167
|
+
socket.close(1013, "Reconnect to resynchronize");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const text = JSON.stringify(frame);
|
|
171
|
+
if (socket.send(text) === -1)
|
|
172
|
+
socket.close(1013, "Reconnect to resynchronize");
|
|
173
|
+
} catch {
|
|
174
|
+
socket.close(1011, "Reconnect to resynchronize");
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
socket.data.unsubscribe = socket.data.session.hub.subscribe(
|
|
178
|
+
socket.data.cursor,
|
|
179
|
+
send,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
message(socket) {
|
|
183
|
+
socket.close(1008, "Submit operations over HTTPS");
|
|
184
|
+
},
|
|
185
|
+
close(socket) {
|
|
186
|
+
sockets.delete(socket);
|
|
187
|
+
socket.data.unsubscribe?.();
|
|
188
|
+
// Detach only. The hosted runtime and its AbortController stay alive.
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
port: server.port!,
|
|
194
|
+
async stopTransport() {
|
|
195
|
+
for (const socket of sockets) {
|
|
196
|
+
socket.data.unsubscribe?.();
|
|
197
|
+
socket.terminate();
|
|
198
|
+
}
|
|
199
|
+
// Bun 1.3.14 can leave stop's promise pending after a closing TLS socket
|
|
200
|
+
// is terminated. stop(true) synchronously closes the listener/connections;
|
|
201
|
+
// bound its completion wait so runtime disposal can always proceed.
|
|
202
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
try {
|
|
204
|
+
await Promise.race([
|
|
205
|
+
server.stop(true),
|
|
206
|
+
new Promise<void>((resolve) => {
|
|
207
|
+
timer = setTimeout(resolve, 250);
|
|
208
|
+
}),
|
|
209
|
+
]);
|
|
210
|
+
} finally {
|
|
211
|
+
if (timer) clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function json(value: unknown, status = 200): Response {
|
|
218
|
+
return Response.json(value, {
|
|
219
|
+
status,
|
|
220
|
+
headers: {
|
|
221
|
+
"Cache-Control": "no-store",
|
|
222
|
+
"X-Content-Type-Options": "nosniff",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function optionalInteger(
|
|
227
|
+
value: string | null,
|
|
228
|
+
minimum: number,
|
|
229
|
+
maximum: number,
|
|
230
|
+
): number | undefined {
|
|
231
|
+
if (value === null) return undefined;
|
|
232
|
+
if (
|
|
233
|
+
!/^\d+$/.test(value) ||
|
|
234
|
+
!Number.isSafeInteger(Number(value)) ||
|
|
235
|
+
Number(value) < minimum ||
|
|
236
|
+
Number(value) > maximum
|
|
237
|
+
)
|
|
238
|
+
throw new RemoteError(400, "INVALID_CURSOR", "Invalid history or event cursor.");
|
|
239
|
+
return Number(value);
|
|
240
|
+
}
|