larkin 0.4.19 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ export const DEFAULT_WAIT_SECONDS = 60;
6
+ export const DEFAULT_LIFE_SECONDS = 600;
7
+ export const LARKIN_SUPERVISED_COMMAND_CAPABILITY = "larkin-pi-supervised-command-v1";
8
+ const MAX_STREAM_BYTES = 64 * 1024;
9
+ export function supervisedWaitSeconds() {
10
+ const raw = Number.parseInt(process.env.LARKIN_PI_SUPERVISED_WAIT_SECONDS || "", 10);
11
+ if (Number.isInteger(raw) && raw > 0)
12
+ return Math.min(raw, DEFAULT_WAIT_SECONDS);
13
+ return DEFAULT_WAIT_SECONDS;
14
+ }
15
+ export function supervisedLifeSeconds() {
16
+ const raw = Number.parseInt(process.env.LARKIN_PI_SUPERVISED_LIFE_SECONDS || "", 10);
17
+ if (Number.isInteger(raw) && raw > 0)
18
+ return Math.min(raw, DEFAULT_LIFE_SECONDS);
19
+ return DEFAULT_LIFE_SECONDS;
20
+ }
21
+ export function resolveSupervisedCwd(root, requested) {
22
+ if (typeof root !== "string" || root.length === 0)
23
+ throw new Error("supervised cwd root is required");
24
+ if (fs.lstatSync(root).isSymbolicLink())
25
+ throw new Error("supervised cwd must not be a symlink");
26
+ const base = fs.realpathSync(root);
27
+ const target = requested && requested.length > 0 ? path.resolve(base, requested) : base;
28
+ const relative = path.relative(base, target);
29
+ if (relative.startsWith("..") || path.isAbsolute(relative))
30
+ throw new Error("supervised cwd escapes the session root");
31
+ let current = base;
32
+ if (relative && relative !== ".") {
33
+ for (const part of relative.split(path.sep)) {
34
+ if (!part || part === ".")
35
+ continue;
36
+ current = path.join(current, part);
37
+ if (fs.lstatSync(current).isSymbolicLink())
38
+ throw new Error("supervised cwd must not be a symlink");
39
+ }
40
+ }
41
+ const real = fs.realpathSync(current);
42
+ const prefix = base.endsWith(path.sep) ? base : base + path.sep;
43
+ if (real !== base && !real.startsWith(prefix))
44
+ throw new Error("supervised cwd escapes the session root");
45
+ if (!fs.statSync(real).isDirectory())
46
+ throw new Error("supervised cwd is not a directory");
47
+ return real;
48
+ }
49
+ function pidAlive(pid) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function listDescendantPids(root) {
59
+ if (process.platform === "win32")
60
+ return [];
61
+ const found = new Set();
62
+ const queue = [root];
63
+ while (queue.length > 0) {
64
+ const current = queue.pop();
65
+ if (current === undefined)
66
+ break;
67
+ const listed = spawnSync("pgrep", ["-P", String(current)], { encoding: "utf8" });
68
+ if (listed.status !== 0 || !listed.stdout)
69
+ continue;
70
+ for (const line of listed.stdout.split(/\s+/)) {
71
+ const child = Number.parseInt(line, 10);
72
+ if (!Number.isInteger(child) || child <= 0 || found.has(child) || child === root)
73
+ continue;
74
+ found.add(child);
75
+ queue.push(child);
76
+ }
77
+ }
78
+ return [...found];
79
+ }
80
+ function refreshTreePids(pid, known) {
81
+ known.add(pid);
82
+ for (const child of listDescendantPids(pid))
83
+ known.add(child);
84
+ return [...known];
85
+ }
86
+ async function killProcessTree(pid, known = new Set([pid])) {
87
+ const descendants = refreshTreePids(pid, known).filter((child) => child !== pid);
88
+ if (process.platform === "win32") {
89
+ const result = await new Promise((resolve) => {
90
+ const child = spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
91
+ child.once("error", (error) => resolve({ status: null, error }));
92
+ child.once("exit", (status) => resolve({ status }));
93
+ });
94
+ if (result.error && pidAlive(pid))
95
+ throw result.error;
96
+ }
97
+ else {
98
+ for (const child of [...descendants].reverse()) {
99
+ try {
100
+ process.kill(child, "SIGKILL");
101
+ }
102
+ catch { /* already dead */ }
103
+ }
104
+ try {
105
+ process.kill(-pid, "SIGKILL");
106
+ }
107
+ catch {
108
+ try {
109
+ process.kill(pid, "SIGKILL");
110
+ }
111
+ catch { /* already dead */ }
112
+ }
113
+ }
114
+ const started = Date.now();
115
+ const pending = () => pidAlive(pid) || descendants.some((child) => pidAlive(child));
116
+ while (pending() && Date.now() - started < 2000) {
117
+ await new Promise((resolve) => setTimeout(resolve, 50));
118
+ if (process.platform !== "win32") {
119
+ for (const child of descendants) {
120
+ try {
121
+ process.kill(child, "SIGKILL");
122
+ }
123
+ catch { /* already dead */ }
124
+ }
125
+ try {
126
+ process.kill(pid, "SIGKILL");
127
+ }
128
+ catch { /* already dead */ }
129
+ }
130
+ }
131
+ if (pending())
132
+ throw new Error(`supervised process tree ${pid} still alive`);
133
+ }
134
+ class ByteCursor {
135
+ chunks = [];
136
+ dropped = 0;
137
+ write(chunk) {
138
+ let data = chunk;
139
+ if (chunk.length > MAX_STREAM_BYTES) {
140
+ this.dropped += chunk.length - MAX_STREAM_BYTES;
141
+ data = chunk.subarray(chunk.length - MAX_STREAM_BYTES);
142
+ }
143
+ this.chunks.push(data);
144
+ let total = this.chunks.reduce((n, c) => n + c.length, 0);
145
+ while (total > MAX_STREAM_BYTES && this.chunks.length) {
146
+ const overflow = total - MAX_STREAM_BYTES;
147
+ const head = this.chunks[0];
148
+ if (head.length <= overflow) {
149
+ this.chunks.shift();
150
+ this.dropped += head.length;
151
+ total -= head.length;
152
+ }
153
+ else {
154
+ this.chunks[0] = head.subarray(overflow);
155
+ this.dropped += overflow;
156
+ total -= overflow;
157
+ }
158
+ }
159
+ }
160
+ snapshot(cursor) {
161
+ const joined = Buffer.concat(this.chunks);
162
+ let start = Math.max(0, cursor - this.dropped);
163
+ if (start < joined.length) {
164
+ while (start < joined.length && (joined[start] & 0b1100_0000) === 0b1000_0000)
165
+ start++;
166
+ }
167
+ let end = joined.length;
168
+ if (end > start) {
169
+ let cont = 0;
170
+ let i = end;
171
+ while (i > start && (joined[i - 1] & 0b1100_0000) === 0b1000_0000) {
172
+ i--;
173
+ cont++;
174
+ }
175
+ if (i > start) {
176
+ const lead = joined[i - 1];
177
+ const need = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : 4;
178
+ if (cont + 1 < need)
179
+ end = i - 1;
180
+ }
181
+ }
182
+ const slice = joined.subarray(Math.min(start, end), end);
183
+ return {
184
+ text: slice.toString("utf8"),
185
+ cursor: this.dropped + end,
186
+ truncated: this.dropped > cursor || start > Math.max(0, cursor - this.dropped),
187
+ };
188
+ }
189
+ }
190
+ const handles = new Map();
191
+ const terminals = new Map();
192
+ const MAX_TERMINALS = 32;
193
+ function rememberTerminal(id, owner, snapshot) {
194
+ terminals.set(id, { owner, snapshot });
195
+ while (terminals.size > MAX_TERMINALS) {
196
+ const oldest = terminals.keys().next().value;
197
+ if (!oldest)
198
+ break;
199
+ terminals.delete(oldest);
200
+ }
201
+ }
202
+ function notify(record) {
203
+ const waiters = record.exitWaiters.splice(0);
204
+ for (const wake of waiters)
205
+ wake();
206
+ }
207
+ function finish(record, status, exitCode) {
208
+ if (record.lifetimeTimer) {
209
+ clearTimeout(record.lifetimeTimer);
210
+ record.lifetimeTimer = undefined;
211
+ }
212
+ if (record.treePoll) {
213
+ clearInterval(record.treePoll);
214
+ record.treePoll = undefined;
215
+ }
216
+ if (record.status === "running") {
217
+ record.status = status;
218
+ record.exitCode = exitCode;
219
+ }
220
+ notify(record);
221
+ }
222
+ export function startSupervisedCommand(input) {
223
+ if (typeof input.executable !== "string" || input.executable.length === 0) {
224
+ throw new Error("executable must be a non-empty string");
225
+ }
226
+ if (!Array.isArray(input.args) || input.args.some((arg) => typeof arg !== "string")) {
227
+ throw new Error("args must be a string array");
228
+ }
229
+ const cwd = resolveSupervisedCwd(input.cwd);
230
+ const child = spawn(input.executable, input.args, {
231
+ cwd,
232
+ shell: false,
233
+ detached: process.platform !== "win32",
234
+ stdio: ["ignore", "pipe", "pipe"],
235
+ windowsHide: true,
236
+ });
237
+ let record;
238
+ child.on("error", (error) => {
239
+ if (!record)
240
+ return;
241
+ const current = record;
242
+ void current.chain.then(async () => {
243
+ await killProcessTree(current.pid, current.treePids);
244
+ finish(current, "killed", null);
245
+ });
246
+ void error;
247
+ });
248
+ if (!child.pid)
249
+ throw new Error("failed to spawn supervised command");
250
+ const id = randomBytes(16).toString("hex");
251
+ record = {
252
+ id,
253
+ owner: input.owner,
254
+ child,
255
+ pid: child.pid,
256
+ startedAt: Date.now(),
257
+ deadlineAt: Date.now() + supervisedLifeSeconds() * 1000,
258
+ stdout: new ByteCursor(),
259
+ stderr: new ByteCursor(),
260
+ stdoutCursor: 0,
261
+ stderrCursor: 0,
262
+ status: "running",
263
+ exitCode: null,
264
+ terminalConsumed: false,
265
+ cancelRequested: false,
266
+ exitWaiters: [],
267
+ treePids: new Set([child.pid]),
268
+ chain: Promise.resolve(),
269
+ closed: Promise.resolve(),
270
+ };
271
+ const started = record;
272
+ let markClosed = () => { };
273
+ started.closed = new Promise((resolve) => { markClosed = resolve; });
274
+ child.stdout?.on("data", (chunk) => started.stdout.write(Buffer.from(chunk)));
275
+ child.stderr?.on("data", (chunk) => started.stderr.write(Buffer.from(chunk)));
276
+ started.treePoll = setInterval(() => {
277
+ refreshTreePids(started.pid, started.treePids);
278
+ }, 50);
279
+ started.treePoll.unref?.();
280
+ child.once("close", (code) => {
281
+ markClosed();
282
+ void started.chain.then(async () => {
283
+ await killProcessTree(started.pid, started.treePids);
284
+ finish(started, "exited", code);
285
+ });
286
+ });
287
+ started.lifetimeTimer = setTimeout(() => {
288
+ void started.chain.then(async () => {
289
+ if (started.status !== "running")
290
+ return;
291
+ await killProcessTree(started.pid, started.treePids);
292
+ finish(started, "lifetime_exceeded", null);
293
+ });
294
+ }, supervisedLifeSeconds() * 1000);
295
+ started.lifetimeTimer.unref?.();
296
+ handles.set(id, started);
297
+ return { handle: id, pid: started.pid };
298
+ }
299
+ function snapshot(record) {
300
+ const out = record.stdout.snapshot(record.stdoutCursor);
301
+ const err = record.stderr.snapshot(record.stderrCursor);
302
+ record.stdoutCursor = out.cursor;
303
+ record.stderrCursor = err.cursor;
304
+ return {
305
+ handle: record.id,
306
+ pid: record.pid,
307
+ status: record.status,
308
+ exitCode: record.exitCode,
309
+ stdout: out.text,
310
+ stderr: err.text,
311
+ stdoutCursor: out.cursor,
312
+ stderrCursor: err.cursor,
313
+ truncated: out.truncated || err.truncated,
314
+ };
315
+ }
316
+ function requireHandle(owner, handle) {
317
+ const record = handles.get(handle);
318
+ if (!record || record.owner !== owner)
319
+ throw new Error("supervised handle not found");
320
+ return record;
321
+ }
322
+ function enqueue(record, work) {
323
+ const next = record.chain.then(work, work);
324
+ record.chain = next.then(() => undefined, () => undefined);
325
+ return next;
326
+ }
327
+ export async function waitSupervisedCommand(owner, handle, timeoutSeconds) {
328
+ const remembered = terminals.get(handle);
329
+ if (remembered && remembered.owner === owner && !handles.has(handle))
330
+ return remembered.snapshot;
331
+ const record = requireHandle(owner, handle);
332
+ return enqueue(record, async () => {
333
+ const cap = supervisedWaitSeconds();
334
+ const requested = typeof timeoutSeconds === "number" && timeoutSeconds > 0 ? timeoutSeconds : cap;
335
+ if (requested > cap)
336
+ throw new Error(`wait timeout:${requested} exceeds the ${cap}s supervised wait limit`);
337
+ const remainingLife = Math.max(0, record.deadlineAt - Date.now());
338
+ const waitMs = Math.min(requested * 1000, remainingLife, cap * 1000);
339
+ if (record.status === "running" && waitMs > 0) {
340
+ await new Promise((resolve) => {
341
+ let done = false;
342
+ const finishWait = () => {
343
+ if (done)
344
+ return;
345
+ done = true;
346
+ record.exitWaiters = record.exitWaiters.filter((item) => item !== finishWait);
347
+ resolve();
348
+ };
349
+ const timer = setTimeout(finishWait, waitMs);
350
+ record.exitWaiters.push(() => {
351
+ clearTimeout(timer);
352
+ finishWait();
353
+ });
354
+ });
355
+ }
356
+ if (record.status !== "running" && record.terminalConsumed) {
357
+ throw new Error("supervised terminal result already consumed");
358
+ }
359
+ const result = snapshot(record);
360
+ if (result.status !== "running") {
361
+ record.terminalConsumed = true;
362
+ rememberTerminal(record.id, owner, result);
363
+ handles.delete(record.id);
364
+ }
365
+ return result;
366
+ });
367
+ }
368
+ export async function cancelSupervisedCommand(owner, handle) {
369
+ const remembered = terminals.get(handle);
370
+ if (remembered && remembered.owner === owner && !handles.has(handle))
371
+ return remembered.snapshot;
372
+ const record = requireHandle(owner, handle);
373
+ return enqueue(record, async () => {
374
+ record.cancelRequested = true;
375
+ notify(record);
376
+ if (record.status === "running") {
377
+ await killProcessTree(record.pid, record.treePids);
378
+ record.status = "killed";
379
+ }
380
+ if (record.lifetimeTimer) {
381
+ clearTimeout(record.lifetimeTimer);
382
+ record.lifetimeTimer = undefined;
383
+ }
384
+ await record.closed;
385
+ const result = record.terminalConsumed && remembered?.owner === owner
386
+ ? remembered.snapshot
387
+ : snapshot(record);
388
+ record.terminalConsumed = true;
389
+ rememberTerminal(record.id, owner, result);
390
+ handles.delete(record.id);
391
+ return result;
392
+ });
393
+ }
394
+ export async function reapSupervisedCommands(owner) {
395
+ for (const [id, remembered] of terminals) {
396
+ if (remembered.owner === owner)
397
+ terminals.delete(id);
398
+ }
399
+ const owned = [...handles.values()].filter((record) => record.owner === owner);
400
+ const errors = [];
401
+ await Promise.all(owned.map(async (record) => {
402
+ try {
403
+ await cancelSupervisedCommand(owner, record.id);
404
+ }
405
+ catch (error) {
406
+ errors.push(error);
407
+ }
408
+ if (record.lifetimeTimer)
409
+ clearTimeout(record.lifetimeTimer);
410
+ try {
411
+ await killProcessTree(record.pid, record.treePids);
412
+ }
413
+ catch (error) {
414
+ errors.push(error);
415
+ }
416
+ handles.delete(record.id);
417
+ }));
418
+ if (errors.length > 0)
419
+ throw errors[0];
420
+ }
@@ -19,6 +19,7 @@ import { beginBuiltinPiCredentialTransaction, createOfficialPiCredentialRuntime,
19
19
  import { documentCommentSubscriptionCapability, markDocumentCommentSubscriptionVerified, } from "../platform/callback-capability.mjs";
20
20
  import { openPlatformHost, parseLarkinTenant, registerAppAccountsHost, } from "../feishu/platform-hosts.mjs";
21
21
  import { authorizationUrlFailureMessage, presentAuthorizationUrl } from "./setup-authorization-url.mjs";
22
+ import { missingGrantedTenantScopes } from "./tenant-scope-grant.mjs";
22
23
  // qrcode-terminal does not publish TypeScript declarations.
23
24
  // @ts-expect-error bundled CommonJS dependency
24
25
  import qrcodePackage from "qrcode-terminal";
@@ -28,6 +29,7 @@ const testFixture = process.env.LARKIN_TEST_BOT_REGISTER_MODULE
28
29
  const registerApp = testFixture?.registerApp ?? channelRegisterApp;
29
30
  const qrcode = testFixture?.qrcode ?? qrcodePackage;
30
31
  const spawnSync = testFixture?.spawnSync ?? systemSpawnSync;
32
+ const spawnChild = testFixture?.spawn ?? systemSpawn;
31
33
  const synchronizeAgentProfile = testFixture?.syncAgentProfile ?? syncAgentProfile;
32
34
  const resolveOfficialCli = testFixture?.resolveOfficialLarkCli ?? resolveOfficialLarkCli;
33
35
  const wait = testFixture?.wait ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
@@ -306,7 +308,7 @@ async function runBindProcess(command, args) {
306
308
  return spawnSync(command, [...args], { env: process.env, stdio: "inherit" }).status;
307
309
  }
308
310
  return await new Promise((resolve, reject) => {
309
- const child = systemSpawn(command, [...args], { env: process.env, stdio: "inherit" });
311
+ const child = spawnChild(command, [...args], { env: process.env, stdio: "inherit" });
310
312
  trackPendingChild(child);
311
313
  let killTimer = null;
312
314
  const abort = () => {
@@ -339,7 +341,7 @@ async function runBoundedCliProcess(command, args, env, label) {
339
341
  return { status: result.status, stdout: result.stdout || "", stderr: result.stderr || "" };
340
342
  }
341
343
  return await new Promise((resolve, reject) => {
342
- const child = systemSpawn(command, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
344
+ const child = spawnChild(command, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
343
345
  trackPendingChild(child);
344
346
  let stdout = "";
345
347
  let stderr = "";
@@ -641,12 +643,13 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
641
643
  : commentSubscription === "none" || !priorSubscription
642
644
  ? { mode: "none", status: "safe-default", source: "setup-default", updatedAt: requestedAt }
643
645
  : priorSubscription;
644
- if (!flag("--runtime") && (!testFixture || process.env.LARKIN_TEST_ENABLE_AGENT_CHOICE === "1")) {
646
+ const injectedAgentChoice = testFixture?.collectSetupAgentChoice;
647
+ if (!flag("--runtime") && (!testFixture || injectedAgentChoice || process.env.LARKIN_TEST_ENABLE_AGENT_CHOICE === "1")) {
645
648
  const existing = larkinConfig.loadConfig(process.env).config.agents[id];
646
649
  // The official resolver performs a synchronous login-shell probe. Resolve it
647
650
  // exactly once before the credential heartbeat lock becomes active.
648
651
  resolvedSetupOfficialCli = resolveOfficialCli({ env: process.env });
649
- const questioner = terminalSetupQuestioner();
652
+ const questioner = injectedAgentChoice ? { ask: async () => "", secret: async () => "" } : terminalSetupQuestioner();
650
653
  // One setup-owned transaction starts before status/logout and remains active
651
654
  // through selection, login, bind, readiness, and the final setup commit.
652
655
  pendingPiAuthTransaction = beginBuiltinPiCredentialTransaction(CFG_DIR, id);
@@ -657,37 +660,43 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
657
660
  report: (message) => say(message),
658
661
  };
659
662
  try {
660
- const requested = await collectSetupAgentChoice(questioner, existing, authServices);
661
- const choice = await recoverUnavailableExternalPi(requested, questioner, () => probeNativeRuntimeReadiness({
662
- runtime: "pi", agentId: id, cwd: path.join(CFG_DIR, "agents", id), env: process.env,
663
- }), (message) => say(`! ${message}`), authServices);
663
+ const requested = injectedAgentChoice
664
+ ? await injectedAgentChoice()
665
+ : await collectSetupAgentChoice(questioner, existing, authServices);
666
+ const choice = injectedAgentChoice
667
+ ? requested
668
+ : await recoverUnavailableExternalPi(requested, questioner, () => probeNativeRuntimeReadiness({
669
+ runtime: "pi", agentId: id, cwd: path.join(CFG_DIR, "agents", id), env: process.env,
670
+ }), (message) => say(`! ${message}`), authServices);
664
671
  if (choice) {
665
672
  let serializedChoice = choice;
666
673
  if (choice.runtime === "pi" && choice.distribution === "builtin") {
667
- const official = choice.preset === "official" ? choice : null;
668
- const configured = official ? null : configureBuiltinPiProviderModel(CFG_DIR, id, choice);
669
- const providerId = official?.providerId || configured.provider;
670
- const authType = official?.authType || "api_key";
671
- let piRuntime;
672
- try {
673
- say(`正在通过捆绑官方 Pi 登录 ${providerId}(${authType})…`);
674
- piRuntime = await createOfficialPiModelRuntime(CFG_DIR, id);
675
- await runOfficialPiLogin(piRuntime, providerId, authType, createOfficialPiAuthInteraction({ questioner, report: (message) => say(message), openUrl: openBrowser }));
676
- }
677
- catch {
678
- pendingPiAuthTransaction.rollback();
679
- pendingPiAuthTransaction = null;
680
- throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
681
- }
682
- if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
683
- say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
674
+ if (!injectedAgentChoice) {
675
+ const official = choice.preset === "official" ? choice : null;
676
+ const configured = official ? null : configureBuiltinPiProviderModel(CFG_DIR, id, choice);
677
+ const providerId = official?.providerId || configured.provider;
678
+ const authType = official?.authType || "api_key";
679
+ let piRuntime;
684
680
  try {
685
- await verifyOfficialPiProviderTurn(piRuntime, choice.model);
681
+ say(`正在通过捆绑官方 Pi 登录 ${providerId}(${authType})…`);
682
+ piRuntime = await createOfficialPiModelRuntime(CFG_DIR, id);
683
+ await runOfficialPiLogin(piRuntime, providerId, authType, createOfficialPiAuthInteraction({ questioner, report: (message) => say(message), openUrl: openBrowser }));
686
684
  }
687
685
  catch {
688
686
  pendingPiAuthTransaction.rollback();
689
687
  pendingPiAuthTransaction = null;
690
- throw new Error(`官方 Pi ${providerId} readiness 失败;credential/config 未修改`);
688
+ throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
689
+ }
690
+ if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
691
+ say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
692
+ try {
693
+ await verifyOfficialPiProviderTurn(piRuntime, choice.model);
694
+ }
695
+ catch {
696
+ pendingPiAuthTransaction.rollback();
697
+ pendingPiAuthTransaction = null;
698
+ throw new Error(`官方 Pi ${providerId} readiness 失败;credential/config 未修改`);
699
+ }
691
700
  }
692
701
  }
693
702
  serializedChoice = { ...choice, authCompleted: true, readinessCompleted: true };
@@ -850,6 +859,21 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
850
859
  }
851
860
  if (!verified)
852
861
  throw new Error("Bot identity verification failed");
862
+ const scopeResult = await runIdentityProcess(official.command, [...official.argsPrefix, "api", "GET", "/open-apis/application/v6/scopes"], cliEnv);
863
+ let scopePayload;
864
+ try {
865
+ scopePayload = JSON.parse(scopeResult.stdout || "");
866
+ }
867
+ catch {
868
+ scopePayload = null;
869
+ }
870
+ const missing = missingGrantedTenantScopes(scopePayload);
871
+ if (scopeResult.status !== 0 || missing.length > 0) {
872
+ const reason = scopeResult.status !== 0
873
+ ? `scopes API 失败 status=${scopeResult.status}`
874
+ : `缺 ${missing.join(", ")}`;
875
+ throw new Error(`Bot tenant scope 未就绪,${reason};+chat-list 成功不能当作可发群消息。请在开发者后台开通后重跑 setup,不要重建 Agent。`);
876
+ }
853
877
  if (commentSubscription !== "preserve") {
854
878
  const reconciled = await applyDocumentCommentSubscription({
855
879
  mode: commentSubscription,
@@ -901,6 +925,8 @@ Lark 必须用 --tenant lark:授权二维码是官方 /page/cli(begin 仍走
901
925
  }
902
926
  catch (error) {
903
927
  const diagnostic = errorMessage(error);
928
+ if (diagnostic.startsWith("Bot tenant scope 未就绪"))
929
+ die(diagnostic);
904
930
  if (diagnostic.startsWith("document comment application subscription")
905
931
  || diagnostic.startsWith("document comment local verified-state persistence"))
906
932
  say(`! ${diagnostic}`);
@@ -10,6 +10,7 @@ import * as larkinConfig from "../platform/config.mjs";
10
10
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
11
11
  import { loadValidatedBotCredential } from "./run-credential-preflight.mjs";
12
12
  import { parseLarkinTenant, registerAppAccountsHost } from "../feishu/platform-hosts.mjs";
13
+ import { presentAuthorizationUrl } from "./setup-authorization-url.mjs";
13
14
  // qrcode-terminal does not publish TypeScript declarations.
14
15
  // @ts-expect-error bundled CommonJS dependency
15
16
  import qrcodePackage from "qrcode-terminal";
@@ -108,18 +109,26 @@ export async function main() {
108
109
  },
109
110
  onQRCodeReady: (info) => {
110
111
  const minutes = Math.max(1, Math.round(info.expireIn / 60));
112
+ const official = resolveManagedOfficialCli(selected, process.env);
113
+ const presented = presentAuthorizationUrl(info.url, {
114
+ tenant: TENANT,
115
+ larkCliVersion: official.command.version,
116
+ });
117
+ if (TENANT === "lark" && /\/page\/launcher(?:\?|$)/.test(presented)) {
118
+ throw new Error("Lark 租户拒绝把 /page/launcher 交给浏览器");
119
+ }
111
120
  log("\n请用飞书(Lark)扫码或打开链接,确认给应用增补权限:\n");
112
- qrcode.generate(info.url, { small: true });
113
- log(`\n链接: ${info.url}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
121
+ qrcode.generate(presented, { small: true });
122
+ log(`\n链接: ${presented}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
114
123
  if (URL_FILE) {
115
124
  try {
116
- fs.writeFileSync(URL_FILE, info.url);
125
+ fs.writeFileSync(URL_FILE, presented);
117
126
  }
118
127
  catch (error) {
119
128
  log(`[grant] 写 url-file 失败: ${error instanceof Error ? error.message : String(error)}`);
120
129
  }
121
130
  }
122
- sendUrlToChat(info.url, minutes);
131
+ sendUrlToChat(presented, minutes);
123
132
  },
124
133
  onStatusChange: (info) => log(`[grant] 状态: ${info.status}`),
125
134
  domain: registerAppAccountsHost(TENANT),
@@ -0,0 +1,16 @@
1
+ /** Tenant scopes the freshness gate needs on the Bot application. */
2
+ export const FRESHNESS_REQUIRED_TENANT_SCOPES = ["im:message.group_msg"];
3
+ function asRows(payload) {
4
+ if (!payload || typeof payload !== "object")
5
+ return [];
6
+ const root = payload;
7
+ const raw = Array.isArray(root.data?.scopes) ? root.data.scopes
8
+ : Array.isArray(root.scopes) ? root.scopes
9
+ : [];
10
+ return raw.filter((row) => !!row && typeof row === "object");
11
+ }
12
+ /** grant_status 1 is granted on GET /open-apis/application/v6/scopes. */
13
+ export function missingGrantedTenantScopes(payload, required = FRESHNESS_REQUIRED_TENANT_SCOPES) {
14
+ const rows = asRows(payload);
15
+ return required.filter((name) => !rows.some((row) => row.scope_name === name && row.grant_status === 1));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [