@timeryu/dsh-larklink 0.0.0-stage → 0.1.0
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/README.md +32 -2
- package/cordis.patch.yml +4 -0
- package/lib/client.cjs +11 -0
- package/lib/index.d.mts +51 -0
- package/lib/index.mjs +1486 -0
- package/package.json +68 -4
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1486 @@
|
|
|
1
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
+
import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
// \0rolldown/runtime.js
|
|
9
|
+
var __defProp = Object.defineProperty;
|
|
10
|
+
var __exportAll = (all, no_symbols) => {
|
|
11
|
+
let target = {};
|
|
12
|
+
for (var name in all) __defProp(target, name, {
|
|
13
|
+
get: all[name],
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
17
|
+
return target;
|
|
18
|
+
};
|
|
19
|
+
// ../cli/src/larkcli.ts
|
|
20
|
+
/**
|
|
21
|
+
* lark-cli 子进程封装。所有 spawn 只走这里——命令一律 argv 数组,绝不安字符串拼接
|
|
22
|
+
* (lark-shared 安全红线)。runner 以接口注入,测试用 mock 替换。
|
|
23
|
+
*/
|
|
24
|
+
/** 解析 lark-cli 可执行文件:env 覆盖 > PATH 上的原生 exe > npm 全局已知位置。Windows 只接受 .exe(shim 脚本无法被 spawn(shell:false) 执行)。 */
|
|
25
|
+
function resolveLarkCli() {
|
|
26
|
+
const envOverride = process.env["LARKLINK_LARK_CLI"];
|
|
27
|
+
if (envOverride && envOverride.trim() !== "") return envOverride;
|
|
28
|
+
const isWin = process.platform === "win32";
|
|
29
|
+
const candidates = [];
|
|
30
|
+
const pathDirs = (process.env["PATH"] ?? "").split(delimiter).filter((d) => d.trim() !== "");
|
|
31
|
+
for (const dir of pathDirs) if (isWin) candidates.push(join(dir, "lark-cli.exe"));
|
|
32
|
+
else candidates.push(join(dir, "lark-cli"));
|
|
33
|
+
const home = process.env["USERPROFILE"] ?? process.env["HOME"];
|
|
34
|
+
if (home) candidates.push(join(home, ".npm-global", "node_modules", "@larksuite", "cli", "bin", "lark-cli.exe"));
|
|
35
|
+
for (const c of candidates) try {
|
|
36
|
+
if (existsSync(c)) return c;
|
|
37
|
+
} catch {}
|
|
38
|
+
throw new Error(`找不到可执行的 lark-cli${isWin ? "(Windows 需要 lark-cli.exe 原生二进制)" : ""}:请安装 lark-cli(npm i -g @larksuite/cli)或设置 LARKLINK_LARK_CLI 指向其原生可执行路径`);
|
|
39
|
+
}
|
|
40
|
+
/** 默认 runner:spawn 原生 exe,超时击杀并标记 timedOut(Q7:超时=结果未知)。 */
|
|
41
|
+
const defaultLarkRunner = (args, opts = {}) => {
|
|
42
|
+
const bin = resolveLarkCli();
|
|
43
|
+
const timeoutMs = opts.timeoutMs ?? 12e4;
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
const child = spawn(bin, args, {
|
|
47
|
+
shell: false,
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
stdio: [
|
|
50
|
+
"pipe",
|
|
51
|
+
"pipe",
|
|
52
|
+
"pipe"
|
|
53
|
+
]
|
|
54
|
+
});
|
|
55
|
+
let stdout = "";
|
|
56
|
+
let stderr = "";
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
timedOut = true;
|
|
60
|
+
child.kill();
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
child.stdout.on("data", (d) => stdout += d.toString("utf8"));
|
|
63
|
+
child.stderr.on("data", (d) => stderr += d.toString("utf8"));
|
|
64
|
+
if (opts.stdin !== void 0) {
|
|
65
|
+
child.stdin.write(opts.stdin);
|
|
66
|
+
child.stdin.end();
|
|
67
|
+
} else child.stdin.end();
|
|
68
|
+
child.on("error", (e) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
reject(e);
|
|
71
|
+
});
|
|
72
|
+
child.on("close", (code) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve({
|
|
75
|
+
exitCode: timedOut ? -1 : code ?? -1,
|
|
76
|
+
stdout,
|
|
77
|
+
stderr,
|
|
78
|
+
durationMs: Date.now() - started,
|
|
79
|
+
timedOut
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
/** lark-cli 输出信封解析:stdout JSON 优先,退回 stderr JSON(lark-shared:成功看 ok/exit 0,非 code==0)。 */
|
|
85
|
+
function parseEnvelope(result) {
|
|
86
|
+
const out = safeJson(result.stdout);
|
|
87
|
+
const err = safeJson(result.stderr);
|
|
88
|
+
if (result.timedOut) return {
|
|
89
|
+
ok: false,
|
|
90
|
+
payload: out,
|
|
91
|
+
error: {
|
|
92
|
+
type: "timeout",
|
|
93
|
+
message: "lark-cli 超时被击杀,服务端结果未知(Q7)"
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
if (result.exitCode === 0 && out !== null) return {
|
|
97
|
+
ok: true,
|
|
98
|
+
payload: out,
|
|
99
|
+
error: null
|
|
100
|
+
};
|
|
101
|
+
if (result.exitCode === 0) return {
|
|
102
|
+
ok: true,
|
|
103
|
+
payload: { raw: result.stdout },
|
|
104
|
+
error: null
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
payload: out,
|
|
109
|
+
error: err ?? {
|
|
110
|
+
type: "unknown",
|
|
111
|
+
message: result.stderr || result.stdout || `exit ${result.exitCode}`
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function safeJson(text) {
|
|
116
|
+
const t = text.trim();
|
|
117
|
+
if (t === "") return null;
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(t);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ../core/src/url.ts
|
|
125
|
+
/**
|
|
126
|
+
* 文档 URL / 裸 token 解析器(design.md §5 步骤 1-2)。
|
|
127
|
+
* 事实依据:lark-drive SKILL「文档 URL 格式与 Token 处理」表、lark-shared
|
|
128
|
+
* wiki-token-routing、lark-minutes/lark-slides/lark-drive-inspect 实例(2026-10-22 核对)。
|
|
129
|
+
*/
|
|
130
|
+
/** 路径前缀 → 资源类型。顺序无关(前缀互不冲突),但保持与设计文档一致的清单。 */
|
|
131
|
+
const PATH_RULES = [
|
|
132
|
+
["/docx/", "docx"],
|
|
133
|
+
["/doc/", "doc"],
|
|
134
|
+
["/wiki/", "wiki"],
|
|
135
|
+
["/sheets/", "sheets"],
|
|
136
|
+
["/base/", "base"],
|
|
137
|
+
["/slides/", "slides"],
|
|
138
|
+
["/mindnotes/", "mindnotes"],
|
|
139
|
+
["/whiteboard/", "whiteboard"],
|
|
140
|
+
["/minutes/", "minutes"],
|
|
141
|
+
["/drive/folder/", "folder"],
|
|
142
|
+
["/page/", "apps"]
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* 裸 token 前缀 → 资源类型。仅收录有本地文档实例佐证的前缀;
|
|
146
|
+
* slides 为宽松前缀(实例既见 `slides…` 也见无前缀形态)。
|
|
147
|
+
*/
|
|
148
|
+
const TOKEN_RULES = [
|
|
149
|
+
["doxcn", "docx"],
|
|
150
|
+
["doccn", "doc"],
|
|
151
|
+
["wikcn", "wiki"],
|
|
152
|
+
["shtcn", "sheets"],
|
|
153
|
+
["bascn", "base"],
|
|
154
|
+
["fldcn", "folder"],
|
|
155
|
+
["obcn", "minutes"],
|
|
156
|
+
["slides", "slides"]
|
|
157
|
+
];
|
|
158
|
+
const WIKI_TYPES = /* @__PURE__ */ new Set(["wiki"]);
|
|
159
|
+
/** 域名 → 品牌证据。doubao.com 及一切未列域名 → "none"(Q3 决议:不做默认品牌猜测)。 */
|
|
160
|
+
const BRAND_DOMAIN_SUFFIXES = [
|
|
161
|
+
["feishu.cn", "feishu"],
|
|
162
|
+
["larksuite.com", "lark"],
|
|
163
|
+
["larkoffice.com", "lark"]
|
|
164
|
+
];
|
|
165
|
+
function brandFromDomain(host) {
|
|
166
|
+
for (const [suffix, brand] of BRAND_DOMAIN_SUFFIXES) if (host === suffix || host.endsWith(`.${suffix}`)) return brand;
|
|
167
|
+
return "none";
|
|
168
|
+
}
|
|
169
|
+
function makeParsed(input, kind, domain, resourceType, token) {
|
|
170
|
+
return {
|
|
171
|
+
input,
|
|
172
|
+
kind,
|
|
173
|
+
domain,
|
|
174
|
+
brand: domain ? brandFromDomain(domain) : "none",
|
|
175
|
+
resourceType,
|
|
176
|
+
token,
|
|
177
|
+
wikiUnwrapNeeded: resourceType !== null && WIKI_TYPES.has(resourceType)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const HOST_PATH_RE = /^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,}\//;
|
|
181
|
+
/**
|
|
182
|
+
* 解析用户粘贴的 URL 或裸 token。纯函数,不抛异常——识别失败以
|
|
183
|
+
* resourceType=null / token="" 表达,由 resolve 决定如何拒绝。
|
|
184
|
+
*/
|
|
185
|
+
function parseDoc(rawInput) {
|
|
186
|
+
const input = rawInput.trim();
|
|
187
|
+
if (input === "") return makeParsed(input, "token", null, null, "");
|
|
188
|
+
let urlString = null;
|
|
189
|
+
if (/:\/\//.test(input)) urlString = input;
|
|
190
|
+
else if (HOST_PATH_RE.test(input)) urlString = `https://${input}`;
|
|
191
|
+
if (urlString !== null) {
|
|
192
|
+
let url;
|
|
193
|
+
try {
|
|
194
|
+
url = new URL(urlString);
|
|
195
|
+
} catch {
|
|
196
|
+
return makeParsed(input, "url", null, null, "");
|
|
197
|
+
}
|
|
198
|
+
const path = url.pathname;
|
|
199
|
+
for (const [prefix, type] of PATH_RULES) {
|
|
200
|
+
const idx = path.indexOf(prefix);
|
|
201
|
+
if (idx === -1) continue;
|
|
202
|
+
const token = path.slice(idx + prefix.length).split("/").find((seg) => seg !== "") ?? "";
|
|
203
|
+
return makeParsed(input, "url", url.hostname.toLowerCase(), type, decodeURIComponent(token));
|
|
204
|
+
}
|
|
205
|
+
return makeParsed(input, "url", url.hostname.toLowerCase(), null, "");
|
|
206
|
+
}
|
|
207
|
+
for (const [prefix, type] of TOKEN_RULES) if (input.startsWith(prefix)) return makeParsed(input, "token", null, type, input);
|
|
208
|
+
return makeParsed(input, "token", null, null, "");
|
|
209
|
+
}
|
|
210
|
+
// ../core/src/registry.ts
|
|
211
|
+
/** Registry 内存操作(纯函数;文件 IO/锁在 CLI 层,M2)。 */
|
|
212
|
+
function createRegistry() {
|
|
213
|
+
return {
|
|
214
|
+
version: 1,
|
|
215
|
+
bindings: [],
|
|
216
|
+
pins: []
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function findBinding(registry, profile) {
|
|
220
|
+
return registry.bindings.find((b) => b.profile === profile);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 可路由绑定:排除 incomplete(Q8 决议)。
|
|
224
|
+
* brand 为具体品牌时按品牌过滤(design.md §5 步骤 3);"none" 不过滤。
|
|
225
|
+
*/
|
|
226
|
+
function routableBindings(registry, brand) {
|
|
227
|
+
return registry.bindings.filter((b) => b.status !== "incomplete" && (brand === "none" || b.brand === brand));
|
|
228
|
+
}
|
|
229
|
+
/** pin 以归一化 token 为键精确匹配(Q3/Q10:永久、无 TTL)。 */
|
|
230
|
+
function findPin(registry, token) {
|
|
231
|
+
return registry.pins.find((p) => p.token === token);
|
|
232
|
+
}
|
|
233
|
+
function upsertPin(registry, pin) {
|
|
234
|
+
const rest = registry.pins.filter((p) => p.token !== pin.token);
|
|
235
|
+
return {
|
|
236
|
+
...registry,
|
|
237
|
+
pins: [...rest, pin]
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function upsertBinding(registry, binding) {
|
|
241
|
+
const rest = registry.bindings.filter((b) => b.profile !== binding.profile);
|
|
242
|
+
return {
|
|
243
|
+
...registry,
|
|
244
|
+
bindings: [...rest, binding]
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** 结构校验:profile 唯一、appId 全局唯一(ADR-0001 不变量)、pin 指向存在的绑定。 */
|
|
248
|
+
function validateRegistry(registry) {
|
|
249
|
+
const issues = [];
|
|
250
|
+
const profiles = /* @__PURE__ */ new Set();
|
|
251
|
+
const appIds = /* @__PURE__ */ new Map();
|
|
252
|
+
for (const b of registry.bindings) {
|
|
253
|
+
if (!b.profile) issues.push({
|
|
254
|
+
field: "bindings[].profile",
|
|
255
|
+
message: "profile 名为空"
|
|
256
|
+
});
|
|
257
|
+
if (profiles.has(b.profile)) issues.push({
|
|
258
|
+
field: "bindings[].profile",
|
|
259
|
+
message: `profile "${b.profile}" 重复`
|
|
260
|
+
});
|
|
261
|
+
profiles.add(b.profile);
|
|
262
|
+
const existingOwner = appIds.get(b.appId);
|
|
263
|
+
if (existingOwner !== void 0) issues.push({
|
|
264
|
+
field: "bindings[].appId",
|
|
265
|
+
message: `appId "${b.appId}" 同时被 "${existingOwner}" 与 "${b.profile}" 使用(ADR-0001:每个绑定独立应用)`
|
|
266
|
+
});
|
|
267
|
+
else appIds.set(b.appId, b.profile);
|
|
268
|
+
}
|
|
269
|
+
for (const p of registry.pins) if (!profiles.has(p.profile)) issues.push({
|
|
270
|
+
field: "pins[].profile",
|
|
271
|
+
message: `pin(token=${p.token.slice(0, 8)}…) 指向不存在的绑定 "${p.profile}"`
|
|
272
|
+
});
|
|
273
|
+
return issues;
|
|
274
|
+
}
|
|
275
|
+
// ../core/src/resolve.ts
|
|
276
|
+
var ResolveError = class extends Error {
|
|
277
|
+
code;
|
|
278
|
+
parsed;
|
|
279
|
+
constructor(code, message, parsed) {
|
|
280
|
+
super(message);
|
|
281
|
+
this.name = "ResolveError";
|
|
282
|
+
this.code = code;
|
|
283
|
+
this.parsed = parsed;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
function toCandidate(b, domain) {
|
|
287
|
+
return {
|
|
288
|
+
profile: b.profile,
|
|
289
|
+
brand: b.brand,
|
|
290
|
+
status: b.status,
|
|
291
|
+
tenantHintMatch: domain !== null && (b.tenantHints ?? []).includes(domain)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function resolve(registry, input, opts = {}) {
|
|
295
|
+
const parsed = parseDoc(input);
|
|
296
|
+
if (parsed.resourceType === null || parsed.token === "") throw new ResolveError("unrecognized-input", `无法识别的文档输入:${JSON.stringify(input)}(支持 URL / doubao 同构链接 / 已知前缀的裸 token;纯描述不支持)`, parsed);
|
|
297
|
+
if (parsed.resourceType === "apps") throw new ResolveError("unsupported-resource", "/page/ 链接是妙搭创意模式应用,不属于文档域(lark-apps SKILL),不在 larklink 操作范围", parsed);
|
|
298
|
+
const named = opts.explicitProfile ?? opts.askedProfile;
|
|
299
|
+
if (named !== void 0) {
|
|
300
|
+
const binding = findBinding(registry, named);
|
|
301
|
+
if (binding === void 0) throw new ResolveError("binding-not-found", `绑定 "${named}" 不存在`, parsed);
|
|
302
|
+
if (binding.status === "incomplete") throw new ResolveError("binding-incomplete", `绑定 "${named}" 授权未完成,不可路由(Q8)`, parsed);
|
|
303
|
+
if (parsed.brand !== "none" && parsed.brand !== binding.brand) throw new ResolveError("brand-mismatch", `显式指定绑定 "${named}" 品牌为 ${binding.brand},与输入品牌证据 ${parsed.brand} 不符(Q5-②)`, parsed);
|
|
304
|
+
const confidence = opts.askedProfile !== void 0 ? "asked" : "explicit";
|
|
305
|
+
return {
|
|
306
|
+
status: "resolved",
|
|
307
|
+
parsed,
|
|
308
|
+
profile: binding.profile,
|
|
309
|
+
brand: binding.brand,
|
|
310
|
+
confidence,
|
|
311
|
+
bindingStatus: binding.status,
|
|
312
|
+
reason: parsed.brand === "none" ? `用户显式指定(输入无品牌证据,选择同时确定品牌)` : `用户显式指定(品牌证据 ${parsed.brand} 校验通过)`
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const pin = findPin(registry, parsed.token);
|
|
316
|
+
if (pin !== void 0) {
|
|
317
|
+
const binding = findBinding(registry, pin.profile);
|
|
318
|
+
if (binding !== void 0 && binding.status !== "incomplete") return {
|
|
319
|
+
status: "resolved",
|
|
320
|
+
parsed,
|
|
321
|
+
profile: binding.profile,
|
|
322
|
+
brand: binding.brand,
|
|
323
|
+
confidence: "pinned",
|
|
324
|
+
bindingStatus: binding.status,
|
|
325
|
+
reason: `文档 pin 命中(记忆于 ${pin.updatedAt ?? "未知时间"};失效不自动换绑定,Q1)`
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const candidates = routableBindings(registry, parsed.brand);
|
|
329
|
+
if (candidates.length === 0) throw new ResolveError("no-routable-binding", `无可路由绑定(${parsed.brand === "none" ? "全部绑定" : `${parsed.brand} 品牌绑定`} 中没有可用绑定;incomplete 不可路由)。请先 bindings add / adopt 或完成授权。`, parsed);
|
|
330
|
+
if (candidates.length === 1) {
|
|
331
|
+
const sole = candidates[0];
|
|
332
|
+
const repair = sole.status !== "ready" ? `;该绑定状态 ${sole.status},需修复授权而非换绑定` : "";
|
|
333
|
+
return {
|
|
334
|
+
status: "resolved",
|
|
335
|
+
parsed,
|
|
336
|
+
profile: sole.profile,
|
|
337
|
+
brand: sole.brand,
|
|
338
|
+
confidence: "sole",
|
|
339
|
+
bindingStatus: sole.status,
|
|
340
|
+
reason: `品牌候选集内唯一绑定(存在性判定,不看登录状态,Q3-②)${repair}`
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
status: "ambiguous",
|
|
345
|
+
parsed,
|
|
346
|
+
candidates: candidates.map((b) => toCandidate(b, parsed.domain)),
|
|
347
|
+
reason: parsed.brand === "none" ? "输入无品牌证据且存在多个候选绑定,需用户选择(选择同时确定品牌并记为 pin,Q3-③)" : "同品牌多个候选绑定,需用户选择(选择记为 pin,Q1/Q3)"
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// ../core/src/index.ts
|
|
351
|
+
var src_exports = /* @__PURE__ */ __exportAll({ parseDoc: () => parseDoc });
|
|
352
|
+
// ../cli/src/store.ts
|
|
353
|
+
/**
|
|
354
|
+
* registry 持久化:~/.larklink/registry.json(机器级,Q10-②)。
|
|
355
|
+
* LARKLINK_HOME 环境变量可重定向(测试与多实例用)。
|
|
356
|
+
* 并发写:文件锁(.lock,创建式)+ 原子替换(tmp + rename)。
|
|
357
|
+
*/
|
|
358
|
+
function larklinkHome() {
|
|
359
|
+
return process.env["LARKLINK_HOME"]?.trim() || join(process.env["USERPROFILE"] ?? process.env["HOME"] ?? ".", ".larklink");
|
|
360
|
+
}
|
|
361
|
+
function registryPath(home = larklinkHome()) {
|
|
362
|
+
return join(home, "registry.json");
|
|
363
|
+
}
|
|
364
|
+
function auditPath(home = larklinkHome()) {
|
|
365
|
+
return join(home, "audit.jsonl");
|
|
366
|
+
}
|
|
367
|
+
async function loadRegistry(home = larklinkHome()) {
|
|
368
|
+
const file = registryPath(home);
|
|
369
|
+
if (!existsSync(file)) return createRegistry();
|
|
370
|
+
const text = await readFile(file, "utf8");
|
|
371
|
+
if (text.trim() === "") return createRegistry();
|
|
372
|
+
const parsed = safeParse(text);
|
|
373
|
+
if (parsed === null || typeof parsed !== "object" || parsed.version !== 1) throw new Error(`registry 文件损坏或版本不兼容:${file}(请人工检查备份后删除重建)`);
|
|
374
|
+
return parsed;
|
|
375
|
+
}
|
|
376
|
+
/** 保存并做结构校验(ADR-0001 appId 唯一等);返回新 registry(引用不变性由调用方维护)。 */
|
|
377
|
+
async function saveRegistry(registry, home = larklinkHome()) {
|
|
378
|
+
const issues = validateRegistry(registry);
|
|
379
|
+
if (issues.length > 0) {
|
|
380
|
+
if (issues.filter((i) => i.field.startsWith("bindings")).length > 0) throw new Error(`registry 结构校验失败,拒绝写入:\n${issues.map((i) => ` - [${i.field}] ${i.message}`).join("\n")}`);
|
|
381
|
+
}
|
|
382
|
+
const dir = home;
|
|
383
|
+
await mkdir(dir, { recursive: true });
|
|
384
|
+
await withLock(dir, async () => {
|
|
385
|
+
const tmp = join(dir, `registry.json.${process.pid}.tmp`);
|
|
386
|
+
await writeFile(tmp, JSON.stringify(registry, null, 2) + "\n", "utf8");
|
|
387
|
+
await rename(tmp, join(dir, "registry.json"));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const LOCK_FILE = "registry.lock";
|
|
391
|
+
const STALE_MS = 5e3;
|
|
392
|
+
async function withLock(dir, fn) {
|
|
393
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
394
|
+
let handle = null;
|
|
395
|
+
for (let attempt = 0; attempt < 60; attempt++) try {
|
|
396
|
+
handle = await open(lockPath, "wx");
|
|
397
|
+
break;
|
|
398
|
+
} catch {
|
|
399
|
+
try {
|
|
400
|
+
const st = await stat(lockPath);
|
|
401
|
+
if (Date.now() - st.mtimeMs > STALE_MS) await rm(lockPath, { force: true });
|
|
402
|
+
} catch {}
|
|
403
|
+
await sleep(50);
|
|
404
|
+
}
|
|
405
|
+
if (handle === null) throw new Error(`registry 锁竞争超时:${lockPath}`);
|
|
406
|
+
try {
|
|
407
|
+
await fn();
|
|
408
|
+
} finally {
|
|
409
|
+
await handle.close();
|
|
410
|
+
await rm(lockPath, { force: true });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function sleep(ms) {
|
|
414
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
415
|
+
}
|
|
416
|
+
function safeParse(text) {
|
|
417
|
+
try {
|
|
418
|
+
return JSON.parse(text);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ../cli/src/audit.ts
|
|
424
|
+
/**
|
|
425
|
+
* 审计日志(Q10-③:默认脱敏)。每行一个 JSON:resolve/exec 的决策与结果摘要。
|
|
426
|
+
* 载荷参数(--content/--data 等)不记值,只记长度与 sha1;LARKLINK_AUDIT_VERBOSE=1 记全量。
|
|
427
|
+
*/
|
|
428
|
+
/** 值即文档内容/记录数据的参数(Q10-③ 脱敏对象)。 */
|
|
429
|
+
const PAYLOAD_FLAGS = [
|
|
430
|
+
"--content",
|
|
431
|
+
"--data",
|
|
432
|
+
"--body",
|
|
433
|
+
"--markdown",
|
|
434
|
+
"--html",
|
|
435
|
+
"--text",
|
|
436
|
+
"--payload",
|
|
437
|
+
"--blocks"
|
|
438
|
+
];
|
|
439
|
+
function redactValue(value) {
|
|
440
|
+
const sha1 = createHash("sha1").update(value, "utf8").digest("hex").slice(0, 12);
|
|
441
|
+
return `<redacted len=${value.length} sha1=${sha1}>`;
|
|
442
|
+
}
|
|
443
|
+
function redactArgv(argv) {
|
|
444
|
+
const out = [];
|
|
445
|
+
let i = 0;
|
|
446
|
+
const verbose = process.env["LARKLINK_AUDIT_VERBOSE"] === "1";
|
|
447
|
+
while (i < argv.length) {
|
|
448
|
+
const arg = argv[i];
|
|
449
|
+
const eq = arg.indexOf("=");
|
|
450
|
+
const bare = eq === -1 ? arg : arg.slice(0, eq);
|
|
451
|
+
if (PAYLOAD_FLAGS.includes(bare)) {
|
|
452
|
+
if (eq !== -1) {
|
|
453
|
+
out.push(verbose ? arg : `${bare}=${redactValue(arg.slice(eq + 1))}`);
|
|
454
|
+
i += 1;
|
|
455
|
+
} else if (i + 1 < argv.length) {
|
|
456
|
+
out.push(arg);
|
|
457
|
+
out.push(verbose ? argv[i + 1] : redactValue(argv[i + 1]));
|
|
458
|
+
i += 2;
|
|
459
|
+
} else {
|
|
460
|
+
out.push(arg);
|
|
461
|
+
i += 1;
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
out.push(arg);
|
|
465
|
+
i += 1;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
/** 追加一条审计记录;审计关闭(LARKLINK_AUDIT=off)时静默跳过。 */
|
|
471
|
+
async function appendAudit(entry, home = larklinkHome()) {
|
|
472
|
+
if (process.env["LARKLINK_AUDIT"] === "off") return;
|
|
473
|
+
const record = {
|
|
474
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
475
|
+
...entry
|
|
476
|
+
};
|
|
477
|
+
if (record.argv !== void 0) record.argv = redactArgv(record.argv);
|
|
478
|
+
await mkdir(home, { recursive: true });
|
|
479
|
+
await appendFile(auditPath(home), JSON.stringify(record) + "\n", "utf8");
|
|
480
|
+
}
|
|
481
|
+
// ../cli/src/commands/bindings.ts
|
|
482
|
+
/**
|
|
483
|
+
* bindings 生命周期命令(design.md §6,决议 Q8/Q10)。
|
|
484
|
+
* 所有 lark-cli 调用经注入的 runner,便于测试 mock。
|
|
485
|
+
*/
|
|
486
|
+
const stdEmit = (payload) => console.log(JSON.stringify(payload, null, 2));
|
|
487
|
+
/** lark-cli 状态字符串 → BindingStatus。无已登录用户 = incomplete(Q8:不可路由)。 */
|
|
488
|
+
function mapStatus(raw, hasUser) {
|
|
489
|
+
const s = (raw ?? "").toLowerCase();
|
|
490
|
+
if (!hasUser) return "incomplete";
|
|
491
|
+
if (s.includes("refresh") || s.includes("expire")) return "needs_refresh";
|
|
492
|
+
if (s.includes("unauthorized") || s.includes("revoked") || s.includes("invalid")) return "unauthorized";
|
|
493
|
+
return "ready";
|
|
494
|
+
}
|
|
495
|
+
function bindingFromProfileList(e) {
|
|
496
|
+
const profile = e.name;
|
|
497
|
+
if (!profile || !e.brand || e.brand !== "feishu" && e.brand !== "lark") return null;
|
|
498
|
+
const hasUser = e.user !== void 0 && e.user !== null && e.user !== "";
|
|
499
|
+
return {
|
|
500
|
+
profile,
|
|
501
|
+
brand: e.brand,
|
|
502
|
+
appId: (e.appId ?? e.app_id ?? "").toString(),
|
|
503
|
+
userName: userNameOf(e.user),
|
|
504
|
+
status: mapStatus(e.tokenStatus ?? e.token_status, hasUser),
|
|
505
|
+
adopted: true,
|
|
506
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function userNameOf(user) {
|
|
510
|
+
if (typeof user === "string") return user || void 0;
|
|
511
|
+
return user?.name || void 0;
|
|
512
|
+
}
|
|
513
|
+
/** adopt:导入既有 lark-cli profile(不动原 profile,Q8-①)。 */
|
|
514
|
+
async function cmdAdopt(ctx, opts, emit = stdEmit) {
|
|
515
|
+
const home = ctx.home ?? larklinkHome();
|
|
516
|
+
const res = await ctx.runner(["profile", "list"], { timeoutMs: 2e4 });
|
|
517
|
+
const payload = safeJson(res.stdout);
|
|
518
|
+
const entries = Array.isArray(payload) ? payload : [];
|
|
519
|
+
if (entries.length === 0) {
|
|
520
|
+
emit({
|
|
521
|
+
ok: false,
|
|
522
|
+
code: "lark-cli-error",
|
|
523
|
+
message: `profile list 无结果(exit ${res.exitCode}):${res.stderr.slice(0, 300)}`
|
|
524
|
+
});
|
|
525
|
+
return 1;
|
|
526
|
+
}
|
|
527
|
+
const wanted = opts.profiles ? new Set(opts.profiles) : null;
|
|
528
|
+
let registry = await loadRegistry(home);
|
|
529
|
+
const adopted = [];
|
|
530
|
+
const skipped = [];
|
|
531
|
+
for (const e of entries) {
|
|
532
|
+
const b = bindingFromProfileList(e);
|
|
533
|
+
if (!b) {
|
|
534
|
+
skipped.push(e.name ?? "(未知)");
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (wanted && !wanted.has(b.profile)) continue;
|
|
538
|
+
const existing = findBinding(registry, b.profile);
|
|
539
|
+
registry = upsertBinding(registry, existing ? {
|
|
540
|
+
...existing,
|
|
541
|
+
...b,
|
|
542
|
+
adopted: existing.adopted ?? b.adopted
|
|
543
|
+
} : b);
|
|
544
|
+
adopted.push(b.profile);
|
|
545
|
+
}
|
|
546
|
+
await saveRegistry(registry, home);
|
|
547
|
+
await appendAudit({
|
|
548
|
+
kind: "bindings",
|
|
549
|
+
message: `adopt: ${adopted.join(", ") || "(none)"}`,
|
|
550
|
+
profile: adopted.join(",")
|
|
551
|
+
}, home);
|
|
552
|
+
emit({
|
|
553
|
+
ok: true,
|
|
554
|
+
adopted,
|
|
555
|
+
skipped,
|
|
556
|
+
hint: skipped.length ? "跳过项为无 brand/用户的 profile" : void 0
|
|
557
|
+
}, null, 2);
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 用真实 `auth status --json --verify` 结构同步(identities.user.{userName,tokenStatus,refreshExpiresAt})。
|
|
562
|
+
* 无 user 身份 → incomplete(绑定=app+user,bot-only 不可作用户身份路由)。
|
|
563
|
+
*/
|
|
564
|
+
async function syncBindingStatus(ctx, home, profile) {
|
|
565
|
+
const user = safeJson((await ctx.runner([
|
|
566
|
+
"--profile",
|
|
567
|
+
profile,
|
|
568
|
+
"auth",
|
|
569
|
+
"status",
|
|
570
|
+
"--json",
|
|
571
|
+
"--verify"
|
|
572
|
+
], { timeoutMs: 2e4 })).stdout)?.identities?.user;
|
|
573
|
+
if (user === void 0) {
|
|
574
|
+
await setBindingStatus(home, profile, "incomplete");
|
|
575
|
+
return {
|
|
576
|
+
status: "incomplete",
|
|
577
|
+
warning: `绑定 ${profile} 无 user 身份(bot-only)→ incomplete,不可路由`
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
const status = mapStatus(user.tokenStatus ?? user.status, true);
|
|
581
|
+
let warning;
|
|
582
|
+
if (user.refreshExpiresAt !== void 0) {
|
|
583
|
+
const expiry = Date.parse(user.refreshExpiresAt);
|
|
584
|
+
if (!Number.isNaN(expiry)) {
|
|
585
|
+
const hoursLeft = (expiry - Date.now()) / 36e5;
|
|
586
|
+
if (hoursLeft < 0) warning = `绑定 ${profile} refresh token 已过期,需重新授权`;
|
|
587
|
+
else if (hoursLeft < 48) warning = `绑定 ${profile} refresh token ${hoursLeft.toFixed(1)}h 后过期,建议尽快重新授权`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
let registry = await loadRegistry(home);
|
|
591
|
+
const existing = findBinding(registry, profile);
|
|
592
|
+
if (existing) {
|
|
593
|
+
registry = upsertBinding(registry, {
|
|
594
|
+
...existing,
|
|
595
|
+
status,
|
|
596
|
+
userName: user.userName ?? existing.userName,
|
|
597
|
+
openId: user.openId ?? existing.openId
|
|
598
|
+
});
|
|
599
|
+
await saveRegistry(registry, home);
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
status,
|
|
603
|
+
userName: user.userName,
|
|
604
|
+
openId: user.openId,
|
|
605
|
+
warning
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
async function setBindingStatus(home, profile, status) {
|
|
609
|
+
let registry = await loadRegistry(home);
|
|
610
|
+
const existing = findBinding(registry, profile);
|
|
611
|
+
if (!existing) return false;
|
|
612
|
+
registry = upsertBinding(registry, {
|
|
613
|
+
...existing,
|
|
614
|
+
status
|
|
615
|
+
});
|
|
616
|
+
await saveRegistry(registry, home);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
/** list / health:同步各绑定真实状态;health 额外清理死 pin(Q10-①)。 */
|
|
620
|
+
async function cmdList(ctx, opts, emit = stdEmit) {
|
|
621
|
+
const home = ctx.home ?? larklinkHome();
|
|
622
|
+
let registry = await loadRegistry(home);
|
|
623
|
+
const warnings = [];
|
|
624
|
+
if (!opts.fast) {
|
|
625
|
+
for (const b of registry.bindings) try {
|
|
626
|
+
const s = await syncBindingStatus(ctx, home, b.profile);
|
|
627
|
+
if (s.warning) warnings.push(s.warning);
|
|
628
|
+
if (opts.health && s.status && s.status !== "ready" && s.status !== "incomplete") warnings.push(`绑定 ${b.profile} 状态 ${s.status}:请 larklink bindings auth ${b.profile} 修复`);
|
|
629
|
+
} catch (e) {
|
|
630
|
+
warnings.push(`绑定 ${b.profile} 状态同步失败:${e.message}`);
|
|
631
|
+
}
|
|
632
|
+
registry = await loadRegistry(home);
|
|
633
|
+
}
|
|
634
|
+
if (opts.health) {
|
|
635
|
+
const profiles = new Set(registry.bindings.map((b) => b.profile));
|
|
636
|
+
const dead = registry.pins.filter((p) => !profiles.has(p.profile));
|
|
637
|
+
if (dead.length > 0) {
|
|
638
|
+
for (const d of dead) registry = {
|
|
639
|
+
...registry,
|
|
640
|
+
pins: registry.pins.filter((p) => p.token !== d.token)
|
|
641
|
+
};
|
|
642
|
+
await saveRegistry(registry, home);
|
|
643
|
+
warnings.push(`清理死 pin ${dead.length} 条`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
emit({
|
|
647
|
+
ok: true,
|
|
648
|
+
bindings: registry.bindings.map((b) => ({
|
|
649
|
+
profile: b.profile,
|
|
650
|
+
brand: b.brand,
|
|
651
|
+
appId: b.appId,
|
|
652
|
+
userName: b.userName,
|
|
653
|
+
status: b.status,
|
|
654
|
+
adopted: b.adopted ?? false
|
|
655
|
+
})),
|
|
656
|
+
pins: registry.pins,
|
|
657
|
+
warnings
|
|
658
|
+
}, null, 2);
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
/** pin:手动增删查(兜底入口)。 */
|
|
662
|
+
async function cmdPin(opts, home = larklinkHome(), emit = stdEmit) {
|
|
663
|
+
const registry = await loadRegistry(home);
|
|
664
|
+
if (opts.op === "list") {
|
|
665
|
+
emit({
|
|
666
|
+
ok: true,
|
|
667
|
+
pins: registry.pins
|
|
668
|
+
}, null, 2);
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
if (!opts.token || opts.op === "add" && !opts.profile) {
|
|
672
|
+
emit({
|
|
673
|
+
ok: false,
|
|
674
|
+
code: "usage",
|
|
675
|
+
message: "pin add 需要 --token 与 --profile;remove 只需 --token"
|
|
676
|
+
});
|
|
677
|
+
return 2;
|
|
678
|
+
}
|
|
679
|
+
if (opts.op === "add") {
|
|
680
|
+
const token = opts.token;
|
|
681
|
+
const profile = opts.profile;
|
|
682
|
+
if (!token || !profile) {
|
|
683
|
+
emit({
|
|
684
|
+
ok: false,
|
|
685
|
+
code: "usage",
|
|
686
|
+
message: "pin add 需要 --token 与 --profile"
|
|
687
|
+
});
|
|
688
|
+
return 2;
|
|
689
|
+
}
|
|
690
|
+
if (!findBinding(registry, profile)) {
|
|
691
|
+
emit({
|
|
692
|
+
ok: false,
|
|
693
|
+
code: "binding-not-found",
|
|
694
|
+
message: `绑定 ${profile} 不存在`
|
|
695
|
+
});
|
|
696
|
+
return 2;
|
|
697
|
+
}
|
|
698
|
+
await saveRegistry(upsertPin(registry, {
|
|
699
|
+
token,
|
|
700
|
+
profile,
|
|
701
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
702
|
+
}), home);
|
|
703
|
+
emit({
|
|
704
|
+
ok: true,
|
|
705
|
+
pinned: token,
|
|
706
|
+
profile
|
|
707
|
+
});
|
|
708
|
+
return 0;
|
|
709
|
+
}
|
|
710
|
+
await saveRegistry({
|
|
711
|
+
...registry,
|
|
712
|
+
pins: registry.pins.filter((p) => p.token !== opts.token)
|
|
713
|
+
}, home);
|
|
714
|
+
emit({
|
|
715
|
+
ok: true,
|
|
716
|
+
removed: opts.token && findPin(registry, opts.token) ? opts.token : null
|
|
717
|
+
});
|
|
718
|
+
return 0;
|
|
719
|
+
}
|
|
720
|
+
// ../cli/src/commands/resolve.ts
|
|
721
|
+
/**
|
|
722
|
+
* resolve 命令:core 消歧 + Q4 只读探测(标注不代选)+ pin 写入。
|
|
723
|
+
*/
|
|
724
|
+
/** 只读探测(Q4:8s 超时,真实 API 调用会留租户审计痕迹;永不影响选择)。 */
|
|
725
|
+
function classifyProbe(result) {
|
|
726
|
+
if (result.timedOut) return "timeout";
|
|
727
|
+
if (result.exitCode === 0) return "accessible";
|
|
728
|
+
const raw = safeJson(result.stderr) ?? safeJson(result.stdout);
|
|
729
|
+
const err = raw?.error ?? raw;
|
|
730
|
+
const text = `${err?.type ?? ""} ${err?.message ?? ""} ${result.stderr}`.toLowerCase();
|
|
731
|
+
if (err?.code === 403 || text.includes("permission") || text.includes("forbidden")) return "no-permission";
|
|
732
|
+
if (text.includes("auth") || text.includes("token") || text.includes("login")) return "auth-failed";
|
|
733
|
+
return "error";
|
|
734
|
+
}
|
|
735
|
+
async function probeCandidate(runner, input, profile, timeoutMs = 8e3) {
|
|
736
|
+
return classifyProbe(await runner([
|
|
737
|
+
"--profile",
|
|
738
|
+
profile,
|
|
739
|
+
"drive",
|
|
740
|
+
"+inspect",
|
|
741
|
+
"--url",
|
|
742
|
+
input,
|
|
743
|
+
"--json"
|
|
744
|
+
], { timeoutMs }));
|
|
745
|
+
}
|
|
746
|
+
async function cmdResolve(runner, opts, home = larklinkHome(), emit = stdEmit) {
|
|
747
|
+
let registry = await loadRegistry(home);
|
|
748
|
+
const result = resolve(registry, opts.input, {
|
|
749
|
+
explicitProfile: opts.explicitProfile,
|
|
750
|
+
askedProfile: opts.askedProfile
|
|
751
|
+
});
|
|
752
|
+
if (result.status === "ambiguous") {
|
|
753
|
+
let candidates = result.candidates;
|
|
754
|
+
if (opts.probe !== false) candidates = await Promise.all(result.candidates.map(async (c) => {
|
|
755
|
+
try {
|
|
756
|
+
return {
|
|
757
|
+
...c,
|
|
758
|
+
probe: await probeCandidate(runner, opts.input, c.profile, opts.probeTimeoutMs)
|
|
759
|
+
};
|
|
760
|
+
} catch {
|
|
761
|
+
return {
|
|
762
|
+
...c,
|
|
763
|
+
probe: "error"
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
}));
|
|
767
|
+
await appendAudit({
|
|
768
|
+
kind: "resolve",
|
|
769
|
+
outcome: "ambiguous",
|
|
770
|
+
confidence: "none",
|
|
771
|
+
target: result.parsed.token,
|
|
772
|
+
argv: [opts.input],
|
|
773
|
+
message: `候选:${candidates.map((c) => c.profile).join(", ")}`
|
|
774
|
+
}, home);
|
|
775
|
+
emit({
|
|
776
|
+
ok: true,
|
|
777
|
+
status: "ambiguous",
|
|
778
|
+
parsed: result.parsed,
|
|
779
|
+
candidates,
|
|
780
|
+
reason: result.reason,
|
|
781
|
+
next: `询问用户后:larklink resolve <input> --asked <profile> --pin`
|
|
782
|
+
}, null, 2);
|
|
783
|
+
return 3;
|
|
784
|
+
}
|
|
785
|
+
if (opts.pin && (result.confidence === "asked" || result.confidence === "explicit")) {
|
|
786
|
+
registry = upsertPin(registry, {
|
|
787
|
+
token: result.parsed.token,
|
|
788
|
+
profile: result.profile,
|
|
789
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
790
|
+
});
|
|
791
|
+
await saveRegistry(registry, home);
|
|
792
|
+
}
|
|
793
|
+
await appendAudit({
|
|
794
|
+
kind: "resolve",
|
|
795
|
+
outcome: "resolved",
|
|
796
|
+
confidence: result.confidence,
|
|
797
|
+
profile: result.profile,
|
|
798
|
+
target: result.parsed.token,
|
|
799
|
+
argv: [opts.input]
|
|
800
|
+
}, home);
|
|
801
|
+
emit({
|
|
802
|
+
ok: true,
|
|
803
|
+
status: "resolved",
|
|
804
|
+
profile: result.profile,
|
|
805
|
+
brand: result.brand,
|
|
806
|
+
confidence: result.confidence,
|
|
807
|
+
bindingStatus: result.bindingStatus,
|
|
808
|
+
reason: result.reason,
|
|
809
|
+
parsed: result.parsed,
|
|
810
|
+
wikiUnwrapNeeded: result.parsed.wikiUnwrapNeeded,
|
|
811
|
+
pinned: opts.pin === true
|
|
812
|
+
}, null, 2);
|
|
813
|
+
return 0;
|
|
814
|
+
}
|
|
815
|
+
// ../cli/src/execguard.ts
|
|
816
|
+
/**
|
|
817
|
+
* exec 守卫链(决议 Q5/Q6/Q7/Q9 落地):
|
|
818
|
+
* - Q9:命令域白名单(文档域)
|
|
819
|
+
* - Q6:执行身份只能来自工具参数 as;argv 夹带 --as 一律拒绝
|
|
820
|
+
* - Q5:argv 提取目标与路由结果比对,不一致即拒;未识别形态放行(fail-open)
|
|
821
|
+
* - Q6-②:confirmationId = profile+as+argv 指纹;--yes 重试必须携带一致指纹
|
|
822
|
+
*/
|
|
823
|
+
/** Q9-①:v1 仅文档域。 */
|
|
824
|
+
const EXEC_DOMAINS = /* @__PURE__ */ new Set([
|
|
825
|
+
"docs",
|
|
826
|
+
"drive",
|
|
827
|
+
"sheets",
|
|
828
|
+
"markdown",
|
|
829
|
+
"slides",
|
|
830
|
+
"base",
|
|
831
|
+
"whiteboard",
|
|
832
|
+
"wiki",
|
|
833
|
+
"mindnotes"
|
|
834
|
+
]);
|
|
835
|
+
/** Q5-①:从 argv 提取目标文档的白名单旗标(fail-open:未收录旗标不比对)。 */
|
|
836
|
+
const TARGET_FLAGS = [
|
|
837
|
+
"--doc",
|
|
838
|
+
"--url",
|
|
839
|
+
"--file",
|
|
840
|
+
"--token"
|
|
841
|
+
];
|
|
842
|
+
function domainOf(argv) {
|
|
843
|
+
return (argv[0] ?? "").replace(/^:+/, "");
|
|
844
|
+
}
|
|
845
|
+
/** argv 中出现 --as / --as=... → 拒绝(显式声明唯一入口是工具参数 as)。 */
|
|
846
|
+
function findArgvAs(argv) {
|
|
847
|
+
for (let i = 0; i < argv.length; i++) {
|
|
848
|
+
const a = argv[i];
|
|
849
|
+
if (a === "--as") return {
|
|
850
|
+
value: argv[i + 1] ?? "",
|
|
851
|
+
index: i
|
|
852
|
+
};
|
|
853
|
+
if (a.startsWith("--as=")) return {
|
|
854
|
+
value: a.slice(5),
|
|
855
|
+
index: i
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
function extractTargets(argv) {
|
|
861
|
+
const out = [];
|
|
862
|
+
for (let i = 0; i < argv.length; i++) {
|
|
863
|
+
const a = argv[i];
|
|
864
|
+
const eq = a.indexOf("=");
|
|
865
|
+
const bare = eq === -1 ? a : a.slice(0, eq);
|
|
866
|
+
if (TARGET_FLAGS.includes(bare)) {
|
|
867
|
+
if (eq !== -1) out.push({
|
|
868
|
+
flag: bare,
|
|
869
|
+
value: a.slice(eq + 1)
|
|
870
|
+
});
|
|
871
|
+
else if (i + 1 < argv.length) out.push({
|
|
872
|
+
flag: bare,
|
|
873
|
+
value: argv[i + 1]
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return out;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Q5-① 目标一致性:能解析出的目标 token 与路由 token 全量比对。
|
|
881
|
+
* 解析不出 token 的目标值放行(fail-open,M3 扩白名单)。
|
|
882
|
+
*/
|
|
883
|
+
function checkTargets(targets, resolved) {
|
|
884
|
+
for (const t of targets) {
|
|
885
|
+
if (t.value === resolved.token || t.value === resolved.input) continue;
|
|
886
|
+
const p = parseDoc(t.value);
|
|
887
|
+
if (p.token === "") continue;
|
|
888
|
+
if (p.token !== resolved.token) return {
|
|
889
|
+
ok: false,
|
|
890
|
+
error: {
|
|
891
|
+
code: "target-mismatch",
|
|
892
|
+
message: `argv ${t.flag} 的目标与路由结果不符:路由=${resolved.input}(token ${resolved.token}),命令指向 ${t.value}(token ${p.token})。拒绝执行,防错账写入。`
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
return { ok: true };
|
|
897
|
+
}
|
|
898
|
+
/** Q6-② 确认指纹:覆盖 profile + 执行身份 + 去除确认旗标后的最终 argv。 */
|
|
899
|
+
function computeConfirmationId(profile, as, argv) {
|
|
900
|
+
return createHash("sha256").update(JSON.stringify({
|
|
901
|
+
profile,
|
|
902
|
+
as,
|
|
903
|
+
argv
|
|
904
|
+
}), "utf8").digest("hex").slice(0, 32);
|
|
905
|
+
}
|
|
906
|
+
// ../cli/src/commands/exec.ts
|
|
907
|
+
/**
|
|
908
|
+
* exec 命令:守卫链 + 信封透传(决议 Q5/Q6/Q7/Q9 全量落地)。
|
|
909
|
+
* 退出码:0 成功 | 1 lark-cli 业务错误 | 2 larklink 守卫/路由错误 | 3 歧义待选择
|
|
910
|
+
* | 10 needsConfirmation(镜像 lark-cli) | 11 timeout-unknown
|
|
911
|
+
*/
|
|
912
|
+
async function cmdExec(runner, opts, home = larklinkHome(), emit = stdEmit) {
|
|
913
|
+
const fail = (code, message, extra) => {
|
|
914
|
+
emit({
|
|
915
|
+
ok: false,
|
|
916
|
+
code,
|
|
917
|
+
message,
|
|
918
|
+
...extra
|
|
919
|
+
}, null, 2);
|
|
920
|
+
return 2;
|
|
921
|
+
};
|
|
922
|
+
const rawArgs = opts.rawArgs;
|
|
923
|
+
if (rawArgs.length === 0) return fail("usage", "缺少命令:exec -- <lark-cli 命令参数…>");
|
|
924
|
+
const domain = domainOf(rawArgs);
|
|
925
|
+
if (!EXEC_DOMAINS.has(domain)) return fail("domain-not-allowed", `域 "${domain}" 不在文档域白名单(Q9-①):${[...EXEC_DOMAINS].join(", ")}。其他域请直接使用 lark-cli。`);
|
|
926
|
+
const argvAs = findArgvAs(rawArgs);
|
|
927
|
+
if (argvAs !== null) return fail("argv-as-forbidden", `argv 中的 --as(${argvAs.value || "…"})被拒绝:执行身份只能用 exec 的 --as 参数显式声明(Q6-①)`);
|
|
928
|
+
const as = opts.as ?? "user";
|
|
929
|
+
const registry = await loadRegistry(home);
|
|
930
|
+
let profile;
|
|
931
|
+
let confidence;
|
|
932
|
+
let routedToken = null;
|
|
933
|
+
if (opts.url !== void 0) try {
|
|
934
|
+
const result = resolve(registry, opts.url, { explicitProfile: opts.profile });
|
|
935
|
+
if (result.status === "ambiguous") {
|
|
936
|
+
emit({
|
|
937
|
+
ok: false,
|
|
938
|
+
code: "ambiguous",
|
|
939
|
+
message: result.reason,
|
|
940
|
+
candidates: result.candidates,
|
|
941
|
+
parsed: result.parsed,
|
|
942
|
+
next: "larklink resolve 后用 --profile 显式指定"
|
|
943
|
+
}, null, 2);
|
|
944
|
+
return 3;
|
|
945
|
+
}
|
|
946
|
+
profile = result.profile;
|
|
947
|
+
confidence = result.confidence;
|
|
948
|
+
routedToken = result.parsed.token;
|
|
949
|
+
if (result.bindingStatus !== "ready") {
|
|
950
|
+
await appendAudit({
|
|
951
|
+
kind: "exec",
|
|
952
|
+
profile,
|
|
953
|
+
outcome: "blocked-auth",
|
|
954
|
+
confidence,
|
|
955
|
+
domain,
|
|
956
|
+
as
|
|
957
|
+
}, home);
|
|
958
|
+
emit({
|
|
959
|
+
ok: false,
|
|
960
|
+
code: "binding-needs-repair",
|
|
961
|
+
profile,
|
|
962
|
+
bindingStatus: result.bindingStatus,
|
|
963
|
+
message: `绑定 ${profile} 状态 ${result.bindingStatus},请 larklink bindings auth ${profile} 修复后重试(不自动换绑定,Q1)`
|
|
964
|
+
}, null, 2);
|
|
965
|
+
return 2;
|
|
966
|
+
}
|
|
967
|
+
} catch (e) {
|
|
968
|
+
const err = e;
|
|
969
|
+
emit({
|
|
970
|
+
ok: false,
|
|
971
|
+
code: err.code ?? "resolve-error",
|
|
972
|
+
message: err.message
|
|
973
|
+
}, null, 2);
|
|
974
|
+
return 2;
|
|
975
|
+
}
|
|
976
|
+
else if (opts.profile !== void 0) {
|
|
977
|
+
const binding = findBinding(registry, opts.profile);
|
|
978
|
+
if (!binding) return fail("binding-not-found", `绑定 ${opts.profile} 不存在`);
|
|
979
|
+
if (binding.status === "incomplete") return fail("binding-incomplete", `绑定 ${opts.profile} 授权未完成(Q8)`);
|
|
980
|
+
profile = opts.profile;
|
|
981
|
+
confidence = "explicit";
|
|
982
|
+
} else return fail("usage", "需要 --url(自动路由)或 --profile(显式指定)之一");
|
|
983
|
+
if (routedToken !== null) {
|
|
984
|
+
const check = checkTargets(extractTargets(rawArgs), (await Promise.resolve().then(() => src_exports)).parseDoc(opts.url));
|
|
985
|
+
if (!check.ok) {
|
|
986
|
+
await appendAudit({
|
|
987
|
+
kind: "exec",
|
|
988
|
+
profile,
|
|
989
|
+
outcome: "blocked-target-mismatch",
|
|
990
|
+
confidence,
|
|
991
|
+
domain,
|
|
992
|
+
as,
|
|
993
|
+
argv: rawArgs
|
|
994
|
+
}, home);
|
|
995
|
+
emit({
|
|
996
|
+
ok: false,
|
|
997
|
+
code: check.error.code,
|
|
998
|
+
message: check.error.message
|
|
999
|
+
}, null, 2);
|
|
1000
|
+
return 2;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const confirmationId = computeConfirmationId(profile, as, rawArgs);
|
|
1004
|
+
if (opts.yes === true) {
|
|
1005
|
+
if (!opts.confirmationId) return fail("confirmation-id-required", "--yes 必须携带首次 needsConfirmation 返回的 --confirmation-id(Q6-②)");
|
|
1006
|
+
if (opts.confirmationId !== confirmationId) return fail("confirmation-id-mismatch", `确认指纹不符:本次命令与用户确认的不是同一条请求(期望 ${confirmationId},收到 ${opts.confirmationId})。确认不可挪用。`);
|
|
1007
|
+
}
|
|
1008
|
+
const finalArgv = [
|
|
1009
|
+
"--profile",
|
|
1010
|
+
profile,
|
|
1011
|
+
...rawArgs,
|
|
1012
|
+
"--as",
|
|
1013
|
+
as,
|
|
1014
|
+
...opts.yes ? ["--yes"] : []
|
|
1015
|
+
];
|
|
1016
|
+
if (opts.dryRun) {
|
|
1017
|
+
emit({
|
|
1018
|
+
ok: true,
|
|
1019
|
+
status: "dry-run",
|
|
1020
|
+
profile,
|
|
1021
|
+
confidence,
|
|
1022
|
+
as,
|
|
1023
|
+
argv: finalArgv,
|
|
1024
|
+
confirmationId
|
|
1025
|
+
}, null, 2);
|
|
1026
|
+
return 0;
|
|
1027
|
+
}
|
|
1028
|
+
const result = await runner(finalArgv, { timeoutMs: opts.timeoutMs });
|
|
1029
|
+
const envelope = parseEnvelope(result);
|
|
1030
|
+
const errObj = envelope.error ?? {};
|
|
1031
|
+
const isConfirmation = result.exitCode === 10 || errObj["type"] === "confirmation";
|
|
1032
|
+
let status;
|
|
1033
|
+
let exit;
|
|
1034
|
+
if (result.timedOut) {
|
|
1035
|
+
status = "timeout-unknown";
|
|
1036
|
+
exit = 11;
|
|
1037
|
+
} else if (isConfirmation && opts.yes !== true) {
|
|
1038
|
+
status = "needs-confirmation";
|
|
1039
|
+
exit = 10;
|
|
1040
|
+
} else if (envelope.ok) {
|
|
1041
|
+
status = "success";
|
|
1042
|
+
exit = 0;
|
|
1043
|
+
} else {
|
|
1044
|
+
status = "error";
|
|
1045
|
+
exit = 1;
|
|
1046
|
+
}
|
|
1047
|
+
await appendAudit({
|
|
1048
|
+
kind: "exec",
|
|
1049
|
+
profile,
|
|
1050
|
+
confidence,
|
|
1051
|
+
domain,
|
|
1052
|
+
as,
|
|
1053
|
+
outcome: status,
|
|
1054
|
+
durationMs: result.durationMs,
|
|
1055
|
+
argv: finalArgv
|
|
1056
|
+
}, home);
|
|
1057
|
+
const out = {
|
|
1058
|
+
ok: status === "success",
|
|
1059
|
+
status,
|
|
1060
|
+
profile,
|
|
1061
|
+
confidence,
|
|
1062
|
+
as,
|
|
1063
|
+
argv: finalArgv,
|
|
1064
|
+
durationMs: result.durationMs,
|
|
1065
|
+
exitCode: result.exitCode,
|
|
1066
|
+
result: envelope.payload ?? safeJson(result.stdout),
|
|
1067
|
+
error: envelope.error
|
|
1068
|
+
};
|
|
1069
|
+
if (status === "needs-confirmation") {
|
|
1070
|
+
out["confirmationId"] = confirmationId;
|
|
1071
|
+
out["next"] = `用户同意后:原命令 + --yes --confirmation-id ${confirmationId}`;
|
|
1072
|
+
out["message"] = "高风险操作需用户确认(exit 10 门禁透传)。用户看到的就是这条 argv,同意的也是它。";
|
|
1073
|
+
}
|
|
1074
|
+
if (status === "timeout-unknown") out["message"] = "超时被击杀,服务端结果未知(Q7):禁止直接重试同 argv;先只读核查(如 drive +inspect)确认实际状态。";
|
|
1075
|
+
emit(out, null, 2);
|
|
1076
|
+
return exit;
|
|
1077
|
+
}
|
|
1078
|
+
// src/tools.ts
|
|
1079
|
+
/**
|
|
1080
|
+
* larklink 工具注册:三个模型可见工具,全部薄适配到 CLI 命令函数(同一执行引擎)。
|
|
1081
|
+
* 信封经 emit 缝捕获后作为工具结果返回。
|
|
1082
|
+
*/
|
|
1083
|
+
/** defineTool 泛型按字面量收窄过严(动态 spec 推断为 never);宽松壳仅放宽类型,运行时原样。 */
|
|
1084
|
+
const defineToolLoose = defineTool;
|
|
1085
|
+
/** 捕获 emit 信封(工具结果)。 */
|
|
1086
|
+
function capture() {
|
|
1087
|
+
let value;
|
|
1088
|
+
const emit = (p) => {
|
|
1089
|
+
value = p;
|
|
1090
|
+
};
|
|
1091
|
+
return {
|
|
1092
|
+
emit,
|
|
1093
|
+
payload: () => value
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function asStringArray(value, field) {
|
|
1097
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) throw new Error(`${field} 必须是字符串数组(lark-cli 命令参数,逐个元素)`);
|
|
1098
|
+
return value;
|
|
1099
|
+
}
|
|
1100
|
+
function registerLarklinkTools(deps) {
|
|
1101
|
+
const { ctx, runner, config } = deps;
|
|
1102
|
+
const home = config.home;
|
|
1103
|
+
const disposers = [];
|
|
1104
|
+
const register = (spec) => {
|
|
1105
|
+
const dispose = ctx.tools.register(defineToolLoose({
|
|
1106
|
+
output: {
|
|
1107
|
+
schema: {
|
|
1108
|
+
type: "object",
|
|
1109
|
+
additionalProperties: true,
|
|
1110
|
+
description: "larklink JSON envelope"
|
|
1111
|
+
},
|
|
1112
|
+
render: (_args, value) => value
|
|
1113
|
+
},
|
|
1114
|
+
...spec
|
|
1115
|
+
}));
|
|
1116
|
+
disposers.push(typeof dispose === "function" ? dispose : () => {});
|
|
1117
|
+
};
|
|
1118
|
+
register({
|
|
1119
|
+
name: "larklink_resolve",
|
|
1120
|
+
description: "Resolve which lark/feishu account (binding) should operate on a document URL or bare token. Returns the routing decision (profile, confidence, brand) or an ambiguous candidate list with read-only probe annotations (accessible / no-permission / auth-failed / timeout). Never auto-picks among multiple candidates — ask the user.",
|
|
1121
|
+
parameters: {
|
|
1122
|
+
input: {
|
|
1123
|
+
type: "string",
|
|
1124
|
+
required: true,
|
|
1125
|
+
description: "Document URL or bare token (doxcn/shtcn/wikcn/bascn/...)"
|
|
1126
|
+
},
|
|
1127
|
+
explicitProfile: {
|
|
1128
|
+
type: "string",
|
|
1129
|
+
description: "User explicitly named the binding to use this turn."
|
|
1130
|
+
},
|
|
1131
|
+
askedProfile: {
|
|
1132
|
+
type: "string",
|
|
1133
|
+
description: "The user's answer to an ambiguity question (records confidence=asked)."
|
|
1134
|
+
},
|
|
1135
|
+
pin: {
|
|
1136
|
+
type: "boolean",
|
|
1137
|
+
description: "Remember the asked/explicit choice for this document token (document-level preference)."
|
|
1138
|
+
},
|
|
1139
|
+
probe: {
|
|
1140
|
+
type: "boolean",
|
|
1141
|
+
description: "Read-only probe of ambiguous candidates via drive +inspect (default true)."
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
async execute(args) {
|
|
1145
|
+
const cap = capture();
|
|
1146
|
+
await cmdResolve(runner, {
|
|
1147
|
+
input: String(args["input"]),
|
|
1148
|
+
explicitProfile: args["explicitProfile"] !== void 0 ? String(args["explicitProfile"]) : void 0,
|
|
1149
|
+
askedProfile: args["askedProfile"] !== void 0 ? String(args["askedProfile"]) : void 0,
|
|
1150
|
+
pin: args["pin"] === true,
|
|
1151
|
+
probe: args["probe"] !== false,
|
|
1152
|
+
probeTimeoutMs: config.probeTimeoutMs
|
|
1153
|
+
}, home, cap.emit);
|
|
1154
|
+
return cap.payload();
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
register({
|
|
1158
|
+
name: "larklink_exec",
|
|
1159
|
+
description: "Execute a guarded lark-cli document-domain command (docs/drive/sheets/markdown/slides/base/whiteboard/wiki/mindnotes) as the resolved or explicitly named binding. Enforces: target consistency vs routing, no --as smuggling (use the as param), exit-10 confirmation fingerprint binding, timeout-unknown reporting. Prefer larklink_resolve first.",
|
|
1160
|
+
parameters: {
|
|
1161
|
+
args: {
|
|
1162
|
+
type: "array",
|
|
1163
|
+
items: { type: "string" },
|
|
1164
|
+
required: true,
|
|
1165
|
+
description: "lark-cli command after the profile flag, e.g. [\"docs\",\"+update\",\"--doc\",\"<url>\",\"--content\",\"…\"]"
|
|
1166
|
+
},
|
|
1167
|
+
url: {
|
|
1168
|
+
type: "string",
|
|
1169
|
+
description: "Document URL/token being operated on (enables routing + target guard)."
|
|
1170
|
+
},
|
|
1171
|
+
profile: {
|
|
1172
|
+
type: "string",
|
|
1173
|
+
description: "Explicit binding name (skips routing)."
|
|
1174
|
+
},
|
|
1175
|
+
as: {
|
|
1176
|
+
type: "string",
|
|
1177
|
+
enum: ["user", "bot"],
|
|
1178
|
+
description: "Execution identity; default user; bot requires explicit user request."
|
|
1179
|
+
},
|
|
1180
|
+
yes: {
|
|
1181
|
+
type: "boolean",
|
|
1182
|
+
description: "Confirmed retry after needs-confirmation; must carry the matching confirmationId."
|
|
1183
|
+
},
|
|
1184
|
+
confirmationId: {
|
|
1185
|
+
type: "string",
|
|
1186
|
+
description: "The confirmationId from the needs-confirmation envelope."
|
|
1187
|
+
},
|
|
1188
|
+
dryRun: {
|
|
1189
|
+
type: "boolean",
|
|
1190
|
+
description: "Return the final argv without touching lark-cli."
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
async execute(args) {
|
|
1194
|
+
const cap = capture();
|
|
1195
|
+
await cmdExec(runner, {
|
|
1196
|
+
rawArgs: asStringArray(args["args"], "args"),
|
|
1197
|
+
url: args["url"] !== void 0 ? String(args["url"]) : void 0,
|
|
1198
|
+
profile: args["profile"] !== void 0 ? String(args["profile"]) : void 0,
|
|
1199
|
+
as: args["as"] === "bot" ? "bot" : "user",
|
|
1200
|
+
yes: args["yes"] === true,
|
|
1201
|
+
confirmationId: args["confirmationId"] !== void 0 ? String(args["confirmationId"]) : void 0,
|
|
1202
|
+
dryRun: args["dryRun"] === true,
|
|
1203
|
+
timeoutMs: config.execTimeoutMs
|
|
1204
|
+
}, home, cap.emit);
|
|
1205
|
+
return cap.payload();
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
register({
|
|
1209
|
+
name: "larklink_bindings",
|
|
1210
|
+
description: "Non-secret binding management: adopt existing lark-cli profiles as larklink bindings, list bindings with live status, run health sync (also cleans dead pins), manage document pins. Adding accounts / QR re-auth / removal involve secrets and must run in the user's terminal (larklink bindings add|auth|remove), never through this tool.",
|
|
1211
|
+
parameters: {
|
|
1212
|
+
action: {
|
|
1213
|
+
type: "string",
|
|
1214
|
+
required: true,
|
|
1215
|
+
enum: [
|
|
1216
|
+
"adopt",
|
|
1217
|
+
"list",
|
|
1218
|
+
"health",
|
|
1219
|
+
"pin-add",
|
|
1220
|
+
"pin-remove",
|
|
1221
|
+
"pin-list"
|
|
1222
|
+
],
|
|
1223
|
+
description: "Management action."
|
|
1224
|
+
},
|
|
1225
|
+
profile: {
|
|
1226
|
+
type: "string",
|
|
1227
|
+
description: "Binding profile name (adopt filter / pin target)."
|
|
1228
|
+
},
|
|
1229
|
+
token: {
|
|
1230
|
+
type: "string",
|
|
1231
|
+
description: "Document token for pin-add / pin-remove."
|
|
1232
|
+
},
|
|
1233
|
+
fast: {
|
|
1234
|
+
type: "boolean",
|
|
1235
|
+
description: "list: skip live status verification."
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
async execute(args) {
|
|
1239
|
+
const cap = capture();
|
|
1240
|
+
const action = String(args["action"]);
|
|
1241
|
+
const profile = args["profile"] !== void 0 ? String(args["profile"]) : void 0;
|
|
1242
|
+
switch (action) {
|
|
1243
|
+
case "adopt":
|
|
1244
|
+
await cmdAdopt({
|
|
1245
|
+
runner,
|
|
1246
|
+
home
|
|
1247
|
+
}, { profiles: profile ? [profile] : void 0 }, cap.emit);
|
|
1248
|
+
break;
|
|
1249
|
+
case "list":
|
|
1250
|
+
case "health":
|
|
1251
|
+
await cmdList({
|
|
1252
|
+
runner,
|
|
1253
|
+
home
|
|
1254
|
+
}, {
|
|
1255
|
+
fast: args["fast"] === true,
|
|
1256
|
+
health: action === "health"
|
|
1257
|
+
}, cap.emit);
|
|
1258
|
+
break;
|
|
1259
|
+
case "pin-add":
|
|
1260
|
+
case "pin-remove": {
|
|
1261
|
+
const token = args["token"];
|
|
1262
|
+
if (token === void 0 || profile === void 0) throw new Error("pin-add / pin-remove 需要 token 与 profile");
|
|
1263
|
+
await cmdPin({
|
|
1264
|
+
op: action === "pin-add" ? "add" : "remove",
|
|
1265
|
+
token: String(token),
|
|
1266
|
+
profile
|
|
1267
|
+
}, home, cap.emit);
|
|
1268
|
+
break;
|
|
1269
|
+
}
|
|
1270
|
+
case "pin-list":
|
|
1271
|
+
await cmdPin({ op: "list" }, home, cap.emit);
|
|
1272
|
+
break;
|
|
1273
|
+
default: throw new Error(`未知 action: ${action}`);
|
|
1274
|
+
}
|
|
1275
|
+
return cap.payload();
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
return disposers;
|
|
1279
|
+
}
|
|
1280
|
+
// src/routes.ts
|
|
1281
|
+
const ROUTE_BASE = "/larklink";
|
|
1282
|
+
function sendJson(res, status, body) {
|
|
1283
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
1284
|
+
res.end(JSON.stringify(body));
|
|
1285
|
+
}
|
|
1286
|
+
/** 同源栅栏:origin/host 不一致或跨站 sec-fetch 拒绝(照 browser-skill fence 语义)。 */
|
|
1287
|
+
function fenceViolation(req) {
|
|
1288
|
+
const origin = req.headers["origin"];
|
|
1289
|
+
if (typeof origin === "string" && origin !== "") try {
|
|
1290
|
+
const o = new URL(origin);
|
|
1291
|
+
const host = req.headers["host"];
|
|
1292
|
+
if (o.host !== host) return "origin mismatch";
|
|
1293
|
+
} catch {
|
|
1294
|
+
return "bad origin";
|
|
1295
|
+
}
|
|
1296
|
+
const site = req.headers["sec-fetch-site"];
|
|
1297
|
+
if (typeof site === "string" && site !== "same-origin" && site !== "none") return `cross-site ${site}`;
|
|
1298
|
+
}
|
|
1299
|
+
function readBody(req) {
|
|
1300
|
+
return new Promise((resolve, reject) => {
|
|
1301
|
+
let data = "";
|
|
1302
|
+
req.on("data", (c) => data += c.toString("utf8"));
|
|
1303
|
+
req.on("end", () => resolve(data));
|
|
1304
|
+
req.on("error", reject);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
function captureEmit() {
|
|
1308
|
+
let value;
|
|
1309
|
+
const emit = (p) => {
|
|
1310
|
+
value = p;
|
|
1311
|
+
};
|
|
1312
|
+
return {
|
|
1313
|
+
emit,
|
|
1314
|
+
payload: () => value
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
function registerLarklinkRoutes(deps) {
|
|
1318
|
+
const { ctx, runner, home } = deps;
|
|
1319
|
+
let disposed = false;
|
|
1320
|
+
const disposers = [];
|
|
1321
|
+
ctx.inject(["webServer"], (injected) => {
|
|
1322
|
+
const webServer = injected.get("webServer");
|
|
1323
|
+
if (webServer === null || typeof webServer.register !== "function") return () => {};
|
|
1324
|
+
if (disposed) return () => {};
|
|
1325
|
+
const route = (method, path, handler) => {
|
|
1326
|
+
const dispose = webServer.register({
|
|
1327
|
+
kind: "exact",
|
|
1328
|
+
path,
|
|
1329
|
+
handler: (req, res) => {
|
|
1330
|
+
const violation = fenceViolation(req);
|
|
1331
|
+
if (violation !== void 0) {
|
|
1332
|
+
sendJson(res, 403, {
|
|
1333
|
+
ok: false,
|
|
1334
|
+
error: `forbidden: ${violation}`
|
|
1335
|
+
});
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if ((req.method ?? "GET") !== method) {
|
|
1339
|
+
sendJson(res, 405, {
|
|
1340
|
+
ok: false,
|
|
1341
|
+
error: "method not allowed"
|
|
1342
|
+
});
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
Promise.resolve(handler(req, res)).catch((e) => {
|
|
1346
|
+
sendJson(res, 500, {
|
|
1347
|
+
ok: false,
|
|
1348
|
+
error: e.message
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
disposers.push(typeof dispose === "function" ? dispose : () => {});
|
|
1354
|
+
};
|
|
1355
|
+
route("GET", `${ROUTE_BASE}/bindings`, async (req, res) => {
|
|
1356
|
+
await readBody(req).catch(() => "");
|
|
1357
|
+
const live = new URL(req.url ?? "/", "http://localhost").searchParams.get("live") === "1";
|
|
1358
|
+
const cap = captureEmit();
|
|
1359
|
+
await cmdList({
|
|
1360
|
+
runner,
|
|
1361
|
+
home
|
|
1362
|
+
}, {
|
|
1363
|
+
fast: !live,
|
|
1364
|
+
health: false
|
|
1365
|
+
}, cap.emit);
|
|
1366
|
+
sendJson(res, 200, cap.payload());
|
|
1367
|
+
});
|
|
1368
|
+
route("POST", `${ROUTE_BASE}/adopt`, async (req, res) => {
|
|
1369
|
+
await readBody(req);
|
|
1370
|
+
const cap = captureEmit();
|
|
1371
|
+
await cmdAdopt({
|
|
1372
|
+
runner,
|
|
1373
|
+
home
|
|
1374
|
+
}, {}, cap.emit);
|
|
1375
|
+
sendJson(res, 200, cap.payload());
|
|
1376
|
+
});
|
|
1377
|
+
route("POST", `${ROUTE_BASE}/health`, async (req, res) => {
|
|
1378
|
+
await readBody(req);
|
|
1379
|
+
const cap = captureEmit();
|
|
1380
|
+
await cmdList({
|
|
1381
|
+
runner,
|
|
1382
|
+
home
|
|
1383
|
+
}, { health: true }, cap.emit);
|
|
1384
|
+
sendJson(res, 200, cap.payload());
|
|
1385
|
+
});
|
|
1386
|
+
return () => {
|
|
1387
|
+
for (const d of [...disposers].reverse()) d();
|
|
1388
|
+
disposers.length = 0;
|
|
1389
|
+
};
|
|
1390
|
+
});
|
|
1391
|
+
return () => {
|
|
1392
|
+
disposed = true;
|
|
1393
|
+
for (const d of [...disposers].reverse()) d();
|
|
1394
|
+
disposers.length = 0;
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
// src/skill-content.ts
|
|
1398
|
+
/** 内嵌 SKILL 内容(随插件注册到 DSH skills 服务,source: bundled)。 */
|
|
1399
|
+
const LARKLINK_SKILL_NAME = "larklink";
|
|
1400
|
+
const LARKLINK_SKILL_DESCRIPTION = "Multi-account Feishu/Lark document operations routed through larklink tools: resolve which bound account operates on a document URL/token, then execute guarded lark-cli document commands. Use whenever the user provides a feishu.cn / larksuite.com / larkoffice.com / doubao.com document link or a bare token (doxcn/shtcn/wikcn/bascn...) and the machine has more than one lark binding.";
|
|
1401
|
+
const LARKLINK_SKILL_MARKDOWN = `# larklink —— 多账号飞书/Lark 文档操作路由
|
|
1402
|
+
|
|
1403
|
+
## 何时使用
|
|
1404
|
+
|
|
1405
|
+
- 用户给出 feishu.cn / larksuite.com / larkoffice.com / doubao.com 的文档链接,或 doxcn / doccn / wikcn / shtcn / bascn / fldcn / obcn 裸 token,要求读取或修改
|
|
1406
|
+
- 本机有多个 lark 账号(绑定)时,一切文档域操作必须经 larklink 路由
|
|
1407
|
+
|
|
1408
|
+
## 硬纪律(多账号防串号)
|
|
1409
|
+
|
|
1410
|
+
1. registry 存在 **≥2 个可用绑定**时:文档域操作必须 \`larklink_resolve\` → \`larklink_exec\`,**禁止**直接调用 lark-cli 文档命令(会静默打到默认账号)。
|
|
1411
|
+
2. 单一绑定时:默认 profile 即唯一账号,可直接用 lark-* skills,无需 larklink。
|
|
1412
|
+
3. 身份选择优先级:用户显式指定 > 文档 pin > 唯一绑定 > 询问用户。**能力 ≠ 意图**:探测结果、ready 状态、tenantHint 永不构成自动换号理由。绑定失效 → 提示用户修复授权,绝不换绑定重试。
|
|
1413
|
+
4. 执行身份默认 user;以应用身份(bot)执行必须用户明确要求(\`as: "bot"\`),绝不静默回退。
|
|
1414
|
+
|
|
1415
|
+
## 标准流程
|
|
1416
|
+
|
|
1417
|
+
1. \`larklink_resolve(input)\` → \`resolved\`(profile / confidence / wikiUnwrapNeeded)或 \`ambiguous\`(candidates 含 probe 标注:accessible / no-permission / auth-failed / timeout)
|
|
1418
|
+
2. ambiguous → 把候选与标注呈现给用户选择;选择后 \`larklink_resolve(input, askedProfile, pin=true)\` 记住偏好
|
|
1419
|
+
3. \`larklink_exec(args, url 或 profile)\` 执行 lark-cli 文档命令,返回信封:
|
|
1420
|
+
- \`needs-confirmation\`:向用户展示 action 与参数预览;用户明确同意后,**原样命令** + \`yes=true\` + 返回的 \`confirmationId\`(指纹不符会被拒绝——确认不可挪用)
|
|
1421
|
+
- \`timeout-unknown\`:禁止直接重试同命令;先只读核查(drive +inspect / docs +fetch)确认服务端实际状态
|
|
1422
|
+
- \`error\`:读 error 字段修复参数或授权后重试
|
|
1423
|
+
4. wiki 链接(wikcn…)先 \`drive +inspect\` 解包出真实 token/type 再路由到对应命令
|
|
1424
|
+
|
|
1425
|
+
## 写操作纪律
|
|
1426
|
+
|
|
1427
|
+
- \`overwrite\` / 整段替换前**必须先 fetch 最新内容**做增量合并;优先 str_replace 类局部修改(目标文本不存在会失败而非误覆盖)
|
|
1428
|
+
- 批量多文档:逐个 resolve+exec,失败不中断,最后汇总成功/失败清单
|
|
1429
|
+
- 超时(timeout-unknown)后先核查再决定,禁止盲目重试
|
|
1430
|
+
|
|
1431
|
+
## 命令域白名单(v1)
|
|
1432
|
+
|
|
1433
|
+
docs · drive · sheets · markdown · slides · base · whiteboard · wiki · mindnotes
|
|
1434
|
+
|
|
1435
|
+
其他域(im / mail / task / calendar…)不经 larklink,直接用 lark-cli / lark-* skills。
|
|
1436
|
+
|
|
1437
|
+
## 绑定管理
|
|
1438
|
+
|
|
1439
|
+
- 新增账号、授权修复(扫码 split-flow)、删除绑定:**让用户在终端执行** \`larklink bindings add|auth|remove\` —— app secret 与授权码不经模型上下文
|
|
1440
|
+
- 工具 \`larklink_bindings\` 仅做无敏感操作:adopt(导入既有 profile)/ list / health / pin 管理
|
|
1441
|
+
- 绑定授权过期(needs_refresh/unauthorized):报告用户执行 \`larklink bindings auth <profile>\`,不要反复重试
|
|
1442
|
+
`;
|
|
1443
|
+
// src/index.ts
|
|
1444
|
+
/**
|
|
1445
|
+
* @timeryu/dsh-larklink —— DSH 插件入口(cordis 契约:name / inject / Config / apply)。
|
|
1446
|
+
* 工具注册 + 内嵌 SKILL 注册;所有命令走 CLI 层同一执行引擎(Q5-Q9 守卫全量生效)。
|
|
1447
|
+
*/
|
|
1448
|
+
const name = "@timeryu/dsh-larklink";
|
|
1449
|
+
const inject = ["tools"];
|
|
1450
|
+
/** 运行时配置(cordis 校验并填充默认值)。 */
|
|
1451
|
+
const Config = Schema.object({
|
|
1452
|
+
home: Schema.string().default("").description("larklink 主目录覆盖(默认 ~/.larklink;测试/多实例用)。"),
|
|
1453
|
+
execTimeoutMs: Schema.number().default(12e4).description("exec 默认超时(毫秒);超时按 timeout-unknown 处理。"),
|
|
1454
|
+
probeTimeoutMs: Schema.number().default(8e3).description("歧义候选只读探测超时(毫秒)。")
|
|
1455
|
+
});
|
|
1456
|
+
function apply(ctx, config = {}, options = {}) {
|
|
1457
|
+
const disposers = [];
|
|
1458
|
+
disposers.push(...registerLarklinkTools({
|
|
1459
|
+
ctx,
|
|
1460
|
+
runner: options.runner ?? defaultLarkRunner,
|
|
1461
|
+
config: {
|
|
1462
|
+
home: config.home || void 0,
|
|
1463
|
+
execTimeoutMs: config.execTimeoutMs,
|
|
1464
|
+
probeTimeoutMs: config.probeTimeoutMs
|
|
1465
|
+
}
|
|
1466
|
+
}));
|
|
1467
|
+
const skills = ctx.get("skills");
|
|
1468
|
+
if (skills != null && typeof skills.register === "function") {
|
|
1469
|
+
const unregister = skills.register({
|
|
1470
|
+
name: LARKLINK_SKILL_NAME,
|
|
1471
|
+
description: LARKLINK_SKILL_DESCRIPTION,
|
|
1472
|
+
content: LARKLINK_SKILL_MARKDOWN,
|
|
1473
|
+
source: "bundled"
|
|
1474
|
+
});
|
|
1475
|
+
if (typeof unregister === "function") disposers.push(unregister);
|
|
1476
|
+
}
|
|
1477
|
+
disposers.push(registerLarklinkRoutes({
|
|
1478
|
+
ctx,
|
|
1479
|
+
runner: options.runner ?? defaultLarkRunner,
|
|
1480
|
+
home: config.home || void 0
|
|
1481
|
+
}));
|
|
1482
|
+
ctx.effect(() => () => {
|
|
1483
|
+
for (const dispose of [...disposers].reverse()) dispose();
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
export { Config, apply, inject, name };
|