@linzumi/cli 0.0.4-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 +25 -1
- package/src/portForwardApproval.ts +181 -0
- package/src/portForwardWatcher.ts +404 -0
- package/src/protocol.ts +97 -3
- package/src/runner.ts +413 -28
- 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
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
14
14
|
Relationship: Treats the Kandan websocket as a reconnectable transport so
|
|
15
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.
|
|
16
21
|
*/
|
|
17
22
|
import {
|
|
18
23
|
type JsonObject,
|
|
@@ -23,6 +28,7 @@ import {
|
|
|
23
28
|
} from "./protocol";
|
|
24
29
|
|
|
25
30
|
type PendingPush = {
|
|
31
|
+
readonly event: string;
|
|
26
32
|
readonly resolve: (payload: JsonValue) => void;
|
|
27
33
|
readonly reject: (error: Error) => void;
|
|
28
34
|
};
|
|
@@ -48,6 +54,14 @@ export type PhoenixClient = {
|
|
|
48
54
|
|
|
49
55
|
export function phoenixWebsocketUrl(baseUrl: string, token: string): string {
|
|
50
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
|
+
}
|
|
51
65
|
parsed.pathname = parsed.pathname.replace(/\/$/, "") + "/socket/websocket";
|
|
52
66
|
parsed.searchParams.set("token", token);
|
|
53
67
|
parsed.searchParams.set("vsn", "2.0.0");
|
|
@@ -112,6 +126,8 @@ export async function connectPhoenixClient(
|
|
|
112
126
|
|
|
113
127
|
if (name === "phx_error") {
|
|
114
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)}`));
|
|
115
131
|
} else {
|
|
116
132
|
pendingPush.resolve(payload);
|
|
117
133
|
}
|
|
@@ -143,7 +159,7 @@ export async function connectPhoenixClient(
|
|
|
143
159
|
const frame: PhoenixFrame = [null, ref, topic, event, payload];
|
|
144
160
|
|
|
145
161
|
return new Promise((resolve, reject) => {
|
|
146
|
-
pending.set(ref, { resolve, reject });
|
|
162
|
+
pending.set(ref, { event, resolve, reject });
|
|
147
163
|
websocket.send(JSON.stringify(frame));
|
|
148
164
|
});
|
|
149
165
|
};
|
|
@@ -304,6 +320,10 @@ function isJoinReply(value: JsonValue): value is {
|
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
function joinErrorMessage(value: JsonValue): string {
|
|
323
|
+
return replyErrorMessage(value);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function replyErrorMessage(value: JsonValue): string {
|
|
307
327
|
if (!isJsonObject(value)) {
|
|
308
328
|
return "invalid reply";
|
|
309
329
|
}
|
|
@@ -321,6 +341,10 @@ function joinErrorMessage(value: JsonValue): string {
|
|
|
321
341
|
return "unknown";
|
|
322
342
|
}
|
|
323
343
|
|
|
344
|
+
function isNonOkPushReply(value: JsonValue): boolean {
|
|
345
|
+
return isJsonObject(value) && value.status !== "ok";
|
|
346
|
+
}
|
|
347
|
+
|
|
324
348
|
function isKandanControl(value: JsonValue): value is KandanControl {
|
|
325
349
|
return isJsonObject(value) && typeof value.type === "string";
|
|
326
350
|
}
|
|
@@ -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
|
+
}
|