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.
- package/dist/agent/context-prompt.cjs +2 -2
- package/dist/agent/context-prompt.mjs +2 -2
- package/dist/agent/missed-outbound-scan.cjs +123 -0
- package/dist/agent/missed-outbound-scan.mjs +112 -0
- package/dist/feishu/host-shell.cjs +9 -0
- package/dist/feishu/host-shell.mjs +9 -0
- package/dist/feishu/message-policy.cjs +2 -0
- package/dist/feishu/message-policy.mjs +2 -0
- package/dist/runtime/pi-inline-extensions.mjs +40 -9
- package/dist/runtime/pi-subagent-injection.mjs +103 -10
- package/dist/runtime/pi-subagents.bundle.js +131 -58
- package/dist/runtime/pi-supervised-command-extension.mjs +72 -0
- package/dist/runtime/pi-supervised-command.bundle.js +4735 -0
- package/dist/runtime/pi-supervised-command.mjs +420 -0
- package/dist/setup/bot-register.mjs +53 -27
- package/dist/setup/grant-scopes.mjs +13 -4
- package/dist/setup/tenant-scope-grant.mjs +16 -0
- package/package.json +1 -1
- package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +712 -16
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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}
|
|
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(
|
|
113
|
-
log(`\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,
|
|
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(
|
|
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
|
+
}
|