@linzumi/cli 0.0.5-beta → 0.0.6-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 +17 -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,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-26
|
|
3
|
+
Spec: kandan/server_v2/plans/2026-04-26-local-codex-driver-worldclass-spec.md
|
|
4
|
+
Relationship: Provides the local Codex channel session with an amortized O(1)
|
|
5
|
+
pending Kandan message queue so long shared sessions avoid repeated
|
|
6
|
+
`Array.shift()` reindexing while preserving interrupt fusion order.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
interruptQueuedMessages,
|
|
10
|
+
type QueueInterruptResult,
|
|
11
|
+
type QueuedKandanMessage,
|
|
12
|
+
} from "./kandanQueue";
|
|
13
|
+
|
|
14
|
+
export type PendingKandanMessageQueue = {
|
|
15
|
+
items: QueuedKandanMessage[];
|
|
16
|
+
head: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createPendingKandanMessageQueue(): PendingKandanMessageQueue {
|
|
20
|
+
return {
|
|
21
|
+
items: [],
|
|
22
|
+
head: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pendingKandanMessageQueueLength(
|
|
27
|
+
queue: PendingKandanMessageQueue,
|
|
28
|
+
): number {
|
|
29
|
+
return queue.items.length - queue.head;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function enqueuePendingKandanMessage(
|
|
33
|
+
queue: PendingKandanMessageQueue,
|
|
34
|
+
message: QueuedKandanMessage,
|
|
35
|
+
): void {
|
|
36
|
+
queue.items.push(message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function dequeuePendingKandanMessage(
|
|
40
|
+
queue: PendingKandanMessageQueue,
|
|
41
|
+
): QueuedKandanMessage | undefined {
|
|
42
|
+
const message = queue.items[queue.head];
|
|
43
|
+
|
|
44
|
+
if (message === undefined) {
|
|
45
|
+
compactPendingKandanMessageQueue(queue);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
queue.head = queue.head + 1;
|
|
50
|
+
compactPendingKandanMessageQueue(queue);
|
|
51
|
+
return message;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function requeuePendingKandanMessageFront(
|
|
55
|
+
queue: PendingKandanMessageQueue,
|
|
56
|
+
message: QueuedKandanMessage,
|
|
57
|
+
): void {
|
|
58
|
+
if (queue.head > 0) {
|
|
59
|
+
queue.head = queue.head - 1;
|
|
60
|
+
queue.items[queue.head] = message;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
queue.items.unshift(message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function interruptPendingKandanMessages(
|
|
68
|
+
queue: PendingKandanMessageQueue,
|
|
69
|
+
throughSeq: number | undefined,
|
|
70
|
+
): QueueInterruptResult {
|
|
71
|
+
const result = interruptQueuedMessages(pendingKandanMessages(queue), throughSeq);
|
|
72
|
+
|
|
73
|
+
if (result.ok) {
|
|
74
|
+
replacePendingKandanMessages(queue, result.queue);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function replacePendingKandanMessages(
|
|
81
|
+
queue: PendingKandanMessageQueue,
|
|
82
|
+
messages: QueuedKandanMessage[],
|
|
83
|
+
): void {
|
|
84
|
+
queue.items = messages;
|
|
85
|
+
queue.head = 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pendingKandanMessages(
|
|
89
|
+
queue: PendingKandanMessageQueue,
|
|
90
|
+
): QueuedKandanMessage[] {
|
|
91
|
+
return queue.head === 0 ? queue.items : queue.items.slice(queue.head);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compactPendingKandanMessageQueue(queue: PendingKandanMessageQueue): void {
|
|
95
|
+
if (queue.head === 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (queue.head >= queue.items.length) {
|
|
100
|
+
queue.items = [];
|
|
101
|
+
queue.head = 0;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (queue.head > queue.items.length / 2) {
|
|
106
|
+
queue.items = queue.items.slice(queue.head);
|
|
107
|
+
queue.head = 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/phoenix.ts
CHANGED
|
@@ -54,6 +54,14 @@ export type PhoenixClient = {
|
|
|
54
54
|
|
|
55
55
|
export function phoenixWebsocketUrl(baseUrl: string, token: string): string {
|
|
56
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
|
+
}
|
|
57
65
|
parsed.pathname = parsed.pathname.replace(/\/$/, "") + "/socket/websocket";
|
|
58
66
|
parsed.searchParams.set("token", token);
|
|
59
67
|
parsed.searchParams.set("vsn", "2.0.0");
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-26
|
|
3
|
+
Spec: plans/2026-04-26-local-runner-port-forward-approval.md
|
|
4
|
+
Relationship: Detects descendant listener ports for the local Codex runner so
|
|
5
|
+
Kandan can prompt the authorized listener user before exposing a local
|
|
6
|
+
service through the runner forward route.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
|
|
11
|
+
export type ProcessRow = {
|
|
12
|
+
readonly pid: number;
|
|
13
|
+
readonly ppid: number;
|
|
14
|
+
readonly command: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ListenSocketRow = {
|
|
18
|
+
readonly pid: number;
|
|
19
|
+
readonly port: number;
|
|
20
|
+
readonly command: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type PortForwardCandidate = {
|
|
24
|
+
readonly port: number;
|
|
25
|
+
readonly pid: number;
|
|
26
|
+
readonly command: string;
|
|
27
|
+
readonly cwd?: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PortForwardWatcher = {
|
|
31
|
+
readonly close: () => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PortForwardWatcherOptions = {
|
|
35
|
+
readonly rootPid?: number | undefined;
|
|
36
|
+
readonly intervalMs?: number | undefined;
|
|
37
|
+
readonly debounceMs?: number | undefined;
|
|
38
|
+
readonly scanProcesses?: (() => readonly ProcessRow[]) | undefined;
|
|
39
|
+
readonly scanListenSockets?: (() => readonly ListenSocketRow[]) | undefined;
|
|
40
|
+
readonly scanProcessCwds?: ((pids: readonly number[]) => ReadonlyMap<number, string>) | undefined;
|
|
41
|
+
readonly nowMs?: (() => number) | undefined;
|
|
42
|
+
readonly onCandidate: (candidate: PortForwardCandidate) => void | Promise<void>;
|
|
43
|
+
readonly onError?: ((error: Error) => void) | undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const defaultIntervalMs = 2_000;
|
|
47
|
+
const defaultDebounceMs = 750;
|
|
48
|
+
|
|
49
|
+
export type ObservedPortForwardCandidate = {
|
|
50
|
+
readonly candidate: PortForwardCandidate;
|
|
51
|
+
readonly firstSeenAtMs: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function startPortForwardWatcher(
|
|
55
|
+
options: PortForwardWatcherOptions,
|
|
56
|
+
): PortForwardWatcher {
|
|
57
|
+
const rootPid = options.rootPid ?? process.pid;
|
|
58
|
+
const intervalMs = options.intervalMs ?? defaultIntervalMs;
|
|
59
|
+
const debounceMs = options.debounceMs ?? defaultDebounceMs;
|
|
60
|
+
const scanProcesses = options.scanProcesses ?? readProcessRows;
|
|
61
|
+
const scanListenSockets = options.scanListenSockets ?? readListenSocketRows;
|
|
62
|
+
const scanProcessCwds = options.scanProcessCwds ?? readProcessCwdRows;
|
|
63
|
+
const nowMs = options.nowMs ?? Date.now;
|
|
64
|
+
const candidateStabilityByPort = new Map<number, ObservedPortForwardCandidate>();
|
|
65
|
+
const emittedByPort = new Map<number, PortForwardCandidate>();
|
|
66
|
+
const inFlight = { value: false };
|
|
67
|
+
|
|
68
|
+
const scan = () => {
|
|
69
|
+
if (inFlight.value) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
inFlight.value = true;
|
|
74
|
+
void Promise.resolve()
|
|
75
|
+
.then(async () => {
|
|
76
|
+
const descendants = descendantPidSet(scanProcesses(), rootPid);
|
|
77
|
+
const sockets = scanListenSockets();
|
|
78
|
+
const candidatePids = sockets
|
|
79
|
+
.filter(socket => descendants.has(socket.pid))
|
|
80
|
+
.map(socket => socket.pid);
|
|
81
|
+
const candidates = detectedForwardCandidates(
|
|
82
|
+
sockets,
|
|
83
|
+
descendants,
|
|
84
|
+
scanProcessCwds(candidatePids),
|
|
85
|
+
);
|
|
86
|
+
const stable = stableForwardCandidates(
|
|
87
|
+
candidateStabilityByPort,
|
|
88
|
+
candidates,
|
|
89
|
+
nowMs(),
|
|
90
|
+
debounceMs,
|
|
91
|
+
);
|
|
92
|
+
const changes = changedForwardCandidates(emittedByPort, stable.stableCandidates);
|
|
93
|
+
candidateStabilityByPort.clear();
|
|
94
|
+
emittedByPort.clear();
|
|
95
|
+
|
|
96
|
+
for (const [port, observed] of stable.nextObservedByPort) {
|
|
97
|
+
candidateStabilityByPort.set(port, observed);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [port, candidate] of changes.nextObservedByPort) {
|
|
101
|
+
emittedByPort.set(port, candidate);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const candidate of changes.changedCandidates) {
|
|
105
|
+
await options.onCandidate(candidate);
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch(error => {
|
|
109
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
110
|
+
})
|
|
111
|
+
.finally(() => {
|
|
112
|
+
inFlight.value = false;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
scan();
|
|
117
|
+
const interval = setInterval(scan, intervalMs);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
close: () => clearInterval(interval),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function changedForwardCandidates(
|
|
125
|
+
previousObservedByPort: ReadonlyMap<number, PortForwardCandidate>,
|
|
126
|
+
candidates: readonly PortForwardCandidate[],
|
|
127
|
+
): {
|
|
128
|
+
readonly nextObservedByPort: Map<number, PortForwardCandidate>;
|
|
129
|
+
readonly changedCandidates: PortForwardCandidate[];
|
|
130
|
+
} {
|
|
131
|
+
const nextObservedByPort = new Map<number, PortForwardCandidate>();
|
|
132
|
+
const changedCandidates: PortForwardCandidate[] = [];
|
|
133
|
+
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
nextObservedByPort.set(candidate.port, candidate);
|
|
136
|
+
const previous = previousObservedByPort.get(candidate.port);
|
|
137
|
+
|
|
138
|
+
if (previous === undefined || !sameForwardCandidate(previous, candidate)) {
|
|
139
|
+
changedCandidates.push(candidate);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { nextObservedByPort, changedCandidates };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function stableForwardCandidates(
|
|
147
|
+
previousObservedByPort: ReadonlyMap<number, ObservedPortForwardCandidate>,
|
|
148
|
+
candidates: readonly PortForwardCandidate[],
|
|
149
|
+
nowMs: number,
|
|
150
|
+
debounceMs: number,
|
|
151
|
+
): {
|
|
152
|
+
readonly nextObservedByPort: Map<number, ObservedPortForwardCandidate>;
|
|
153
|
+
readonly stableCandidates: PortForwardCandidate[];
|
|
154
|
+
} {
|
|
155
|
+
const nextObservedByPort = new Map<number, ObservedPortForwardCandidate>();
|
|
156
|
+
const stableCandidates: PortForwardCandidate[] = [];
|
|
157
|
+
|
|
158
|
+
for (const candidate of candidates) {
|
|
159
|
+
const previous = previousObservedByPort.get(candidate.port);
|
|
160
|
+
const firstSeenAtMs =
|
|
161
|
+
previous !== undefined && sameForwardCandidate(previous.candidate, candidate)
|
|
162
|
+
? previous.firstSeenAtMs
|
|
163
|
+
: nowMs;
|
|
164
|
+
const observed = { candidate, firstSeenAtMs };
|
|
165
|
+
nextObservedByPort.set(candidate.port, observed);
|
|
166
|
+
|
|
167
|
+
if (debounceMs <= 0 || nowMs - firstSeenAtMs >= debounceMs) {
|
|
168
|
+
stableCandidates.push(candidate);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { nextObservedByPort, stableCandidates };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function sameForwardCandidate(
|
|
176
|
+
left: PortForwardCandidate,
|
|
177
|
+
right: PortForwardCandidate,
|
|
178
|
+
): boolean {
|
|
179
|
+
return (
|
|
180
|
+
left.port === right.port &&
|
|
181
|
+
left.pid === right.pid &&
|
|
182
|
+
left.command === right.command &&
|
|
183
|
+
left.cwd === right.cwd
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function descendantPidSet(
|
|
188
|
+
rows: readonly ProcessRow[],
|
|
189
|
+
rootPid: number,
|
|
190
|
+
): Set<number> {
|
|
191
|
+
const childrenByParent = new Map<number, number[]>();
|
|
192
|
+
|
|
193
|
+
for (const row of rows) {
|
|
194
|
+
const existing = childrenByParent.get(row.ppid) ?? [];
|
|
195
|
+
childrenByParent.set(row.ppid, [...existing, row.pid]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const descendants = new Set<number>();
|
|
199
|
+
const queue = [...(childrenByParent.get(rootPid) ?? [])];
|
|
200
|
+
|
|
201
|
+
while (queue.length > 0) {
|
|
202
|
+
const pid = queue.shift();
|
|
203
|
+
|
|
204
|
+
if (pid === undefined || descendants.has(pid)) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
descendants.add(pid);
|
|
209
|
+
queue.push(...(childrenByParent.get(pid) ?? []));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return descendants;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function detectedForwardCandidates(
|
|
216
|
+
sockets: readonly ListenSocketRow[],
|
|
217
|
+
descendantPids: ReadonlySet<number>,
|
|
218
|
+
processCwds: ReadonlyMap<number, string> = new Map(),
|
|
219
|
+
): PortForwardCandidate[] {
|
|
220
|
+
return sockets
|
|
221
|
+
.filter(socket => descendantPids.has(socket.pid))
|
|
222
|
+
.filter(socket => socket.port > 0 && socket.port < 65_536)
|
|
223
|
+
.sort((left, right) => left.port - right.port)
|
|
224
|
+
.map(socket => {
|
|
225
|
+
const cwd = processCwds.get(socket.pid);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
port: socket.port,
|
|
229
|
+
pid: socket.pid,
|
|
230
|
+
command: socket.command,
|
|
231
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function parseProcessRows(output: string): ProcessRow[] {
|
|
237
|
+
return output
|
|
238
|
+
.split("\n")
|
|
239
|
+
.map(line => line.trim())
|
|
240
|
+
.filter(line => line !== "")
|
|
241
|
+
.map(line => {
|
|
242
|
+
const match = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
243
|
+
|
|
244
|
+
if (match === null) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
pid: Number(match[1]),
|
|
250
|
+
ppid: Number(match[2]),
|
|
251
|
+
command: match[3] ?? "",
|
|
252
|
+
};
|
|
253
|
+
})
|
|
254
|
+
.filter((row): row is ProcessRow => row !== undefined);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function parseLsofListenRows(output: string): ListenSocketRow[] {
|
|
258
|
+
const rows: ListenSocketRow[] = [];
|
|
259
|
+
let currentPid: number | undefined;
|
|
260
|
+
let currentCommand: string | undefined;
|
|
261
|
+
|
|
262
|
+
for (const rawLine of output.split("\n")) {
|
|
263
|
+
const line = rawLine.trim();
|
|
264
|
+
|
|
265
|
+
if (line.startsWith("p")) {
|
|
266
|
+
currentPid = Number(line.slice(1));
|
|
267
|
+
currentCommand = undefined;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (line.startsWith("c")) {
|
|
272
|
+
currentCommand = line.slice(1);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!line.startsWith("n") || currentPid === undefined) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const port = portFromLsofName(line.slice(1));
|
|
281
|
+
|
|
282
|
+
if (port !== undefined) {
|
|
283
|
+
rows.push({
|
|
284
|
+
pid: currentPid,
|
|
285
|
+
port,
|
|
286
|
+
command: currentCommand ?? "unknown",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return rows;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function parseLsofCwdRows(output: string): Map<number, string> {
|
|
295
|
+
const rows = new Map<number, string>();
|
|
296
|
+
let currentPid: number | undefined;
|
|
297
|
+
|
|
298
|
+
for (const rawLine of output.split("\n")) {
|
|
299
|
+
const line = rawLine.trim();
|
|
300
|
+
|
|
301
|
+
if (line.startsWith("p")) {
|
|
302
|
+
currentPid = Number(line.slice(1));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (line.startsWith("n") && currentPid !== undefined) {
|
|
307
|
+
const cwd = line.slice(1).trim();
|
|
308
|
+
|
|
309
|
+
if (cwd !== "") {
|
|
310
|
+
rows.set(currentPid, cwd);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return rows;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function readProcessRows(): ProcessRow[] {
|
|
319
|
+
const result = spawnSync("ps", ["-axo", "pid=,ppid=,command="], {
|
|
320
|
+
encoding: "utf8",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (result.error !== undefined) {
|
|
324
|
+
throw result.error;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (result.status !== 0) {
|
|
328
|
+
throw new Error(`ps failed with status ${result.status}: ${result.stderr}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return parseProcessRows(result.stdout);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function readListenSocketRows(): ListenSocketRow[] {
|
|
335
|
+
const result = spawnSync(
|
|
336
|
+
"lsof",
|
|
337
|
+
["-nP", "-iTCP", "-sTCP:LISTEN", "-FpPcn"],
|
|
338
|
+
{ encoding: "utf8" },
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (result.error !== undefined) {
|
|
342
|
+
throw result.error;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (result.status !== 0) {
|
|
346
|
+
if (result.status === 1 && result.stdout.trim() === "") {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
throw new Error(`lsof failed with status ${result.status}: ${result.stderr}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return parseLsofListenRows(result.stdout);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readProcessCwdRows(pids: readonly number[]): ReadonlyMap<number, string> {
|
|
357
|
+
const uniquePids = [...new Set(pids)].filter(pid => Number.isInteger(pid) && pid > 0);
|
|
358
|
+
|
|
359
|
+
if (uniquePids.length === 0) {
|
|
360
|
+
return new Map();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const result = spawnSync(
|
|
364
|
+
"lsof",
|
|
365
|
+
["-a", "-p", uniquePids.join(","), "-d", "cwd", "-Fn"],
|
|
366
|
+
{ encoding: "utf8" },
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
if (result.error !== undefined) {
|
|
370
|
+
throw result.error;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (result.status !== 0) {
|
|
374
|
+
if (result.status === 1 && result.stdout.trim() === "") {
|
|
375
|
+
return new Map();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
throw new Error(`lsof cwd failed with status ${result.status}: ${result.stderr}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return parseLsofCwdRows(result.stdout);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function portFromLsofName(name: string): number | undefined {
|
|
385
|
+
const match = name.match(/:(\d+)(?:\s|\(|$)/);
|
|
386
|
+
|
|
387
|
+
if (match === null) {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const port = Number(match[1]);
|
|
392
|
+
|
|
393
|
+
return Number.isInteger(port) ? port : undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function commandLabel(command: string): string {
|
|
397
|
+
const trimmed = command.trim();
|
|
398
|
+
|
|
399
|
+
if (trimmed === "") {
|
|
400
|
+
return "unknown";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return basename(trimmed.split(/\s+/)[0] ?? trimmed);
|
|
404
|
+
}
|