@linzumi/cli 0.0.19-beta → 0.0.22-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 +70 -69
- package/bin/linzumi.js +10 -18
- package/dist/assets/linzumi-logo.svg +1 -0
- package/dist/index.js +9135 -0
- package/package.json +9 -4
- package/src/agentBootstrap.ts +0 -872
- package/src/authCache.ts +0 -157
- package/src/authResolution.ts +0 -77
- package/src/boundedCache.ts +0 -57
- package/src/channelSession.ts +0 -4301
- package/src/channelSessionSupport.ts +0 -308
- package/src/codexAppServer.ts +0 -380
- package/src/codexOutput.ts +0 -846
- package/src/codexRuntimeOptions.ts +0 -80
- package/src/dependencyStatus.ts +0 -198
- package/src/forwardTunnel.ts +0 -859
- package/src/forwardTunnelProtocol.ts +0 -324
- package/src/index.ts +0 -1079
- package/src/json.ts +0 -49
- package/src/kandanQueue.ts +0 -113
- package/src/kandanTls.ts +0 -86
- package/src/localCapabilities.ts +0 -143
- package/src/localCodexMessageState.ts +0 -135
- package/src/localCodexTurnState.ts +0 -108
- package/src/localConfig.ts +0 -99
- package/src/localEditor.ts +0 -1061
- package/src/localEditorRuntime.ts +0 -717
- package/src/localForwarding.ts +0 -523
- package/src/oauth.ts +0 -425
- package/src/pendingKandanMessageQueue.ts +0 -109
- package/src/phoenix.ts +0 -359
- package/src/portForwardApproval.ts +0 -181
- package/src/portForwardWatcher.ts +0 -404
- package/src/protocol.ts +0 -321
- package/src/runner.ts +0 -943
- package/src/runnerConsoleReporter.ts +0 -142
- package/src/runnerLogger.ts +0 -50
- package/src/streamDeltaCoalescing.ts +0 -129
- package/src/streamDeltaQueue.ts +0 -102
package/src/phoenix.ts
DELETED
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
- Date: 2026-04-24
|
|
3
|
-
Spec: plans/2026-04-24-local-codex-runner-plan.md
|
|
4
|
-
Relationship: Implements the spec's runner-to-Kandan Phoenix websocket
|
|
5
|
-
transport for structured runner events and server control pushes.
|
|
6
|
-
|
|
7
|
-
- Date: 2026-04-24
|
|
8
|
-
Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
|
|
9
|
-
Relationship: Exposes server-pushed chat events to the channel-bound runner
|
|
10
|
-
so Kandan thread replies can drive the local Codex session.
|
|
11
|
-
|
|
12
|
-
- Date: 2026-04-24
|
|
13
|
-
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
14
|
-
Relationship: Treats the Kandan websocket as a reconnectable transport so
|
|
15
|
-
server restarts do not terminate the durable local Codex session.
|
|
16
|
-
|
|
17
|
-
- Date: 2026-04-26
|
|
18
|
-
Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
|
|
19
|
-
Relationship: Treats non-ok Phoenix replies for runner pushes as transport
|
|
20
|
-
failures so failed Kandan writes cannot be mistaken for synchronized state.
|
|
21
|
-
*/
|
|
22
|
-
import {
|
|
23
|
-
type JsonObject,
|
|
24
|
-
type JsonValue,
|
|
25
|
-
type KandanControl,
|
|
26
|
-
type PhoenixFrame,
|
|
27
|
-
isJsonObject
|
|
28
|
-
} from "./protocol";
|
|
29
|
-
|
|
30
|
-
type PendingPush = {
|
|
31
|
-
readonly event: string;
|
|
32
|
-
readonly resolve: (payload: JsonValue) => void;
|
|
33
|
-
readonly reject: (error: Error) => void;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
type JoinRegistration = {
|
|
37
|
-
readonly payload: () => JsonObject;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type PhoenixClient = {
|
|
41
|
-
readonly join: (
|
|
42
|
-
topic: string,
|
|
43
|
-
payload: JsonObject,
|
|
44
|
-
options?: { readonly rejoinPayload?: (() => JsonObject) | undefined }
|
|
45
|
-
) => Promise<JsonObject>;
|
|
46
|
-
readonly push: (topic: string, event: string, payload: JsonObject) => Promise<JsonValue>;
|
|
47
|
-
readonly onControl: (callback: (control: KandanControl) => void) => void;
|
|
48
|
-
readonly onEvent: (
|
|
49
|
-
callback: (topic: string, event: string, payload: JsonValue) => void
|
|
50
|
-
) => void;
|
|
51
|
-
readonly onReconnect: (callback: () => void | Promise<void>) => void;
|
|
52
|
-
readonly close: () => void;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export function phoenixWebsocketUrl(baseUrl: string, token: string): string {
|
|
56
|
-
const parsed = new URL(baseUrl);
|
|
57
|
-
switch (parsed.protocol) {
|
|
58
|
-
case "http:":
|
|
59
|
-
parsed.protocol = "ws:";
|
|
60
|
-
break;
|
|
61
|
-
case "https:":
|
|
62
|
-
parsed.protocol = "wss:";
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
parsed.pathname = parsed.pathname.replace(/\/$/, "") + "/socket/websocket";
|
|
66
|
-
parsed.searchParams.set("token", token);
|
|
67
|
-
parsed.searchParams.set("vsn", "2.0.0");
|
|
68
|
-
return parsed.toString();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export async function connectPhoenixClient(
|
|
72
|
-
baseUrl: string,
|
|
73
|
-
token: string,
|
|
74
|
-
socketFactory: (url: string) => WebSocket = url => new WebSocket(url)
|
|
75
|
-
): Promise<PhoenixClient> {
|
|
76
|
-
const pending = new Map<string, PendingPush>();
|
|
77
|
-
const joins = new Map<string, JoinRegistration>();
|
|
78
|
-
const controlCallbacks = new Set<(control: KandanControl) => void>();
|
|
79
|
-
const eventCallbacks = new Set<(topic: string, event: string, payload: JsonValue) => void>();
|
|
80
|
-
const reconnectCallbacks = new Set<() => void | Promise<void>>();
|
|
81
|
-
const state: {
|
|
82
|
-
nextRef: number;
|
|
83
|
-
websocket: WebSocket | undefined;
|
|
84
|
-
closed: boolean;
|
|
85
|
-
connected: boolean;
|
|
86
|
-
hasEverConnected: boolean;
|
|
87
|
-
connectionGeneration: number;
|
|
88
|
-
ready: Promise<void>;
|
|
89
|
-
resolveReady: (() => void) | undefined;
|
|
90
|
-
rejectReady: ((error: Error) => void) | undefined;
|
|
91
|
-
reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
-
} = {
|
|
93
|
-
nextRef: 1,
|
|
94
|
-
websocket: undefined,
|
|
95
|
-
closed: false,
|
|
96
|
-
connected: false,
|
|
97
|
-
hasEverConnected: false,
|
|
98
|
-
connectionGeneration: 0,
|
|
99
|
-
ready: Promise.resolve(),
|
|
100
|
-
resolveReady: undefined,
|
|
101
|
-
rejectReady: undefined,
|
|
102
|
-
reconnectTimer: undefined,
|
|
103
|
-
};
|
|
104
|
-
const rejectPending = (message: string) => {
|
|
105
|
-
const error = new Error(message);
|
|
106
|
-
pending.forEach(pendingPush => pendingPush.reject(error));
|
|
107
|
-
pending.clear();
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const resetReady = () => {
|
|
111
|
-
state.ready = new Promise((resolve, reject) => {
|
|
112
|
-
state.resolveReady = resolve;
|
|
113
|
-
state.rejectReady = reject;
|
|
114
|
-
});
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const handleMessage = (event: MessageEvent) => {
|
|
118
|
-
const frame = decodeFrame(String(event.data));
|
|
119
|
-
const [, ref, topic, name, payload] = frame;
|
|
120
|
-
|
|
121
|
-
if (ref !== null && (name === "phx_reply" || name === "phx_error")) {
|
|
122
|
-
const pendingPush = pending.get(ref);
|
|
123
|
-
|
|
124
|
-
if (pendingPush !== undefined) {
|
|
125
|
-
pending.delete(ref);
|
|
126
|
-
|
|
127
|
-
if (name === "phx_error") {
|
|
128
|
-
pendingPush.reject(new Error("phoenix push failed"));
|
|
129
|
-
} else if (isNonOkPushReply(payload) && pendingPush.event !== "phx_join") {
|
|
130
|
-
pendingPush.reject(new Error(`phoenix push failed: ${replyErrorMessage(payload)}`));
|
|
131
|
-
} else {
|
|
132
|
-
pendingPush.resolve(payload);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (topic.startsWith("local_runner:") && name === "control" && isKandanControl(payload)) {
|
|
138
|
-
controlCallbacks.forEach(callback => callback(payload));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (ref === null && name !== "phx_reply" && name !== "phx_error") {
|
|
142
|
-
eventCallbacks.forEach(callback => callback(topic, name, payload));
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const pushOnOpenSocket = (
|
|
147
|
-
topic: string,
|
|
148
|
-
event: string,
|
|
149
|
-
payload: JsonObject
|
|
150
|
-
): Promise<JsonValue> => {
|
|
151
|
-
const websocket = state.websocket;
|
|
152
|
-
|
|
153
|
-
if (websocket === undefined || websocket.readyState !== WebSocket.OPEN) {
|
|
154
|
-
return Promise.reject(new Error("phoenix websocket is not open"));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const ref = String(state.nextRef);
|
|
158
|
-
state.nextRef += 1;
|
|
159
|
-
const frame: PhoenixFrame = [null, ref, topic, event, payload];
|
|
160
|
-
|
|
161
|
-
return new Promise((resolve, reject) => {
|
|
162
|
-
pending.set(ref, { event, resolve, reject });
|
|
163
|
-
websocket.send(JSON.stringify(frame));
|
|
164
|
-
});
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const replayJoins = async () => {
|
|
168
|
-
for (const [topic, registration] of joins) {
|
|
169
|
-
const reply = await pushOnOpenSocket(topic, "phx_join", registration.payload());
|
|
170
|
-
|
|
171
|
-
if (!isJoinReply(reply)) {
|
|
172
|
-
throw new Error(`phoenix rejoin failed for ${topic}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const scheduleReconnect = () => {
|
|
178
|
-
if (state.closed || state.reconnectTimer !== undefined) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
resetReady();
|
|
183
|
-
state.reconnectTimer = setTimeout(() => {
|
|
184
|
-
state.reconnectTimer = undefined;
|
|
185
|
-
void openSocket();
|
|
186
|
-
}, 250);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const handleDisconnect = (message: string) => {
|
|
190
|
-
state.connected = false;
|
|
191
|
-
rejectPending(message);
|
|
192
|
-
scheduleReconnect();
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
const openSocket = async (): Promise<void> => {
|
|
196
|
-
if (state.closed) {
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const websocket = socketFactory(phoenixWebsocketUrl(baseUrl, token));
|
|
201
|
-
state.websocket = websocket;
|
|
202
|
-
websocket.addEventListener("message", handleMessage);
|
|
203
|
-
websocket.addEventListener(
|
|
204
|
-
"close",
|
|
205
|
-
() => {
|
|
206
|
-
if (state.websocket === websocket) {
|
|
207
|
-
handleDisconnect("phoenix websocket closed");
|
|
208
|
-
}
|
|
209
|
-
},
|
|
210
|
-
{ once: true },
|
|
211
|
-
);
|
|
212
|
-
websocket.addEventListener(
|
|
213
|
-
"error",
|
|
214
|
-
() => {
|
|
215
|
-
if (state.websocket === websocket) {
|
|
216
|
-
handleDisconnect("phoenix websocket error");
|
|
217
|
-
}
|
|
218
|
-
},
|
|
219
|
-
{ once: true },
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
await waitForOpen(websocket);
|
|
224
|
-
state.connectionGeneration += 1;
|
|
225
|
-
await replayJoins();
|
|
226
|
-
state.connected = true;
|
|
227
|
-
state.hasEverConnected = true;
|
|
228
|
-
state.resolveReady?.();
|
|
229
|
-
|
|
230
|
-
if (state.connectionGeneration > 1) {
|
|
231
|
-
reconnectCallbacks.forEach(callback => {
|
|
232
|
-
void Promise.resolve(callback()).catch(() => undefined);
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
} catch (error) {
|
|
236
|
-
if (!state.closed) {
|
|
237
|
-
if (!state.hasEverConnected) {
|
|
238
|
-
state.rejectReady?.(error instanceof Error ? error : new Error(String(error)));
|
|
239
|
-
}
|
|
240
|
-
handleDisconnect("phoenix websocket reconnect failed");
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
resetReady();
|
|
246
|
-
void openSocket();
|
|
247
|
-
await state.ready;
|
|
248
|
-
|
|
249
|
-
const push = async (
|
|
250
|
-
topic: string,
|
|
251
|
-
event: string,
|
|
252
|
-
payload: JsonObject
|
|
253
|
-
): Promise<JsonValue> => {
|
|
254
|
-
await state.ready;
|
|
255
|
-
return pushOnOpenSocket(topic, event, payload);
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
return {
|
|
259
|
-
join: async (topic, payload, options) => {
|
|
260
|
-
const reply = await push(topic, "phx_join", payload);
|
|
261
|
-
|
|
262
|
-
if (isJoinReply(reply)) {
|
|
263
|
-
joins.set(topic, { payload: options?.rejoinPayload ?? (() => payload) });
|
|
264
|
-
return reply.response;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
throw new Error(`phoenix join failed: ${joinErrorMessage(reply)}`);
|
|
268
|
-
},
|
|
269
|
-
push,
|
|
270
|
-
onControl: callback => {
|
|
271
|
-
controlCallbacks.add(callback);
|
|
272
|
-
},
|
|
273
|
-
onEvent: callback => {
|
|
274
|
-
eventCallbacks.add(callback);
|
|
275
|
-
},
|
|
276
|
-
onReconnect: callback => {
|
|
277
|
-
reconnectCallbacks.add(callback);
|
|
278
|
-
},
|
|
279
|
-
close: () => {
|
|
280
|
-
state.closed = true;
|
|
281
|
-
if (state.reconnectTimer !== undefined) {
|
|
282
|
-
clearTimeout(state.reconnectTimer);
|
|
283
|
-
}
|
|
284
|
-
state.websocket?.close();
|
|
285
|
-
rejectPending("phoenix websocket closed");
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export function encodeFrame(frame: PhoenixFrame): string {
|
|
291
|
-
return JSON.stringify(frame);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export function decodeFrame(text: string): PhoenixFrame {
|
|
295
|
-
const parsed = JSON.parse(text);
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
Array.isArray(parsed) &&
|
|
299
|
-
parsed.length === 5 &&
|
|
300
|
-
(parsed[0] === null || typeof parsed[0] === "string") &&
|
|
301
|
-
(parsed[1] === null || typeof parsed[1] === "string") &&
|
|
302
|
-
typeof parsed[2] === "string" &&
|
|
303
|
-
typeof parsed[3] === "string"
|
|
304
|
-
) {
|
|
305
|
-
return parsed as unknown as PhoenixFrame;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
throw new Error("invalid Phoenix frame");
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function isJoinReply(value: JsonValue): value is {
|
|
312
|
-
readonly status: "ok";
|
|
313
|
-
readonly response: JsonObject;
|
|
314
|
-
} {
|
|
315
|
-
return (
|
|
316
|
-
isJsonObject(value) &&
|
|
317
|
-
value.status === "ok" &&
|
|
318
|
-
isJsonObject(value.response)
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function joinErrorMessage(value: JsonValue): string {
|
|
323
|
-
return replyErrorMessage(value);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function replyErrorMessage(value: JsonValue): string {
|
|
327
|
-
if (!isJsonObject(value)) {
|
|
328
|
-
return "invalid reply";
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const response = value.response;
|
|
332
|
-
|
|
333
|
-
if (isJsonObject(response) && typeof response.error === "string") {
|
|
334
|
-
return response.error;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (typeof value.error === "string") {
|
|
338
|
-
return value.error;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return "unknown";
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function isNonOkPushReply(value: JsonValue): boolean {
|
|
345
|
-
return isJsonObject(value) && value.status !== "ok";
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function isKandanControl(value: JsonValue): value is KandanControl {
|
|
349
|
-
return isJsonObject(value) && typeof value.type === "string";
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function waitForOpen(websocket: WebSocket): Promise<void> {
|
|
353
|
-
return new Promise((resolve, reject) => {
|
|
354
|
-
websocket.addEventListener("open", () => resolve(), { once: true });
|
|
355
|
-
websocket.addEventListener("error", () => reject(new Error("websocket open failed")), {
|
|
356
|
-
once: true
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
- Date: 2026-04-27
|
|
3
|
-
Spec: plans/2026-04-26-local-runner-subdomain-forwarding-epr.md
|
|
4
|
-
Relationship: Keeps local runner port-forward approval and revocation policy
|
|
5
|
-
pure so security decisions are tested directly instead of hiding inside the
|
|
6
|
-
channel-session orchestration module.
|
|
7
|
-
*/
|
|
8
|
-
import { type JsonObject } from "./protocol";
|
|
9
|
-
import {
|
|
10
|
-
commandLabel,
|
|
11
|
-
sameForwardCandidate,
|
|
12
|
-
type PortForwardCandidate,
|
|
13
|
-
} from "./portForwardWatcher";
|
|
14
|
-
|
|
15
|
-
export type PendingPortForwardRequest = {
|
|
16
|
-
readonly requestId: string;
|
|
17
|
-
readonly port: number;
|
|
18
|
-
readonly pid: number;
|
|
19
|
-
readonly command: string;
|
|
20
|
-
readonly cwd?: string | undefined;
|
|
21
|
-
readonly sourceSeq: number;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export type PortForwardCandidateReview =
|
|
25
|
-
| {
|
|
26
|
-
readonly type: "skip";
|
|
27
|
-
readonly reason:
|
|
28
|
-
| "thread_not_bound"
|
|
29
|
-
| "suppressed_port"
|
|
30
|
-
| "already_pending"
|
|
31
|
-
| "already_approved"
|
|
32
|
-
| "recently_dismissed";
|
|
33
|
-
}
|
|
34
|
-
| { readonly type: "remember_approved_target"; readonly target: PortForwardCandidate }
|
|
35
|
-
| {
|
|
36
|
-
readonly type: "revoke_and_prompt";
|
|
37
|
-
readonly revoked: PortForwardCandidate;
|
|
38
|
-
readonly candidate: PortForwardCandidate;
|
|
39
|
-
readonly reason: "listener_changed";
|
|
40
|
-
}
|
|
41
|
-
| { readonly type: "prompt"; readonly candidate: PortForwardCandidate };
|
|
42
|
-
|
|
43
|
-
export function reviewPortForwardCandidate(options: {
|
|
44
|
-
readonly candidate: PortForwardCandidate;
|
|
45
|
-
readonly threadBound: boolean;
|
|
46
|
-
readonly suppressedPorts?: ReadonlySet<number> | undefined;
|
|
47
|
-
readonly approvedPorts: ReadonlySet<number>;
|
|
48
|
-
readonly approvedTargets: ReadonlyMap<number, PortForwardCandidate>;
|
|
49
|
-
readonly dismissedTargets?: ReadonlyMap<number, PortForwardCandidate> | undefined;
|
|
50
|
-
readonly pendingRequests: readonly PendingPortForwardRequest[];
|
|
51
|
-
}): PortForwardCandidateReview {
|
|
52
|
-
if (!options.threadBound) {
|
|
53
|
-
return { type: "skip", reason: "thread_not_bound" };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (options.suppressedPorts?.has(options.candidate.port) === true) {
|
|
57
|
-
return { type: "skip", reason: "suppressed_port" };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const dismissedTarget = options.dismissedTargets?.get(options.candidate.port);
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
dismissedTarget !== undefined &&
|
|
64
|
-
sameDismissedPortForwardTarget(dismissedTarget, options.candidate)
|
|
65
|
-
) {
|
|
66
|
-
return { type: "skip", reason: "recently_dismissed" };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (options.approvedPorts.has(options.candidate.port)) {
|
|
70
|
-
const approvedTarget = options.approvedTargets.get(options.candidate.port);
|
|
71
|
-
|
|
72
|
-
if (approvedTarget === undefined) {
|
|
73
|
-
return { type: "remember_approved_target", target: options.candidate };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (sameForwardCandidate(approvedTarget, options.candidate)) {
|
|
77
|
-
return { type: "skip", reason: "already_approved" };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
type: "revoke_and_prompt",
|
|
82
|
-
revoked: approvedTarget,
|
|
83
|
-
candidate: options.candidate,
|
|
84
|
-
reason: "listener_changed",
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (options.pendingRequests.some(request => request.port === options.candidate.port)) {
|
|
89
|
-
return { type: "skip", reason: "already_pending" };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return { type: "prompt", candidate: options.candidate };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function pendingRequestFromCandidate(options: {
|
|
96
|
-
readonly requestId: string;
|
|
97
|
-
readonly sourceSeq: number;
|
|
98
|
-
readonly candidate: PortForwardCandidate;
|
|
99
|
-
}): PendingPortForwardRequest {
|
|
100
|
-
return {
|
|
101
|
-
requestId: options.requestId,
|
|
102
|
-
port: options.candidate.port,
|
|
103
|
-
pid: options.candidate.pid,
|
|
104
|
-
command: options.candidate.command,
|
|
105
|
-
...(options.candidate.cwd === undefined ? {} : { cwd: options.candidate.cwd }),
|
|
106
|
-
sourceSeq: options.sourceSeq,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function approvedTargetFromRequest(
|
|
111
|
-
request: PendingPortForwardRequest,
|
|
112
|
-
): PortForwardCandidate {
|
|
113
|
-
return {
|
|
114
|
-
port: request.port,
|
|
115
|
-
pid: request.pid,
|
|
116
|
-
command: request.command,
|
|
117
|
-
...(request.cwd === undefined ? {} : { cwd: request.cwd }),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function portForwardPromptLabel(candidate: PortForwardCandidate): string {
|
|
122
|
-
return commandLabel(candidate.command);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function portForwardPromptBody(
|
|
126
|
-
candidate: PortForwardCandidate,
|
|
127
|
-
requestId: string,
|
|
128
|
-
): string {
|
|
129
|
-
const label = portForwardPromptLabel(candidate);
|
|
130
|
-
const cwdLine = candidate.cwd === undefined ? undefined : `Working directory: ${candidate.cwd}`;
|
|
131
|
-
|
|
132
|
-
return [
|
|
133
|
-
`Detected ${label} listening from a descendant process.`,
|
|
134
|
-
`Port: ${candidate.port}`,
|
|
135
|
-
`PID: ${candidate.pid}`,
|
|
136
|
-
`Command: ${candidate.command}`,
|
|
137
|
-
...(cwdLine === undefined ? [] : [cwdLine]),
|
|
138
|
-
"Kandan can open this as an authenticated HTTP, HTTPS, or WebSocket preview. It does not expose arbitrary TCP or UDP protocols.",
|
|
139
|
-
"Open this preview in Kandan?",
|
|
140
|
-
"",
|
|
141
|
-
`Fallback commands: ${portForwardDecisionCommand("approve", requestId)} or ${portForwardDecisionCommand("deny", requestId)}`,
|
|
142
|
-
].join("\n");
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function portForwardPromptReason(candidate: PortForwardCandidate): string {
|
|
146
|
-
return [
|
|
147
|
-
`Port ${candidate.port}`,
|
|
148
|
-
`PID ${candidate.pid}`,
|
|
149
|
-
`command: ${candidate.command}`,
|
|
150
|
-
...(candidate.cwd === undefined ? [] : [`cwd: ${candidate.cwd}`]),
|
|
151
|
-
"preview protocols: HTTP, HTTPS, WebSocket",
|
|
152
|
-
].join(" / ");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function portForwardDecisionCommand(
|
|
156
|
-
decision: "approve" | "deny",
|
|
157
|
-
requestId: string,
|
|
158
|
-
): string {
|
|
159
|
-
return `/kandan ${decision}-port-forward ${requestId}`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function forwardPreviewPath(runnerId: string, port: number): string {
|
|
163
|
-
return `/local-codex-runners/${encodeURIComponent(runnerId)}/forwards/${port}/preview`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function revocationCapabilities(
|
|
167
|
-
capabilities: JsonObject | undefined,
|
|
168
|
-
port: number,
|
|
169
|
-
): JsonObject {
|
|
170
|
-
return {
|
|
171
|
-
...(capabilities ?? {}),
|
|
172
|
-
revokedPorts: [port],
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function sameDismissedPortForwardTarget(
|
|
177
|
-
left: PortForwardCandidate,
|
|
178
|
-
right: PortForwardCandidate,
|
|
179
|
-
): boolean {
|
|
180
|
-
return left.port === right.port && left.command === right.command && left.cwd === right.cwd;
|
|
181
|
-
}
|