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..c0e3b471 --- /dev/null +++ b/apps/cli/test/lark-connection.test.ts @@ -0,0 +1,353 @@ +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("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"); + 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/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 { 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..360244fb --- /dev/null +++ b/packages/lark-adapter/lark-boundary.md @@ -0,0 +1,50 @@ +# 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()` 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. + +`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 + +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 +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..8a81abaf --- /dev/null +++ b/packages/lark-adapter/src/inbound.ts @@ -0,0 +1,116 @@ +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) : {}; +} + +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; + 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); + const createdAt = timestamp < 1e12 ? timestamp * 1000 : timestamp; + if ( + !Number.isFinite(createdAt) || + createdAt <= 0 || + now - createdAt > MESSAGE_MAX_AGE_MS || + createdAt - now > MAX_CLOCK_SKEW_MS + ) { + 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" }; + // 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 >= 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 } }; + } + + 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..854a0038 --- /dev/null +++ b/packages/lark-adapter/src/transport.ts @@ -0,0 +1,115 @@ +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() {} }; + +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; + private readonly socket: WSClient; + private readonly dispatcher: EventDispatcher; + private closed = false; + private started = false; + private settleStartup?: (error?: Error) => void; + + 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: STARTUP_TIMEOUT_MS, + onReady: () => { + if (this.closed || !this.started) return; + this.settleStartup?.(); + options.onState("connected"); + }, + onReconnected: () => { + if (!this.closed) options.onState("connected"); + }, + onReconnecting: () => { + if (!this.closed) options.onState("degraded"); + }, + onError: () => { + if (this.closed) return; + if (this.settleStartup) this.close(); + else options.onState("degraded"); + }, + }); + } + + get chatId(): string | undefined { + 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 { + void Promise.resolve(this.socket.start({ eventDispatcher: this.dispatcher })).catch(() => this.close()); + } catch { + this.close(); + } + 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/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..eced8c7c --- /dev/null +++ b/packages/lark-adapter/test/inbound.test.ts @@ -0,0 +1,131 @@ +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 throughout the freshness window and can release all state", () => { + const gate = new LarkInboundGate("bot"); + gate.accept(message(), now); + 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).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"] }], + ["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..711dd06f --- /dev/null +++ b/packages/lark-adapter/test/sdk-lifecycle.test.ts @@ -0,0 +1,63 @@ +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(); + 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..9724ce03 --- /dev/null +++ b/packages/lark-adapter/test/transport.test.ts @@ -0,0 +1,159 @@ +import { afterEach, 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(); + }); + 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" }, + botId: "bot", + onMessage: vi.fn(), + onCardAction: vi.fn(), + onState: state, + }); + 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(); + }); + + 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, + }); + const startup = transport.start(); + (sdk.options.onReady as () => void)(); + await startup; + state.mockClear(); + 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.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({ + 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 },