From e5e90b366006157ce67b5cc1256c932547a807e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=97=AD?= <334921176+xuxujo97-coder@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:01:30 +0800 Subject: [PATCH 1/3] feat(coding-agent): connect terminal sessions to Lark with mirrored approvals --- apps/cli/package.json | 5 +- apps/cli/src/bootstrap/extensions.ts | 2 + apps/cli/src/commands/connect.ts | 8 + apps/cli/src/ui/index.ts | 1 + apps/cli/src/ui/runtime/lark-connection.ts | 341 +++++++++++++ apps/cli/src/ui/runtime/lark-projection.ts | 98 ++++ apps/cli/src/ui/view/chrome/footer.ts | 15 +- apps/cli/src/ui/view/dialogs/lark-pairing.ts | 46 ++ apps/cli/test/footer-width.test.ts | 23 + apps/cli/test/lark-connection.test.ts | 323 ++++++++++++ apps/cli/test/lark-pairing.test.ts | 57 +++ docs/command-permissions.md | 15 + docs/lark-connection.md | 74 +++ package.json | 4 +- .../src/core/extensions/runner.ts | 26 +- .../coding-agent/src/core/extensions/types.ts | 5 + .../src/core/extensions/ui-decisions.ts | 81 +++ packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/step/permissions.ts | 2 +- .../test/step-permissions.test.ts | 3 +- .../coding-agent/test/ui-decisions.test.ts | 97 ++++ packages/lark-adapter/AGENTS.md | 7 + packages/lark-adapter/lark-boundary.md | 41 ++ packages/lark-adapter/package.json | 11 + packages/lark-adapter/src/api.ts | 176 +++++++ packages/lark-adapter/src/decisions.ts | 33 ++ packages/lark-adapter/src/inbound.ts | 109 ++++ packages/lark-adapter/src/index.ts | 8 + packages/lark-adapter/src/registration.ts | 191 +++++++ packages/lark-adapter/src/transport.ts | 96 ++++ packages/lark-adapter/test/api.test.ts | 115 +++++ packages/lark-adapter/test/decisions.test.ts | 32 ++ packages/lark-adapter/test/inbound.test.ts | 97 ++++ .../lark-adapter/test/registration.test.ts | 215 ++++++++ .../lark-adapter/test/sdk-lifecycle.test.ts | 28 + packages/lark-adapter/test/transport.test.ts | 107 ++++ packages/lark-adapter/tsconfig.build.json | 6 + packages/lark-adapter/vitest.config.ts | 2 + pnpm-lock.yaml | 482 +++++++++++++++++- scripts/workspace-registry.json | 1 + test.sh | 1 + tsconfig.json | 1 + vitest.base.ts | 2 + 43 files changed, 2958 insertions(+), 30 deletions(-) create mode 100644 apps/cli/src/commands/connect.ts create mode 100644 apps/cli/src/ui/runtime/lark-connection.ts create mode 100644 apps/cli/src/ui/runtime/lark-projection.ts create mode 100644 apps/cli/src/ui/view/dialogs/lark-pairing.ts create mode 100644 apps/cli/test/lark-connection.test.ts create mode 100644 apps/cli/test/lark-pairing.test.ts create mode 100644 docs/lark-connection.md create mode 100644 packages/coding-agent/src/core/extensions/ui-decisions.ts create mode 100644 packages/coding-agent/test/ui-decisions.test.ts create mode 100644 packages/lark-adapter/AGENTS.md create mode 100644 packages/lark-adapter/lark-boundary.md create mode 100644 packages/lark-adapter/package.json create mode 100644 packages/lark-adapter/src/api.ts create mode 100644 packages/lark-adapter/src/decisions.ts create mode 100644 packages/lark-adapter/src/inbound.ts create mode 100644 packages/lark-adapter/src/index.ts create mode 100644 packages/lark-adapter/src/registration.ts create mode 100644 packages/lark-adapter/src/transport.ts create mode 100644 packages/lark-adapter/test/api.test.ts create mode 100644 packages/lark-adapter/test/decisions.test.ts create mode 100644 packages/lark-adapter/test/inbound.test.ts create mode 100644 packages/lark-adapter/test/registration.test.ts create mode 100644 packages/lark-adapter/test/sdk-lifecycle.test.ts create mode 100644 packages/lark-adapter/test/transport.test.ts create mode 100644 packages/lark-adapter/tsconfig.build.json create mode 100644 packages/lark-adapter/vitest.config.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index c6bcc5db..55ce2706 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,11 +29,14 @@ "@step-harness/coding-agent": "^0.84.4", "@step-harness/pi-tui": "^0.84.4", "@step-harness/config": "workspace:*", + "@step-harness/lark-adapter": "workspace:*", "chalk": "5.6.2", - "grok-mermaid": "0.2.2" + "grok-mermaid": "0.2.2", + "qrcode": "1.5.4" }, "devDependencies": { "@types/proper-lockfile": "4.1.4", + "@types/qrcode": "1.5.6", "proper-lockfile": "4.1.2", "typebox": "1.3.7", "vitest": "4.1.9" diff --git a/apps/cli/src/bootstrap/extensions.ts b/apps/cli/src/bootstrap/extensions.ts index 33e12c13..fed497d3 100644 --- a/apps/cli/src/bootstrap/extensions.ts +++ b/apps/cli/src/bootstrap/extensions.ts @@ -17,6 +17,7 @@ import { type StepTelemetryReporter, type TraceHeaderPolicy, } from "@step-harness/coding-agent"; +import { createLarkConnectionExtension } from "#ui/index"; export interface StepExtensionFactoryDeps { /** Shared telemetry runtime handed to every Step extension. */ @@ -52,6 +53,7 @@ export function createStepExtensionFactories(deps: StepExtensionFactoryDeps): In createStepCapabilitiesExtensionInline({ telemetry: deps.telemetry }), createStepCronExtension({ telemetry: deps.telemetry }), createStepGoalExtension({ telemetry: deps.telemetry }), + { name: "connect-lark", factory: createLarkConnectionExtension(), hidden: true }, ...(deps.stepCodeProviderExtension ? [deps.stepCodeProviderExtension] : []), ]; } diff --git a/apps/cli/src/commands/connect.ts b/apps/cli/src/commands/connect.ts new file mode 100644 index 00000000..56b33881 --- /dev/null +++ b/apps/cli/src/commands/connect.ts @@ -0,0 +1,8 @@ +export type ConnectIntent = "connect" | "disconnect" | "invalid"; + +export function parseConnectCommand(args: string): ConnectIntent { + const value = args.trim().replace(/\s+/g, " "); + if (value === "" || value === "lark") return "connect"; + if (value === "off" || value === "lark off") return "disconnect"; + return "invalid"; +} diff --git a/apps/cli/src/ui/index.ts b/apps/cli/src/ui/index.ts index 1fa252d5..33df0dcf 100644 --- a/apps/cli/src/ui/index.ts +++ b/apps/cli/src/ui/index.ts @@ -16,6 +16,7 @@ export type { InteractiveModeOptions } from "@step-harness/coding-agent"; export { selectConfig } from "./config-selector.ts"; export { InteractiveMode } from "./interactive-mode.ts"; +export { createLarkConnectionExtension } from "./runtime/lark-connection.ts"; export { selectSession } from "./session-picker.ts"; export { createStartupTui, diff --git a/apps/cli/src/ui/runtime/lark-connection.ts b/apps/cli/src/ui/runtime/lark-connection.ts new file mode 100644 index 00000000..9c0c146d --- /dev/null +++ b/apps/cli/src/ui/runtime/lark-connection.ts @@ -0,0 +1,341 @@ +import { + type ExtensionContext, + type ExtensionFactory, + imageFileToContent, + type MessageEndEvent, + STEPCODE_VERSION, + type UIDecisionRequest, +} from "@step-harness/coding-agent"; +import { + LarkApi, + LarkDecisions, + type LarkMessage, + LarkRegistration, + LarkTransport, + larkCallbackSetupUrl, +} from "@step-harness/lark-adapter"; +import type { ImageContent, TextContent } from "@step-harness/providers"; +import { parseConnectCommand } from "../../commands/connect.ts"; +import { createLarkPairingView } from "../view/dialogs/lark-pairing.ts"; +import { LarkProjection } from "./lark-projection.ts"; + +interface Connection { + controller: AbortController; + context: ExtensionContext; + api?: LarkApi; + transport?: LarkTransport; + input: Promise; + inputCount: number; + blocked: boolean; + compacting: boolean; + projection?: LarkProjection; + decisions: LarkDecisions; + unsubscribeDecision?: () => void; +} + +/** UI connection state only; session input and permissions stay in coding-agent. */ +export function createLarkConnectionExtension(): ExtensionFactory { + return (pi) => { + let connection: Connection | undefined; + const disconnect = async (notifyChat = false) => { + const current = connection; + connection = undefined; + if (!current) return; + const chat = current.transport?.chatId; + current.controller.abort(); + current.unsubscribeDecision?.(); + current.decisions.close(); + current.projection?.close(); + current.transport?.close(); + current.context.ui.setWidget("lark-pairing", undefined); + current.context.ui.setStatus("lark", undefined); + if (notifyChat && current.api && chat) { + await current.api + .sendCard(chat, { + config: { enable_forward: false }, + elements: [ + { + tag: "div", + text: { + tag: "plain_text", + content: + "Terminal disconnected. This session no longer receives messages or handles remote approvals.", + }, + }, + ], + }) + .catch(() => {}); + } + await current.api?.close(); + }; + const mirrorDecision = (current: Connection, request: UIDecisionRequest) => { + const chat = current.transport?.chatId; + const api = current.api; + if (!chat || !api || connection !== current || request.signal.aborted) return; + if ( + request.options.length > 20 || + request.options.some((option) => option.length > 200) || + request.title.length > 200 || + (request.message?.length ?? 0) > 8000 + ) { + current.context.ui.notify( + "This decision is too large for Lark. Use the terminal to review it in full.", + "warning", + ); + return; + } + const token = current.decisions.open(chat, request.options, (value) => { + request.choose(value); + }); + let cardId: string | undefined; + const completed = () => { + current.decisions.dismiss(token); + current.controller.signal.removeEventListener("abort", detached); + if (cardId && connection === current) + void api + .updateCard(cardId, { + config: { enable_forward: false }, + elements: [ + { + tag: "div", + text: { + tag: "plain_text", + content: "Decision closed. This card can no longer approve a tool.", + }, + }, + ], + }) + .catch(() => {}); + }; + const detached = () => { + request.signal.removeEventListener("abort", completed); + current.decisions.dismiss(token); + }; + current.controller.signal.addEventListener("abort", detached, { once: true }); + request.signal.addEventListener("abort", completed, { once: true }); + void api + .sendCard(chat, { + config: { wide_screen_mode: true, enable_forward: false }, + header: { title: { tag: "plain_text", content: request.title } }, + elements: [ + { + tag: "div", + text: { tag: "plain_text", content: request.message ?? "Choose an option." }, + }, + ...request.options.map((option) => ({ + tag: "action", + actions: [ + { + tag: "button", + text: { tag: "plain_text", content: option }, + value: { token, option }, + }, + ], + })), + ], + }) + .then((id) => { + cardId = id; + if (request.signal.aborted) completed(); + }) + .catch(() => { + current.decisions.dismiss(token); + request.signal.removeEventListener("abort", completed); + current.controller.signal.removeEventListener("abort", detached); + if (connection === current) + current.context.ui.notify("Lark approval card failed. Use the terminal.", "warning"); + }); + }; + const receive = async (current: Connection, message: LarkMessage) => { + if (connection !== current || current.controller.signal.aborted) return; + if (current.blocked || current.compacting) { + current.context.ui.notify( + "Lark input ignored while a terminal dialog or compaction is active. Retry afterward.", + "warning", + ); + return; + } + const content: (TextContent | ImageContent)[] = []; + const notes: string[] = message.text ? [message.text] : []; + if (message.imageKeys.length + message.files.length > 8) throw new Error("Too many Lark attachments"); + for (const key of message.imageKeys) { + const file = await current.api?.download(message.id, key, "image", "image"); + if (!file) continue; + const image = await imageFileToContent(file.path); + if (image) content.push(image); + else notes.push("[Lark image omitted: unsupported image format.]"); + } + for (const file of message.files) { + const downloaded = await current.api?.download(message.id, file.key, file.name, "file"); + if (downloaded) notes.push(`[Lark file downloaded to local path ${JSON.stringify(downloaded.path)}]`); + } + if (connection !== current || current.controller.signal.aborted || current.blocked || current.compacting) + return; + if (notes.length) content.unshift({ type: "text", text: notes.join("\n\n") }); + if (!content.length) return; + // Do not execute remote slash commands, login flows or editor actions. + pi.sendUserMessage(content, { deliverAs: "followUp", expandPromptTemplates: false }); + }; + const pair = async (current: Connection) => { + let stage = "pairing initialization"; + try { + const registration = new LarkRegistration({ version: STEPCODE_VERSION.value }); + const code = await registration.begin(current.controller.signal); + if (connection !== current) return; + const pairing = await createLarkPairingView(code, () => process.stdout.rows || 40); + if (connection !== current) return; + current.context.ui.setWidget("lark-pairing", () => pairing); + stage = "authorization polling"; + const result = await registration.waitForAuthorization(code, current.controller.signal); + if (connection !== current) return; + if (result.status !== "authorized") { + await disconnect(); + current.context.ui.notify( + result.status === "expired" + ? "Lark pairing expired. Retry /connect lark." + : "Lark pairing denied. Retry /connect lark.", + "warning", + ); + return; + } + stage = "bot identity lookup"; + current.api = new LarkApi(result.credentials); + const bot = await current.api.getBotIdentity(); + if (connection !== current) return; + current.transport = new LarkTransport({ + credentials: result.credentials, + botId: bot.openId, + onMessage: (message) => { + if (connection !== current || current.inputCount >= 16) return; + current.inputCount++; + current.input = current.input + .then(() => receive(current, message)) + .catch(() => { + if (connection === current) + current.context.ui.notify("Lark input or attachment could not be delivered.", "warning"); + }) + .finally(() => { + current.inputCount--; + }); + }, + onCardAction: (action) => { + if (connection === current) current.decisions.choose(action.token, action.chatId, action.option); + }, + onState: (state) => { + if (connection === current) + current.context.ui.setStatus( + "lark", + `lark · ${state} · @${bot.name} · ${current.context.sessionManager.getSessionId()}`, + ); + }, + }); + current.context.ui.setWidget("lark-pairing", undefined); + current.context.ui.setStatus("lark", "Lark: connecting"); + stage = "WebSocket handshake"; + await current.transport.start(); + if (connection === current) { + current.context.ui.setStatus( + "lark", + `lark · connected · @${bot.name} · ${current.context.sessionManager.getSessionId()}`, + ); + current.context.ui.setWidget("lark-pairing", [ + "Lark connected. Enable card callbacks for remote approvals:", + larkCallbackSetupUrl(result.credentials), + "Confirm card.action.trigger in the browser. Keep this terminal connected.", + ]); + current.context.ui.notify( + `connect · lark · connected · @${bot.name} · ${current.context.sessionManager.getSessionId()}`, + ); + current.context.ui.notify( + "向飞书机器人发送消息即可绑定当前会话。使用 /connect off 断开。远程审批仍需通过上方链接确认 card.action.trigger 回调配置。", + ); + } + } catch { + if (connection !== current) return; + await disconnect(); + current.context.ui.notify(`Lark connection failed during ${stage}. Retry /connect lark.`, "warning"); + } + }; + pi.registerCommand("connect", { + description: "Mirror this terminal session to Lark; /connect off disconnects", + handler: async (args, context) => { + const intent = parseConnectCommand(args); + if (intent === "invalid") { + context.ui.notify("Usage: /connect lark | /connect off", "warning"); + return; + } + if (intent === "disconnect") { + await disconnect(true); + context.ui.notify("connect · lark · disconnected"); + return; + } + if (context.mode !== "tui") { + context.ui.notify("Lark connection requires interactive terminal mode.", "warning"); + return; + } + if (connection) { + context.ui.notify("Lark is already connecting or connected. Use /connect off first.", "warning"); + return; + } + const current: Connection = { + controller: new AbortController(), + context, + input: Promise.resolve(), + inputCount: 0, + blocked: false, + compacting: false, + decisions: new LarkDecisions(), + }; + connection = current; + current.unsubscribeDecision = context.ui.onDecision?.((request) => mirrorDecision(current, request)); + context.ui.setStatus("lark", "Lark: pairing"); + void pair(current); + }, + }); + pi.on("session_shutdown", () => disconnect()); + pi.on("before_agent_start", (event, context) => { + const current = connection; + const chat = current?.transport?.chatId; + if (!current?.api || !chat) return; + current.projection ??= new LarkProjection(current.api, chat, () => { + if (connection === current) current.context.ui.setStatus("lark", "Lark: output failed"); + }); + current.projection.setSettings(context.model?.id, context.thinkingLevel); + current.projection.begin(event.prompt); + }); + pi.on("model_select", (event, context) => { + connection?.projection?.setSettings(event.model.id, context.thinkingLevel); + }); + pi.on("thinking_level_select", (event, context) => { + connection?.projection?.setSettings(context.model?.id, event.level); + }); + const projectAssistant = (event: Pick) => { + if (event.message.role === "assistant") + connection?.projection?.update( + event.message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + ); + }; + pi.on("message_update", projectAssistant); + pi.on("message_end", projectAssistant); + pi.on("tool_execution_start", (event) => connection?.projection?.working(event.toolName)); + pi.on("agent_settled", () => connection?.projection?.done()); + pi.on("session_before_compact", () => { + if (connection) connection.compacting = true; + }); + pi.on("session_compact", () => { + if (connection) connection.compacting = false; + }); + pi.on("session_compact_failed", () => { + if (connection) connection.compacting = false; + }); + pi.on("ui_prompt_start", () => { + if (connection) connection.blocked = true; + }); + pi.on("ui_prompt_end", () => { + if (connection) connection.blocked = false; + }); + }; +} diff --git a/apps/cli/src/ui/runtime/lark-projection.ts b/apps/cli/src/ui/runtime/lark-projection.ts new file mode 100644 index 00000000..500e5e17 --- /dev/null +++ b/apps/cli/src/ui/runtime/lark-projection.ts @@ -0,0 +1,98 @@ +import type { LarkApi } from "@step-harness/lark-adapter"; + +/** Ephemeral rendering projection; never reads history or owns session messages. */ +export class LarkProjection { + private readonly api: LarkApi; + private readonly chatId: string; + private readonly onError: () => void; + private cardId: string | undefined; + private text = ""; + private prompt = ""; + private status = "thinking"; + private settings = ""; + private timer: ReturnType | undefined; + private writes = Promise.resolve(); + private generation = 0; + private closed = false; + + constructor(api: LarkApi, chatId: string, onError: () => void) { + this.api = api; + this.chatId = chatId; + this.onError = onError; + } + + begin(prompt: string): void { + this.generation++; + this.cardId = undefined; + this.prompt = prompt.slice(0, 4000); + this.text = ""; + this.status = "thinking"; + this.flush(); + } + + setSettings(model: string | undefined, effort: string | undefined): void { + const settings = `Model: ${model ?? "no-model"} | Effort: ${effort ?? "off"}`; + if (settings === this.settings) return; + this.settings = settings; + if (this.generation > 0) this.flush(); + } + + update(text: string): void { + this.text = text.slice(-20_000); + this.schedule(); + } + working(toolName: string): void { + this.status = `working: ${toolName}`; + this.schedule(); + } + done(): void { + this.status = "done"; + this.flush(); + } + + private schedule(): void { + if (this.closed || this.timer) return; + this.timer = setTimeout(() => { + this.timer = undefined; + this.flush(); + }, 2000); + } + + private flush(): void { + if (this.closed) return; + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + const generation = this.generation; + const card = { + config: { wide_screen_mode: true, enable_forward: false }, + header: { title: { tag: "plain_text", content: `StepCode: ${this.status}` } }, + elements: [ + { tag: "div", text: { tag: "plain_text", content: this.settings } }, + { tag: "div", text: { tag: "plain_text", content: this.prompt } }, + { tag: "markdown", content: this.text || "Waiting for agent output." }, + ], + }; + this.writes = this.writes + .then(async () => { + if (this.closed || generation !== this.generation) return; + if (this.cardId) await this.api.updateCard(this.cardId, card); + else { + const id = await this.api.sendCard(this.chatId, card); + if (!this.closed && generation === this.generation) { + this.cardId = id; + } + } + }) + .catch(() => { + if (!this.closed && generation === this.generation) this.onError(); + }); + } + + close(): void { + this.closed = true; + this.generation++; + if (this.timer) clearTimeout(this.timer); + } +} diff --git a/apps/cli/src/ui/view/chrome/footer.ts b/apps/cli/src/ui/view/chrome/footer.ts index c25ea6e6..2655d9c5 100644 --- a/apps/cli/src/ui/view/chrome/footer.ts +++ b/apps/cli/src/ui/view/chrome/footer.ts @@ -123,7 +123,18 @@ export class FooterComponent implements Component { render(width: number): string[] { if (this.presentation === "step") { - return this.renderStepPresentation(width); + const lines = this.renderStepPresentation(width); + const larkStatus = sanitizeStatusText(this.footerData.getExtensionStatuses().get("lark") ?? ""); + if (larkStatus) { + lines.unshift( + truncateToWidth( + theme.fg(larkStatus.startsWith("lark · connected") ? "success" : "muted", larkStatus), + Math.max(1, Math.floor(width)), + "", + ), + ); + } + return lines; } const state = this.session.state; @@ -369,7 +380,7 @@ export class FooterComponent implements Component { if (safeWidth >= 100) { for (const [key, status] of [...extensionStatuses.entries()].sort(([a], [b]) => a.localeCompare(b))) { // Permission and Ultracode already have leading segments. - if (key === "step-permission" || key === "ultracode") continue; + if (key === "step-permission" || key === "ultracode" || key === "lark") continue; const cleaned = status .replace(/[\r\n\t]+/gu, " ") .replace(/ +/gu, " ") diff --git a/apps/cli/src/ui/view/dialogs/lark-pairing.ts b/apps/cli/src/ui/view/dialogs/lark-pairing.ts new file mode 100644 index 00000000..1587fb36 --- /dev/null +++ b/apps/cli/src/ui/view/dialogs/lark-pairing.ts @@ -0,0 +1,46 @@ +import type { LarkPairingCode } from "@step-harness/lark-adapter"; +import { type Component, Text, truncateToWidth, visibleWidth } from "@step-harness/pi-tui"; +import QRCode from "qrcode"; + +/** Keep the QR intact; wrapping or clipping its rows makes it unscannable. */ +export async function createLarkPairingView(code: LarkPairingCode, getRows: () => number): Promise { + let qr: string[] = []; + try { + qr = (await QRCode.toString(code.verificationUrl, { type: "terminal", small: true, errorCorrectionLevel: "L" })) + .trimEnd() + .split("\n") + .filter((line) => visibleWidth(line) > 0); + } catch { + // Pairing remains usable through the complete link if encoding fails. + } + const userCode = new URL(code.verificationUrl).searchParams.get("user_code"); + const lifetime = + code.expiresInSeconds >= 60 ? `${Math.floor(code.expiresInSeconds / 60)} 分钟` : `${code.expiresInSeconds} 秒`; + const heading = new Text("connect · 扫码或打开下面的链接,选择要绑定的机器人(可复用已有的):", 1, 0); + const details = new Text( + [ + code.verificationUrl, + "", + `${userCode ? `验证码 ${userCode},` : "链接"}${lifetime}内有效。`, + "凭据只保存在当前进程内存中,断开后本 CLI 不再使用。", + "使用 /connect off 取消。", + ].join("\n"), + 1, + 0, + ); + return { + invalidate() { + heading.invalidate(); + details.invalidate(); + }, + render(width) { + const top = heading.render(width).map((line) => truncateToWidth(line, width, "")); + const bottom = details.render(width).map((line) => truncateToWidth(line, width, "")); + const fits = + qr.length > 0 && + qr.every((line) => visibleWidth(line) + 2 <= width) && + top.length + qr.length + bottom.length + 8 <= getRows(); + return [...top, ...(fits ? ["", ...qr.map((line) => ` ${line} `), ""] : []), ...bottom]; + }, + }; +} diff --git a/apps/cli/test/footer-width.test.ts b/apps/cli/test/footer-width.test.ts index 296b8cf0..d2cb11fb 100644 --- a/apps/cli/test/footer-width.test.ts +++ b/apps/cli/test/footer-width.test.ts @@ -443,4 +443,27 @@ describe("FooterComponent width handling", () => { } } }); + it.each([20, 60, 80, 120])("renders Lark on its own row above the unchanged operational footer at %i columns", (width) => { + const statuses = new Map([["lark", "lark · connected · @周旭的飞书 CLI"]]); + const footer = new FooterComponent(createSession({ sessionName: "", modelId: "model-".repeat(30) }), createFooterData(1, statuses), { presentation: "step" }); + const lines = footer.render(width); + expect(lines).toHaveLength(2); + expect(stripAnsi(lines[0])).toContain("lark · connected"); + expect(stripAnsi(lines[1])).not.toContain("lark"); + const plain = stripAnsi(lines.join("\n")); + expect(plain).toContain("lark · connected"); + expect(plain.match(/lark/gu)).toHaveLength(1); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); + statuses.delete("lark"); + expect(footer.render(width)).toEqual([lines[1]]); + }); + it("shows the complete connected bot and session identity in a wide footer", () => { + const identity = "lark · connected · @周旭的飞书 CLI · 825d3cc5-3761-4e92-8cfc-56ab2969d262"; + const footer = new FooterComponent(createSession({ sessionName: "" }), createFooterData(1, new Map([["lark", identity]])), { presentation: "step" }); + const lines = footer.render(180); + expect(stripAnsi(lines[0])).toBe(identity); + expect(stripAnsi(lines[1])).toContain("⏵ Ask"); + expect(stripAnsi(lines[1])).toContain("test-model"); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(180); + }); }); diff --git a/apps/cli/test/lark-connection.test.ts b/apps/cli/test/lark-connection.test.ts new file mode 100644 index 00000000..b7fb533a --- /dev/null +++ b/apps/cli/test/lark-connection.test.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionAPI, ExtensionCommandContext, UIDecisionRequest } from "@step-harness/coding-agent"; +import type * as LarkAdapter from "@step-harness/lark-adapter"; +import { createLarkConnectionExtension } from "../src/ui/runtime/lark-connection.ts"; + +const fixture = vi.hoisted(() => ({ + begin: vi.fn(), wait: vi.fn(), identity: vi.fn(), close: vi.fn(async () => {}), + start: vi.fn(async () => {}), stop: vi.fn(), send: vi.fn(async (_chat: string, _card: unknown) => "card"), update: vi.fn(async () => {}), + options: {} as Record, +})); +vi.mock("@step-harness/lark-adapter", async () => ({ + ...await vi.importActual("@step-harness/lark-adapter"), + LarkRegistration: class { begin = fixture.begin; waitForAuthorization = fixture.wait; }, + LarkApi: class { getBotIdentity = fixture.identity; close = fixture.close; sendCard = fixture.send; updateCard = fixture.update; }, + LarkTransport: class { + constructor(options: Record) { fixture.options = options; } + start = fixture.start; + chatId: string | undefined = "chat"; + close() { this.chatId = undefined; fixture.stop(); } + }, +})); + +describe("current-session Lark connection", () => { + beforeEach(() => { + vi.clearAllMocks(); + fixture.begin.mockResolvedValue({ deviceCode: "device", verificationUrl: "https://open.feishu.cn/page/cli", expiresInSeconds: 30, pollIntervalSeconds: 2 }); + fixture.wait.mockResolvedValue({ status: "authorized", credentials: { appId: "app", appSecret: "fixture", brand: "feishu" } }); + fixture.identity.mockResolvedValue({ openId: "bot", name: "Step" }); + }); + + function setup(mode: "tui" | "print" = "tui") { + let command: (args: string, context: ExtensionCommandContext) => Promise = async () => {}; + const submit = vi.fn(); + const notify = vi.fn(); + const widget = vi.fn(); + const status = vi.fn(); + const handlers = new Map unknown>(); + let decision: ((request: UIDecisionRequest) => void) | undefined; + const unsubscribe = vi.fn(() => { decision = undefined; }); + const pi = { + registerCommand: (_name: string, options: { handler: typeof command }) => { command = options.handler; }, + on: (name: string, handler: (...args: unknown[]) => unknown) => { handlers.set(name, handler); }, + sendUserMessage: submit, + } as unknown as ExtensionAPI; + const context = { mode, sessionManager: { getSessionId: () => "session-fixture" }, model: { id: "step-3.5-flash" }, thinkingLevel: "high", ui: { notify, setStatus: status, setWidget: widget, onDecision: (listener: (request: UIDecisionRequest) => void) => { decision = listener; return unsubscribe; } } } as unknown as ExtensionCommandContext; + createLarkConnectionExtension()(pi); + return { command: (args: string) => command(args, context), context, submit, notify, widget, status, handlers, decision: (request: UIDecisionRequest) => decision?.(request), unsubscribe }; + } + + it("shows callback setup only after connecting and removes it on disconnect", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(harness.widget).toHaveBeenCalledWith("lark-pairing", [ + "Lark connected. Enable card callbacks for remote approvals:", + expect.stringContaining("https://open.feishu.cn/page/launcher?clientID=app&addons="), + "Confirm card.action.trigger in the browser. Keep this terminal connected.", + ])); + expect(JSON.stringify(harness.widget.mock.calls)).not.toContain("fixture"); + expect(harness.status).toHaveBeenCalledWith("lark", "lark · connected · @Step · session-fixture"); + expect(harness.notify).toHaveBeenCalledWith("connect · lark · connected · @Step · session-fixture"); + await harness.command("off"); + expect(harness.widget).toHaveBeenLastCalledWith("lark-pairing", undefined); + expect(harness.status).toHaveBeenLastCalledWith("lark", undefined); + }); + + it.each(["off", "lark off"])("notifies the bound chat before closing on %s", async (command) => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + await harness.command(command); + expect(fixture.send).toHaveBeenCalledExactlyOnceWith("chat", expect.objectContaining({ + elements: [{ tag: "div", text: { tag: "plain_text", content: "Terminal disconnected. This session no longer receives messages or handles remote approvals." } }], + })); + expect(fixture.send.mock.invocationCallOrder[0]).toBeLessThan(fixture.close.mock.invocationCallOrder[0]!); + }); + + it("cleans up even when the disconnect notification fails", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + fixture.send.mockRejectedValueOnce(new Error("private SDK response")); + await harness.command("off"); + expect(fixture.close).toHaveBeenCalledTimes(1); + expect(harness.notify).toHaveBeenLastCalledWith("connect · lark · disconnected"); + expect(JSON.stringify(harness.notify.mock.calls)).not.toContain("private SDK response"); + }); + + it("updates the bound output card when terminal model and effort selections change", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + harness.handlers.get("before_agent_start")?.({ prompt: "hello" }, harness.context); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalled()); + expect(JSON.stringify(fixture.send.mock.calls)).toContain("Model: step-3.5-flash | Effort: high"); + harness.handlers.get("model_select")?.({ model: { id: "new-model" } }, harness.context); + await vi.waitFor(() => expect(JSON.stringify(fixture.update.mock.calls)).toContain("Model: new-model | Effort: high")); + harness.context.model = { id: "new-model" } as typeof harness.context.model; + harness.handlers.get("thinking_level_select")?.({ level: "low" }, harness.context); + await vi.waitFor(() => expect(JSON.stringify(fixture.update.mock.calls)).toContain("Model: new-model | Effort: low")); + await harness.command("off"); + const count = fixture.update.mock.calls.length; + harness.handlers.get("thinking_level_select")?.({ level: "high" }, harness.context); + await Promise.resolve(); + expect(fixture.update).toHaveBeenCalledTimes(count); + }); + + it("installs a full pairing component and does not announce success before the handshake", async () => { + let ready: () => void = () => {}; + fixture.start.mockReturnValueOnce(new Promise((resolve) => { ready = resolve; })); + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + expect(harness.widget).toHaveBeenCalledWith("lark-pairing", expect.any(Function)); + expect(harness.notify).not.toHaveBeenCalledWith(expect.stringContaining("connect · lark · connected")); + ready(); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith("connect · lark · connected · @Step · session-fixture")); + await harness.command("off"); + }); + + it("submits accepted input without a reaction API and rejects input during a dialog", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + const message = fixture.options.onMessage as (message: unknown) => void; + message({ id: "accepted", chatId: "chat", text: "hello", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.submit).toHaveBeenCalledTimes(1)); + harness.handlers.get("ui_prompt_start")?.({}); + message({ id: "blocked", chatId: "chat", text: "blocked", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith(expect.stringContaining("terminal dialog"), "warning")); + expect(harness.submit).toHaveBeenCalledTimes(1); + await harness.command("off"); + }); + + it("continues delivering queued input while an earlier reply card is pending", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + const message = fixture.options.onMessage as (message: unknown) => void; + message({ id: "first", chatId: "chat", text: "first", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.submit).toHaveBeenCalledTimes(1)); + let deliver: (id: string) => void = () => {}; + fixture.send.mockReturnValueOnce(new Promise((resolve) => { deliver = resolve; })); + harness.handlers.get("before_agent_start")?.({ prompt: "first" }, harness.context); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalled()); + message({ id: "second", chatId: "chat", text: "second", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.submit).toHaveBeenCalledTimes(2)); + deliver("reply"); + await Promise.resolve(); + harness.handlers.get("before_agent_start")?.({ prompt: "second" }, harness.context); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalledTimes(2)); + expect(JSON.stringify(fixture.send.mock.calls[1])).toContain("second"); + await harness.command("lark off"); + expect(harness.status).toHaveBeenLastCalledWith("lark", undefined); + expect(harness.notify).toHaveBeenLastCalledWith("connect · lark · disconnected"); + }); + + it("does not report an old turn's failed card as an output failure for the new turn", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + let fail: (error: Error) => void = () => {}; + fixture.send.mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + harness.handlers.get("before_agent_start")?.({ prompt: "first" }, harness.context); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalledTimes(1)); + harness.handlers.get("before_agent_start")?.({ prompt: "second" }, harness.context); + fail(new Error("old card failure")); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalledTimes(2)); + expect(harness.status).not.toHaveBeenCalledWith("lark", "Lark: output failed"); + await harness.command("off"); + }); + + it.each([ + ["begin", "pairing initialization"], + ["wait", "authorization polling"], + ["identity", "bot identity lookup"], + ["start", "WebSocket handshake"], + ] as const)("reports the %s failure stage without exposing raw errors", async (operation, stage) => { + fixture[operation].mockRejectedValueOnce(new Error("sensitive app_secret and request body")); + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith(`Lark connection failed during ${stage}. Retry /connect lark.`, "warning")); + expect(JSON.stringify(harness.notify.mock.calls)).not.toContain("sensitive"); + expect(harness.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it.each(["expired", "denied"])("reports %s authorization separately from connection failures", async (status) => { + fixture.wait.mockResolvedValueOnce({ status }); + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith(`Lark pairing ${status}. Retry /connect lark.`, "warning")); + expect(fixture.identity).not.toHaveBeenCalled(); + }); + + it("mirrors typed approvals, rejects invalid clicks and revokes local-completed cards", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + const controller = new AbortController(); + const choose = vi.fn(() => { controller.abort(); return true; }); + harness.decision({ kind: "confirm", title: "Approve bash", message: "Run a harmless fixture", options: ["Yes", "No"], signal: controller.signal, choose }); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalled()); + const card = fixture.send.mock.calls[0]?.[1] as { elements: { actions?: { value: { token: string; option: string } }[] }[] }; + const token = card.elements[1]?.actions?.[0]?.value.token; + const action = fixture.options.onCardAction as (value: unknown) => void; + action({ token, chatId: "other", option: "Yes" }); + action({ token, chatId: "chat", option: "invalid" }); + expect(choose).not.toHaveBeenCalled(); + action({ token, chatId: "chat", option: "Yes" }); + action({ token, chatId: "chat", option: "No" }); + expect(choose).toHaveBeenCalledExactlyOnceWith("Yes"); + await vi.waitFor(() => expect(fixture.update).toHaveBeenCalled()); + const local = new AbortController(); + const localChoice = vi.fn(() => true); + harness.decision({ kind: "select", title: "Choose", options: ["one"], signal: local.signal, choose: localChoice }); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalledTimes(2)); + const second = fixture.send.mock.calls[1]?.[1] as typeof card; + local.abort(); + action({ token: second.elements[1]?.actions?.[0]?.value.token, chatId: "chat", option: "one" }); + expect(localChoice).not.toHaveBeenCalled(); + await harness.command("off"); + expect(harness.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("does not begin pairing outside interactive mode", async () => { + const harness = setup("print"); + await harness.command("lark"); + expect(fixture.begin).not.toHaveBeenCalled(); + expect(harness.notify).toHaveBeenCalledWith("Lark connection requires interactive terminal mode.", "warning"); + }); + + it("disconnect revokes pending remote approvals without resolving the terminal request", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + const controller = new AbortController(); + const choose = vi.fn(() => true); + harness.decision({ kind: "confirm", title: "Approve", options: ["Yes", "No"], signal: controller.signal, choose }); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalled()); + const card = fixture.send.mock.calls[0]?.[1] as { elements: { actions?: { value: { token: string } }[] }[] }; + const action = fixture.options.onCardAction as (value: unknown) => void; + await harness.command("off"); + action({ token: card.elements[1]?.actions?.[0]?.value.token, chatId: "chat", option: "Yes" }); + expect(choose).not.toHaveBeenCalled(); + expect(controller.signal.aborted).toBe(false); + controller.abort(); + }); + + it("does not offer truncated approval content", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + harness.decision({ kind: "confirm", title: "Approve", message: "x".repeat(8001), options: ["Yes", "No"], signal: new AbortController().signal, choose: vi.fn(() => true) }); + expect(fixture.send).not.toHaveBeenCalled(); + expect(harness.notify).toHaveBeenCalledWith(expect.stringContaining("review it in full"), "warning"); + await harness.command("off"); + }); + + it("cancels pairing and ignores authorization arriving after disconnect", async () => { + let authorize: (value: unknown) => void = () => {}; + fixture.wait.mockReturnValueOnce(new Promise((resolve) => { authorize = resolve; })); + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.wait).toHaveBeenCalled()); + const signal = fixture.wait.mock.calls[0]?.[1] as AbortSignal; + await harness.command("off"); + expect(signal.aborted).toBe(true); + expect(fixture.send).not.toHaveBeenCalled(); + authorize({ status: "authorized", credentials: { appId: "app", appSecret: "fixture", brand: "feishu" } }); + await Promise.resolve(); + expect(fixture.identity).not.toHaveBeenCalled(); + expect(fixture.start).not.toHaveBeenCalled(); + }); + + it("closes on session shutdown and prevents duplicate connections", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + await harness.command("lark"); + expect(fixture.begin).toHaveBeenCalledTimes(1); + await harness.handlers.get("session_shutdown")?.(); + expect(fixture.send).not.toHaveBeenCalled(); + const message = fixture.options.onMessage as (message: unknown) => void; + message({ id: "late", chatId: "chat", text: "late", imageKeys: [], files: [] }); + await Promise.resolve(); + expect(harness.submit).not.toHaveBeenCalled(); + expect(fixture.stop).toHaveBeenCalledTimes(1); + expect(fixture.close).toHaveBeenCalledTimes(1); + }); + + it.each(["session_compact", "session_compact_failed"])("rejects input during compaction and recovers after %s", async (event) => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + const message = fixture.options.onMessage as (message: unknown) => void; + harness.handlers.get("session_before_compact")?.({}); + message({ id: "during", chatId: "chat", text: "during", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith(expect.stringContaining("compaction is active"), "warning")); + expect(harness.submit).not.toHaveBeenCalled(); + harness.handlers.get(event)?.({}); + message({ id: "after", chatId: "chat", text: "after", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.submit).toHaveBeenCalledTimes(1)); + await harness.command("off"); + }); + + it("pairs, submits through the existing session action and ignores late input after disconnect", async () => { + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + const message = fixture.options.onMessage as (message: unknown) => void; + message({ id: "m1", chatId: "chat", text: "hello", imageKeys: [], files: [] }); + await vi.waitFor(() => expect(harness.submit).toHaveBeenCalledWith([{ type: "text", text: "hello" }], { deliverAs: "followUp", expandPromptTemplates: false })); + harness.handlers.get("before_agent_start")?.({ prompt: "hello" }, harness.context); + await vi.waitFor(() => expect(fixture.send).toHaveBeenCalled()); + harness.handlers.get("message_end")?.({ message: { role: "assistant", content: [{ type: "text", text: "answer" }] } }); + harness.handlers.get("agent_settled")?.({}); + await vi.waitFor(() => expect(fixture.update).toHaveBeenCalledWith("card", expect.objectContaining({ header: expect.objectContaining({ title: { tag: "plain_text", content: "StepCode: done" } }) }))); + await harness.command("off"); + message({ id: "m2", chatId: "chat", text: "late", imageKeys: [], files: [] }); + expect(harness.submit).toHaveBeenCalledTimes(1); + expect(fixture.stop).toHaveBeenCalledTimes(1); + expect(fixture.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/test/lark-pairing.test.ts b/apps/cli/test/lark-pairing.test.ts new file mode 100644 index 00000000..fbc697c4 --- /dev/null +++ b/apps/cli/test/lark-pairing.test.ts @@ -0,0 +1,57 @@ +import { visibleWidth } from "@step-harness/pi-tui"; +import QRCode from "qrcode"; +import { describe, expect, it, vi } from "vitest"; +import { createLarkPairingView } from "../src/ui/view/dialogs/lark-pairing.ts"; + +const code = { + deviceCode: "never-display-device-code", + verificationUrl: "https://open.feishu.cn/page/cli?user_code=TEST-CODE&lpv=0.1.0&ocv=0.1.0&from=cli", + expiresInSeconds: 3600, + pollIntervalSeconds: 5, +}; + +describe("Lark pairing view", () => { + it("renders the complete encoder output and Step CLI pairing copy", async () => { + const view = await createLarkPairingView(code, () => 60); + const lines = view.render(120); + const expected = (await QRCode.toString(code.verificationUrl, { type: "terminal", small: true, errorCorrectionLevel: "L" })) + .trimEnd().split("\n").filter((line) => visibleWidth(line) > 0).map((line) => ` ${line} `); + expect(expected.length).toBeGreaterThan(10); + expect(lines.slice(2, 2 + expected.length)).toEqual(expected); + expect(lines.join("\n")).toContain("选择要绑定的机器人(可复用已有的)"); + expect(lines.join("\n")).toContain(code.verificationUrl); + expect(lines.join("\n")).toContain("验证码 TEST-CODE,60 分钟内有效。"); + expect(lines.join("\n")).not.toContain(code.deviceCode); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(120); + }); + + it("hides rather than clips the QR on narrow or short terminals and restores it on resize", async () => { + let rows = 60; + const view = await createLarkPairingView(code, () => rows); + const wide = view.render(120); + for (const width of [1, 20, 40]) { + const lines = view.render(width); + expect(lines.join("\n")).not.toMatch(/[▀▄█]/u); + for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + rows = 24; + expect(view.render(120).join("\n")).not.toMatch(/[▀▄█]/u); + expect(view.render(120).join("\n")).toContain(code.verificationUrl); + rows = 60; + view.invalidate(); + expect(view.render(120)).toEqual(wide); + }); + + it("retains the complete link when encoding fails and uses the actual lifetime", async () => { + const encoder = vi.spyOn(QRCode, "toString").mockRejectedValueOnce(new Error("encoder fixture")); + try { + const view = await createLarkPairingView({ ...code, expiresInSeconds: 30 }, () => 60); + const rendered = view.render(120).join("\n"); + expect(rendered).toContain(code.verificationUrl); + expect(rendered).toContain("验证码 TEST-CODE,30 秒内有效。"); + expect(rendered).not.toContain("encoder fixture"); + } finally { + encoder.mockRestore(); + } + }); +}); diff --git a/docs/command-permissions.md b/docs/command-permissions.md index 9ec86960..806229d3 100644 --- a/docs/command-permissions.md +++ b/docs/command-permissions.md @@ -26,6 +26,21 @@ Unsupported shells and shell-configuration failures still terminate because a model retry cannot repair the execution environment. Explicit per-tool denial still terminates, and dangerous commands remain blocked outside Full Access. +## Mirrored Approvals + +Interactive tool confirmations explicitly set `mirror: true` on the existing +`ui.confirm` request. A connected Lark screen can answer the same typed request; +it does not change the permission preset or create an unattended approval policy. +The first legal local or remote choice resolves the request and aborts its UI +signal, dismissing the terminal dialog and revoking the remote token. Cancellation +and runtime invalidation fail closed. Disconnect revokes the remote token but +leaves the terminal decision available. + +Other extension dialogs are terminal-only unless their caller explicitly opts in +with `mirror: true`. Never opt in secret input, login, or arbitrary custom UI. +The mirror refuses oversized decisions rather than showing a truncated approval. +See [Lark connection](lark-connection.md) for current limits and verification scope. + The product policy in `packages/coding-agent/src/step/permissions.ts` runs through the existing `tool_call` hook, before foreground/background execution. Clients render the existing confirmation request; they do not implement another policy. diff --git a/docs/lark-connection.md b/docs/lark-connection.md new file mode 100644 index 00000000..40030292 --- /dev/null +++ b/docs/lark-connection.md @@ -0,0 +1,74 @@ +# Current-Session Lark Connection + +In interactive terminal mode, `/connect lark` starts an in-memory pairing attempt. +Open the displayed link and authorize the personal agent. `/connect off` cancels +pairing or disconnects. The first valid message binds one chat to the current +session; group messages require a bot mention. Duplicate, stale and bot messages +are filtered by the adapter. There is no history backfill or background daemon. + +Pairing shows a terminal QR and the complete verification link, with the user code +and server-provided lifetime. Narrow or short terminals show the link without +clipping the QR. After the WebSocket handshake succeeds, a separate notification +shows `connect · lark · connected · @ · `. This confirms the +transport connection, not callback configuration or the first-message chat binding. +Disconnect discards local credentials; it does not revoke the app on the platform. + +Text and attachments use the existing session user-message action as follow-ups, +with remote slash-command execution disabled. Downloads are limited to 20 MiB per +resource and eight attachments per message, with at most sixteen pending inbound +deliveries. Files live in private temporary directories removed on disconnect. +Credentials stay in memory and SDK diagnostics are not logged. + +The composer footer below the input shows `lark · connected · @ · ` +on a separate row above the Ask/model/effort/workspace row, without repeating it +on the bottom row. Transport state changes update this indicator; disconnect +removes it. Narrow terminals truncate the bot name before hiding connection state. +Inbound messages do not receive receipt reactions; feedback is shown in reply +cards. `/connect off` (or `/connect lark off`) +clears the footer and confirms `connect · lark · disconnected`. +Explicit off commands also attempt a disconnect notification card in the bound +chat before closing REST resources. Delivery failure does not prevent cleanup; +unbound pairing and session shutdown do not send this notice. + +Pairing polling handles normal pending, slow-down, denied and expired outcomes +even when returned with HTTP 400. Failed connections report a static stage +(pairing initialization, authorization polling, bot identity lookup or WebSocket +handshake), never a raw response or SDK error. Creating the personal agent in the +browser does not confirm connection; wait for the terminal's paired notification. + +After connecting, the terminal shows a prefilled callback setup link using the +official CLI launcher addons contract. Open it and confirm `card.action.trigger` +while the terminal remains connected. This is user-confirmed configuration, not +silent provisioning; registration alone does not guarantee remote approvals. +The link contains the application ID and callback name, never credentials. +If the launcher does not support the personal agent, use the application's +Events & Callbacks > Callback Configuration page to select long-connection +delivery and add the new `card.action.trigger` callback (not the legacy version). +Follow the platform's publishing requirements before testing a fresh approval. + +Assistant text and tool-name activity update an ephemeral card. Completion waits +for `agent_settled`, not `agent_end`, so retry and automatic compaction do not mark +an unfinished run complete. No second agent loop or persistent transcript is used. +Output cards include the current model and effort. Terminal model and thinking +level selections update the current card, including after the turn has settled; +before the first output card, settings appear when the next turn begins. These +are read-only projections of the existing session, not remote configuration. + +Tool confirmations can be answered in the terminal or the bound Lark chat. The +first legal choice wins. Completion, cancellation and teardown revoke card tokens; +disconnect leaves an unresolved terminal dialog available. Login, text input, +editors and arbitrary custom components remain terminal-only. Large decision +content is not truncated into an approval card; review it in the terminal instead. + +## Current Limits + +Ordinary inbound messages received during a blocking terminal dialog or compaction +are rejected with a terminal warning; compaction-deferred delivery is not yet +implemented. Tool results and thinking content are not projected. Output cards +currently retain only the latest assistant text, not every message in a multi-tool +run. Session replacement, reload and quit use `session_shutdown` for disconnect. + +Offline tests cover protocol filtering, SDK cleanup, REST requests, bounded +downloads, pairing cancellation, session shutdown and first-wins decisions. +A live Lark handshake, interactive dual-screen acceptance and binary build have +not yet been verified. This feature remains work in progress. diff --git a/package.json b/package.json index d0146c9f..436b28ed 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "packageManager": "pnpm@9.15.9", "scripts": { "clean": "pnpm -r --if-present clean", - "build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../providers && npm run build && cd ../agent-core && npm run build && cd ../config && npm run build && cd ../coding-agent && npm run build && cd ../../apps/cli && npm run build", - "build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../providers && npm run build:offline && cd ../agent-core && npm run build && cd ../config && npm run build && cd ../coding-agent && npm run build && cd ../../apps/cli && npm run build", + "build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../providers && npm run build && cd ../agent-core && npm run build && cd ../config && npm run build && cd ../lark-adapter && npm run build && cd ../coding-agent && npm run build && cd ../../apps/cli && npm run build", + "build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../providers && npm run build:offline && cd ../agent-core && npm run build && cd ../config && npm run build && cd ../lark-adapter && npm run build && cd ../coding-agent && npm run build && cd ../../apps/cli && npm run build", "build:binaries": "bash scripts/build-binaries.sh --product step --offline-model-data", "check": "biome check --write --error-on-warnings . && pnpm run check:pinned-deps && pnpm run check:ts-imports && pnpm run check:layer-direction && pnpm run check:ui-layer && pnpm run check:workspace-registry && pnpm run check:tui-no-ai && pnpm run check:coding-agent-entry-freeze && pnpm run check:contracts-deps-empty && pnpm run check:derived-compat-only && pnpm run check:no-provider-dispatch && pnpm run check:metadata-not-in-dispatch && pnpm run check:no-secret-leak && pnpm run check:legacy-scope-prefix && pnpm run check:no-observability && pnpm run check:public-boundary && tsgo --noEmit && pnpm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 37768a5a..e2a7c66c 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -66,6 +66,7 @@ import type { UserBashEvent, UserBashEventResult, } from "./types.ts"; +import { UIDecisions } from "./ui-decisions.ts"; // Extension shortcuts compete with canonical keybinding ids from keybindings.json. // Only editor-global shortcuts are reserved here. Picker-specific bindings are not. @@ -300,6 +301,7 @@ export class ExtensionRunner { private staleMessage: string | undefined; private uiPromptDepth = 0; private activeUIPrompt: { kind: UIPromptKind; title?: string } | undefined; + private readonly uiDecisions = new UIDecisions(); constructor( extensions: Extension[], @@ -445,8 +447,27 @@ export class ExtensionRunner { private wrapUIPromptContext(ui: ExtensionUIContext): ExtensionUIContext { return { ...ui, - select: (title, options, opts) => this.withUIPrompt("select", title, () => ui.select(title, options, opts)), - confirm: (title, message, opts) => this.withUIPrompt("confirm", title, () => ui.confirm(title, message, opts)), + onDecision: (listener) => this.uiDecisions.subscribe(listener), + select: (title, options, opts) => + this.withUIPrompt("select", title, () => + opts?.mirror + ? this.uiDecisions.run( + { kind: "select", title, options }, + (signal) => ui.select(title, options, { ...opts, signal }), + opts.signal, + ) + : ui.select(title, options, opts), + ), + confirm: (title, message, opts) => + this.withUIPrompt("confirm", title, async () => + opts?.mirror + ? (await this.uiDecisions.run( + { kind: "confirm", title, message, options: ["Yes", "No"] }, + async (signal) => ((await ui.confirm(title, message, { ...opts, signal })) ? "Yes" : "No"), + opts.signal, + )) === "Yes" + : ui.confirm(title, message, opts), + ), input: (title, placeholder, opts) => this.withUIPrompt("input", title, () => ui.input(title, placeholder, opts)), editor: (title, prefill) => this.withUIPrompt("editor", title, () => ui.editor(title, prefill)), @@ -607,6 +628,7 @@ export class ExtensionRunner { message = "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", ): void { if (!this.staleMessage) { + this.uiDecisions.close(); this.staleMessage = message; this.runtime.invalidate(message); } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 08333157..7fc04617 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -84,6 +84,7 @@ import type { ReadToolInput, WriteToolInput, } from "../tools/index.ts"; +import type { UIDecisionRequest } from "./ui-decisions.ts"; export type { ExecOptions, ExecResult } from "../exec.ts"; export type { BuildSystemPromptOptions } from "../system-prompt.ts"; @@ -96,6 +97,8 @@ export type { AppKeybinding, KeybindingsManager } from "../keybindings.ts"; /** Options for extension UI dialogs. */ export interface ExtensionUIDialogOptions { + /** Explicitly allow an in-process mirror to display and resolve this select/confirm. Never use for secrets. */ + mirror?: boolean; /** AbortSignal to programmatically dismiss the dialog. */ signal?: AbortSignal; /** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */ @@ -173,6 +176,8 @@ export type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: Keybindi * Each mode (interactive, RPC, print) provides its own implementation. */ export interface ExtensionUIContext { + /** Observe explicitly mirrorable decisions; unsubscribe does not dismiss the local dialog. */ + onDecision?(listener: (request: UIDecisionRequest) => void): () => void; /** Show a selector and return the user's choice. */ select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise; diff --git a/packages/coding-agent/src/core/extensions/ui-decisions.ts b/packages/coding-agent/src/core/extensions/ui-decisions.ts new file mode 100644 index 00000000..63a38722 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/ui-decisions.ts @@ -0,0 +1,81 @@ +export interface UIDecisionRequest { + readonly kind: "select" | "confirm"; + readonly title: string; + readonly message?: string; + readonly options: readonly string[]; + /** Aborted after local/remote completion, timeout, cancellation or runtime teardown. */ + readonly signal: AbortSignal; + /** Returns false for an invalid, expired or already resolved choice. */ + choose(value: string): boolean; +} + +/** In-process UI arbitration only; permission policy remains with the caller. */ +export class UIDecisions { + private readonly listeners = new Set<(request: UIDecisionRequest) => void>(); + private readonly pending = new Set<() => void>(); + private closed = false; + + subscribe(listener: (request: UIDecisionRequest) => void): () => void { + if (this.closed) return () => {}; + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + run( + request: Pick, + local: (signal: AbortSignal) => Promise, + signal?: AbortSignal, + ): Promise { + if (this.closed || signal?.aborted) return Promise.resolve(undefined); + const controller = new AbortController(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (value: string | undefined, error?: unknown) => { + if (settled) return; + settled = true; + this.pending.delete(cancel); + signal?.removeEventListener("abort", cancel); + controller.abort(); + if (error !== undefined) reject(error); + else resolve(value); + }; + const cancel = () => finish(undefined); + this.pending.add(cancel); + signal?.addEventListener("abort", cancel, { once: true }); + const options = Object.freeze([...request.options]); + const choice = Object.freeze({ + ...request, + options, + signal: controller.signal, + choose: (value) => { + if (settled || !options.includes(value)) return false; + finish(value); + return true; + }, + }); + try { + // Mount the real local dialog before exposing the remote resolver. + void local(controller.signal).then( + (value) => finish(value !== undefined && options.includes(value) ? value : undefined), + (error: unknown) => finish(undefined, error), + ); + for (const listener of this.listeners) { + if (settled) break; + try { + listener(choice); + } catch { + /* A mirror failure must leave local approval available. */ + } + } + } catch (error: unknown) { + finish(undefined, error); + } + }); + } + + close(): void { + this.closed = true; + for (const cancel of this.pending) cancel(); + this.listeners.clear(); + } +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 9451c774..a5c42d74 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -202,6 +202,7 @@ export { wrapRegisteredTools, } from "./core/extensions/index.ts"; export type { ToolRenderContext } from "./core/extensions/types.ts"; +export type { UIDecisionRequest } from "./core/extensions/ui-decisions.ts"; // Footer data provider (git branch + extension statuses - data not otherwise available to extensions) export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts"; export { FooterDataProvider } from "./core/footer-data-provider.ts"; diff --git a/packages/coding-agent/src/step/permissions.ts b/packages/coding-agent/src/step/permissions.ts index c5db2317..edfaf6f3 100644 --- a/packages/coding-agent/src/step/permissions.ts +++ b/packages/coding-agent/src/step/permissions.ts @@ -647,7 +647,7 @@ export class StepPermissionController { const approved = await context.ui.confirm( `${decision.hazardous ? "Dangerous" : "Approve"} ${event.toolName} [${event.toolCallId.slice(-8)}]`, `Call: ${event.toolCallId}\n${decision.reason}\n\n${summarizeToolInput(event.toolName, input)}\n\nBatch calls may ask separately\nbefore approved tools begin running.`, - { signal: context.signal, overlay: true }, + { signal: context.signal, overlay: true, mirror: true }, ); if (approved) return undefined; return { block: true, reason: `Tool call denied: ${event.toolName}` }; diff --git a/packages/coding-agent/test/step-permissions.test.ts b/packages/coding-agent/test/step-permissions.test.ts index 42c25373..b29cb68c 100644 --- a/packages/coding-agent/test/step-permissions.test.ts +++ b/packages/coding-agent/test/step-permissions.test.ts @@ -262,12 +262,13 @@ describe("Step permission presets", () => { expect(confirm).toHaveBeenCalledWith( expect.stringMatching(new RegExp(`run_command.*${toolCallId.slice(-8)}`, "u")), expect.stringContaining(toolCallId), - { signal, overlay: true }, + { signal, overlay: true, mirror: true }, ); } expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("separately"), { signal, overlay: true, + mirror: true, }); }); diff --git a/packages/coding-agent/test/ui-decisions.test.ts b/packages/coding-agent/test/ui-decisions.test.ts new file mode 100644 index 00000000..ccb1dcd9 --- /dev/null +++ b/packages/coding-agent/test/ui-decisions.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { createExtensionRuntime } from "../src/core/extensions/loader.ts"; +import { ExtensionRunner } from "../src/core/extensions/runner.ts"; +import type { ExtensionUIContext } from "../src/core/extensions/types.ts"; +import { type UIDecisionRequest, UIDecisions } from "../src/core/extensions/ui-decisions.ts"; +import type { ModelRegistry } from "../src/core/model-registry.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; + +describe("typed UI decisions", () => { + it("exposes only opt-in dialogs through the runner and invalidates their resolvers", async () => { + const runner = new ExtensionRunner( + [], + createExtensionRuntime(), + process.cwd(), + SessionManager.inMemory(), + {} as ModelRegistry, + ); + const confirm = vi.fn(async () => false); + const select = vi.fn(() => new Promise(() => {})); + runner.setUIContext({ confirm, select } as unknown as ExtensionUIContext, "tui"); + const ui = runner.getUIContext(); + const requests: UIDecisionRequest[] = []; + ui.onDecision?.((request) => requests.push(request)); + await expect(ui.confirm("Login", "Private fixture")).resolves.toBe(false); + expect(requests).toHaveLength(0); + const approval = ui.confirm("Approve", "Harmless command", { mirror: true }); + expect(requests[0]?.choose("Yes")).toBe(true); + await expect(approval).resolves.toBe(true); + const selection = ui.select("Choose", ["one", "two"], { mirror: true }); + runner.invalidate(); + await expect(selection).resolves.toBeUndefined(); + expect(requests[1]?.choose("two")).toBe(false); + }); + it("accepts the first legal remote choice and dismisses the real local dialog", async () => { + const decisions = new UIDecisions(); + let request: UIDecisionRequest | undefined; + decisions.subscribe((value) => { + request = value; + }); + let localSignal: AbortSignal | undefined; + const result = decisions.run({ kind: "confirm", title: "Approve", options: ["Yes", "No"] }, (signal) => { + localSignal = signal; + return new Promise((resolve) => signal.addEventListener("abort", () => resolve(undefined), { once: true })); + }); + expect(request?.choose("invalid")).toBe(false); + expect(request?.choose("Yes")).toBe(true); + expect(request?.choose("No")).toBe(false); + expect(localSignal?.aborted).toBe(true); + await expect(result).resolves.toBe("Yes"); + }); + + it("revokes remote choices when the terminal wins", async () => { + const decisions = new UIDecisions(); + let request: UIDecisionRequest | undefined; + decisions.subscribe((value) => { + request = value; + }); + const result = decisions.run({ kind: "select", title: "Choose", options: ["one", "two"] }, async () => "two"); + await expect(result).resolves.toBe("two"); + expect(request?.signal.aborted).toBe(true); + expect(request?.choose("one")).toBe(false); + }); + + it("fails closed when the local selector returns an invalid option", async () => { + const decisions = new UIDecisions(); + let request: UIDecisionRequest | undefined; + decisions.subscribe((value) => { + request = value; + }); + await expect( + decisions.run({ kind: "select", title: "Choose", options: ["one"] }, async () => "invalid"), + ).resolves.toBeUndefined(); + expect(request?.signal.aborted).toBe(true); + expect(request?.choose("one")).toBe(false); + expect(Object.isFrozen(request)).toBe(true); + expect(Object.isFrozen(request?.options)).toBe(true); + }); + + it("fails closed on cancellation or runtime teardown", async () => { + const decisions = new UIDecisions(); + const requests: UIDecisionRequest[] = []; + decisions.subscribe((value) => requests.push(value)); + const controller = new AbortController(); + const local = vi.fn(() => new Promise(() => {})); + const first = decisions.run( + { kind: "confirm", title: "Approve", options: ["Yes", "No"] }, + local, + controller.signal, + ); + controller.abort(); + await expect(first).resolves.toBeUndefined(); + const second = decisions.run({ kind: "confirm", title: "Approve", options: ["Yes", "No"] }, local); + decisions.close(); + await expect(second).resolves.toBeUndefined(); + for (const request of requests) expect(request.choose("Yes")).toBe(false); + }); +}); diff --git a/packages/lark-adapter/AGENTS.md b/packages/lark-adapter/AGENTS.md new file mode 100644 index 00000000..6070df4f --- /dev/null +++ b/packages/lark-adapter/AGENTS.md @@ -0,0 +1,7 @@ +# Lark Adapter Boundary + +- External Feishu/Lark protocol and SDK only. No internal runtime, session, tool or UI imports. +- Credentials remain in memory. SDK diagnostics are silent; never log raw SDK errors or payloads. +- One socket per connection. Close forcefully and idempotently; late callbacks cannot reach the host. +- Tests use offline protocol and SDK fixtures, never live credentials or network services. +- Chat binding and decision tokens are input state, not a session or transcript store. diff --git a/packages/lark-adapter/lark-boundary.md b/packages/lark-adapter/lark-boundary.md new file mode 100644 index 00000000..604dcb9a --- /dev/null +++ b/packages/lark-adapter/lark-boundary.md @@ -0,0 +1,41 @@ +# Lark Adapter + +This package owns the external Feishu/Lark protocol boundary. It has no internal +workspace dependencies and does not own an agent, permission policy or transcript. + +## Registration + +Create one `LarkRegistration` per pairing attempt. `begin(signal)` returns the +verification URL and device code. `waitForAuthorization(code, signal)` polls at +the supplied interval, adds five seconds after slow-down and stops at expiry or +cancellation. Keep returned credentials in memory only. + +## Transport + +`LarkTransport` requires credentials and the bot's verified open ID. Its host +supplies message, card-action and health callbacks. `start()` initiates the +connection; only the handshake callback reports connected. The first valid input +binds the chat. Group inputs require a bot mention. Card actions must carry the +bound chat ID, token and option; the host must additionally validate the token +against `LarkDecisions` and the current UI request. + +`close()` forcefully closes the socket, clears routing state and suppresses late +callbacks. A transport cannot restart. SDK logs are silent because SDK errors may +contain credentials and stdout belongs to the terminal renderer. + +The SDK is pinned to 1.76.0. Version 1.51.0, used in the reference manifest, lacks +the public close and health callback APIs required for safe teardown. No private +SDK fields or compatibility casts are used. + +## Current Limits + +`LarkApi` discovers the bot identity, sends/updates cards and downloads attachment +resources with cancellation and a streaming 20 MiB limit. Private temporary files +are removed on close. Message callbacks contain references; the CLI chooses when +to download them and routes accepted content through the existing session action. + +`LarkDecisions` owns random, chat-scoped card tokens, not permission policy or UI +requests. The host revokes each token when its typed UI decision closes. CLI +composition is documented in [Lark connection](../../docs/lark-connection.md). +Tests are offline, including a real SDK pre-start cleanup test; no live handshake +has been verified. diff --git a/packages/lark-adapter/package.json b/packages/lark-adapter/package.json new file mode 100644 index 00000000..9a6757eb --- /dev/null +++ b/packages/lark-adapter/package.json @@ -0,0 +1,11 @@ +{ + "name": "@step-harness/lark-adapter", + "version": "0.84.4", + "private": true, + "type": "module", + "description": "Abortable Feishu/Lark pairing and single-chat transport without agent or UI dependencies", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "scripts": { "build": "tsgo -p tsconfig.build.json", "test": "vitest --run" }, + "dependencies": { "@larksuiteoapi/node-sdk": "1.76.0" }, + "devDependencies": { "@types/node": "22.19.19", "vitest": "4.1.9" } +} diff --git a/packages/lark-adapter/src/api.ts b/packages/lark-adapter/src/api.ts new file mode 100644 index 00000000..4ac7fd5d --- /dev/null +++ b/packages/lark-adapter/src/api.ts @@ -0,0 +1,176 @@ +import { createWriteStream } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { Client, LoggerLevel } from "@larksuiteoapi/node-sdk"; +import { LARK_OPEN_HOSTS, type LarkCredentials } from "./registration.ts"; + +/** In-memory authenticated REST boundary; never forwards SDK diagnostics. */ +export class LarkApi { + private readonly client: Client; + private readonly controller = new AbortController(); + private readonly downloads = new Set>(); + private readonly directories = new Set(); + private closing: Promise | undefined; + + constructor(credentials: LarkCredentials) { + this.client = new Client({ + appId: credentials.appId, + appSecret: credentials.appSecret, + domain: LARK_OPEN_HOSTS[credentials.brand], + logger: { error() {}, warn() {}, info() {}, debug() {}, trace() {} }, + loggerLevel: LoggerLevel.error, + }); + } + + async getBotIdentity(): Promise<{ openId: string; name: string }> { + try { + this.controller.signal.throwIfAborted(); + const response = await this.client.request<{ code?: number; bot?: { open_id?: string; app_name?: string } }>({ + url: "/open-apis/bot/v3/info", + method: "GET", + signal: this.controller.signal, + timeout: 15_000, + }); + this.controller.signal.throwIfAborted(); + if (response.code !== 0 || typeof response.bot?.open_id !== "string" || !response.bot.open_id) + throw new Error("Invalid bot identity"); + return { + openId: response.bot.open_id, + name: typeof response.bot.app_name === "string" ? response.bot.app_name : "bot", + }; + } catch { + throw new Error("Lark bot identity request failed"); + } + } + + async sendCard(chatId: string, card: Record, replyTo?: string): Promise { + try { + this.controller.signal.throwIfAborted(); + const response = await this.client.request<{ code?: number; data?: { message_id?: string } }>({ + url: replyTo + ? `/open-apis/im/v1/messages/${encodeURIComponent(replyTo)}/reply` + : "/open-apis/im/v1/messages", + method: "POST", + signal: this.controller.signal, + timeout: 15_000, + params: replyTo ? undefined : { receive_id_type: "chat_id" }, + data: { + ...(replyTo ? {} : { receive_id: chatId }), + msg_type: "interactive", + content: JSON.stringify(card), + }, + }); + this.controller.signal.throwIfAborted(); + if (response.code !== 0 || typeof response.data?.message_id !== "string" || !response.data.message_id) + throw new Error("Invalid card response"); + return response.data.message_id; + } catch { + throw new Error("Lark card send failed"); + } + } + + async updateCard(messageId: string, card: Record): Promise { + try { + this.controller.signal.throwIfAborted(); + const response = await this.client.request<{ code?: number }>({ + url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`, + method: "PATCH", + signal: this.controller.signal, + timeout: 15_000, + data: { content: JSON.stringify(card) }, + }); + this.controller.signal.throwIfAborted(); + if (response.code !== 0) throw new Error("Invalid card response"); + } catch { + throw new Error("Lark card update failed"); + } + } + + async download( + messageId: string, + key: string, + name: string, + type: "image" | "file", + ): Promise<{ path: string; size: number }> { + const operation = this.downloadResource(messageId, key, name, type); + this.downloads.add(operation); + try { + return await operation; + } finally { + this.downloads.delete(operation); + } + } + + private async downloadResource( + messageId: string, + key: string, + name: string, + type: "image" | "file", + ): Promise<{ path: string; size: number }> { + let stream: Readable | undefined; + let directory: string | undefined; + let oversized = false; + const signal = AbortSignal.any([this.controller.signal, AbortSignal.timeout(30_000)]); + try { + signal.throwIfAborted(); + const response = await this.client.request({ + url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(key)}`, + method: "GET", + params: { type }, + responseType: "stream", + signal, + timeout: 15_000, + }); + if (!(response instanceof Readable)) throw new Error("Invalid resource stream"); + stream = response; + signal.throwIfAborted(); + directory = await mkdtemp(join(tmpdir(), "step-lark-")); + this.directories.add(directory); + signal.throwIfAborted(); + const safeName = name + .replaceAll("\\", "/") + .split("/") + .pop() + ?.replace(/[\x00-\x1f\x7f]/g, "_") + .slice(0, 120); + const path = join(directory, !safeName || safeName === "." || safeName === ".." ? "attachment" : safeName); + let size = 0; + const limit = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + size += chunk.length; + if (size > 20 * 1024 * 1024) { + oversized = true; + callback(new Error("Oversized attachment")); + } else callback(null, chunk); + }, + }); + await pipeline(stream, limit, createWriteStream(path, { flags: "wx", mode: 0o600 }), { signal }); + signal.throwIfAborted(); + return { path, size }; + } catch { + stream?.destroy(); + if (directory) { + await rm(directory, { recursive: true, force: true }); + this.directories.delete(directory); + } + throw new Error(oversized ? "Lark attachment exceeds 20 MiB" : "Lark attachment download failed"); + } + } + + close(): Promise { + if (!this.closing) { + this.controller.abort(); + this.closing = (async () => { + await Promise.allSettled(this.downloads); + await Promise.all( + [...this.directories].map((directory) => rm(directory, { recursive: true, force: true })), + ); + this.directories.clear(); + })(); + } + return this.closing; + } +} diff --git a/packages/lark-adapter/src/decisions.ts b/packages/lark-adapter/src/decisions.ts new file mode 100644 index 00000000..e7a621ec --- /dev/null +++ b/packages/lark-adapter/src/decisions.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; + +/** Ephemeral card tokens. Local completion and teardown revoke them before any late click. */ +export class LarkDecisions { + private readonly pending = new Map< + string, + { chatId: string; options: readonly string[]; resolve: (value: string) => void } + >(); + private closed = false; + + open(chatId: string, options: readonly string[], resolve: (value: string) => void): string { + if (this.closed) throw new Error("Lark decisions closed"); + const token = randomUUID(); + this.pending.set(token, { chatId, options: [...options], resolve }); + return token; + } + + choose(token: string, chatId: string, value: string): boolean { + const request = this.pending.get(token); + if (!request || request.chatId !== chatId || !request.options.includes(value)) return false; + this.pending.delete(token); + request.resolve(value); + return true; + } + + dismiss(token: string): void { + this.pending.delete(token); + } + close(): void { + this.closed = true; + this.pending.clear(); + } +} diff --git a/packages/lark-adapter/src/inbound.ts b/packages/lark-adapter/src/inbound.ts new file mode 100644 index 00000000..d370d25c --- /dev/null +++ b/packages/lark-adapter/src/inbound.ts @@ -0,0 +1,109 @@ +export interface LarkMessage { + id: string; + chatId: string; + text: string; + imageKeys: string[]; + files: { key: string; name: string }[]; +} + +export type LarkInboundResult = { action: "accept"; message: LarkMessage } | { action: "ignore"; reason: string }; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +/** Input-only state, not a session or transcript. Invalid events cannot claim the chat binding. */ +export class LarkInboundGate { + private readonly botId: string; + private readonly seen = new Map(); + private boundChat: string | undefined; + private closed = false; + + constructor(botId: string) { + this.botId = botId; + } + get chatId(): string | undefined { + return this.boundChat; + } + + accept(event: unknown, now = Date.now()): LarkInboundResult { + const message = record(record(event).message); + const sender = record(record(record(event).sender).sender_id); + const { message_id: id, chat_id: chatId, message_type: type } = message; + if ( + this.closed || + typeof id !== "string" || + !id || + typeof chatId !== "string" || + !chatId || + sender.open_id === this.botId + ) { + return { action: "ignore", reason: "malformed" }; + } + const timestamp = Number(message.create_time); + if ( + !Number.isFinite(timestamp) || + timestamp <= 0 || + now - (timestamp < 1e12 ? timestamp * 1000 : timestamp) > 600_000 + ) { + return { action: "ignore", reason: "stale" }; + } + const mentions = Array.isArray(message.mentions) ? message.mentions.map(record) : []; + if (message.chat_type !== "p2p" && !mentions.some((mention) => record(mention.id).open_id === this.botId)) { + return { action: "ignore", reason: "not-mentioned" }; + } + let content: Record; + try { + content = record(JSON.parse(typeof message.content === "string" ? message.content : "")); + } catch { + return { action: "ignore", reason: "malformed" }; + } + let text = ""; + const imageKeys: string[] = []; + const files: LarkMessage["files"] = []; + if (type === "text") text = typeof content.text === "string" ? content.text : ""; + else if (type === "image" && typeof content.image_key === "string" && content.image_key) + imageKeys.push(content.image_key); + else if (type === "file" && typeof content.file_key === "string" && content.file_key) { + files.push({ + key: content.file_key, + name: typeof content.file_name === "string" ? content.file_name : "file", + }); + } else if (type === "post") { + const body = record(content.zh_cn ?? content.en_us ?? content); + const lines: string[] = typeof body.title === "string" ? [body.title] : []; + for (const row of Array.isArray(body.content) ? body.content : []) { + let line = ""; + for (const raw of Array.isArray(row) ? row : []) { + const node = record(raw); + if (node.tag === "text" && typeof node.text === "string") line += node.text; + if (node.tag === "a" && typeof node.href === "string") line += node.href; + if (node.tag === "img" && typeof node.image_key === "string" && node.image_key) + imageKeys.push(node.image_key); + } + lines.push(line); + } + text = lines.join("\n"); + } else return { action: "ignore", reason: "unsupported-type" }; + for (const mention of mentions) { + if (record(mention.id).open_id === this.botId && typeof mention.key === "string" && mention.key) { + text = text.replaceAll(mention.key, ""); + } + } + text = text.trim(); + if (!text && !imageKeys.length && !files.length) return { action: "ignore", reason: "empty" }; + if (this.boundChat && this.boundChat !== chatId) return { action: "ignore", reason: "other-chat" }; + for (const [key, firstSeen] of this.seen) if (now - firstSeen > 60_000) this.seen.delete(key); + if (this.seen.has(id)) return { action: "ignore", reason: "duplicate" }; + if (this.seen.size >= 4096) return { action: "ignore", reason: "rate-limit" }; + this.seen.set(id, now); + this.boundChat = chatId; + return { action: "accept", message: { id, chatId, text, imageKeys, files } }; + } + + close(): void { + this.closed = true; + this.boundChat = undefined; + this.seen.clear(); + } +} diff --git a/packages/lark-adapter/src/index.ts b/packages/lark-adapter/src/index.ts new file mode 100644 index 00000000..901f3859 --- /dev/null +++ b/packages/lark-adapter/src/index.ts @@ -0,0 +1,8 @@ +export { LarkApi } from "./api.ts"; +export { LarkDecisions } from "./decisions.ts"; +export type { LarkInboundResult, LarkMessage } from "./inbound.ts"; +export { LarkInboundGate } from "./inbound.ts"; +export type { LarkBrand, LarkCredentials, LarkPairingCode, LarkPairingResult } from "./registration.ts"; +export { LARK_OPEN_HOSTS, LarkRegistration, larkCallbackSetupUrl } from "./registration.ts"; +export type { LarkTransportOptions } from "./transport.ts"; +export { LarkTransport } from "./transport.ts"; diff --git a/packages/lark-adapter/src/registration.ts b/packages/lark-adapter/src/registration.ts new file mode 100644 index 00000000..bad20c8e --- /dev/null +++ b/packages/lark-adapter/src/registration.ts @@ -0,0 +1,191 @@ +import { gzipSync } from "node:zlib"; + +export type LarkBrand = "feishu" | "lark"; + +export interface LarkCredentials { + appId: string; + appSecret: string; + brand: LarkBrand; +} + +export interface LarkPairingCode { + deviceCode: string; + verificationUrl: string; + expiresInSeconds: number; + pollIntervalSeconds: number; +} + +export type LarkPairingResult = + | { status: "authorized"; credentials: LarkCredentials } + | { status: "pending" | "slow-down" | "denied" | "expired" }; + +type FormPoster = (url: string, form: Record, signal: AbortSignal) => Promise>; + +const ACCOUNTS = { feishu: "https://accounts.feishu.cn", lark: "https://accounts.larksuite.com" }; +export const LARK_OPEN_HOSTS = { feishu: "https://open.feishu.cn", lark: "https://open.larksuite.com" }; + +/** Official CLI launcher contract; the user must confirm the configuration. */ +export function larkCallbackSetupUrl(credentials: LarkCredentials): string { + const addons = gzipSync(JSON.stringify({ callbacks: { items: ["card.action.trigger"] } })).toString("base64url"); + const params = new URLSearchParams({ clientID: credentials.appId, addons }); + return `${LARK_OPEN_HOSTS[credentials.brand]}/page/launcher?${params}`; +} + +async function postForm( + url: string, + form: Record, + signal: AbortSignal, +): Promise> { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(form), + signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]), + }); + const data: unknown = await response.json(); + if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid Lark pairing response"); + if (!response.ok) { + const error = (data as Record).error; + // Device authorization can report normal polling outcomes with HTTP 400. + if ( + form.action !== "poll" || + response.status !== 400 || + typeof error !== "string" || + !["authorization_pending", "slow_down", "access_denied", "expired_token"].includes(error) + ) + throw new Error(`Lark pairing request failed (HTTP ${response.status})`); + return { error, user_info: (data as Record).user_info }; + } + return data as Record; +} + +/** One in-memory registration attempt; credentials never enter configuration or diagnostics. */ +export class LarkRegistration { + private readonly version: string; + private readonly post: FormPoster; + private brand: LarkBrand = "feishu"; + private brandResolved = false; + + constructor(options: { version: string; post?: FormPoster }) { + this.version = options.version; + this.post = options.post ?? postForm; + } + + async begin(signal: AbortSignal): Promise { + signal.throwIfAborted(); + const data = await this.post( + `${ACCOUNTS.feishu}/oauth/v1/app/registration`, + { + action: "begin", + archetype: "PersonalAgent", + auth_method: "client_secret", + request_user_info: "open_id tenant_brand", + }, + signal, + ); + signal.throwIfAborted(); + const expires = data.expires_in ?? data.expire_in ?? 300; + const interval = data.interval ?? 5; + if ( + typeof data.device_code !== "string" || + !data.device_code || + typeof data.user_code !== "string" || + !data.user_code || + typeof expires !== "number" || + !Number.isFinite(expires) || + expires <= 0 || + typeof interval !== "number" || + !Number.isFinite(interval) || + interval <= 0 + ) + throw new Error("Invalid Lark pairing response"); + const params = new URLSearchParams({ + user_code: data.user_code, + lpv: this.version, + ocv: this.version, + from: "cli", + }); + return { + deviceCode: data.device_code, + verificationUrl: `${LARK_OPEN_HOSTS.feishu}/page/cli?${params}`, + expiresInSeconds: expires, + pollIntervalSeconds: interval, + }; + } + + async poll(deviceCode: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const data = await this.post( + `${ACCOUNTS[this.brand]}/oauth/v1/app/registration`, + { + action: "poll", + device_code: deviceCode, + }, + signal, + ); + signal.throwIfAborted(); + const info = data.user_info; + if (!this.brandResolved && info && typeof info === "object" && !Array.isArray(info)) { + const raw = (info as Record).tenant_brand; + if (typeof raw === "string") { + const brand = raw.toLowerCase(); + if (brand.includes("lark") || brand.includes("feishu")) { + this.brand = brand.includes("lark") ? "lark" : "feishu"; + this.brandResolved = true; + } + } + } + if ( + typeof data.client_id === "string" && + data.client_id && + typeof data.client_secret === "string" && + data.client_secret + ) { + return { + status: "authorized", + credentials: { appId: data.client_id, appSecret: data.client_secret, brand: this.brand }, + }; + } + if (!data.error || data.error === "authorization_pending") return { status: "pending" }; + if (data.error === "slow_down") return { status: "slow-down" }; + if (data.error === "expired_token") return { status: "expired" }; + return { status: "denied" }; + } + + async waitForAuthorization(code: LarkPairingCode, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const deadline = Date.now() + code.expiresInSeconds * 1000; + let interval = code.pollIntervalSeconds * 1000; + while (Date.now() < deadline) { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + signal.removeEventListener("abort", abort); + resolve(); + }, + Math.min(interval, deadline - Date.now()), + ); + const abort = () => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + reject(signal.reason); + }; + signal.addEventListener("abort", abort, { once: true }); + }); + signal.throwIfAborted(); + if (Date.now() >= deadline) break; + const remaining = AbortSignal.timeout(Math.max(1, deadline - Date.now())); + let result: LarkPairingResult; + try { + result = await this.poll(code.deviceCode, AbortSignal.any([signal, remaining])); + } catch (error) { + signal.throwIfAborted(); + if (remaining.aborted) return { status: "expired" }; + throw error; + } + if (result.status === "slow-down") interval += 5000; + else if (result.status !== "pending") return result; + } + return { status: "expired" }; + } +} diff --git a/packages/lark-adapter/src/transport.ts b/packages/lark-adapter/src/transport.ts new file mode 100644 index 00000000..7b6d6d1e --- /dev/null +++ b/packages/lark-adapter/src/transport.ts @@ -0,0 +1,96 @@ +import { EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk"; +import { LarkInboundGate, type LarkMessage } from "./inbound.ts"; +import { LARK_OPEN_HOSTS, type LarkCredentials } from "./registration.ts"; + +export interface LarkTransportOptions { + credentials: LarkCredentials; + botId: string; + onMessage: (message: LarkMessage) => void; + onCardAction: (action: { chatId: string; token: string; option: string }) => void; + onState: (state: "connected" | "degraded") => void; +} + +// SDK diagnostics can contain request credentials and must never reach the terminal. +const silentLogger = { error() {}, warn() {}, info() {}, debug() {}, trace() {} }; + +/** Single-use socket; owns input routing only, never an agent or transcript. */ +export class LarkTransport { + private readonly gate: LarkInboundGate; + private readonly socket: WSClient; + private readonly dispatcher: EventDispatcher; + private closed = false; + private started = false; + + constructor(options: LarkTransportOptions) { + this.gate = new LarkInboundGate(options.botId); + this.dispatcher = new EventDispatcher({ logger: silentLogger, loggerLevel: LoggerLevel.error }).register({ + "im.message.receive_v1": (event) => { + const result = this.gate.accept(event); + if (result.action === "accept") options.onMessage(result.message); + return {}; + }, + "card.action.trigger": (event: { + context?: { open_chat_id?: unknown }; + action?: { value?: { token?: unknown; option?: unknown } }; + }) => { + const chatId = event?.context?.open_chat_id; + const value = event?.action?.value; + if ( + !this.closed && + typeof chatId === "string" && + chatId && + chatId === this.gate.chatId && + typeof value?.token === "string" && + value.token && + typeof value.option === "string" && + value.option + ) { + options.onCardAction({ chatId, token: value.token, option: value.option }); + } + return {}; + }, + }); + this.socket = new WSClient({ + appId: options.credentials.appId, + appSecret: options.credentials.appSecret, + domain: LARK_OPEN_HOSTS[options.credentials.brand], + logger: silentLogger, + loggerLevel: LoggerLevel.error, + handshakeTimeoutMs: 15_000, + onReady: () => { + if (!this.closed) options.onState("connected"); + }, + onReconnected: () => { + if (!this.closed) options.onState("connected"); + }, + onReconnecting: () => { + if (!this.closed) options.onState("degraded"); + }, + onError: () => { + if (!this.closed) options.onState("degraded"); + }, + }); + } + + get chatId(): string | undefined { + return this.gate.chatId; + } + + async start(): Promise { + if (this.closed || this.started) throw new Error("Lark transport cannot be restarted"); + this.started = true; + try { + await this.socket.start({ eventDispatcher: this.dispatcher }); + } catch { + this.close(); + throw new Error("Lark transport failed to start"); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.gate.close(); + this.socket.close({ force: true }); + } +} diff --git a/packages/lark-adapter/test/api.test.ts b/packages/lark-adapter/test/api.test.ts new file mode 100644 index 00000000..5b5c5f4d --- /dev/null +++ b/packages/lark-adapter/test/api.test.ts @@ -0,0 +1,115 @@ +import { access, readFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { Readable } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LarkApi } from "../src/api.ts"; + +const sdk = vi.hoisted(() => ({ request: vi.fn(), options: {} as Record })); +vi.mock("@larksuiteoapi/node-sdk", () => ({ + LoggerLevel: { error: 1 }, + Client: class { + constructor(options: Record) { + sdk.options = options; + } + request = sdk.request; + }, +})); + +function api() { + return new LarkApi({ appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "lark" }); +} + +describe("Lark REST boundary", () => { + beforeEach(() => vi.clearAllMocks()); + it("queries and validates bot identity without exposing raw API errors", async () => { + const client = api(); + try { + sdk.request.mockResolvedValueOnce({ code: 0, bot: { open_id: "bot", app_name: "Step" } }); + expect(await client.getBotIdentity()).toEqual({ openId: "bot", name: "Step" }); + expect(sdk.request).toHaveBeenCalledWith( + expect.objectContaining({ url: "/open-apis/bot/v3/info", method: "GET" }), + ); + sdk.request.mockResolvedValueOnce({ code: 999, msg: "secret", bot: { open_id: "bot" } }); + await expect(client.getBotIdentity()).rejects.toThrow("Lark bot identity request failed"); + } finally { + await client.close(); + } + }); + it("sends, replies and updates cards while rejecting API failures", async () => { + const client = api(); + const card = { elements: [{ tag: "markdown", content: "hello" }] }; + try { + sdk.request.mockResolvedValue({ code: 0, data: { message_id: "card" } }); + expect(await client.sendCard("chat", card)).toBe("card"); + expect(sdk.request).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: "/open-apis/im/v1/messages", + method: "POST", + params: { receive_id_type: "chat_id" }, + data: { receive_id: "chat", msg_type: "interactive", content: JSON.stringify(card) }, + }), + ); + expect(await client.sendCard("chat", card, "request/1")).toBe("card"); + expect(sdk.request).toHaveBeenLastCalledWith( + expect.objectContaining({ url: "/open-apis/im/v1/messages/request%2F1/reply" }), + ); + await client.updateCard("card", card); + expect(sdk.request).toHaveBeenLastCalledWith( + expect.objectContaining({ method: "PATCH", url: "/open-apis/im/v1/messages/card" }), + ); + sdk.request.mockResolvedValueOnce({ code: 999, msg: "private payload" }); + await expect(client.updateCard("card", card)).rejects.toThrow("Lark card update failed"); + } finally { + await client.close(); + } + }); + it("downloads bounded streams to unique safe paths and removes them on close", async () => { + const client = api(); + sdk.request.mockImplementation(async () => Readable.from([Buffer.from("hello")])); + const first = await client.download("message", "file", "../../report.txt", "file"); + const second = await client.download("message", "file", "../../report.txt", "file"); + expect(first.path).not.toBe(second.path); + expect(basename(first.path)).toBe("report.txt"); + expect(await readFile(first.path, "utf8")).toBe("hello"); + expect(first.size).toBe(5); + await client.close(); + await expect(access(first.path)).rejects.toThrow(); + await expect(client.download("message", "file", "report.txt", "file")).rejects.toThrow(); + }); + it("stops oversized downloads while streaming rather than buffering the whole resource", async () => { + const client = api(); + const stream = Readable.from([Buffer.alloc(11 * 1024 * 1024), Buffer.alloc(11 * 1024 * 1024)]); + sdk.request.mockResolvedValueOnce(stream); + try { + await expect(client.download("message", "file", "large.txt", "file")).rejects.toThrow( + "Lark attachment exceeds 20 MiB", + ); + expect(stream.destroyed).toBe(true); + } finally { + await client.close(); + } + }); + it("cancels an active resource stream and discards late bot identity responses", async () => { + const client = api(); + const stream = new Readable({ read() {} }); + sdk.request.mockResolvedValueOnce(stream); + const download = client.download("message", "key", "file", "file"); + const rejected = expect(download).rejects.toThrow("Lark attachment download failed"); + await client.close(); + await rejected; + expect(stream.destroyed).toBe(true); + const second = api(); + let resolve: (value: unknown) => void = () => {}; + sdk.request.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + const identity = second.getBotIdentity(); + const stale = expect(identity).rejects.toThrow("Lark bot identity request failed"); + await second.close(); + resolve({ code: 0, bot: { open_id: "late" } }); + await stale; + }); +}); diff --git a/packages/lark-adapter/test/decisions.test.ts b/packages/lark-adapter/test/decisions.test.ts new file mode 100644 index 00000000..4283a24b --- /dev/null +++ b/packages/lark-adapter/test/decisions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { LarkDecisions } from "../src/decisions.ts"; + +describe("Lark decision arbitration", () => { + it("accepts only a current token, allowed option and bound chat", () => { + const decisions = new LarkDecisions(); + const resolve = vi.fn(); + const token = decisions.open("chat", ["Yes", "No"], resolve); + expect(decisions.choose(token, "other", "Yes")).toBe(false); + expect(decisions.choose(token, "chat", "allow-all")).toBe(false); + expect(decisions.choose(token, "chat", "Yes")).toBe(true); + expect(decisions.choose(token, "chat", "No")).toBe(false); + expect(resolve).toHaveBeenCalledExactlyOnceWith("Yes"); + }); + + it("local completion invalidates the remote token without another decision", () => { + const decisions = new LarkDecisions(); + const resolve = vi.fn(); + const token = decisions.open("chat", ["Yes"], resolve); + decisions.dismiss(token); + expect(decisions.choose(token, "chat", "Yes")).toBe(false); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("teardown invalidates every old token and cannot reopen", () => { + const decisions = new LarkDecisions(); + const token = decisions.open("chat", ["Yes"], vi.fn()); + decisions.close(); + expect(decisions.choose(token, "chat", "Yes")).toBe(false); + expect(() => decisions.open("chat", ["Yes"], vi.fn())).toThrow("closed"); + }); +}); diff --git a/packages/lark-adapter/test/inbound.test.ts b/packages/lark-adapter/test/inbound.test.ts new file mode 100644 index 00000000..c92fc69c --- /dev/null +++ b/packages/lark-adapter/test/inbound.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { LarkInboundGate } from "../src/inbound.ts"; + +const now = 1_800_000_000_000; +function message(id = "m1", chat = "c1", text = "hello") { + return { + sender: { sender_id: { open_id: "user" } }, + message: { + message_id: id, + chat_id: chat, + chat_type: "p2p", + message_type: "text", + create_time: String(now), + content: JSON.stringify({ text }), + }, + }; +} + +describe("single-session Lark input", () => { + it("binds the first valid chat and rejects attempts to take it over", () => { + const gate = new LarkInboundGate("bot"); + expect(gate.accept(message(), now)).toMatchObject({ action: "accept", message: { text: "hello" } }); + expect(gate.accept(message("m2", "c2"), now)).toEqual({ action: "ignore", reason: "other-chat" }); + expect(gate.chatId).toBe("c1"); + }); + + it("does not bind on malformed, empty, stale, bot or unmentioned group messages", () => { + for (const event of [ + null, + [], + {}, + message("empty", "c2", ""), + { ...message(), sender: { sender_id: { open_id: "bot" } } }, + { message: { ...message().message, create_time: String(now - 600_001) } }, + { message: { ...message().message, chat_type: "group" } }, + { message: { ...message().message, content: "null" } }, + { message: { ...message().message, chat_id: "" } }, + ]) { + const gate = new LarkInboundGate("bot"); + expect(gate.accept(event, now).action).toBe("ignore"); + expect(gate.chatId).toBeUndefined(); + } + }); + + it("requires the bot mention and removes only matching mention markers", () => { + const gate = new LarkInboundGate("bot"); + const event = { + message: { + ...message().message, + chat_type: "group", + content: JSON.stringify({ text: "@_user_1 ask @_user_2" }), + mentions: [{ key: "@_user_1", id: { open_id: "bot" } }], + }, + }; + expect(gate.accept(event, now)).toMatchObject({ action: "accept", message: { text: "ask @_user_2" } }); + }); + + it("deduplicates within a bounded window and can release all state", () => { + const gate = new LarkInboundGate("bot"); + gate.accept(message(), now); + expect(gate.accept(message(), now + 100).action).toBe("ignore"); + expect(gate.accept(message(), now + 60_001).action).toBe("accept"); + gate.close(); + expect(gate.accept(message("new", "c2"), now + 60_002).action).toBe("ignore"); + expect(gate.chatId).toBeUndefined(); + }); + + it("parses image, file and rich text references without downloading", () => { + for (const [type, content, expected] of [ + ["image", { image_key: "image" }, { imageKeys: ["image"] }], + ["file", { file_key: "file", file_name: "report.txt" }, { files: [{ key: "file", name: "report.txt" }] }], + [ + "post", + { + zh_cn: { + title: "Title", + content: [ + [ + { tag: "text", text: "Body" }, + { tag: "img", image_key: "image" }, + ], + ], + }, + }, + { text: "Title\nBody", imageKeys: ["image"] }, + ], + ] as const) { + const gate = new LarkInboundGate("bot"); + expect( + gate.accept( + { message: { ...message().message, message_type: type, content: JSON.stringify(content) } }, + now, + ), + ).toMatchObject({ action: "accept", message: expected }); + } + }); +}); diff --git a/packages/lark-adapter/test/registration.test.ts b/packages/lark-adapter/test/registration.test.ts new file mode 100644 index 00000000..3a227b1c --- /dev/null +++ b/packages/lark-adapter/test/registration.test.ts @@ -0,0 +1,215 @@ +import { gunzipSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; +import { LarkRegistration, larkCallbackSetupUrl } from "../src/registration.ts"; + +describe("Lark device registration", () => { + it.each(["feishu", "lark"] as const)( + "builds the official prefilled callback launcher for %s without credentials", + (brand) => { + const url = new URL(larkCallbackSetupUrl({ appId: "cli_fixture", appSecret: "sensitive", brand })); + expect(url.origin).toBe(brand === "feishu" ? "https://open.feishu.cn" : "https://open.larksuite.com"); + expect(url.pathname).toBe("/page/launcher"); + expect(url.searchParams.get("clientID")).toBe("cli_fixture"); + const addons = JSON.parse( + gunzipSync(Buffer.from(url.searchParams.get("addons") ?? "", "base64url")).toString(), + ); + expect(addons).toEqual({ callbacks: { items: ["card.action.trigger"] } }); + expect(url.href).not.toContain("sensitive"); + }, + ); + it("continues polling after an HTTP 400 authorization-pending response", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch"); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: "authorization_pending", + user_info: { tenant_brand: "larksuite" }, + client_id: "untrusted", + client_secret: "untrusted", + }), + { status: 400 }, + ), + ); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ client_id: "app", client_secret: "fixture" }))); + try { + const registration = new LarkRegistration({ version: "test" }); + const signal = new AbortController().signal; + expect(await registration.poll("device", signal)).toEqual({ status: "pending" }); + expect(await registration.poll("device", signal)).toMatchObject({ status: "authorized" }); + expect(fetchMock.mock.calls[1]?.[0]).toBe("https://accounts.larksuite.com/oauth/v1/app/registration"); + } finally { + fetchMock.mockRestore(); + } + }); + + it.each(["authorization_pending", "slow_down", "access_denied", "expired_token"])( + "parses HTTP 400 %s without accepting credentials from failed requests", + async (error) => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ error }), { status: 400 })); + try { + const registration = new LarkRegistration({ version: "test" }); + expect(await registration.poll("device", new AbortController().signal)).toMatchObject({ + status: { + authorization_pending: "pending", + slow_down: "slow-down", + access_denied: "denied", + expired_token: "expired", + }[error], + }); + } finally { + fetchMock.mockRestore(); + } + }, + ); + + it.each([400, 401, 500])("rejects unrecognized HTTP %s responses without echoing response data", async (status) => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ client_id: "app", client_secret: "sensitive" }), { status }), + ); + try { + const registration = new LarkRegistration({ version: "test" }); + await expect(registration.poll("device", new AbortController().signal)).rejects.toThrow( + `Lark pairing request failed (HTTP ${status})`, + ); + } finally { + fetchMock.mockRestore(); + } + }); + + it("begins on Feishu and builds a CLI verification URL", async () => { + const post = vi.fn(async (_url: string, _form: Record, _signal: AbortSignal) => ({ + device_code: "device", + user_code: "code", + expires_in: 120, + interval: 2, + })); + const registration = new LarkRegistration({ version: "0.84.4", post }); + const result = await registration.begin(new AbortController().signal); + expect(post.mock.calls[0]?.[0]).toBe("https://accounts.feishu.cn/oauth/v1/app/registration"); + expect(result).toMatchObject({ deviceCode: "device", expiresInSeconds: 120, pollIntervalSeconds: 2 }); + expect(new URL(result.verificationUrl).searchParams.get("user_code")).toBe("code"); + expect(new URL(result.verificationUrl).searchParams.get("from")).toBe("cli"); + }); + + it("switches brand when the first response carrying tenant information arrives", async () => { + const responses = [ + { error: "authorization_pending" }, + { error: "authorization_pending", user_info: { tenant_brand: "larksuite" } }, + { client_id: "app", client_secret: "secret", user_info: { tenant_brand: "feishu" } }, + ]; + const urls: string[] = []; + const registration = new LarkRegistration({ + version: "0.84.4", + post: async (url) => { + urls.push(url); + return responses.shift() ?? {}; + }, + }); + const signal = new AbortController().signal; + await registration.poll("device", signal); + await registration.poll("device", signal); + expect(await registration.poll("device", signal)).toEqual({ + status: "authorized", + credentials: { appId: "app", appSecret: "secret", brand: "lark" }, + }); + expect(urls[2]).toBe("https://accounts.larksuite.com/oauth/v1/app/registration"); + }); + + it("returns slow-down, denial and expiry as distinct outcomes", async () => { + for (const [error, status] of [ + ["slow_down", "slow-down"], + ["access_denied", "denied"], + ["expired_token", "expired"], + ] as const) { + const registration = new LarkRegistration({ version: "test", post: async () => ({ error }) }); + expect(await registration.poll("device", new AbortController().signal)).toMatchObject({ status }); + } + }); + + it("rejects malformed begin data without echoing secrets", async () => { + const registration = new LarkRegistration({ + version: "test", + post: async () => ({ client_secret: "sensitive" }), + }); + await expect(registration.begin(new AbortController().signal)).rejects.toThrow("Invalid Lark pairing response"); + }); + + it("rejects invalid lifetimes rather than creating a tight poll loop", async () => { + const registration = new LarkRegistration({ + version: "test", + post: async () => ({ device_code: "device", user_code: "code", interval: 0, expires_in: -1 }), + }); + await expect(registration.begin(new AbortController().signal)).rejects.toThrow("Invalid Lark pairing response"); + }); + + it("does not start a request after cancellation", async () => { + const post = vi.fn(async () => ({})); + const registration = new LarkRegistration({ version: "test", post }); + const controller = new AbortController(); + controller.abort(); + await expect(registration.begin(controller.signal)).rejects.toThrow(); + expect(post).not.toHaveBeenCalled(); + }); + + it("waits between polls and increases the interval after slow-down", async () => { + vi.useFakeTimers(); + try { + const post = vi.fn(async () => ({ error: "slow_down" })); + const registration = new LarkRegistration({ version: "test", post }); + const controller = new AbortController(); + const result = registration.waitForAuthorization( + { + deviceCode: "device", + verificationUrl: "https://open.feishu.cn", + expiresInSeconds: 30, + pollIntervalSeconds: 2, + }, + controller.signal, + ); + const cancelled = expect(result).rejects.toThrow(); + await vi.advanceTimersByTimeAsync(1999); + expect(post).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(post).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(6999); + expect(post).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(post).toHaveBeenCalledTimes(2); + controller.abort(); + await cancelled; + } finally { + vi.useRealTimers(); + } + }); + + it("returns authorized credentials and expires without polling after the deadline", async () => { + vi.useFakeTimers(); + try { + const code = { + deviceCode: "device", + verificationUrl: "https://open.feishu.cn", + expiresInSeconds: 3, + pollIntervalSeconds: 2, + }; + const post = vi.fn(async () => ({ client_id: "app", client_secret: "fixture" })); + const registration = new LarkRegistration({ version: "test", post }); + const result = registration.waitForAuthorization(code, new AbortController().signal); + await vi.advanceTimersByTimeAsync(2000); + expect(await result).toMatchObject({ status: "authorized", credentials: { appId: "app" } }); + post.mockClear(); + const expired = registration.waitForAuthorization( + { ...code, pollIntervalSeconds: 5 }, + new AbortController().signal, + ); + await vi.advanceTimersByTimeAsync(3000); + expect(await expired).toEqual({ status: "expired" }); + expect(post).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/lark-adapter/test/sdk-lifecycle.test.ts b/packages/lark-adapter/test/sdk-lifecycle.test.ts new file mode 100644 index 00000000..a4f11bd8 --- /dev/null +++ b/packages/lark-adapter/test/sdk-lifecycle.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { LarkTransport } from "../src/transport.ts"; + +describe("installed Lark SDK lifecycle", () => { + it("releases SDK timers when closed before startup without writing diagnostics", () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + const output = ["log", "info", "warn", "error", "debug"].map((method) => + vi.spyOn(console, method as "log" | "info" | "warn" | "error" | "debug").mockImplementation(() => {}), + ); + try { + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: vi.fn(), + }); + transport.close(); + transport.close(); + expect(vi.getTimerCount()).toBe(baseline); + for (const spy of output) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of output) spy.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/packages/lark-adapter/test/transport.test.ts b/packages/lark-adapter/test/transport.test.ts new file mode 100644 index 00000000..2ccc1a5c --- /dev/null +++ b/packages/lark-adapter/test/transport.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LarkTransport } from "../src/transport.ts"; + +const sdk = vi.hoisted(() => ({ + options: {} as Record, + handlers: {} as Record unknown>, + start: vi.fn(async () => {}), + close: vi.fn(), +})); +vi.mock("@larksuiteoapi/node-sdk", () => ({ + LoggerLevel: { error: 1 }, + WSClient: class { + constructor(options: Record) { + sdk.options = options; + } + start = sdk.start; + close = sdk.close; + }, + EventDispatcher: class { + register(handlers: typeof sdk.handlers) { + sdk.handlers = handlers; + return this; + } + }, +})); + +describe("Lark transport lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + sdk.start.mockResolvedValue(); + }); + it("reports connected only after the WebSocket handshake succeeds", async () => { + const state = vi.fn(); + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: state, + }); + await transport.start(); + expect(state).not.toHaveBeenCalled(); + (sdk.options.onReady as () => void)(); + expect(state).toHaveBeenCalledExactlyOnceWith("connected"); + transport.close(); + }); + + it("accepts card decisions only from the bound chat and suppresses late callbacks after close", async () => { + const message = vi.fn(); + const action = vi.fn(); + const state = vi.fn(); + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "lark" }, + botId: "bot", + onMessage: message, + onCardAction: action, + onState: state, + }); + await transport.start(); + const event = { + sender: { sender_id: { open_id: "user" } }, + message: { + message_id: "m1", + chat_id: "chat", + chat_type: "p2p", + message_type: "text", + create_time: String(Date.now()), + content: '{"text":"hello"}', + }, + }; + const decision = { context: { open_chat_id: "chat" }, action: { value: { token: "token", option: "yes" } } }; + for (const invalid of [null, {}, { action: { value: "bad" } }, { ...decision, action: { value: {} } }]) { + expect(() => sdk.handlers["card.action.trigger"](invalid)).not.toThrow(); + } + sdk.handlers["card.action.trigger"](decision); + expect(action).not.toHaveBeenCalled(); + sdk.handlers["im.message.receive_v1"](event); + sdk.handlers["card.action.trigger"]({ ...decision, context: { open_chat_id: "other" } }); + expect(action).not.toHaveBeenCalled(); + sdk.handlers["card.action.trigger"](decision); + expect(action).toHaveBeenCalledExactlyOnceWith({ chatId: "chat", token: "token", option: "yes" }); + transport.close(); + transport.close(); + sdk.handlers["im.message.receive_v1"]({ ...event, message: { ...event.message, message_id: "m2" } }); + sdk.handlers["card.action.trigger"](decision); + (sdk.options.onReconnected as () => void)(); + expect(message).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); + expect(state).not.toHaveBeenCalled(); + expect(sdk.close).toHaveBeenCalledExactlyOnceWith({ force: true }); + await expect(transport.start()).rejects.toThrow("cannot be restarted"); + }); + + it("closes a failed startup without exposing the SDK error or allowing a retry", async () => { + sdk.start.mockRejectedValueOnce(new Error("request contains fixture secret")); + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: vi.fn(), + }); + await expect(transport.start()).rejects.toThrow("Lark transport failed to start"); + expect(sdk.close).toHaveBeenCalledExactlyOnceWith({ force: true }); + await expect(transport.start()).rejects.toThrow("cannot be restarted"); + }); +}); diff --git a/packages/lark-adapter/tsconfig.build.json b/packages/lark-adapter/tsconfig.build.json new file mode 100644 index 00000000..adb28777 --- /dev/null +++ b/packages/lark-adapter/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/lark-adapter/vitest.config.ts b/packages/lark-adapter/vitest.config.ts new file mode 100644 index 00000000..84aa1e74 --- /dev/null +++ b/packages/lark-adapter/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"] } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58306684..b417bf11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@step-harness/config': specifier: workspace:* version: link:../../packages/config + '@step-harness/lark-adapter': + specifier: workspace:* + version: link:../../packages/lark-adapter '@step-harness/pi-tui': specifier: ^0.84.4 version: link:../../packages/tui @@ -71,10 +74,16 @@ importers: grok-mermaid: specifier: 0.2.2 version: 0.2.2 + qrcode: + specifier: 1.5.4 + version: 1.5.4 devDependencies: '@types/proper-lockfile': specifier: 4.1.4 version: 4.1.4 + '@types/qrcode': + specifier: 1.5.6 + version: 1.5.6 proper-lockfile: specifier: 4.1.2 version: 4.1.2 @@ -267,6 +276,19 @@ importers: specifier: 4.1.9 version: 4.1.9(@types/node@22.19.19)(@vitest/coverage-v8@4.1.9)(vite@8.2.2(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.1)(yaml@2.9.0)) + packages/lark-adapter: + dependencies: + '@larksuiteoapi/node-sdk': + specifier: 1.76.0 + version: 1.76.0 + devDependencies: + '@types/node': + specifier: 22.19.19 + version: 22.19.19 + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@22.19.19)(@vitest/coverage-v8@4.1.9)(vite@8.2.2(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.1)(yaml@2.9.0)) + packages/providers: dependencies: '@anthropic-ai/sdk': @@ -389,28 +411,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.3.5': resolution: {integrity: sha512-u/pybjTBPGBHB66ku4pK1gj+Dxgx7/+Z0jAriZISPX1ocTO8aHh8x8e7Kb1rB4Ms0nA/SzjtNOVJ4exVavQBCw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.5': resolution: {integrity: sha512-awVuycTPpVTH/+WDVnEEYSf6nbCBHf/4wB3lquwT7puhNg8R4XvonWNZzUsfHZrCkjkLhFH/vCZK5jHatD9FEg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.3.5': resolution: {integrity: sha512-XrIVi9YAW6ye0CGQ+yax0gLfx+BFOtKaNX74n+xHWla6Cl6huUmcKNO7HPx7BiKnJUzrxXY1qYlm7xMvi08X4g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.3.5': resolution: {integrity: sha512-DlBiMlBZZ9eIq4H7RimDSGsYcOtfOIfZOaI5CqsWiSlbTfqbPVfWtCf92wNzx8GNMbu1s7/g3ZZESr6+GwM/SA==} @@ -623,6 +641,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@larksuiteoapi/node-sdk@1.76.0': + resolution: {integrity: sha512-v/bxQNCugwAmMPbZtBGJtSu8hdQLiB1/29Jr+GBIG18DyQ4LYyp7gxQ74pkhXVSuoUeH4v+/ZHAw/SW+KMrj2g==} + '@mariozechner/clipboard-darwin-arm64@0.3.9': resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} engines: {node: '>= 10'} @@ -645,35 +666,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} @@ -719,6 +735,33 @@ packages: '@pondwader/socks5-server@1.0.10': resolution: {integrity: sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rolldown/binding-android-arm-eabi@1.2.8': resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -760,42 +803,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.8': resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.8': resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.8': resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.8': resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.8': resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.8': resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} @@ -854,6 +891,9 @@ packages: '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/retry@0.12.5': resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} @@ -944,6 +984,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -959,6 +1003,14 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -969,6 +1021,12 @@ packages: ast-v8-to-istanbul@1.0.6: resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1013,6 +1071,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + canvas@3.2.3: resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} engines: {node: ^18.12.0 || >= 20.9.0} @@ -1032,6 +1094,20 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -1084,6 +1160,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1092,6 +1172,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1104,6 +1188,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1111,6 +1198,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1133,6 +1223,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1208,6 +1302,23 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1227,6 +1338,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -1269,6 +1384,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1295,6 +1414,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1341,6 +1464,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1426,28 +1553,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -1465,9 +1588,25 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -1507,10 +1646,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -1599,6 +1746,18 @@ packages: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1606,6 +1765,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@2.0.1: resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} engines: {node: '>=4'} @@ -1638,6 +1801,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -1651,13 +1818,26 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -1688,10 +1868,17 @@ packages: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -1740,6 +1927,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -1823,9 +2013,17 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-eof@1.0.0: resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} engines: {node: '>=0.10.0'} @@ -2008,6 +2206,9 @@ packages: jsdom: optional: true + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -2022,6 +2223,10 @@ packages: engines: {node: '>=8'} hasBin: true + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2037,11 +2242,22 @@ packages: utf-8-validate: optional: true + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -2237,6 +2453,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@larksuiteoapi/node-sdk@1.76.0': + dependencies: + axios: 1.20.0 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.6.5 + qs: 6.16.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + '@mariozechner/clipboard-darwin-arm64@0.3.9': optional: true @@ -2319,6 +2550,26 @@ snapshots: '@pondwader/socks5-server@1.0.10': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rolldown/binding-android-arm-eabi@1.2.8': optional: true @@ -2401,6 +2652,10 @@ snapshots: dependencies: '@types/retry': 0.12.5 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.19 + '@types/retry@0.12.5': {} '@types/semver@7.7.1': {} @@ -2498,6 +2753,12 @@ snapshots: mime-types: 3.0.2 negotiator: 1.1.0 + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv-formats@3.0.1(ajv@8.20.0): @@ -2511,6 +2772,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -2523,6 +2790,18 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + asynckit@0.4.0: {} + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.1 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + balanced-match@4.0.4: {} base64-js@1.5.1: {} @@ -2579,6 +2858,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase@5.3.1: {} + canvas@3.2.3: dependencies: node-addon-api: 7.1.1 @@ -2594,6 +2875,22 @@ snapshots: chownr@1.1.4: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} content-disposition@1.1.0: {} @@ -2637,18 +2934,24 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 deep-extend@0.6.0: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} detect-libc@2.1.2: {} diff@8.0.4: {} + dijkstrajs@1.0.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2657,6 +2960,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -2673,6 +2978,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -2806,6 +3118,21 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + follow-redirects@1.16.1: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -2817,6 +3144,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -2857,6 +3186,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -2886,6 +3219,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -2919,6 +3259,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3008,8 +3350,20 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.18.1: {} + lodash.identity@3.0.0: {} + + lodash.merge@4.6.2: {} + + lodash.pickby@4.6.0: {} + + long@5.3.2: {} + lru-cache@11.5.2: {} magic-string@0.30.21: @@ -3041,8 +3395,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -3105,10 +3465,22 @@ snapshots: p-finally@1.0.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + parseurl@1.3.3: {} partial-json@0.1.7: {} + path-exists@4.0.0: {} + path-key@2.0.1: {} path-key@3.1.1: {} @@ -3127,6 +3499,8 @@ snapshots: pkce-challenge@5.0.1: {} + pngjs@5.0.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.18 @@ -3154,16 +3528,38 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.19.19 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -3201,8 +3597,12 @@ snapshots: dependencies: resolve: 1.22.12 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -3282,6 +3682,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shebang-command@1.2.0: @@ -3368,10 +3770,20 @@ snapshots: std-env@4.2.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-eof@1.0.0: {} strip-json-comments@2.0.1: {} @@ -3495,6 +3907,8 @@ snapshots: transitivePeerDependencies: - msw + which-module@2.0.1: {} + which@1.3.1: dependencies: isexe: 2.0.0 @@ -3508,13 +3922,39 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} - ws@8.21.3: - optional: true + ws@8.21.3: {} + + y18n@4.0.3: {} yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + zod-to-json-schema@3.25.2(zod@4.6.1): dependencies: zod: 4.6.1 diff --git a/scripts/workspace-registry.json b/scripts/workspace-registry.json index f434ca82..6b87479e 100644 --- a/scripts/workspace-registry.json +++ b/scripts/workspace-registry.json @@ -18,6 +18,7 @@ { "name": "@step-harness/cli", "dir": "apps/cli", "role": "app", "reason": "唯一进程入口宿主骨架(S0 起);进程生命周期/argv/退出码" }, { "name": "@step-harness/contracts", "dir": "packages/contracts", "role": "contract", "reason": "host 到 agent 的稳定契约(in-process facade 类型 + wire 帧),依赖为空" }, { "name": "@step-harness/config", "dir": "packages/config", "role": "engine", "reason": "产品中立的配置格式/加载与启动迁移(config/migrations),不含资源解析" }, + { "name": "@step-harness/lark-adapter", "dir": "packages/lark-adapter", "role": "adapter", "reason": "用户批准的飞书外部协议边界;仅 CLI 依赖,离线协议测试,连接独立取消与销毁,不依赖 Agent 或 UI" }, { "name": "pi-extension-with-deps", "dir": "packages/coding-agent/examples/extensions/with-deps", "role": "example", "reason": "扩展示例" }, { "name": "pi-extension-sandbox", "dir": "packages/coding-agent/examples/extensions/sandbox", "role": "example", "reason": "扩展示例" }, { "name": "pi-extension-gondolin", "dir": "packages/coding-agent/examples/extensions/gondolin", "role": "example", "reason": "扩展示例(tsconfig 排除)" } diff --git a/test.sh b/test.sh index 9efe2e4f..72fcd4f7 100755 --- a/test.sh +++ b/test.sh @@ -14,6 +14,7 @@ build_outputs=( "packages/providers/dist/index.js" "packages/agent-core/dist/index.js" "packages/config/dist/index.js" + "packages/lark-adapter/dist/index.js" "packages/coding-agent/dist/index.js" "apps/cli/dist/main.js" ) diff --git a/tsconfig.json b/tsconfig.json index a19be8e7..93ea9b3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "@step-harness/contracts": ["./packages/contracts/src/in-process/index.ts"], "@step-harness/contracts/wire": ["./packages/contracts/src/wire/index.ts"], "@step-harness/config": ["./packages/config/src/index.ts"], + "@step-harness/lark-adapter": ["./packages/lark-adapter/src/index.ts"], "@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-ai": ["./packages/providers/src/index.ts"], diff --git a/vitest.base.ts b/vitest.base.ts index eadbb801..948ba412 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -13,6 +13,7 @@ export const workspaceSourcePaths = { codingAgentIndex: fileURLToPath(new URL("./packages/coding-agent/src/index.ts", import.meta.url)), tuiIndex: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)), configIndex: fileURLToPath(new URL("./packages/config/src/index.ts", import.meta.url)), + larkAdapterIndex: fileURLToPath(new URL("./packages/lark-adapter/src/index.ts", import.meta.url)), } as const; export default { @@ -33,6 +34,7 @@ export default { { find: /^@step-harness\/coding-agent$/, replacement: workspaceSourcePaths.codingAgentIndex }, { find: /^@step-harness\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex }, { find: /^@step-harness\/config$/, replacement: workspaceSourcePaths.configIndex }, + { find: /^@step-harness\/lark-adapter$/, replacement: workspaceSourcePaths.larkAdapterIndex }, // A1 backward-compat: legacy @earendil-works/pi-* specifiers (used by example // extensions and old user extensions) resolve to the same renamed sources. { find: /^@earendil-works\/pi-telemetry$/, replacement: workspaceSourcePaths.telemetryIndex }, From 038faca3d608e9a27ab32d8ab5788179e0420b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E6=97=AD?= <334921176+xuxujo97-coder@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:59:56 +0800 Subject: [PATCH 2/3] fix(coding-agent): isolate tool output regression from MCP startup --- .../coding-agent/test/suite/agent-session-tool-output.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/coding-agent/test/suite/agent-session-tool-output.test.ts b/packages/coding-agent/test/suite/agent-session-tool-output.test.ts index 10d6d049..8a513bb2 100644 --- a/packages/coding-agent/test/suite/agent-session-tool-output.test.ts +++ b/packages/coding-agent/test/suite/agent-session-tool-output.test.ts @@ -8,6 +8,7 @@ import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "../../src/core/tools/truncate.ts"; import { createStepExtension } from "../../src/features/step.ts"; +import * as stepMcp from "../../src/step/mcp.ts"; import { createStepToolProfile } from "../../src/step/tool-profile.ts"; import { createHarness, type Harness } from "./harness.ts"; @@ -238,6 +239,8 @@ describe("AgentSession final tool output", () => { it("keeps the tail and exit status of a failed command that bash already truncated", async () => { if (process.platform === "win32") return; + // PR #260: output retention must not wait for unrelated MCP discovery or StepPage installation. + vi.spyOn(stepMcp, "createStepMcpExtension").mockReturnValue(() => {}); const sandbox = mkdtempSync(join(tmpdir(), "step-tool-output-bash-")); vi.stubEnv("STEP_CODING_AGENT_DIR", join(sandbox, "agent")); try { From 5e793eefbe8f0651f32e47bb5ba246abf20f591a Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Tue, 20 Oct 2026 11:41:53 +0800 Subject: [PATCH 3/3] fix(lark): await handshake and deduplicate eligible messages Settle transport startup on readiness, initial failure, timeout or close instead of trusting the SDK start return. Retain message IDs throughout their eligibility window and bound future clock skew without evicting live IDs. Cover startup cleanup, retries, late success, installed SDK behavior and redelivery boundaries with offline regressions. --- apps/cli/test/lark-connection.test.ts | 30 ++++++++++ packages/lark-adapter/lark-boundary.md | 15 ++++- packages/lark-adapter/src/inbound.ts | 19 ++++-- packages/lark-adapter/src/transport.ts | 29 +++++++-- packages/lark-adapter/test/inbound.test.ts | 42 +++++++++++-- .../lark-adapter/test/sdk-lifecycle.test.ts | 35 +++++++++++ packages/lark-adapter/test/transport.test.ts | 60 +++++++++++++++++-- 7 files changed, 208 insertions(+), 22 deletions(-) diff --git a/apps/cli/test/lark-connection.test.ts b/apps/cli/test/lark-connection.test.ts index b7fb533a..c0e3b471 100644 --- a/apps/cli/test/lark-connection.test.ts +++ b/apps/cli/test/lark-connection.test.ts @@ -117,6 +117,36 @@ describe("current-session Lark connection", () => { await harness.command("off"); }); + it("disconnects during startup without announcing late success", async () => { + let ready: () => void = () => {}; + fixture.start.mockReturnValueOnce(new Promise((resolve) => { ready = resolve; })); + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(fixture.start).toHaveBeenCalled()); + await harness.command("off"); + ready(); + await Promise.resolve(); + expect(harness.notify).not.toHaveBeenCalledWith(expect.stringContaining("connect · lark · connected")); + expect(harness.status).toHaveBeenLastCalledWith("lark", undefined); + await harness.command("lark"); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith("connect · lark · connected · @Step · session-fixture")); + await harness.command("off"); + }); + + it("cleans up a failed handshake and permits a new connection", async () => { + fixture.start.mockRejectedValueOnce(new Error("fixture handshake failed")); + const harness = setup(); + await harness.command("lark"); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith("Lark connection failed during WebSocket handshake. Retry /connect lark.", "warning")); + expect(fixture.stop).toHaveBeenCalledTimes(1); + expect(fixture.close).toHaveBeenCalledTimes(1); + expect(harness.status).toHaveBeenLastCalledWith("lark", undefined); + await harness.command("lark"); + await vi.waitFor(() => expect(harness.notify).toHaveBeenCalledWith("connect · lark · connected · @Step · session-fixture")); + expect(fixture.begin).toHaveBeenCalledTimes(2); + await harness.command("off"); + }); + it("submits accepted input without a reaction API and rejects input during a dialog", async () => { const harness = setup(); await harness.command("lark"); diff --git a/packages/lark-adapter/lark-boundary.md b/packages/lark-adapter/lark-boundary.md index 604dcb9a..360244fb 100644 --- a/packages/lark-adapter/lark-boundary.md +++ b/packages/lark-adapter/lark-boundary.md @@ -13,9 +13,12 @@ cancellation. Keep returned credentials in memory only. ## Transport `LarkTransport` requires credentials and the bot's verified open ID. Its host -supplies message, card-action and health callbacks. `start()` initiates the -connection; only the handshake callback reports connected. The first valid input -binds the chat. Group inputs require a bot mention. Card actions must carry the +supplies message, card-action and health callbacks. `start()` resolves only after +`onReady` confirms the initial handshake, not when the SDK's `start()` returns. +An initial error, a 15-second startup deadline or `close()` rejects startup and +forcefully closes the socket; the host can retry with a new transport. After a +successful handshake, health errors report degraded state without closing it. +The first valid input binds the chat. Group inputs require a bot mention. Card actions must carry the bound chat ID, token and option; the host must additionally validate the token against `LarkDecisions` and the current UI request. @@ -29,6 +32,12 @@ SDK fields or compatibility casts are used. ## Current Limits +Messages must be at most ten minutes old, with at most 30 seconds of future clock +skew. Accepted message IDs remain deduplicated through their timestamp's entire +ten-minute eligibility window (including its inclusive endpoint). The gate holds +at most 4096 live IDs and rejects new messages at capacity rather than evicting +IDs that could still be redelivered. Expired entries are released on valid input. + `LarkApi` discovers the bot identity, sends/updates cards and downloads attachment resources with cancellation and a streaming 20 MiB limit. Private temporary files are removed on close. Message callbacks contain references; the CLI chooses when diff --git a/packages/lark-adapter/src/inbound.ts b/packages/lark-adapter/src/inbound.ts index d370d25c..8a81abaf 100644 --- a/packages/lark-adapter/src/inbound.ts +++ b/packages/lark-adapter/src/inbound.ts @@ -12,6 +12,10 @@ function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } +const MESSAGE_MAX_AGE_MS = 600_000; +const MAX_CLOCK_SKEW_MS = 30_000; +const MAX_SEEN_MESSAGES = 4096; + /** Input-only state, not a session or transcript. Invalid events cannot claim the chat binding. */ export class LarkInboundGate { private readonly botId: string; @@ -41,10 +45,12 @@ export class LarkInboundGate { return { action: "ignore", reason: "malformed" }; } const timestamp = Number(message.create_time); + const createdAt = timestamp < 1e12 ? timestamp * 1000 : timestamp; if ( - !Number.isFinite(timestamp) || - timestamp <= 0 || - now - (timestamp < 1e12 ? timestamp * 1000 : timestamp) > 600_000 + !Number.isFinite(createdAt) || + createdAt <= 0 || + now - createdAt > MESSAGE_MAX_AGE_MS || + createdAt - now > MAX_CLOCK_SKEW_MS ) { return { action: "ignore", reason: "stale" }; } @@ -93,10 +99,11 @@ export class LarkInboundGate { text = text.trim(); if (!text && !imageKeys.length && !files.length) return { action: "ignore", reason: "empty" }; if (this.boundChat && this.boundChat !== chatId) return { action: "ignore", reason: "other-chat" }; - for (const [key, firstSeen] of this.seen) if (now - firstSeen > 60_000) this.seen.delete(key); + // Retain IDs for their entire eligibility window; never evict a live ID to admit new work. + for (const [key, expiresAt] of this.seen) if (now > expiresAt) this.seen.delete(key); if (this.seen.has(id)) return { action: "ignore", reason: "duplicate" }; - if (this.seen.size >= 4096) return { action: "ignore", reason: "rate-limit" }; - this.seen.set(id, now); + if (this.seen.size >= MAX_SEEN_MESSAGES) return { action: "ignore", reason: "rate-limit" }; + this.seen.set(id, createdAt + MESSAGE_MAX_AGE_MS); this.boundChat = chatId; return { action: "accept", message: { id, chatId, text, imageKeys, files } }; } diff --git a/packages/lark-adapter/src/transport.ts b/packages/lark-adapter/src/transport.ts index 7b6d6d1e..854a0038 100644 --- a/packages/lark-adapter/src/transport.ts +++ b/packages/lark-adapter/src/transport.ts @@ -13,6 +13,8 @@ export interface LarkTransportOptions { // SDK diagnostics can contain request credentials and must never reach the terminal. const silentLogger = { error() {}, warn() {}, info() {}, debug() {}, trace() {} }; +const STARTUP_TIMEOUT_MS = 15_000; + /** Single-use socket; owns input routing only, never an agent or transcript. */ export class LarkTransport { private readonly gate: LarkInboundGate; @@ -20,6 +22,7 @@ export class LarkTransport { private readonly dispatcher: EventDispatcher; private closed = false; private started = false; + private settleStartup?: (error?: Error) => void; constructor(options: LarkTransportOptions) { this.gate = new LarkInboundGate(options.botId); @@ -56,9 +59,11 @@ export class LarkTransport { domain: LARK_OPEN_HOSTS[options.credentials.brand], logger: silentLogger, loggerLevel: LoggerLevel.error, - handshakeTimeoutMs: 15_000, + handshakeTimeoutMs: STARTUP_TIMEOUT_MS, onReady: () => { - if (!this.closed) options.onState("connected"); + if (this.closed || !this.started) return; + this.settleStartup?.(); + options.onState("connected"); }, onReconnected: () => { if (!this.closed) options.onState("connected"); @@ -67,7 +72,9 @@ export class LarkTransport { if (!this.closed) options.onState("degraded"); }, onError: () => { - if (!this.closed) options.onState("degraded"); + if (this.closed) return; + if (this.settleStartup) this.close(); + else options.onState("degraded"); }, }); } @@ -76,20 +83,32 @@ export class LarkTransport { return this.gate.chatId; } + /** Resolves only after onReady; initial error, timeout or close rejects and releases the socket. */ async start(): Promise { if (this.closed || this.started) throw new Error("Lark transport cannot be restarted"); this.started = true; + const startup = new Promise((resolve, reject) => { + const timeout = setTimeout(() => this.close(), STARTUP_TIMEOUT_MS); + this.settleStartup = (error) => { + clearTimeout(timeout); + this.settleStartup = undefined; + if (error) reject(error); + else resolve(); + }; + }); + // SDK start() only launches reconnect work. Its return value is not handshake evidence. try { - await this.socket.start({ eventDispatcher: this.dispatcher }); + void Promise.resolve(this.socket.start({ eventDispatcher: this.dispatcher })).catch(() => this.close()); } catch { this.close(); - throw new Error("Lark transport failed to start"); } + return startup; } close(): void { if (this.closed) return; this.closed = true; + this.settleStartup?.(new Error("Lark transport failed to start")); this.gate.close(); this.socket.close({ force: true }); } diff --git a/packages/lark-adapter/test/inbound.test.ts b/packages/lark-adapter/test/inbound.test.ts index c92fc69c..eced8c7c 100644 --- a/packages/lark-adapter/test/inbound.test.ts +++ b/packages/lark-adapter/test/inbound.test.ts @@ -55,16 +55,50 @@ describe("single-session Lark input", () => { expect(gate.accept(event, now)).toMatchObject({ action: "accept", message: { text: "ask @_user_2" } }); }); - it("deduplicates within a bounded window and can release all state", () => { + it("deduplicates throughout the freshness window and can release all state", () => { const gate = new LarkInboundGate("bot"); gate.accept(message(), now); - expect(gate.accept(message(), now + 100).action).toBe("ignore"); - expect(gate.accept(message(), now + 60_001).action).toBe("accept"); + for (const elapsed of [100, 61_000, 599_999, 600_000]) { + expect(gate.accept(message(), now + elapsed)).toEqual({ action: "ignore", reason: "duplicate" }); + } + expect(gate.accept(message(), now + 600_001)).toEqual({ action: "ignore", reason: "stale" }); gate.close(); - expect(gate.accept(message("new", "c2"), now + 60_002).action).toBe("ignore"); + expect(gate.accept(message("new", "c2"), now).action).toBe("ignore"); expect(gate.chatId).toBeUndefined(); }); + it("fails closed at capacity without evicting live IDs, and releases expired entries", () => { + const gate = new LarkInboundGate("bot"); + for (let index = 0; index < 4096; index++) { + expect(gate.accept(message(`m${index}`), now).action).toBe("accept"); + } + expect(gate.accept(message("overflow"), now)).toEqual({ action: "ignore", reason: "rate-limit" }); + expect(gate.accept(message("m0"), now + 61_000)).toEqual({ action: "ignore", reason: "duplicate" }); + const fresh = { message: { ...message("fresh").message, create_time: String(now + 600_001) } }; + expect(gate.accept(fresh, now + 600_001).action).toBe("accept"); + }); + + it("bounds future clock skew and retains IDs until their timestamp expires", () => { + const future = { message: { ...message().message, create_time: String(now + 30_000) } }; + const gate = new LarkInboundGate("bot"); + expect(gate.accept(future, now).action).toBe("accept"); + expect(gate.accept(future, now + 630_000)).toEqual({ action: "ignore", reason: "duplicate" }); + expect(gate.accept(future, now + 630_001)).toEqual({ action: "ignore", reason: "stale" }); + const invalid = new LarkInboundGate("bot"); + expect(invalid.accept({ message: { ...future.message, create_time: String(now + 30_001) } }, now)).toEqual({ + action: "ignore", + reason: "stale", + }); + expect(invalid.chatId).toBeUndefined(); + }); + + it("applies the same freshness and deduplication rules to second timestamps", () => { + const gate = new LarkInboundGate("bot"); + const event = { message: { ...message().message, create_time: String(now / 1000) } }; + expect(gate.accept(event, now).action).toBe("accept"); + expect(gate.accept(event, now + 600_000)).toEqual({ action: "ignore", reason: "duplicate" }); + }); + it("parses image, file and rich text references without downloading", () => { for (const [type, content, expected] of [ ["image", { image_key: "image" }, { imageKeys: ["image"] }], diff --git a/packages/lark-adapter/test/sdk-lifecycle.test.ts b/packages/lark-adapter/test/sdk-lifecycle.test.ts index a4f11bd8..711dd06f 100644 --- a/packages/lark-adapter/test/sdk-lifecycle.test.ts +++ b/packages/lark-adapter/test/sdk-lifecycle.test.ts @@ -1,7 +1,42 @@ +import { defaultHttpInstance } from "@larksuiteoapi/node-sdk"; import { describe, expect, it, vi } from "vitest"; import { LarkTransport } from "../src/transport.ts"; describe("installed Lark SDK lifecycle", () => { + it("does not treat the real SDK's early start return as a successful handshake", async () => { + let fail: (value: unknown) => void = () => {}; + const request = vi.spyOn(defaultHttpInstance, "request").mockImplementationOnce( + () => + new Promise((resolve) => { + fail = resolve; + }), + ); + const state = vi.fn(); + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: state, + }); + const ready = vi.fn(); + const startup = transport.start().then(ready); + const rejected = expect(startup).rejects.toThrow("Lark transport failed to start"); + try { + await Promise.resolve(); + await Promise.resolve(); + expect(request).toHaveBeenCalledTimes(1); + expect(ready).not.toHaveBeenCalled(); + expect(state).not.toHaveBeenCalled(); + fail({ code: 100004, msg: "fixture rejected", data: {} }); + await rejected; + expect(ready).not.toHaveBeenCalled(); + } finally { + transport.close(); + request.mockRestore(); + } + }); + it("releases SDK timers when closed before startup without writing diagnostics", () => { vi.useFakeTimers(); const baseline = vi.getTimerCount(); diff --git a/packages/lark-adapter/test/transport.test.ts b/packages/lark-adapter/test/transport.test.ts index 2ccc1a5c..9724ce03 100644 --- a/packages/lark-adapter/test/transport.test.ts +++ b/packages/lark-adapter/test/transport.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LarkTransport } from "../src/transport.ts"; const sdk = vi.hoisted(() => ({ @@ -29,7 +29,8 @@ describe("Lark transport lifecycle", () => { vi.clearAllMocks(); sdk.start.mockResolvedValue(); }); - it("reports connected only after the WebSocket handshake succeeds", async () => { + afterEach(() => vi.useRealTimers()); + it("reports connected and resolves startup only after the WebSocket handshake succeeds", async () => { const state = vi.fn(); const transport = new LarkTransport({ credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, @@ -38,9 +39,14 @@ describe("Lark transport lifecycle", () => { onCardAction: vi.fn(), onState: state, }); - await transport.start(); + const ready = vi.fn(); + const startup = transport.start().then(ready); + await Promise.resolve(); + expect(ready).not.toHaveBeenCalled(); expect(state).not.toHaveBeenCalled(); (sdk.options.onReady as () => void)(); + await startup; + expect(ready).toHaveBeenCalledTimes(1); expect(state).toHaveBeenCalledExactlyOnceWith("connected"); transport.close(); }); @@ -56,7 +62,10 @@ describe("Lark transport lifecycle", () => { onCardAction: action, onState: state, }); - await transport.start(); + const startup = transport.start(); + (sdk.options.onReady as () => void)(); + await startup; + state.mockClear(); const event = { sender: { sender_id: { open_id: "user" } }, message: { @@ -91,6 +100,49 @@ describe("Lark transport lifecycle", () => { await expect(transport.start()).rejects.toThrow("cannot be restarted"); }); + it.each(["error", "timeout", "close"])("settles %s during startup and suppresses late success", async (failure) => { + vi.useFakeTimers(); + const state = vi.fn(); + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: state, + }); + const rejected = expect(transport.start()).rejects.toThrow("Lark transport failed to start"); + if (failure === "error") (sdk.options.onError as () => void)(); + else if (failure === "timeout") await vi.advanceTimersByTimeAsync(15_000); + else transport.close(); + await rejected; + (sdk.options.onReady as () => void)(); + (sdk.options.onReconnected as () => void)(); + expect(state).not.toHaveBeenCalled(); + expect(sdk.close).toHaveBeenCalledExactlyOnceWith({ force: true }); + expect(vi.getTimerCount()).toBe(0); + await expect(transport.start()).rejects.toThrow("cannot be restarted"); + }); + + it("clears the startup deadline and keeps an established connection on a health error", async () => { + vi.useFakeTimers(); + const state = vi.fn(); + const transport = new LarkTransport({ + credentials: { appId: "cli_0123456789abcdef", appSecret: "fixture", brand: "feishu" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: state, + }); + const startup = transport.start(); + (sdk.options.onReady as () => void)(); + await startup; + expect(vi.getTimerCount()).toBe(0); + (sdk.options.onError as () => void)(); + expect(state).toHaveBeenLastCalledWith("degraded"); + expect(sdk.close).not.toHaveBeenCalled(); + transport.close(); + }); + it("closes a failed startup without exposing the SDK error or allowing a retry", async () => { sdk.start.mockRejectedValueOnce(new Error("request contains fixture secret")); const transport = new LarkTransport({