@linzumi/cli 0.0.1-beta → 0.0.2-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/src/oauth.ts ADDED
@@ -0,0 +1,294 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Implements the runner-side browser authorization flow used
5
+ when the operator does not pass a pre-existing Kandan token.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
+
10
+ export type LocalRunnerOAuthOptions = {
11
+ readonly kandanUrl: string;
12
+ readonly workspaceSlug?: string | undefined;
13
+ readonly channelSlug?: string | undefined;
14
+ readonly callbackHost?: string | undefined;
15
+ readonly openAuthorizationUrl?: ((url: string) => Promise<void> | void) | undefined;
16
+ };
17
+
18
+ export type LocalRunnerOAuthToken = {
19
+ readonly accessToken: string;
20
+ readonly expiresInSeconds?: number | undefined;
21
+ };
22
+
23
+ export async function acquireLocalRunnerToken(
24
+ options: LocalRunnerOAuthOptions,
25
+ ): Promise<string> {
26
+ const token = await acquireLocalRunnerTokenDetails(options);
27
+ return token.accessToken;
28
+ }
29
+
30
+ export async function acquireLocalRunnerTokenDetails(
31
+ options: LocalRunnerOAuthOptions,
32
+ ): Promise<LocalRunnerOAuthToken> {
33
+ const httpBaseUrl = kandanHttpBaseUrl(options.kandanUrl);
34
+ const state = randomBytes(18).toString("base64url");
35
+ const callback = await startCallbackServer({
36
+ host: oauthCallbackHost(options.kandanUrl, options.callbackHost),
37
+ });
38
+ const authorizeUrl = authorizationUrl({
39
+ httpBaseUrl,
40
+ redirectUri: callback.redirectUri,
41
+ state,
42
+ workspaceSlug: options.workspaceSlug,
43
+ channelSlug: options.channelSlug,
44
+ });
45
+
46
+ process.stderr.write(`Authorize the local Codex runner:\n${authorizeUrl}\n`);
47
+
48
+ try {
49
+ await (options.openAuthorizationUrl ?? openBrowser)(authorizeUrl);
50
+ const result = await callback.waitForCallback();
51
+
52
+ if (result.state !== state) {
53
+ throw new Error("local runner OAuth state mismatch");
54
+ }
55
+
56
+ return await exchangeCodeForToken({
57
+ httpBaseUrl,
58
+ code: result.code,
59
+ redirectUri: callback.redirectUri,
60
+ });
61
+ } finally {
62
+ callback.close();
63
+ }
64
+ }
65
+
66
+ export async function validateLocalRunnerToken(args: {
67
+ readonly kandanUrl: string;
68
+ readonly accessToken: string;
69
+ readonly workspaceSlug?: string | undefined;
70
+ readonly channelSlug?: string | undefined;
71
+ }): Promise<boolean> {
72
+ const url = new URL(
73
+ "/api/v2/local-codex-runner/oauth/validate",
74
+ kandanHttpBaseUrl(args.kandanUrl),
75
+ );
76
+
77
+ if (args.workspaceSlug !== undefined) {
78
+ url.searchParams.set("workspace", args.workspaceSlug);
79
+ }
80
+
81
+ if (args.channelSlug !== undefined) {
82
+ url.searchParams.set("channel", args.channelSlug);
83
+ }
84
+
85
+ const response = await fetch(
86
+ url,
87
+ {
88
+ method: "GET",
89
+ headers: { authorization: `Bearer ${args.accessToken}` },
90
+ },
91
+ );
92
+
93
+ switch (response.status) {
94
+ case 200: {
95
+ const body: unknown = await response.json();
96
+ return typeof body === "object" && body !== null && "ok" in body && body.ok === true;
97
+ }
98
+ case 401:
99
+ case 403:
100
+ return false;
101
+ default:
102
+ throw new Error(`local runner auth validation failed with HTTP ${response.status}`);
103
+ }
104
+ }
105
+
106
+ export function kandanHttpBaseUrl(kandanUrl: string): string {
107
+ const parsed = new URL(kandanUrl);
108
+
109
+ switch (parsed.protocol) {
110
+ case "ws:":
111
+ parsed.protocol = "http:";
112
+ break;
113
+ case "wss:":
114
+ parsed.protocol = "https:";
115
+ break;
116
+ case "http:":
117
+ case "https:":
118
+ break;
119
+ default:
120
+ throw new Error("--kandan-url must be ws://, wss://, http://, or https://");
121
+ }
122
+
123
+ parsed.pathname = "";
124
+ parsed.search = "";
125
+ parsed.hash = "";
126
+ return parsed.toString().replace(/\/$/, "");
127
+ }
128
+
129
+ export function oauthCallbackHost(
130
+ kandanUrl: string,
131
+ explicitHost?: string | undefined,
132
+ ): string {
133
+ const normalizedExplicitHost = explicitHost?.trim();
134
+
135
+ if (normalizedExplicitHost !== undefined && normalizedExplicitHost !== "") {
136
+ return normalizedExplicitHost;
137
+ }
138
+
139
+ const host = new URL(kandanUrl).hostname.trim();
140
+
141
+ return isPrivateCallbackHost(host) && host !== "localhost" ? host : "127.0.0.1";
142
+ }
143
+
144
+ function isPrivateCallbackHost(host: string): boolean {
145
+ const octets = host.split(".").map((part) => Number.parseInt(part, 10));
146
+
147
+ if (
148
+ octets.length !== 4 ||
149
+ octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
150
+ ) {
151
+ return host === "localhost";
152
+ }
153
+
154
+ const [a, b] = octets;
155
+
156
+ if (a === undefined || b === undefined) {
157
+ return false;
158
+ }
159
+
160
+ return (
161
+ a === 10 ||
162
+ a === 127 ||
163
+ (a === 172 && b >= 16 && b <= 31) ||
164
+ (a === 192 && b === 168) ||
165
+ (a === 169 && b === 254) ||
166
+ (a === 100 && b >= 64 && b <= 127)
167
+ );
168
+ }
169
+
170
+ function authorizationUrl(args: {
171
+ readonly httpBaseUrl: string;
172
+ readonly redirectUri: string;
173
+ readonly state: string;
174
+ readonly workspaceSlug?: string | undefined;
175
+ readonly channelSlug?: string | undefined;
176
+ }): string {
177
+ const url = new URL("/api/v2/local-codex-runner/oauth/authorize", args.httpBaseUrl);
178
+ url.searchParams.set("redirect_uri", args.redirectUri);
179
+ url.searchParams.set("state", args.state);
180
+
181
+ if (args.workspaceSlug !== undefined) {
182
+ url.searchParams.set("workspace", args.workspaceSlug);
183
+ }
184
+
185
+ if (args.channelSlug !== undefined) {
186
+ url.searchParams.set("channel", args.channelSlug);
187
+ }
188
+
189
+ return url.toString();
190
+ }
191
+
192
+ async function exchangeCodeForToken(args: {
193
+ readonly httpBaseUrl: string;
194
+ readonly code: string;
195
+ readonly redirectUri: string;
196
+ }): Promise<LocalRunnerOAuthToken> {
197
+ const response = await fetch(
198
+ new URL("/api/v2/local-codex-runner/oauth/token", args.httpBaseUrl),
199
+ {
200
+ method: "POST",
201
+ headers: { "content-type": "application/json" },
202
+ body: JSON.stringify({
203
+ code: args.code,
204
+ redirect_uri: args.redirectUri,
205
+ }),
206
+ },
207
+ );
208
+ const body: unknown = await response.json();
209
+
210
+ if (!response.ok || typeof body !== "object" || body === null) {
211
+ throw new Error("local runner OAuth token exchange failed");
212
+ }
213
+
214
+ const token = "access_token" in body ? body.access_token : undefined;
215
+
216
+ if (typeof token !== "string" || token.trim() === "") {
217
+ throw new Error("local runner OAuth token response did not include access_token");
218
+ }
219
+
220
+ const expiresIn = "expires_in" in body ? body.expires_in : undefined;
221
+
222
+ return {
223
+ accessToken: token,
224
+ expiresInSeconds: typeof expiresIn === "number" ? expiresIn : undefined,
225
+ };
226
+ }
227
+
228
+ function startCallbackServer(args: {
229
+ readonly host: string;
230
+ }): Promise<{
231
+ readonly redirectUri: string;
232
+ readonly waitForCallback: () => Promise<{ readonly code: string; readonly state: string }>;
233
+ readonly close: () => void;
234
+ }> {
235
+ return new Promise((resolve) => {
236
+ let resolveCallback:
237
+ | ((value: { readonly code: string; readonly state: string }) => void)
238
+ | undefined;
239
+ const callbackPromise = new Promise<{ readonly code: string; readonly state: string }>(
240
+ (callbackResolve) => {
241
+ resolveCallback = callbackResolve;
242
+ },
243
+ );
244
+
245
+ const server = Bun.serve({
246
+ hostname: args.host,
247
+ port: 0,
248
+ fetch(request) {
249
+ const url = new URL(request.url);
250
+ const code = url.searchParams.get("code");
251
+ const state = url.searchParams.get("state");
252
+
253
+ if (code === null || state === null || code.trim() === "" || state.trim() === "") {
254
+ return new Response("Missing local runner authorization code.", { status: 400 });
255
+ }
256
+
257
+ resolveCallback?.({ code, state });
258
+ return new Response("Kandan local Codex runner authorized. You can close this tab.", {
259
+ headers: { "content-type": "text/plain; charset=utf-8" },
260
+ });
261
+ },
262
+ });
263
+
264
+ resolve({
265
+ redirectUri: `http://${args.host}:${server.port}/callback`,
266
+ waitForCallback: () => callbackPromise,
267
+ close: () => {
268
+ server.stop(true);
269
+ },
270
+ });
271
+ });
272
+ }
273
+
274
+ function openBrowser(url: string): Promise<void> {
275
+ const command =
276
+ process.platform === "darwin"
277
+ ? "open"
278
+ : process.platform === "win32"
279
+ ? "cmd"
280
+ : "xdg-open";
281
+ const args =
282
+ process.platform === "win32"
283
+ ? ["/c", "start", "", url]
284
+ : [url];
285
+
286
+ return new Promise((resolve) => {
287
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
288
+ child.on("error", () => resolve());
289
+ child.on("spawn", () => {
290
+ child.unref();
291
+ resolve();
292
+ });
293
+ });
294
+ }
package/src/phoenix.ts ADDED
@@ -0,0 +1,335 @@
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
+ import {
18
+ type JsonObject,
19
+ type JsonValue,
20
+ type KandanControl,
21
+ type PhoenixFrame,
22
+ isJsonObject
23
+ } from "./protocol";
24
+
25
+ type PendingPush = {
26
+ readonly resolve: (payload: JsonValue) => void;
27
+ readonly reject: (error: Error) => void;
28
+ };
29
+
30
+ type JoinRegistration = {
31
+ readonly payload: () => JsonObject;
32
+ };
33
+
34
+ export type PhoenixClient = {
35
+ readonly join: (
36
+ topic: string,
37
+ payload: JsonObject,
38
+ options?: { readonly rejoinPayload?: (() => JsonObject) | undefined }
39
+ ) => Promise<JsonObject>;
40
+ readonly push: (topic: string, event: string, payload: JsonObject) => Promise<JsonValue>;
41
+ readonly onControl: (callback: (control: KandanControl) => void) => void;
42
+ readonly onEvent: (
43
+ callback: (topic: string, event: string, payload: JsonValue) => void
44
+ ) => void;
45
+ readonly onReconnect: (callback: () => void | Promise<void>) => void;
46
+ readonly close: () => void;
47
+ };
48
+
49
+ export function phoenixWebsocketUrl(baseUrl: string, token: string): string {
50
+ const parsed = new URL(baseUrl);
51
+ parsed.pathname = parsed.pathname.replace(/\/$/, "") + "/socket/websocket";
52
+ parsed.searchParams.set("token", token);
53
+ parsed.searchParams.set("vsn", "2.0.0");
54
+ return parsed.toString();
55
+ }
56
+
57
+ export async function connectPhoenixClient(
58
+ baseUrl: string,
59
+ token: string,
60
+ socketFactory: (url: string) => WebSocket = url => new WebSocket(url)
61
+ ): Promise<PhoenixClient> {
62
+ const pending = new Map<string, PendingPush>();
63
+ const joins = new Map<string, JoinRegistration>();
64
+ const controlCallbacks = new Set<(control: KandanControl) => void>();
65
+ const eventCallbacks = new Set<(topic: string, event: string, payload: JsonValue) => void>();
66
+ const reconnectCallbacks = new Set<() => void | Promise<void>>();
67
+ const state: {
68
+ nextRef: number;
69
+ websocket: WebSocket | undefined;
70
+ closed: boolean;
71
+ connected: boolean;
72
+ hasEverConnected: boolean;
73
+ connectionGeneration: number;
74
+ ready: Promise<void>;
75
+ resolveReady: (() => void) | undefined;
76
+ rejectReady: ((error: Error) => void) | undefined;
77
+ reconnectTimer: ReturnType<typeof setTimeout> | undefined;
78
+ } = {
79
+ nextRef: 1,
80
+ websocket: undefined,
81
+ closed: false,
82
+ connected: false,
83
+ hasEverConnected: false,
84
+ connectionGeneration: 0,
85
+ ready: Promise.resolve(),
86
+ resolveReady: undefined,
87
+ rejectReady: undefined,
88
+ reconnectTimer: undefined,
89
+ };
90
+ const rejectPending = (message: string) => {
91
+ const error = new Error(message);
92
+ pending.forEach(pendingPush => pendingPush.reject(error));
93
+ pending.clear();
94
+ };
95
+
96
+ const resetReady = () => {
97
+ state.ready = new Promise((resolve, reject) => {
98
+ state.resolveReady = resolve;
99
+ state.rejectReady = reject;
100
+ });
101
+ };
102
+
103
+ const handleMessage = (event: MessageEvent) => {
104
+ const frame = decodeFrame(String(event.data));
105
+ const [, ref, topic, name, payload] = frame;
106
+
107
+ if (ref !== null && (name === "phx_reply" || name === "phx_error")) {
108
+ const pendingPush = pending.get(ref);
109
+
110
+ if (pendingPush !== undefined) {
111
+ pending.delete(ref);
112
+
113
+ if (name === "phx_error") {
114
+ pendingPush.reject(new Error("phoenix push failed"));
115
+ } else {
116
+ pendingPush.resolve(payload);
117
+ }
118
+ }
119
+ }
120
+
121
+ if (topic.startsWith("local_runner:") && name === "control" && isKandanControl(payload)) {
122
+ controlCallbacks.forEach(callback => callback(payload));
123
+ }
124
+
125
+ if (ref === null && name !== "phx_reply" && name !== "phx_error") {
126
+ eventCallbacks.forEach(callback => callback(topic, name, payload));
127
+ }
128
+ };
129
+
130
+ const pushOnOpenSocket = (
131
+ topic: string,
132
+ event: string,
133
+ payload: JsonObject
134
+ ): Promise<JsonValue> => {
135
+ const websocket = state.websocket;
136
+
137
+ if (websocket === undefined || websocket.readyState !== WebSocket.OPEN) {
138
+ return Promise.reject(new Error("phoenix websocket is not open"));
139
+ }
140
+
141
+ const ref = String(state.nextRef);
142
+ state.nextRef += 1;
143
+ const frame: PhoenixFrame = [null, ref, topic, event, payload];
144
+
145
+ return new Promise((resolve, reject) => {
146
+ pending.set(ref, { resolve, reject });
147
+ websocket.send(JSON.stringify(frame));
148
+ });
149
+ };
150
+
151
+ const replayJoins = async () => {
152
+ for (const [topic, registration] of joins) {
153
+ const reply = await pushOnOpenSocket(topic, "phx_join", registration.payload());
154
+
155
+ if (!isJoinReply(reply)) {
156
+ throw new Error(`phoenix rejoin failed for ${topic}`);
157
+ }
158
+ }
159
+ };
160
+
161
+ const scheduleReconnect = () => {
162
+ if (state.closed || state.reconnectTimer !== undefined) {
163
+ return;
164
+ }
165
+
166
+ resetReady();
167
+ state.reconnectTimer = setTimeout(() => {
168
+ state.reconnectTimer = undefined;
169
+ void openSocket();
170
+ }, 250);
171
+ };
172
+
173
+ const handleDisconnect = (message: string) => {
174
+ state.connected = false;
175
+ rejectPending(message);
176
+ scheduleReconnect();
177
+ };
178
+
179
+ const openSocket = async (): Promise<void> => {
180
+ if (state.closed) {
181
+ return;
182
+ }
183
+
184
+ const websocket = socketFactory(phoenixWebsocketUrl(baseUrl, token));
185
+ state.websocket = websocket;
186
+ websocket.addEventListener("message", handleMessage);
187
+ websocket.addEventListener(
188
+ "close",
189
+ () => {
190
+ if (state.websocket === websocket) {
191
+ handleDisconnect("phoenix websocket closed");
192
+ }
193
+ },
194
+ { once: true },
195
+ );
196
+ websocket.addEventListener(
197
+ "error",
198
+ () => {
199
+ if (state.websocket === websocket) {
200
+ handleDisconnect("phoenix websocket error");
201
+ }
202
+ },
203
+ { once: true },
204
+ );
205
+
206
+ try {
207
+ await waitForOpen(websocket);
208
+ state.connectionGeneration += 1;
209
+ await replayJoins();
210
+ state.connected = true;
211
+ state.hasEverConnected = true;
212
+ state.resolveReady?.();
213
+
214
+ if (state.connectionGeneration > 1) {
215
+ reconnectCallbacks.forEach(callback => {
216
+ void Promise.resolve(callback()).catch(() => undefined);
217
+ });
218
+ }
219
+ } catch (error) {
220
+ if (!state.closed) {
221
+ if (!state.hasEverConnected) {
222
+ state.rejectReady?.(error instanceof Error ? error : new Error(String(error)));
223
+ }
224
+ handleDisconnect("phoenix websocket reconnect failed");
225
+ }
226
+ }
227
+ };
228
+
229
+ resetReady();
230
+ void openSocket();
231
+ await state.ready;
232
+
233
+ const push = async (
234
+ topic: string,
235
+ event: string,
236
+ payload: JsonObject
237
+ ): Promise<JsonValue> => {
238
+ await state.ready;
239
+ return pushOnOpenSocket(topic, event, payload);
240
+ };
241
+
242
+ return {
243
+ join: async (topic, payload, options) => {
244
+ const reply = await push(topic, "phx_join", payload);
245
+
246
+ if (isJoinReply(reply)) {
247
+ joins.set(topic, { payload: options?.rejoinPayload ?? (() => payload) });
248
+ return reply.response;
249
+ }
250
+
251
+ throw new Error(`phoenix join failed: ${joinErrorMessage(reply)}`);
252
+ },
253
+ push,
254
+ onControl: callback => {
255
+ controlCallbacks.add(callback);
256
+ },
257
+ onEvent: callback => {
258
+ eventCallbacks.add(callback);
259
+ },
260
+ onReconnect: callback => {
261
+ reconnectCallbacks.add(callback);
262
+ },
263
+ close: () => {
264
+ state.closed = true;
265
+ if (state.reconnectTimer !== undefined) {
266
+ clearTimeout(state.reconnectTimer);
267
+ }
268
+ state.websocket?.close();
269
+ rejectPending("phoenix websocket closed");
270
+ }
271
+ };
272
+ }
273
+
274
+ export function encodeFrame(frame: PhoenixFrame): string {
275
+ return JSON.stringify(frame);
276
+ }
277
+
278
+ export function decodeFrame(text: string): PhoenixFrame {
279
+ const parsed = JSON.parse(text);
280
+
281
+ if (
282
+ Array.isArray(parsed) &&
283
+ parsed.length === 5 &&
284
+ (parsed[0] === null || typeof parsed[0] === "string") &&
285
+ (parsed[1] === null || typeof parsed[1] === "string") &&
286
+ typeof parsed[2] === "string" &&
287
+ typeof parsed[3] === "string"
288
+ ) {
289
+ return parsed as unknown as PhoenixFrame;
290
+ }
291
+
292
+ throw new Error("invalid Phoenix frame");
293
+ }
294
+
295
+ function isJoinReply(value: JsonValue): value is {
296
+ readonly status: "ok";
297
+ readonly response: JsonObject;
298
+ } {
299
+ return (
300
+ isJsonObject(value) &&
301
+ value.status === "ok" &&
302
+ isJsonObject(value.response)
303
+ );
304
+ }
305
+
306
+ function joinErrorMessage(value: JsonValue): string {
307
+ if (!isJsonObject(value)) {
308
+ return "invalid reply";
309
+ }
310
+
311
+ const response = value.response;
312
+
313
+ if (isJsonObject(response) && typeof response.error === "string") {
314
+ return response.error;
315
+ }
316
+
317
+ if (typeof value.error === "string") {
318
+ return value.error;
319
+ }
320
+
321
+ return "unknown";
322
+ }
323
+
324
+ function isKandanControl(value: JsonValue): value is KandanControl {
325
+ return isJsonObject(value) && typeof value.type === "string";
326
+ }
327
+
328
+ function waitForOpen(websocket: WebSocket): Promise<void> {
329
+ return new Promise((resolve, reject) => {
330
+ websocket.addEventListener("open", () => resolve(), { once: true });
331
+ websocket.addEventListener("error", () => reject(new Error("websocket open failed")), {
332
+ once: true
333
+ });
334
+ });
335
+ }