@linzumi/cli 0.0.5-beta → 0.0.7-beta
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/README.md +197 -85
- package/package.json +18 -11
- package/src/authResolution.ts +2 -0
- package/src/boundedCache.ts +57 -0
- package/src/channelSession.ts +907 -453
- package/src/codexRuntimeOptions.ts +80 -0
- package/src/dependencyStatus.ts +198 -0
- package/src/forwardTunnel.ts +834 -0
- package/src/forwardTunnelProtocol.ts +324 -0
- package/src/index.ts +414 -30
- package/src/kandanTls.ts +86 -0
- package/src/localCapabilities.ts +130 -0
- package/src/localCodexMessageState.ts +135 -0
- package/src/localCodexTurnState.ts +108 -0
- package/src/localEditor.ts +963 -0
- package/src/localEditorRuntime.ts +603 -0
- package/src/localForwarding.ts +500 -0
- package/src/oauth.ts +135 -4
- package/src/pendingKandanMessageQueue.ts +109 -0
- package/src/phoenix.ts +8 -0
- package/src/portForwardApproval.ts +181 -0
- package/src/portForwardWatcher.ts +404 -0
- package/src/protocol.ts +97 -3
- package/src/runner.ts +391 -30
- package/src/streamDeltaCoalescing.ts +129 -0
- package/src/streamDeltaQueue.ts +102 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-26
|
|
3
|
+
Spec: plans/2026-04-26-local-runner-forwarding-and-editor-plan.md
|
|
4
|
+
Relationship: Implements the local side of bounded HTTP preview forwarding,
|
|
5
|
+
enforcing explicit allowed ports before the runner contacts loopback.
|
|
6
|
+
|
|
7
|
+
- Date: 2026-04-26
|
|
8
|
+
Spec: plans/2026-04-26-local-runner-subdomain-forwarding-epr.md
|
|
9
|
+
Relationship: Implements the runner side of local WebSocket forwarding for
|
|
10
|
+
isolated preview subdomains.
|
|
11
|
+
*/
|
|
12
|
+
import { gzipSync } from "node:zlib";
|
|
13
|
+
import { type JsonObject, type JsonValue, isJsonObject } from "./protocol";
|
|
14
|
+
import type { PhoenixClient } from "./phoenix";
|
|
15
|
+
|
|
16
|
+
const maxForwardBodyBytes = 64 * 1024 * 1024;
|
|
17
|
+
const gzipForwardThresholdBytes = 32 * 1024;
|
|
18
|
+
|
|
19
|
+
export type ForwardHttpRequestControl = {
|
|
20
|
+
readonly type: "forward_http_request";
|
|
21
|
+
readonly requestId: string;
|
|
22
|
+
readonly port: number;
|
|
23
|
+
readonly method: string;
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly queryString?: string;
|
|
26
|
+
readonly headers?: JsonValue;
|
|
27
|
+
readonly bodyBase64?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ForwardHttpResponsePayload = JsonObject;
|
|
31
|
+
|
|
32
|
+
type RequestBodyDecision =
|
|
33
|
+
| { readonly ok: true; readonly body: Uint8Array | undefined }
|
|
34
|
+
| { readonly ok: false; readonly error: string };
|
|
35
|
+
|
|
36
|
+
export type ForwardWebSocketOpenControl = {
|
|
37
|
+
readonly type: "forward_websocket_open";
|
|
38
|
+
readonly socketId: string;
|
|
39
|
+
readonly port: number;
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly queryString?: string;
|
|
42
|
+
readonly headers?: JsonValue;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ForwardWebSocketSendControl = {
|
|
46
|
+
readonly type: "forward_websocket_send";
|
|
47
|
+
readonly socketId: string;
|
|
48
|
+
readonly opcode: "text" | "binary" | "ping" | "pong";
|
|
49
|
+
readonly bodyBase64: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ForwardWebSocketCloseControl = {
|
|
53
|
+
readonly type: "forward_websocket_close";
|
|
54
|
+
readonly socketId: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ForwardWebSocketControl =
|
|
58
|
+
| ForwardWebSocketOpenControl
|
|
59
|
+
| ForwardWebSocketSendControl
|
|
60
|
+
| ForwardWebSocketCloseControl;
|
|
61
|
+
|
|
62
|
+
export type ForwardWebSocketManager = {
|
|
63
|
+
readonly handle: (control: ForwardWebSocketControl) => void;
|
|
64
|
+
readonly close: () => void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export async function handleForwardHttpRequest(
|
|
68
|
+
control: ForwardHttpRequestControl,
|
|
69
|
+
allowedPorts: readonly number[],
|
|
70
|
+
): Promise<ForwardHttpResponsePayload> {
|
|
71
|
+
if (!allowedPorts.includes(control.port)) {
|
|
72
|
+
return forwardError(control.requestId, "forward_port_not_allowed");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const bodyDecision = requestBody(control);
|
|
76
|
+
|
|
77
|
+
if (!bodyDecision.ok) {
|
|
78
|
+
return forwardError(control.requestId, bodyDecision.error);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const request = {
|
|
83
|
+
method: control.method,
|
|
84
|
+
headers: requestHeaders(control.headers),
|
|
85
|
+
...(bodyDecision.body === undefined ? {} : { body: bodyDecision.body }),
|
|
86
|
+
};
|
|
87
|
+
const response = await fetchWithHttpsFallback(
|
|
88
|
+
control.port,
|
|
89
|
+
control.path,
|
|
90
|
+
control.queryString,
|
|
91
|
+
request,
|
|
92
|
+
);
|
|
93
|
+
const upstreamBuffer = Buffer.from(await response.arrayBuffer());
|
|
94
|
+
const patchedBody = patchForwardBody(
|
|
95
|
+
control.path,
|
|
96
|
+
response.headers,
|
|
97
|
+
upstreamBuffer,
|
|
98
|
+
);
|
|
99
|
+
const preparedResponse = prepareResponseBodyForChannel(
|
|
100
|
+
control,
|
|
101
|
+
response.headers,
|
|
102
|
+
patchedBody,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (preparedResponse.body.byteLength > maxForwardBodyBytes) {
|
|
106
|
+
return forwardError(control.requestId, "forward_response_too_large");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
requestId: control.requestId,
|
|
111
|
+
ok: true,
|
|
112
|
+
status: response.status,
|
|
113
|
+
headers: preparedResponse.headers,
|
|
114
|
+
bodyBase64: preparedResponse.body.toString("base64"),
|
|
115
|
+
};
|
|
116
|
+
} catch (_error) {
|
|
117
|
+
return forwardError(control.requestId, "forward_target_unavailable");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isForwardHttpRequestControl(control: {
|
|
122
|
+
readonly type: string;
|
|
123
|
+
}): control is ForwardHttpRequestControl {
|
|
124
|
+
return control.type === "forward_http_request";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isForwardWebSocketControl(control: {
|
|
128
|
+
readonly type: string;
|
|
129
|
+
}): control is ForwardWebSocketControl {
|
|
130
|
+
return (
|
|
131
|
+
control.type === "forward_websocket_open" ||
|
|
132
|
+
control.type === "forward_websocket_send" ||
|
|
133
|
+
control.type === "forward_websocket_close"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createForwardWebSocketManager(
|
|
138
|
+
kandan: Pick<PhoenixClient, "push">,
|
|
139
|
+
topic: string,
|
|
140
|
+
allowedPorts: () => readonly number[],
|
|
141
|
+
): ForwardWebSocketManager {
|
|
142
|
+
const sockets = new Map<string, WebSocket>();
|
|
143
|
+
|
|
144
|
+
const pushEvent = (payload: JsonObject) =>
|
|
145
|
+
kandan
|
|
146
|
+
.push(topic, "forward:websocket_event", payload)
|
|
147
|
+
.catch(() => undefined);
|
|
148
|
+
|
|
149
|
+
const closeSocket = (socketId: string) => {
|
|
150
|
+
const socket = sockets.get(socketId);
|
|
151
|
+
sockets.delete(socketId);
|
|
152
|
+
socket?.close();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
handle: (control) => {
|
|
157
|
+
switch (control.type) {
|
|
158
|
+
case "forward_websocket_open": {
|
|
159
|
+
if (!allowedPorts().includes(control.port)) {
|
|
160
|
+
void pushEvent({
|
|
161
|
+
socketId: control.socketId,
|
|
162
|
+
type: "error",
|
|
163
|
+
error: "forward_port_not_allowed",
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
openLocalWebSocket(control, sockets, pushEvent, "ws");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
case "forward_websocket_send": {
|
|
172
|
+
const socket = sockets.get(control.socketId);
|
|
173
|
+
if (socket === undefined || socket.readyState !== WebSocket.OPEN) {
|
|
174
|
+
void pushEvent({
|
|
175
|
+
socketId: control.socketId,
|
|
176
|
+
type: "error",
|
|
177
|
+
error: "websocket_not_open",
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body = Buffer.from(control.bodyBase64, "base64");
|
|
183
|
+
switch (control.opcode) {
|
|
184
|
+
case "text":
|
|
185
|
+
socket.send(body.toString());
|
|
186
|
+
return;
|
|
187
|
+
case "binary":
|
|
188
|
+
case "ping":
|
|
189
|
+
case "pong":
|
|
190
|
+
socket.send(body);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
case "forward_websocket_close":
|
|
195
|
+
closeSocket(control.socketId);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
close: () => {
|
|
200
|
+
for (const socketId of sockets.keys()) {
|
|
201
|
+
closeSocket(socketId);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function openLocalWebSocket(
|
|
208
|
+
control: ForwardWebSocketOpenControl,
|
|
209
|
+
sockets: Map<string, WebSocket>,
|
|
210
|
+
pushEvent: (payload: JsonObject) => Promise<void | JsonValue>,
|
|
211
|
+
scheme: "ws" | "wss",
|
|
212
|
+
): void {
|
|
213
|
+
let opened = false;
|
|
214
|
+
const websocket = new WebSocket(
|
|
215
|
+
localForwardUrl(
|
|
216
|
+
scheme === "ws" ? "http" : "https",
|
|
217
|
+
control.port,
|
|
218
|
+
control.path,
|
|
219
|
+
control.queryString,
|
|
220
|
+
).replace(/^http/, scheme),
|
|
221
|
+
);
|
|
222
|
+
sockets.set(control.socketId, websocket);
|
|
223
|
+
websocket.addEventListener("open", () => {
|
|
224
|
+
opened = true;
|
|
225
|
+
void pushEvent({ socketId: control.socketId, type: "open" });
|
|
226
|
+
});
|
|
227
|
+
websocket.addEventListener("message", (event) => {
|
|
228
|
+
const body =
|
|
229
|
+
typeof event.data === "string"
|
|
230
|
+
? Buffer.from(event.data)
|
|
231
|
+
: Buffer.from(event.data as ArrayBuffer);
|
|
232
|
+
void pushEvent({
|
|
233
|
+
socketId: control.socketId,
|
|
234
|
+
type: "message",
|
|
235
|
+
opcode: typeof event.data === "string" ? "text" : "binary",
|
|
236
|
+
bodyBase64: body.toString("base64"),
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
websocket.addEventListener("close", (event) => {
|
|
240
|
+
sockets.delete(control.socketId);
|
|
241
|
+
void pushEvent({
|
|
242
|
+
socketId: control.socketId,
|
|
243
|
+
type: "close",
|
|
244
|
+
code: event.code,
|
|
245
|
+
reason: event.reason,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
websocket.addEventListener("error", () => {
|
|
249
|
+
sockets.delete(control.socketId);
|
|
250
|
+
if (!opened && scheme === "ws") {
|
|
251
|
+
openLocalWebSocket(control, sockets, pushEvent, "wss");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
void pushEvent({
|
|
256
|
+
socketId: control.socketId,
|
|
257
|
+
type: "error",
|
|
258
|
+
error: "websocket_error",
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function localForwardUrl(
|
|
264
|
+
scheme: "http" | "https",
|
|
265
|
+
port: number,
|
|
266
|
+
path: string,
|
|
267
|
+
queryString: string | undefined,
|
|
268
|
+
): string {
|
|
269
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
270
|
+
const url = new URL(`${scheme}://127.0.0.1:${port}${normalizedPath}`);
|
|
271
|
+
|
|
272
|
+
if (queryString !== undefined && queryString.trim() !== "") {
|
|
273
|
+
url.search = queryString;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return url.toString();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function fetchWithHttpsFallback(
|
|
280
|
+
port: number,
|
|
281
|
+
path: string,
|
|
282
|
+
queryString: string | undefined,
|
|
283
|
+
request: RequestInit,
|
|
284
|
+
): Promise<Response> {
|
|
285
|
+
try {
|
|
286
|
+
return await fetch(
|
|
287
|
+
localForwardUrl("http", port, path, queryString),
|
|
288
|
+
request,
|
|
289
|
+
);
|
|
290
|
+
} catch (httpError) {
|
|
291
|
+
try {
|
|
292
|
+
return await fetch(
|
|
293
|
+
localForwardUrl("https", port, path, queryString),
|
|
294
|
+
request,
|
|
295
|
+
);
|
|
296
|
+
} catch (_httpsError) {
|
|
297
|
+
throw httpError;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function requestBody(
|
|
303
|
+
control: ForwardHttpRequestControl,
|
|
304
|
+
): RequestBodyDecision {
|
|
305
|
+
if (control.method === "GET" || control.method === "HEAD") {
|
|
306
|
+
return { ok: true, body: undefined };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (control.bodyBase64 === undefined || control.bodyBase64 === "") {
|
|
310
|
+
return { ok: true, body: undefined };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const body = decodeBase64Body(control.bodyBase64);
|
|
314
|
+
|
|
315
|
+
if (body === undefined) {
|
|
316
|
+
return { ok: false, error: "invalid_forward_request" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (body.byteLength > maxForwardBodyBytes) {
|
|
320
|
+
return { ok: false, error: "forward_body_too_large" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { ok: true, body };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function decodeBase64Body(value: string): Uint8Array | undefined {
|
|
327
|
+
const normalized = value.trim();
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
normalized.length % 4 === 1 ||
|
|
331
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)
|
|
332
|
+
) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const body = Buffer.from(normalized, "base64");
|
|
337
|
+
const canonicalBody = body.toString("base64").replace(/=+$/, "");
|
|
338
|
+
const canonicalInput = normalized.replace(/=+$/, "");
|
|
339
|
+
|
|
340
|
+
return canonicalBody === canonicalInput ? body : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function requestHeaders(headers: JsonValue | undefined): Headers {
|
|
344
|
+
const result = new Headers();
|
|
345
|
+
|
|
346
|
+
if (!Array.isArray(headers)) {
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const header of headers) {
|
|
351
|
+
if (isHeader(header)) {
|
|
352
|
+
result.set(header.name, header.value);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function responseHeaders(headers: Headers): JsonValue[] {
|
|
360
|
+
return Array.from(headers.entries())
|
|
361
|
+
.filter(([name]) => !blockedForwardHeaderNames.has(name.toLowerCase()))
|
|
362
|
+
.slice(0, 40)
|
|
363
|
+
.map(([name, value]) => ({ name, value }));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function patchForwardBody(path: string, headers: Headers, body: Buffer): Buffer {
|
|
367
|
+
if (!codeServerWorkbenchScript(path, headers)) {
|
|
368
|
+
return body;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return Buffer.from(
|
|
372
|
+
body
|
|
373
|
+
.toString("utf8")
|
|
374
|
+
.replace(
|
|
375
|
+
"message:h(3783,null,\"code-server\")",
|
|
376
|
+
"message:h(3783,null,\"Kandan\")",
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function prepareResponseBodyForChannel(
|
|
382
|
+
control: ForwardHttpRequestControl,
|
|
383
|
+
headers: Headers,
|
|
384
|
+
body: Buffer,
|
|
385
|
+
): { readonly body: Buffer; readonly headers: JsonValue[] } {
|
|
386
|
+
const forwardedHeaders = responseHeaders(headers);
|
|
387
|
+
|
|
388
|
+
if (!shouldGzipResponse(control, headers, body)) {
|
|
389
|
+
return { body, headers: forwardedHeaders };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
body: gzipSync(body),
|
|
394
|
+
headers: [
|
|
395
|
+
...forwardedHeaders,
|
|
396
|
+
{ name: "content-encoding", value: "gzip" },
|
|
397
|
+
{ name: "vary", value: "accept-encoding" },
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function shouldGzipResponse(
|
|
403
|
+
control: ForwardHttpRequestControl,
|
|
404
|
+
headers: Headers,
|
|
405
|
+
body: Buffer,
|
|
406
|
+
): boolean {
|
|
407
|
+
if (
|
|
408
|
+
control.method === "HEAD" ||
|
|
409
|
+
body.byteLength < gzipForwardThresholdBytes ||
|
|
410
|
+
!clientAcceptsGzip(control.headers)
|
|
411
|
+
) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return compressibleResponse(control.path, headers);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function clientAcceptsGzip(headers: JsonValue | undefined): boolean {
|
|
419
|
+
if (!Array.isArray(headers)) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return headers.some(
|
|
424
|
+
header =>
|
|
425
|
+
isWireHeader(header) &&
|
|
426
|
+
header.name.toLowerCase() === "accept-encoding" &&
|
|
427
|
+
header.value
|
|
428
|
+
.toLowerCase()
|
|
429
|
+
.split(",")
|
|
430
|
+
.map(value => value.trim())
|
|
431
|
+
.some(value => value === "gzip" || value.startsWith("gzip;")),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function compressibleResponse(path: string, headers: Headers): boolean {
|
|
436
|
+
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
|
|
437
|
+
|
|
438
|
+
if (
|
|
439
|
+
contentType.startsWith("text/") ||
|
|
440
|
+
contentType.includes("javascript") ||
|
|
441
|
+
contentType.includes("json") ||
|
|
442
|
+
contentType.includes("xml")
|
|
443
|
+
) {
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return /\.(?:css|html|js|json|map|mjs|svg|txt)$/i.test(path);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function codeServerWorkbenchScript(path: string, headers: Headers): boolean {
|
|
451
|
+
return (
|
|
452
|
+
path.endsWith("/out/vs/code/browser/workbench/workbench.js") &&
|
|
453
|
+
(headers.get("content-type") ?? "").toLowerCase().includes("javascript")
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isHeader(
|
|
458
|
+
value: JsonValue,
|
|
459
|
+
): value is { readonly name: string; readonly value: string } {
|
|
460
|
+
return (
|
|
461
|
+
isWireHeader(value) &&
|
|
462
|
+
!blockedForwardHeaderNames.has(value.name.toLowerCase())
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isWireHeader(
|
|
467
|
+
value: JsonValue,
|
|
468
|
+
): value is { readonly name: string; readonly value: string } {
|
|
469
|
+
return (
|
|
470
|
+
isJsonObject(value) &&
|
|
471
|
+
typeof value.name === "string" &&
|
|
472
|
+
typeof value.value === "string"
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function forwardError(
|
|
477
|
+
requestId: string,
|
|
478
|
+
error: string,
|
|
479
|
+
): ForwardHttpResponsePayload {
|
|
480
|
+
return {
|
|
481
|
+
requestId,
|
|
482
|
+
ok: false,
|
|
483
|
+
error,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const blockedForwardHeaderNames = new Set([
|
|
488
|
+
"accept-encoding",
|
|
489
|
+
"connection",
|
|
490
|
+
"content-encoding",
|
|
491
|
+
"content-length",
|
|
492
|
+
"host",
|
|
493
|
+
"keep-alive",
|
|
494
|
+
"proxy-authenticate",
|
|
495
|
+
"proxy-authorization",
|
|
496
|
+
"te",
|
|
497
|
+
"trailer",
|
|
498
|
+
"transfer-encoding",
|
|
499
|
+
"upgrade",
|
|
500
|
+
]);
|
package/src/oauth.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type LocalRunnerOAuthOptions = {
|
|
|
11
11
|
readonly kandanUrl: string;
|
|
12
12
|
readonly workspaceSlug?: string | undefined;
|
|
13
13
|
readonly channelSlug?: string | undefined;
|
|
14
|
+
readonly onboarding?: "start" | undefined;
|
|
14
15
|
readonly callbackHost?: string | undefined;
|
|
15
16
|
readonly openAuthorizationUrl?: ((url: string) => Promise<void> | void) | undefined;
|
|
16
17
|
};
|
|
@@ -18,6 +19,15 @@ export type LocalRunnerOAuthOptions = {
|
|
|
18
19
|
export type LocalRunnerOAuthToken = {
|
|
19
20
|
readonly accessToken: string;
|
|
20
21
|
readonly expiresInSeconds?: number | undefined;
|
|
22
|
+
readonly workspaceSlug?: string | undefined;
|
|
23
|
+
readonly channelSlug?: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type LocalRunnerStartTarget = {
|
|
27
|
+
readonly workspaceSlug: string;
|
|
28
|
+
readonly channelSlug: string;
|
|
29
|
+
readonly workspaceName: string;
|
|
30
|
+
readonly channelName: string;
|
|
21
31
|
};
|
|
22
32
|
|
|
23
33
|
export async function acquireLocalRunnerToken(
|
|
@@ -41,6 +51,7 @@ export async function acquireLocalRunnerTokenDetails(
|
|
|
41
51
|
state,
|
|
42
52
|
workspaceSlug: options.workspaceSlug,
|
|
43
53
|
channelSlug: options.channelSlug,
|
|
54
|
+
onboarding: options.onboarding,
|
|
44
55
|
});
|
|
45
56
|
|
|
46
57
|
process.stderr.write(`Authorize the local Codex runner:\n${authorizeUrl}\n`);
|
|
@@ -103,6 +114,52 @@ export async function validateLocalRunnerToken(args: {
|
|
|
103
114
|
}
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
export async function fetchLocalRunnerStartTarget(args: {
|
|
118
|
+
readonly kandanUrl: string;
|
|
119
|
+
readonly accessToken: string;
|
|
120
|
+
}): Promise<LocalRunnerStartTarget> {
|
|
121
|
+
const response = await fetch(
|
|
122
|
+
new URL(
|
|
123
|
+
"/api/v2/local-codex-runner/onboarding/start",
|
|
124
|
+
kandanHttpBaseUrl(args.kandanUrl),
|
|
125
|
+
),
|
|
126
|
+
{
|
|
127
|
+
method: "GET",
|
|
128
|
+
headers: { authorization: `Bearer ${args.accessToken}` },
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
const body: unknown = await response.json();
|
|
132
|
+
|
|
133
|
+
if (!response.ok || typeof body !== "object" || body === null) {
|
|
134
|
+
throw new Error(`local runner start target failed with HTTP ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const workspace = "workspace" in body ? body.workspace : undefined;
|
|
138
|
+
const channel = "channel" in body ? body.channel : undefined;
|
|
139
|
+
const workspaceName = "workspace_name" in body ? body.workspace_name : undefined;
|
|
140
|
+
const channelName = "channel_name" in body ? body.channel_name : undefined;
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
typeof workspace !== "string" ||
|
|
144
|
+
workspace.trim() === "" ||
|
|
145
|
+
typeof channel !== "string" ||
|
|
146
|
+
channel.trim() === "" ||
|
|
147
|
+
typeof workspaceName !== "string" ||
|
|
148
|
+
workspaceName.trim() === "" ||
|
|
149
|
+
typeof channelName !== "string" ||
|
|
150
|
+
channelName.trim() === ""
|
|
151
|
+
) {
|
|
152
|
+
throw new Error("local runner start target response was incomplete");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
workspaceSlug: workspace,
|
|
157
|
+
channelSlug: channel,
|
|
158
|
+
workspaceName,
|
|
159
|
+
channelName,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
106
163
|
export function kandanHttpBaseUrl(kandanUrl: string): string {
|
|
107
164
|
const parsed = new URL(kandanUrl);
|
|
108
165
|
|
|
@@ -173,6 +230,7 @@ function authorizationUrl(args: {
|
|
|
173
230
|
readonly state: string;
|
|
174
231
|
readonly workspaceSlug?: string | undefined;
|
|
175
232
|
readonly channelSlug?: string | undefined;
|
|
233
|
+
readonly onboarding?: "start" | undefined;
|
|
176
234
|
}): string {
|
|
177
235
|
const url = new URL("/api/v2/local-codex-runner/oauth/authorize", args.httpBaseUrl);
|
|
178
236
|
url.searchParams.set("redirect_uri", args.redirectUri);
|
|
@@ -186,6 +244,10 @@ function authorizationUrl(args: {
|
|
|
186
244
|
url.searchParams.set("channel", args.channelSlug);
|
|
187
245
|
}
|
|
188
246
|
|
|
247
|
+
if (args.onboarding !== undefined) {
|
|
248
|
+
url.searchParams.set("onboarding", args.onboarding);
|
|
249
|
+
}
|
|
250
|
+
|
|
189
251
|
return url.toString();
|
|
190
252
|
}
|
|
191
253
|
|
|
@@ -222,9 +284,17 @@ async function exchangeCodeForToken(args: {
|
|
|
222
284
|
return {
|
|
223
285
|
accessToken: token,
|
|
224
286
|
expiresInSeconds: typeof expiresIn === "number" ? expiresIn : undefined,
|
|
287
|
+
workspaceSlug: stringBodyField(body, "workspace"),
|
|
288
|
+
channelSlug: stringBodyField(body, "channel"),
|
|
225
289
|
};
|
|
226
290
|
}
|
|
227
291
|
|
|
292
|
+
function stringBodyField(body: object, key: string): string | undefined {
|
|
293
|
+
const value = key in body ? body[key as keyof typeof body] : undefined;
|
|
294
|
+
|
|
295
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
228
298
|
function startCallbackServer(args: {
|
|
229
299
|
readonly host: string;
|
|
230
300
|
}): Promise<{
|
|
@@ -236,9 +306,11 @@ function startCallbackServer(args: {
|
|
|
236
306
|
let resolveCallback:
|
|
237
307
|
| ((value: { readonly code: string; readonly state: string }) => void)
|
|
238
308
|
| undefined;
|
|
309
|
+
let rejectCallback: ((reason?: unknown) => void) | undefined;
|
|
239
310
|
const callbackPromise = new Promise<{ readonly code: string; readonly state: string }>(
|
|
240
|
-
(callbackResolve) => {
|
|
311
|
+
(callbackResolve, callbackReject) => {
|
|
241
312
|
resolveCallback = callbackResolve;
|
|
313
|
+
rejectCallback = callbackReject;
|
|
242
314
|
},
|
|
243
315
|
);
|
|
244
316
|
|
|
@@ -249,14 +321,30 @@ function startCallbackServer(args: {
|
|
|
249
321
|
const url = new URL(request.url);
|
|
250
322
|
const code = url.searchParams.get("code");
|
|
251
323
|
const state = url.searchParams.get("state");
|
|
324
|
+
const error = url.searchParams.get("error");
|
|
325
|
+
|
|
326
|
+
if (error !== null && error.trim() !== "") {
|
|
327
|
+
rejectCallback?.(new Error(`local runner OAuth failed: ${error}`));
|
|
328
|
+
return oauthResultHtml({
|
|
329
|
+
title: "Linzumi CLI was not authorized",
|
|
330
|
+
body: "You denied the request. You can close this tab and rerun linzumi start when you are ready.",
|
|
331
|
+
status: 403,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
252
334
|
|
|
253
335
|
if (code === null || state === null || code.trim() === "" || state.trim() === "") {
|
|
254
|
-
return
|
|
336
|
+
return oauthResultHtml({
|
|
337
|
+
title: "Authorization callback was incomplete",
|
|
338
|
+
body: "Kandan did not send the local runner authorization code. Return to your terminal and try again.",
|
|
339
|
+
status: 400,
|
|
340
|
+
});
|
|
255
341
|
}
|
|
256
342
|
|
|
257
343
|
resolveCallback?.({ code, state });
|
|
258
|
-
return
|
|
259
|
-
|
|
344
|
+
return oauthResultHtml({
|
|
345
|
+
title: "Linzumi CLI is connected",
|
|
346
|
+
body: "You can close this tab and return to the terminal. Kandan will finish starting the local runner.",
|
|
347
|
+
status: 200,
|
|
260
348
|
});
|
|
261
349
|
},
|
|
262
350
|
});
|
|
@@ -271,6 +359,49 @@ function startCallbackServer(args: {
|
|
|
271
359
|
});
|
|
272
360
|
}
|
|
273
361
|
|
|
362
|
+
function oauthResultHtml(args: {
|
|
363
|
+
readonly title: string;
|
|
364
|
+
readonly body: string;
|
|
365
|
+
readonly status: number;
|
|
366
|
+
}): Response {
|
|
367
|
+
return new Response(
|
|
368
|
+
`<!doctype html>
|
|
369
|
+
<html>
|
|
370
|
+
<head>
|
|
371
|
+
<meta charset="utf-8" />
|
|
372
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
373
|
+
<title>${escapeHtml(args.title)}</title>
|
|
374
|
+
<style>
|
|
375
|
+
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
376
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f7f9; color: #13161c; }
|
|
377
|
+
main { width: min(520px, calc(100vw - 32px)); border: 1px solid #d9dde5; border-radius: 12px; background: #fff; padding: 30px; box-shadow: 0 24px 70px rgba(18, 25, 38, 0.12); }
|
|
378
|
+
h1 { margin: 0 0 10px; font-size: 24px; line-height: 1.2; }
|
|
379
|
+
p { margin: 0; color: #465160; line-height: 1.55; }
|
|
380
|
+
@media (prefers-color-scheme: dark) {
|
|
381
|
+
body { background: #0f131a; color: #f3f5f8; }
|
|
382
|
+
main { background: #171c25; border-color: #2b3442; box-shadow: 0 24px 70px rgba(0, 0, 0, 0.4); }
|
|
383
|
+
p { color: #aab4c2; }
|
|
384
|
+
}
|
|
385
|
+
</style>
|
|
386
|
+
</head>
|
|
387
|
+
<body>
|
|
388
|
+
<main>
|
|
389
|
+
<h1>${escapeHtml(args.title)}</h1>
|
|
390
|
+
<p>${escapeHtml(args.body)}</p>
|
|
391
|
+
</main>
|
|
392
|
+
</body>
|
|
393
|
+
</html>`,
|
|
394
|
+
{
|
|
395
|
+
status: args.status,
|
|
396
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function escapeHtml(value: string): string {
|
|
402
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
403
|
+
}
|
|
404
|
+
|
|
274
405
|
function openBrowser(url: string): Promise<void> {
|
|
275
406
|
const command =
|
|
276
407
|
process.platform === "darwin"
|