larkin 0.2.76 → 0.2.78

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # Larkin
2
2
 
3
- Larkin connects Codex, Claude Code, and Pi agent runtimes to Feishu. It provides a local runtime host, persistent sessions, reminders, interactive messages, and a local dashboard.
3
+ Larkin connects Codex, Claude Code, and Pi agent runtimes to Feishu (Lark). It provides a local runtime host, persistent sessions, reminders, interactive messages, and a local dashboard.
4
4
 
5
5
  ## Requirements
6
6
 
7
- - A supported macOS or Linux system
7
+ - A supported macOS, Linux, or Windows (x64) system
8
8
  - `lark-cli`
9
9
  - At least one supported agent runtime and its authentication
10
10
  - Bun 1.3.14 when running the npm package or building from source (standalone binaries bundle their own runtime)
@@ -22,7 +22,7 @@ npm install -g larkin
22
22
  larkin --version
23
23
  ```
24
24
 
25
- Standalone binaries for macOS and Linux are attached to every [GitHub Release](https://github.com/eddiearc/larkin/releases) for environments without Bun or npm.
25
+ Standalone binaries for macOS, Linux, and Windows (x64) are attached to every [GitHub Release](https://github.com/eddiearc/larkin/releases) for environments without Bun or npm. Windows binaries are cross-compiled; their runtime behavior has not been end-to-end verified on a Windows host.
26
26
 
27
27
  ## Usage
28
28
 
@@ -105,7 +105,7 @@ function query(requestPath, name) {
105
105
  }
106
106
  function migrationError(group, subcommand) {
107
107
  if (group === "message") {
108
- return "message 已移除:只读查看收件箱用 `larkin inbox check`,领取完整消息用 `larkin inbox poll`;飞书发送、回复和查询请使用 `larkin im +messages-send`、`+messages-reply`、`+chat-messages-list` 或 `+messages-mget`。";
108
+ return "message 已移除:只读查看收件箱用 `larkin inbox check`,领取完整消息用 `larkin inbox poll`;飞书(Lark)发送、回复和查询请使用 `larkin im +messages-send`、`+messages-reply`、`+chat-messages-list` 或 `+messages-mget`。";
109
109
  }
110
110
  if (group === "channel") {
111
111
  return "channel 已移除:群聊操作请使用 `larkin im +chat-list`、`+chat-search`、`chats get`、`+chat-create`、`+chat-update` 或 `chat.members get/create/delete`。";
@@ -114,9 +114,9 @@ function migrationError(group, subcommand) {
114
114
  return "attachment 已移除:发送附件请使用 `larkin im +messages-send --file/--image/--video/--audio`;下载请使用 `larkin im +messages-resources-download`。";
115
115
  }
116
116
  if (group === "server")
117
- return "server 已移除;飞书群与消息信息请通过 `larkin im ...` 查询。";
117
+ return "server 已移除;飞书(Lark)群与消息信息请通过 `larkin im ...` 查询。";
118
118
  if (group === "im")
119
- return "请通过 `larkin im ...` 使用飞书命令;Runtime 会自动绑定当前 Bot identity,并在写入前执行 freshness gate。可运行 `larkin im --help` 查看帮助。";
119
+ return "请通过 `larkin im ...` 使用飞书(Lark)命令;Runtime 会自动绑定当前 Bot identity,并在写入前执行 freshness gate。可运行 `larkin im --help` 查看帮助。";
120
120
  if (group === "profile" && subcommand !== "show") {
121
121
  return "profile 只保留只读的 `larkin profile show`;身份和凭证由 `larkin setup` 管理,不支持 update。";
122
122
  }
@@ -10,6 +10,7 @@ exports.managedOfficialLarkCli = managedOfficialLarkCli;
10
10
  exports.assertAgentWorkspaceBound = assertAgentWorkspaceBound;
11
11
  const node_fs_1 = __importDefault(require("node:fs"));
12
12
  const node_path_1 = __importDefault(require("node:path"));
13
+ const secure_metadata_js_1 = require("../platform/secure-metadata.cjs");
13
14
  const official_lark_cli_js_1 = require("./official-lark-cli.cjs");
14
15
  function larkChannelSourceConfigPath(agent) {
15
16
  if (!agent.stateDir)
@@ -43,13 +44,13 @@ function managedOfficialLarkCli(agent, env, resolve = official_lark_cli_js_1.res
43
44
  }
44
45
  function readPrivateJson(file, label) {
45
46
  const directory = node_fs_1.default.lstatSync(node_path_1.default.dirname(file));
46
- if (!directory.isDirectory() || directory.isSymbolicLink() || (directory.mode & 0o777) !== 0o700
47
+ if (!directory.isDirectory() || directory.isSymbolicLink() || !(0, secure_metadata_js_1.exactMode)(directory, 0o700)
47
48
  || (typeof process.getuid === "function" && directory.uid !== process.getuid()))
48
49
  throw new Error(`${label} 目录不安全`);
49
50
  const fd = node_fs_1.default.openSync(file, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NOFOLLOW || 0));
50
51
  try {
51
52
  const stat = node_fs_1.default.fstatSync(fd);
52
- if (!stat.isFile() || (stat.mode & 0o777) !== 0o600
53
+ if (!stat.isFile() || !(0, secure_metadata_js_1.exactMode)(stat, 0o600)
53
54
  || (typeof process.getuid === "function" && stat.uid !== process.getuid()))
54
55
  throw new Error(`${label} 文件不安全`);
55
56
  return JSON.parse(node_fs_1.default.readFileSync(fd, "utf8"));
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { exactMode } from "../platform/secure-metadata.mjs";
3
4
  import { resolveOfficialLarkCli } from "./official-lark-cli.mjs";
4
5
  export function larkChannelSourceConfigPath(agent) {
5
6
  if (!agent.stateDir)
@@ -33,13 +34,13 @@ export function managedOfficialLarkCli(agent, env, resolve = resolveOfficialLark
33
34
  }
34
35
  function readPrivateJson(file, label) {
35
36
  const directory = fs.lstatSync(path.dirname(file));
36
- if (!directory.isDirectory() || directory.isSymbolicLink() || (directory.mode & 0o777) !== 0o700
37
+ if (!directory.isDirectory() || directory.isSymbolicLink() || !exactMode(directory, 0o700)
37
38
  || (typeof process.getuid === "function" && directory.uid !== process.getuid()))
38
39
  throw new Error(`${label} 目录不安全`);
39
40
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
40
41
  try {
41
42
  const stat = fs.fstatSync(fd);
42
- if (!stat.isFile() || (stat.mode & 0o777) !== 0o600
43
+ if (!stat.isFile() || !exactMode(stat, 0o600)
43
44
  || (typeof process.getuid === "function" && stat.uid !== process.getuid()))
44
45
  throw new Error(`${label} 文件不安全`);
45
46
  return JSON.parse(fs.readFileSync(fd, "utf8"));
package/dist/app/cli.mjs CHANGED
@@ -111,7 +111,7 @@ if (!routes[command] && !wantsHelp) {
111
111
  routes[command] = runtimeAgentAuthority ? ["lark-cli", command] : ["lark", command];
112
112
  }
113
113
  if (!routes[command]) {
114
- console.log(`larkin — Run persistent-personality agents on Feishu
114
+ console.log(`larkin — Run persistent-personality agents on Feishu (Lark)
115
115
 
116
116
  Usage: larkin <command>
117
117
  setup Create or connect a bot, configure its Agent, start it, and open the dashboard
@@ -7,11 +7,14 @@ import path from "node:path";
7
7
  import { readProcessState } from "../platform/process-state.mjs";
8
8
  import { currentProcessMetadata } from "../platform/process-inspect.cjs";
9
9
  import { processCommandToken } from "./internal-command.mjs";
10
+ import { isWindows, notGroupOrWorldAccessible } from "../platform/secure-metadata.mjs";
10
11
  const AGENT_ID = /^cli_[A-Za-z0-9]+$/;
11
12
  const OPERATION_ID = /^[A-Za-z0-9_-]{8,128}$/;
12
13
  const AUTHORIZATION = /^[A-Za-z0-9_-]{43,128}$/;
13
14
  const UNIX_SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
14
15
  function socketPathsFit(root) {
16
+ if (isWindows)
17
+ return true; // Windows named-pipe socket paths use a much larger limit
15
18
  return ["supervisor.sock", "daemon.sock"].every((name) => Buffer.byteLength(path.join(root, name)) <= UNIX_SOCKET_PATH_MAX_BYTES);
16
19
  }
17
20
  function controlSocketRoot(larkinHome) {
@@ -21,6 +24,8 @@ function controlSocketRoot(larkinHome) {
21
24
  const preferred = path.join(path.resolve(os.tmpdir()), leaf);
22
25
  if (socketPathsFit(preferred))
23
26
  return preferred;
27
+ if (isWindows)
28
+ throw new Error("无法生成满足 Windows socket 路径限制的 control root");
24
29
  const fallback = path.join("/tmp", leaf);
25
30
  if (socketPathsFit(fallback))
26
31
  return fallback;
@@ -30,7 +35,7 @@ function assertSecureSocketDirectory(root) {
30
35
  const stat = fs.lstatSync(root);
31
36
  if (!stat.isDirectory() || stat.isSymbolicLink()
32
37
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
33
- || (stat.mode & 0o077) !== 0)
38
+ || !notGroupOrWorldAccessible(stat))
34
39
  throw new Error("control socket root 不安全");
35
40
  return root;
36
41
  }
@@ -52,7 +57,7 @@ function assertSecureRoot(root) {
52
57
  const stat = fs.lstatSync(root);
53
58
  if (!stat.isDirectory() || stat.isSymbolicLink()
54
59
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
55
- || (stat.mode & 0o077) !== 0)
60
+ || !notGroupOrWorldAccessible(stat))
56
61
  throw new Error("Larkin config root 必须由当前用户拥有且不可被其他用户访问");
57
62
  }
58
63
  function removeLegacyResetLedger(larkinHome) {
@@ -67,7 +72,7 @@ function removeLegacyResetLedger(larkinHome) {
67
72
  throw error;
68
73
  }
69
74
  if (!stat.isFile() || stat.isSymbolicLink()
70
- || (typeof process.getuid === "function" && stat.uid !== process.getuid()) || (stat.mode & 0o077) !== 0) {
75
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid()) || !notGroupOrWorldAccessible(stat)) {
71
76
  throw new Error("legacy daemon control operation ledger 不安全");
72
77
  }
73
78
  fs.unlinkSync(file);
@@ -114,7 +119,7 @@ function secureAuthority(larkinHome) {
114
119
  const stat = fs.lstatSync(file);
115
120
  if (!stat.isFile() || stat.isSymbolicLink()
116
121
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
117
- || (stat.mode & 0o077) !== 0)
122
+ || !notGroupOrWorldAccessible(stat))
118
123
  throw new Error("daemon control authority 不安全");
119
124
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
120
125
  const validBinding = (binding) => !!binding && Number.isSafeInteger(binding.pid) && binding.pid > 0
@@ -634,7 +639,7 @@ async function sendSupervisorRecovery(larkinHome, operationId, timeoutMs) {
634
639
  const stat = fs.lstatSync(socket);
635
640
  if (!stat.isSocket() || stat.isSymbolicLink()
636
641
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
637
- || (stat.mode & 0o077) !== 0)
642
+ || !notGroupOrWorldAccessible(stat))
638
643
  throw new Error("supervisor control socket 不安全");
639
644
  return await new Promise((resolve, reject) => {
640
645
  const client = net.createConnection(socket);
@@ -690,7 +695,7 @@ async function requestAgentControl({ larkinHome, timeoutMs = 30_000, request, })
690
695
  const stat = fs.lstatSync(socket);
691
696
  if (!stat.isSocket() || stat.isSymbolicLink()
692
697
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
693
- || (stat.mode & 0o077) !== 0)
698
+ || !notGroupOrWorldAccessible(stat))
694
699
  throw new Error("daemon control socket 不安全");
695
700
  return await new Promise((resolve, reject) => {
696
701
  const client = net.createConnection(socket);
@@ -12,10 +12,20 @@ exports.ensureOfficialLarkCliForSetup = ensureOfficialLarkCliForSetup;
12
12
  const node_child_process_1 = require("node:child_process");
13
13
  const node_fs_1 = __importDefault(require("node:fs"));
14
14
  const node_path_1 = __importDefault(require("node:path"));
15
+ function safeRealpath(target) {
16
+ try {
17
+ return node_fs_1.default.realpathSync(target);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
15
23
  exports.OFFICIAL_LARK_CLI_VERSION = "1.0.79";
16
24
  exports.OFFICIAL_LARK_CLI_INSTALL = `npm install --global @larksuite/cli@${exports.OFFICIAL_LARK_CLI_VERSION}`;
17
25
  function loginShellPath(dependencies) {
18
26
  const env = dependencies.env ?? process.env;
27
+ if ((dependencies.platform ?? process.platform) === "win32")
28
+ return windowsLoginShellPath(dependencies, env);
19
29
  const shell = dependencies.shell || env.SHELL || "/bin/sh";
20
30
  const result = (dependencies.spawn ?? node_child_process_1.spawnSync)(shell, ["-lc", "command -v lark-cli 2>/dev/null"], {
21
31
  encoding: "utf8", env,
@@ -25,6 +35,30 @@ function loginShellPath(dependencies) {
25
35
  const candidate = String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
26
36
  return node_path_1.default.isAbsolute(candidate) ? node_path_1.default.resolve(candidate) : null;
27
37
  }
38
+ /**
39
+ * Windows 登录 shell 探测:npm 全局安装会生成 `lark-cli.cmd`/`lark-cli` shim,真正的
40
+ * 原生二进制位于 `<npm 前缀>/node_modules/@larksuite/cli/bin/lark-cli.exe`。`where lark-cli`
41
+ * 返回 shim 路径,从 shim 所在目录推导原生二进制优先返回;否则回退到命中的 .exe/.cmd
42
+ * 路径(交由 officialPackage 校验)。
43
+ */
44
+ function windowsLoginShellPath(dependencies, env) {
45
+ const shell = dependencies.shell || "cmd.exe";
46
+ const result = (dependencies.spawn ?? node_child_process_1.spawnSync)(shell, ["/d", "/s", "/c", "where lark-cli 2>nul"], {
47
+ encoding: "utf8", env,
48
+ });
49
+ if (result.status !== 0 || result.error)
50
+ return null;
51
+ for (const line of String(result.stdout || "").split(/\r?\n/).map((value) => value.trim()).filter(Boolean)) {
52
+ if (!node_path_1.default.isAbsolute(line))
53
+ continue;
54
+ const native = node_path_1.default.join(node_path_1.default.dirname(line), "node_modules", "@larksuite", "cli", "bin", "lark-cli.exe");
55
+ if (safeRealpath(native))
56
+ return node_path_1.default.resolve(native);
57
+ if (/\.(exe|cmd)$/i.test(line))
58
+ return node_path_1.default.resolve(line);
59
+ }
60
+ return null;
61
+ }
28
62
  function compatibleVersion(version) {
29
63
  const parse = (value) => {
30
64
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
@@ -37,7 +71,7 @@ function compatibleVersion(version) {
37
71
  return actual[0] > minimum[0] || (actual[0] === minimum[0]
38
72
  && (actual[1] > minimum[1] || (actual[1] === minimum[1] && actual[2] >= minimum[2])));
39
73
  }
40
- function officialPackage(executable) {
74
+ function officialPackage(executable, platform = process.platform) {
41
75
  let resolved;
42
76
  try {
43
77
  resolved = node_fs_1.default.realpathSync(executable);
@@ -55,8 +89,11 @@ function officialPackage(executable) {
55
89
  const packageRoot = node_fs_1.default.realpathSync(directory);
56
90
  const requested = node_path_1.default.resolve(packageRoot, bin);
57
91
  const relative = node_path_1.default.relative(packageRoot, requested);
58
- if ((relative === "" || (!relative.startsWith(`..${node_path_1.default.sep}`) && relative !== ".."))
59
- && node_fs_1.default.realpathSync(requested) === resolved) {
92
+ const withinPackage = relative === "" || (!relative.startsWith(`..${node_path_1.default.sep}`) && relative !== "..");
93
+ const matchesBinTarget = withinPackage && safeRealpath(requested) === resolved;
94
+ const matchesNativeWindows = withinPackage && platform === "win32"
95
+ && safeRealpath(node_path_1.default.join(packageRoot, "bin", "lark-cli.exe")) === resolved;
96
+ if (matchesBinTarget || matchesNativeWindows) {
60
97
  return { command: node_path_1.default.resolve(executable), argsPrefix: [], version: manifest.version };
61
98
  }
62
99
  }
@@ -114,8 +151,29 @@ function validateOfficialCommand(command, env, spawn) {
114
151
  * 仅当 PATH 解析出可用的官方 CLI 时才返回结果;否则返回 null 回退到
115
152
  * 登录 shell 探测(保留对 shell 自定义 PATH 环境的兼容)。
116
153
  */
117
- function probePathResolution(env) {
154
+ function probePathResolution(env, platform = process.platform) {
118
155
  const dirs = String(env.PATH || "").split(node_path_1.default.delimiter).filter(Boolean);
156
+ if (platform === "win32") {
157
+ for (const dir of dirs) {
158
+ for (const candidate of [
159
+ node_path_1.default.resolve(dir, "node_modules", "@larksuite", "cli", "bin", "lark-cli.exe"),
160
+ node_path_1.default.resolve(dir, "lark-cli.exe"),
161
+ ]) {
162
+ try {
163
+ if (!node_fs_1.default.statSync(candidate).isFile())
164
+ continue;
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ const command = officialPackage(candidate, platform);
170
+ if (!command)
171
+ continue;
172
+ return validateOfficialCommand(command, env, node_child_process_1.spawnSync);
173
+ }
174
+ }
175
+ return null;
176
+ }
119
177
  for (const dir of dirs) {
120
178
  const candidate = node_path_1.default.resolve(dir, "lark-cli");
121
179
  try {
@@ -125,7 +183,7 @@ function probePathResolution(env) {
125
183
  catch {
126
184
  continue;
127
185
  }
128
- const command = officialPackage(candidate);
186
+ const command = officialPackage(candidate, platform);
129
187
  if (!command)
130
188
  return null;
131
189
  return validateOfficialCommand(command, env, node_child_process_1.spawnSync);
@@ -134,8 +192,9 @@ function probePathResolution(env) {
134
192
  }
135
193
  function probeOfficialLarkCliUncached(dependencies) {
136
194
  const env = dependencies.env ?? process.env;
195
+ const platform = dependencies.platform ?? process.platform;
137
196
  if (!dependencies.spawn && !dependencies.shell) {
138
- const fromPath = probePathResolution(env);
197
+ const fromPath = probePathResolution(env, platform);
139
198
  if (fromPath)
140
199
  return fromPath;
141
200
  }
@@ -146,7 +205,7 @@ function probeOfficialLarkCliUncached(dependencies) {
146
205
  reason: "真实 login shell 找不到官方 lark-cli",
147
206
  nextAction: `运行 larkin setup,并确认执行:${exports.OFFICIAL_LARK_CLI_INSTALL}`,
148
207
  };
149
- const command = officialPackage(executable);
208
+ const command = officialPackage(executable, platform);
150
209
  if (!command)
151
210
  return {
152
211
  state: "conflict",
@@ -1,10 +1,20 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ function safeRealpath(target) {
5
+ try {
6
+ return fs.realpathSync(target);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
4
12
  export const OFFICIAL_LARK_CLI_VERSION = "1.0.79";
5
13
  export const OFFICIAL_LARK_CLI_INSTALL = `npm install --global @larksuite/cli@${OFFICIAL_LARK_CLI_VERSION}`;
6
14
  function loginShellPath(dependencies) {
7
15
  const env = dependencies.env ?? process.env;
16
+ if ((dependencies.platform ?? process.platform) === "win32")
17
+ return windowsLoginShellPath(dependencies, env);
8
18
  const shell = dependencies.shell || env.SHELL || "/bin/sh";
9
19
  const result = (dependencies.spawn ?? spawnSync)(shell, ["-lc", "command -v lark-cli 2>/dev/null"], {
10
20
  encoding: "utf8", env,
@@ -14,6 +24,30 @@ function loginShellPath(dependencies) {
14
24
  const candidate = String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
15
25
  return path.isAbsolute(candidate) ? path.resolve(candidate) : null;
16
26
  }
27
+ /**
28
+ * Windows 登录 shell 探测:npm 全局安装会生成 `lark-cli.cmd`/`lark-cli` shim,真正的
29
+ * 原生二进制位于 `<npm 前缀>/node_modules/@larksuite/cli/bin/lark-cli.exe`。`where lark-cli`
30
+ * 返回 shim 路径,从 shim 所在目录推导原生二进制优先返回;否则回退到命中的 .exe/.cmd
31
+ * 路径(交由 officialPackage 校验)。
32
+ */
33
+ function windowsLoginShellPath(dependencies, env) {
34
+ const shell = dependencies.shell || "cmd.exe";
35
+ const result = (dependencies.spawn ?? spawnSync)(shell, ["/d", "/s", "/c", "where lark-cli 2>nul"], {
36
+ encoding: "utf8", env,
37
+ });
38
+ if (result.status !== 0 || result.error)
39
+ return null;
40
+ for (const line of String(result.stdout || "").split(/\r?\n/).map((value) => value.trim()).filter(Boolean)) {
41
+ if (!path.isAbsolute(line))
42
+ continue;
43
+ const native = path.join(path.dirname(line), "node_modules", "@larksuite", "cli", "bin", "lark-cli.exe");
44
+ if (safeRealpath(native))
45
+ return path.resolve(native);
46
+ if (/\.(exe|cmd)$/i.test(line))
47
+ return path.resolve(line);
48
+ }
49
+ return null;
50
+ }
17
51
  function compatibleVersion(version) {
18
52
  const parse = (value) => {
19
53
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
@@ -26,7 +60,7 @@ function compatibleVersion(version) {
26
60
  return actual[0] > minimum[0] || (actual[0] === minimum[0]
27
61
  && (actual[1] > minimum[1] || (actual[1] === minimum[1] && actual[2] >= minimum[2])));
28
62
  }
29
- function officialPackage(executable) {
63
+ function officialPackage(executable, platform = process.platform) {
30
64
  let resolved;
31
65
  try {
32
66
  resolved = fs.realpathSync(executable);
@@ -44,8 +78,11 @@ function officialPackage(executable) {
44
78
  const packageRoot = fs.realpathSync(directory);
45
79
  const requested = path.resolve(packageRoot, bin);
46
80
  const relative = path.relative(packageRoot, requested);
47
- if ((relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".."))
48
- && fs.realpathSync(requested) === resolved) {
81
+ const withinPackage = relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
82
+ const matchesBinTarget = withinPackage && safeRealpath(requested) === resolved;
83
+ const matchesNativeWindows = withinPackage && platform === "win32"
84
+ && safeRealpath(path.join(packageRoot, "bin", "lark-cli.exe")) === resolved;
85
+ if (matchesBinTarget || matchesNativeWindows) {
49
86
  return { command: path.resolve(executable), argsPrefix: [], version: manifest.version };
50
87
  }
51
88
  }
@@ -103,8 +140,29 @@ function validateOfficialCommand(command, env, spawn) {
103
140
  * 仅当 PATH 解析出可用的官方 CLI 时才返回结果;否则返回 null 回退到
104
141
  * 登录 shell 探测(保留对 shell 自定义 PATH 环境的兼容)。
105
142
  */
106
- function probePathResolution(env) {
143
+ function probePathResolution(env, platform = process.platform) {
107
144
  const dirs = String(env.PATH || "").split(path.delimiter).filter(Boolean);
145
+ if (platform === "win32") {
146
+ for (const dir of dirs) {
147
+ for (const candidate of [
148
+ path.resolve(dir, "node_modules", "@larksuite", "cli", "bin", "lark-cli.exe"),
149
+ path.resolve(dir, "lark-cli.exe"),
150
+ ]) {
151
+ try {
152
+ if (!fs.statSync(candidate).isFile())
153
+ continue;
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ const command = officialPackage(candidate, platform);
159
+ if (!command)
160
+ continue;
161
+ return validateOfficialCommand(command, env, spawnSync);
162
+ }
163
+ }
164
+ return null;
165
+ }
108
166
  for (const dir of dirs) {
109
167
  const candidate = path.resolve(dir, "lark-cli");
110
168
  try {
@@ -114,7 +172,7 @@ function probePathResolution(env) {
114
172
  catch {
115
173
  continue;
116
174
  }
117
- const command = officialPackage(candidate);
175
+ const command = officialPackage(candidate, platform);
118
176
  if (!command)
119
177
  return null;
120
178
  return validateOfficialCommand(command, env, spawnSync);
@@ -123,8 +181,9 @@ function probePathResolution(env) {
123
181
  }
124
182
  function probeOfficialLarkCliUncached(dependencies) {
125
183
  const env = dependencies.env ?? process.env;
184
+ const platform = dependencies.platform ?? process.platform;
126
185
  if (!dependencies.spawn && !dependencies.shell) {
127
- const fromPath = probePathResolution(env);
186
+ const fromPath = probePathResolution(env, platform);
128
187
  if (fromPath)
129
188
  return fromPath;
130
189
  }
@@ -135,7 +194,7 @@ function probeOfficialLarkCliUncached(dependencies) {
135
194
  reason: "真实 login shell 找不到官方 lark-cli",
136
195
  nextAction: `运行 larkin setup,并确认执行:${OFFICIAL_LARK_CLI_INSTALL}`,
137
196
  };
138
- const command = officialPackage(executable);
197
+ const command = officialPackage(executable, platform);
139
198
  if (!command)
140
199
  return {
141
200
  state: "conflict",
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { exactMode, notGroupOrWorldAccessible } from "../platform/secure-metadata.mjs";
6
7
  import { acquireProcessLock } from "../platform/process-state.mjs";
7
8
  import { assertSecureBotsDirectory, readSecureBotCredential, validCredentialRecord, } from "../setup/run-credential-preflight.mjs";
8
9
  import { resolveOfficialLarkCli } from "./official-lark-cli.mjs";
@@ -12,7 +13,7 @@ function assertSecureProfileDirectory(directory) {
12
13
  const stat = fs.lstatSync(directory);
13
14
  if (!stat.isDirectory() || stat.isSymbolicLink()
14
15
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
15
- || (stat.mode & 0o777) !== 0o700)
16
+ || !exactMode(stat, 0o700))
16
17
  throw new Error("lark-cli profile 目录不安全");
17
18
  }
18
19
  function captureProfileSnapshot(file) {
@@ -22,7 +23,7 @@ function captureProfileSnapshot(file) {
22
23
  const stat = fs.fstatSync(fd);
23
24
  if (!stat.isFile()
24
25
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())
25
- || (stat.mode & 0o077) !== 0)
26
+ || !notGroupOrWorldAccessible(stat))
26
27
  throw new Error("lark-cli config.json 不安全");
27
28
  const raw = fs.readFileSync(fd);
28
29
  const value = JSON.parse(raw.toString("utf8"));
@@ -22,7 +22,7 @@ const flag = (name) => { const index = argv.indexOf(name); return index >= 0 ? a
22
22
  const say = (...args) => console.log(...args);
23
23
  const die = (message) => { console.error(`✗ ${message}`); process.exit(1); };
24
24
  if (argv.some((arg) => arg === "--app-id" || arg.startsWith("--app-id="))) {
25
- die("setup 不支持 --app-id;请运行 larkin setup 并在飞书网页中选择机器人");
25
+ die("setup 不支持 --app-id;请运行 larkin setup 并在飞书(Lark)网页中选择机器人");
26
26
  }
27
27
  if (argv.some((arg) => arg === "--no-dashboard" || arg.startsWith("--no-dashboard="))) {
28
28
  die("--no-dashboard 已移除;dashboard 由 larkin start 统一管理");
@@ -67,7 +67,7 @@ if (!OPT.help && normalizedRuntime.distribution === "external" && (flag("--provi
67
67
  die("external-pi 使用已有 pi 环境,不接受 --provider/--api-key/--base-url");
68
68
  }
69
69
  if (OPT.help) {
70
- say(`larkin setup — Create or connect a Feishu bot, then configure and attach its Agent
70
+ say(`larkin setup — Create or connect a Feishu (Lark) bot, then configure and attach its Agent
71
71
 
72
72
  Usage:
73
73
  larkin setup Select a bot in the browser and run setup(默认:内置 Pi + 配置完成即热挂载)
@@ -128,7 +128,7 @@ export async function main() {
128
128
  env: process.env,
129
129
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
130
130
  async confirmInstall(command) {
131
- say("[setup 0/5] Larkin 需要未修改的官方 lark-cli 作为 Feishu 命令下游。");
131
+ say("[setup 0/5] Larkin 需要未修改的官方 lark-cli 作为 Feishu (Lark) 命令下游。");
132
132
  say(`将执行:${command}`);
133
133
  const input = readline.createInterface({ input: process.stdin, output: process.stdout });
134
134
  const answer = (await input.question("是否安装?[y/N] ")).trim().toLowerCase();
@@ -137,7 +137,7 @@ export async function main() {
137
137
  },
138
138
  }).catch((error) => die(error instanceof Error ? error.message : String(error)));
139
139
  say(`[setup 0/5] ✓ 官方 lark-cli ${official.command.version}: ${official.command.command}${official.installed ? "(刚刚安装)" : ""}`);
140
- say("\nAgent 与飞书机器人按 App ID 一一对应:");
140
+ say("\nAgent 与飞书(Lark)机器人按 App ID 一一对应:");
141
141
  say(" • 网页选择同一个机器人 → 热更新该 Agent,不重启其他 Agent");
142
142
  say(" • 网页创建新机器人 → 热挂载新 Agent,状态彼此独立\n");
143
143
  fs.mkdirSync(CFG_DIR, { recursive: true, mode: 0o700 });
@@ -711,6 +711,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
711
711
  domain: agent.feishuDomain || "https://open.feishu.cn",
712
712
  source: "larkin",
713
713
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
714
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
715
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
716
+ batch: { text: { delayMs: 0 } },
714
717
  handshakeTimeoutMs: 15_000,
715
718
  keepalive: {
716
719
  enabled: true,
@@ -823,6 +826,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
823
826
  domain: agent.feishuDomain || "https://open.feishu.cn",
824
827
  source: "larkin",
825
828
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
829
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
830
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
831
+ batch: { text: { delayMs: 0 } },
826
832
  handshakeTimeoutMs: 15_000,
827
833
  keepalive: { enabled: true, intervalMs: 15_000 },
828
834
  includeRawEvent: true,
@@ -671,6 +671,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
671
671
  domain: agent.feishuDomain || "https://open.feishu.cn",
672
672
  source: "larkin",
673
673
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
674
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
675
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
676
+ batch: { text: { delayMs: 0 } },
674
677
  handshakeTimeoutMs: 15_000,
675
678
  keepalive: {
676
679
  enabled: true,
@@ -783,6 +786,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
783
786
  domain: agent.feishuDomain || "https://open.feishu.cn",
784
787
  source: "larkin",
785
788
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
789
+ // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
790
+ // 保证 messageId/threadId/发送者与内容、提及永远同源(见 #66)。
791
+ batch: { text: { delayMs: 0 } },
786
792
  handshakeTimeoutMs: 15_000,
787
793
  keepalive: { enabled: true, intervalMs: 15_000 },
788
794
  includeRawEvent: true,
@@ -50,6 +50,7 @@ exports.verifyCallbackProbe = verifyCallbackProbe;
50
50
  const node_crypto_1 = require("node:crypto");
51
51
  const fs = __importStar(require("node:fs"));
52
52
  const path = __importStar(require("node:path"));
53
+ const secure_metadata_js_1 = require("./secure-metadata.cjs");
53
54
  const APP_ID = /^cli_[A-Za-z0-9]+$/;
54
55
  const NONCE = /^[a-f0-9]{32}$/;
55
56
  function credentialFile(configDir, appId) {
@@ -59,7 +60,7 @@ function credentialFile(configDir, appId) {
59
60
  }
60
61
  function readCredential(file) {
61
62
  const stat = fs.lstatSync(file);
62
- if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o600
63
+ if (!stat.isFile() || stat.isSymbolicLink() || !(0, secure_metadata_js_1.exactMode)(stat, 0o600)
63
64
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
64
65
  throw new Error("callback capability credential must be an owned 0600 regular file");
65
66
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { exactMode } from "./secure-metadata.mjs";
4
5
  const APP_ID = /^cli_[A-Za-z0-9]+$/;
5
6
  const NONCE = /^[a-f0-9]{32}$/;
6
7
  function credentialFile(configDir, appId) {
@@ -10,7 +11,7 @@ function credentialFile(configDir, appId) {
10
11
  }
11
12
  function readCredential(file) {
12
13
  const stat = fs.lstatSync(file);
13
- if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o600
14
+ if (!stat.isFile() || stat.isSymbolicLink() || !exactMode(stat, 0o600)
14
15
  || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
15
16
  throw new Error("callback capability credential must be an owned 0600 regular file");
16
17
  }
@@ -60,6 +60,7 @@ const fs = __importStar(require("node:fs"));
60
60
  const os = __importStar(require("node:os"));
61
61
  const path = __importStar(require("node:path"));
62
62
  const root_layout_js_1 = require("./root-layout.cjs");
63
+ const secure_metadata_js_1 = require("./secure-metadata.cjs");
63
64
  const runtime_model_catalog_js_1 = require("../runtime/runtime-model-catalog.cjs");
64
65
  const process_inspect_cjs_1 = __importDefault(require("./process-inspect.cjs"));
65
66
  const TOP_FIELDS_V3 = new Set(["version", "serverId", "activeAgent", "agents"]);
@@ -264,7 +265,7 @@ function assertPrivateConfigMetadata(metadata, label = "配置文件") {
264
265
  throw new Error(`${label} 必须是普通文件`);
265
266
  if (typeof process.getuid === "function" && metadata.uid !== process.getuid())
266
267
  throw new Error(`${label} owner 不是当前用户`);
267
- if ((metadata.mode & 0o777) !== 0o600)
268
+ if (!(0, secure_metadata_js_1.exactMode)(metadata, 0o600))
268
269
  throw new Error(`${label} 权限必须是 0600`);
269
270
  }
270
271
  function readPrivateFile(file, root, limit, label) {
@@ -663,13 +664,7 @@ function atomicWriteConfig(file, value) {
663
664
  try {
664
665
  fs.renameSync(temporary, file);
665
666
  fs.chmodSync(file, 0o600);
666
- const dirFd = fs.openSync(path.dirname(file), "r");
667
- try {
668
- fs.fsyncSync(dirFd);
669
- }
670
- finally {
671
- fs.closeSync(dirFd);
672
- }
667
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(file);
673
668
  }
674
669
  catch (error) {
675
670
  try {
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { TargetRootLayout, resolveConfigDir as resolveRootConfigDir } from "./root-layout.mjs";
6
+ import { exactMode, fsyncDirectoryOf } from "./secure-metadata.mjs";
6
7
  import { CURRENT_RUNTIME_MODELS } from "../runtime/runtime-model-catalog.mjs";
7
8
  import processInspect from "./process-inspect.cjs";
8
9
  const TOP_FIELDS_V3 = new Set(["version", "serverId", "activeAgent", "agents"]);
@@ -207,7 +208,7 @@ export function assertPrivateConfigMetadata(metadata, label = "配置文件") {
207
208
  throw new Error(`${label} 必须是普通文件`);
208
209
  if (typeof process.getuid === "function" && metadata.uid !== process.getuid())
209
210
  throw new Error(`${label} owner 不是当前用户`);
210
- if ((metadata.mode & 0o777) !== 0o600)
211
+ if (!exactMode(metadata, 0o600))
211
212
  throw new Error(`${label} 权限必须是 0600`);
212
213
  }
213
214
  function readPrivateFile(file, root, limit, label) {
@@ -606,13 +607,7 @@ function atomicWriteConfig(file, value) {
606
607
  try {
607
608
  fs.renameSync(temporary, file);
608
609
  fs.chmodSync(file, 0o600);
609
- const dirFd = fs.openSync(path.dirname(file), "r");
610
- try {
611
- fs.fsyncSync(dirFd);
612
- }
613
- finally {
614
- fs.closeSync(dirFd);
615
- }
610
+ fsyncDirectoryOf(file);
616
611
  }
617
612
  catch (error) {
618
613
  try {
@@ -6,6 +6,7 @@ export const RELEASE_TARGETS = Object.freeze([
6
6
  { platform: "darwin", arch: "x64", bunTarget: "bun-darwin-x64-baseline" },
7
7
  { platform: "linux", arch: "arm64", bunTarget: "bun-linux-arm64" },
8
8
  { platform: "linux", arch: "x64", bunTarget: "bun-linux-x64-baseline" },
9
+ { platform: "windows", arch: "x64", bunTarget: "bun-windows-x64-baseline" },
9
10
  ]);
10
11
  export function artifactFilename(version, platform, arch) {
11
12
  if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version))
@@ -13,14 +14,20 @@ export function artifactFilename(version, platform, arch) {
13
14
  if (!RELEASE_TARGETS.some((target) => target.platform === platform && target.arch === arch)) {
14
15
  throw new Error(`unsupported release target: ${platform}-${arch}`);
15
16
  }
16
- return `larkin-v${version}-${platform}-${arch}`;
17
+ const suffix = platform === "windows" ? ".exe" : "";
18
+ return `larkin-v${version}-${platform}-${arch}${suffix}`;
19
+ }
20
+ /** Node's `os.platform()` reports Windows as "win32"; canonical release manifests use "windows". */
21
+ export function normalizeReleasePlatform(platform) {
22
+ return platform === "win32" ? "windows" : platform;
17
23
  }
18
24
  export function sha256File(file) {
19
25
  return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
20
26
  }
21
27
  export function selectReleaseArtifact(manifest, platform, arch) {
22
- const record = manifest.artifacts.find((candidate) => candidate.platform === platform && candidate.arch === arch);
23
- if (!record || !RELEASE_TARGETS.some((target) => target.platform === platform && target.arch === arch)) {
28
+ const canonicalPlatform = normalizeReleasePlatform(platform);
29
+ const record = manifest.artifacts.find((candidate) => candidate.platform === canonicalPlatform && candidate.arch === arch);
30
+ if (!record || !RELEASE_TARGETS.some((target) => target.platform === canonicalPlatform && target.arch === arch)) {
24
31
  throw new Error(`unsupported platform: ${platform}-${arch}`);
25
32
  }
26
33
  if (record.file !== artifactFilename(manifest.version, record.platform, record.arch) || !/^[a-f0-9]{64}$/.test(record.sha256)) {
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isWindows = void 0;
7
+ exports.exactMode = exactMode;
8
+ exports.notGroupOrWorldAccessible = notGroupOrWorldAccessible;
9
+ exports.fsyncDirectory = fsyncDirectory;
10
+ exports.fsyncDirectoryOf = fsyncDirectoryOf;
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ /**
14
+ * Cross-platform secure-metadata helpers. POSIX platforms enforce ownership and
15
+ * mode bits (fail-closed). Windows has no POSIX mode bits, so mode-bit assertions
16
+ * are skipped there; ownership on Windows is enforced by the filesystem/ACL and the
17
+ * parent-directory containment checks elsewhere. Windows runtime behavior is marked
18
+ * unverified (Owner decision B: no Windows validation in this delivery).
19
+ */
20
+ exports.isWindows = process.platform === "win32";
21
+ /** POSIX: `mode & 0o777` must equal `expected`. Windows: always passes. */
22
+ function exactMode(stat, expected) {
23
+ if (exports.isWindows)
24
+ return true;
25
+ return (stat.mode & 0o777) === expected;
26
+ }
27
+ /** POSIX: group/other must have no access bits. Windows: always passes. */
28
+ function notGroupOrWorldAccessible(stat) {
29
+ if (exports.isWindows)
30
+ return true;
31
+ return (stat.mode & 0o077) === 0;
32
+ }
33
+ /** Fsync a directory after rename for durability. Windows cannot open a directory for fsync, so this is a no-op. */
34
+ function fsyncDirectory(directory) {
35
+ if (exports.isWindows)
36
+ return;
37
+ const fd = node_fs_1.default.openSync(directory, node_fs_1.default.constants.O_RDONLY);
38
+ try {
39
+ node_fs_1.default.fsyncSync(fd);
40
+ }
41
+ finally {
42
+ node_fs_1.default.closeSync(fd);
43
+ }
44
+ }
45
+ /** Fsync the parent directory of `file` after rename for durability. Windows: no-op. */
46
+ function fsyncDirectoryOf(file) {
47
+ fsyncDirectory(node_path_1.default.dirname(file));
48
+ }
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Cross-platform secure-metadata helpers. POSIX platforms enforce ownership and
5
+ * mode bits (fail-closed). Windows has no POSIX mode bits, so mode-bit assertions
6
+ * are skipped there; ownership on Windows is enforced by the filesystem/ACL and the
7
+ * parent-directory containment checks elsewhere. Windows runtime behavior is marked
8
+ * unverified (Owner decision B: no Windows validation in this delivery).
9
+ */
10
+ export const isWindows = process.platform === "win32";
11
+ /** POSIX: `mode & 0o777` must equal `expected`. Windows: always passes. */
12
+ export function exactMode(stat, expected) {
13
+ if (isWindows)
14
+ return true;
15
+ return (stat.mode & 0o777) === expected;
16
+ }
17
+ /** POSIX: group/other must have no access bits. Windows: always passes. */
18
+ export function notGroupOrWorldAccessible(stat) {
19
+ if (isWindows)
20
+ return true;
21
+ return (stat.mode & 0o077) === 0;
22
+ }
23
+ /** Fsync a directory after rename for durability. Windows cannot open a directory for fsync, so this is a no-op. */
24
+ export function fsyncDirectory(directory) {
25
+ if (isWindows)
26
+ return;
27
+ const fd = fs.openSync(directory, fs.constants.O_RDONLY);
28
+ try {
29
+ fs.fsyncSync(fd);
30
+ }
31
+ finally {
32
+ fs.closeSync(fd);
33
+ }
34
+ }
35
+ /** Fsync the parent directory of `file` after rename for durability. Windows: no-op. */
36
+ export function fsyncDirectoryOf(file) {
37
+ fsyncDirectory(path.dirname(file));
38
+ }
@@ -10,6 +10,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const node_zlib_1 = __importDefault(require("node:zlib"));
12
12
  const process_state_js_1 = require("./process-state.cjs");
13
+ const secure_metadata_js_1 = require("./secure-metadata.cjs");
13
14
  const READY = /^(?:span-[0-9a-f-]+|import-[0-9a-f]{64})\.json$/;
14
15
  const REMNANT = /^\.(?:corrupt|write|ack|delete|stale-lock|purge)-[0-9A-Za-z-]+(?:\.(?:json|tmp|dir))?$/;
15
16
  const HEX_TRACE = /^[0-9a-f]{32}$/;
@@ -333,13 +334,7 @@ class TelemetrySpool {
333
334
  descriptor = undefined;
334
335
  node_fs_1.default.renameSync(temporary, file);
335
336
  node_fs_1.default.chmodSync(file, 0o600);
336
- const directory = node_fs_1.default.openSync(this.config.spoolDir, node_fs_1.default.constants.O_RDONLY);
337
- try {
338
- node_fs_1.default.fsyncSync(directory);
339
- }
340
- finally {
341
- node_fs_1.default.closeSync(directory);
342
- }
337
+ (0, secure_metadata_js_1.fsyncDirectory)(this.config.spoolDir);
343
338
  }
344
339
  finally {
345
340
  if (descriptor !== undefined)
@@ -479,13 +474,7 @@ class TelemetrySpool {
479
474
  }
480
475
  for (const entry of moved)
481
476
  node_fs_1.default.unlinkSync(entry.quarantine);
482
- const directory = node_fs_1.default.openSync(this.config.spoolDir, node_fs_1.default.constants.O_RDONLY);
483
- try {
484
- node_fs_1.default.fsyncSync(directory);
485
- }
486
- finally {
487
- node_fs_1.default.closeSync(directory);
488
- }
477
+ (0, secure_metadata_js_1.fsyncDirectory)(this.config.spoolDir);
489
478
  }
490
479
  catch {
491
480
  for (const entry of moved.reverse())
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import zlib from "node:zlib";
5
5
  import { inspectProcess } from "./process-state.mjs";
6
+ import { fsyncDirectory } from "./secure-metadata.mjs";
6
7
  const READY = /^(?:span-[0-9a-f-]+|import-[0-9a-f]{64})\.json$/;
7
8
  const REMNANT = /^\.(?:corrupt|write|ack|delete|stale-lock|purge)-[0-9A-Za-z-]+(?:\.(?:json|tmp|dir))?$/;
8
9
  const HEX_TRACE = /^[0-9a-f]{32}$/;
@@ -326,13 +327,7 @@ export class TelemetrySpool {
326
327
  descriptor = undefined;
327
328
  fs.renameSync(temporary, file);
328
329
  fs.chmodSync(file, 0o600);
329
- const directory = fs.openSync(this.config.spoolDir, fs.constants.O_RDONLY);
330
- try {
331
- fs.fsyncSync(directory);
332
- }
333
- finally {
334
- fs.closeSync(directory);
335
- }
330
+ fsyncDirectory(this.config.spoolDir);
336
331
  }
337
332
  finally {
338
333
  if (descriptor !== undefined)
@@ -472,13 +467,7 @@ export class TelemetrySpool {
472
467
  }
473
468
  for (const entry of moved)
474
469
  fs.unlinkSync(entry.quarantine);
475
- const directory = fs.openSync(this.config.spoolDir, fs.constants.O_RDONLY);
476
- try {
477
- fs.fsyncSync(directory);
478
- }
479
- finally {
480
- fs.closeSync(directory);
481
- }
470
+ fsyncDirectory(this.config.spoolDir);
482
471
  }
483
472
  catch {
484
473
  for (const entry of moved.reverse())
@@ -15,6 +15,7 @@ const sdk_trace_node_1 = require("@opentelemetry/sdk-trace-node");
15
15
  const telemetry_spool_js_1 = require("./telemetry-spool.cjs");
16
16
  const telemetry_uploader_js_1 = require("./telemetry-uploader.cjs");
17
17
  const process_state_js_1 = require("./process-state.cjs");
18
+ const secure_metadata_js_1 = require("./secure-metadata.cjs");
18
19
  const nanos = ([seconds, nanoseconds]) => (BigInt(seconds) * 1000000000n + BigInt(nanoseconds)).toString();
19
20
  // OTel JS uses zero-based SpanKind values while OTLP's enum starts at 1.
20
21
  const kind = (value) => value + 1;
@@ -82,13 +83,7 @@ function atomicStateWrite(file, value) {
82
83
  node_fs_1.default.writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: "wx" });
83
84
  node_fs_1.default.renameSync(temporary, file);
84
85
  node_fs_1.default.chmodSync(file, 0o600);
85
- const directory = node_fs_1.default.openSync(node_path_1.default.dirname(file), node_fs_1.default.constants.O_RDONLY);
86
- try {
87
- node_fs_1.default.fsyncSync(directory);
88
- }
89
- finally {
90
- node_fs_1.default.closeSync(directory);
91
- }
86
+ (0, secure_metadata_js_1.fsyncDirectoryOf)(file);
92
87
  }
93
88
  finally {
94
89
  try {
@@ -8,6 +8,7 @@ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
8
8
  import { TelemetrySpool } from "./telemetry-spool.mjs";
9
9
  import { startTelemetryUploader } from "./telemetry-uploader.mjs";
10
10
  import { inspectProcess } from "./process-state.mjs";
11
+ import { fsyncDirectoryOf } from "./secure-metadata.mjs";
11
12
  const nanos = ([seconds, nanoseconds]) => (BigInt(seconds) * 1000000000n + BigInt(nanoseconds)).toString();
12
13
  // OTel JS uses zero-based SpanKind values while OTLP's enum starts at 1.
13
14
  const kind = (value) => value + 1;
@@ -75,13 +76,7 @@ function atomicStateWrite(file, value) {
75
76
  fs.writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: "wx" });
76
77
  fs.renameSync(temporary, file);
77
78
  fs.chmodSync(file, 0o600);
78
- const directory = fs.openSync(path.dirname(file), fs.constants.O_RDONLY);
79
- try {
80
- fs.fsyncSync(directory);
81
- }
82
- finally {
83
- fs.closeSync(directory);
84
- }
79
+ fsyncDirectoryOf(file);
85
80
  }
86
81
  finally {
87
82
  try {
@@ -435,7 +435,7 @@ export async function main() {
435
435
  die("--result-file 必须是配置根目录内的 .setup-result-<pid>.json");
436
436
  }
437
437
  if (argv.some((arg) => arg === "--app-id" || arg.startsWith("--app-id="))) {
438
- die("不支持 --app-id;机器人必须在飞书网页中选择");
438
+ die("不支持 --app-id;机器人必须在飞书(Lark)网页中选择");
439
439
  }
440
440
  if (!autoSelect)
441
441
  die("setup 注册阶段必须由交互式选择流程启动");
@@ -482,16 +482,16 @@ export async function main() {
482
482
  },
483
483
  onQRCodeReady: ({ url, expireIn }) => {
484
484
  qrcode.generate(url, { small: true }, (code) => say(code));
485
- say(`\n打开以下链接(或扫码),${Math.round(expireIn / 60)} 分钟内有效:\n\n ${url}\n\n[setup 2/5] 等待飞书网页完成授权并回传凭证…`);
485
+ say(`\n打开以下链接(或扫码),${Math.round(expireIn / 60)} 分钟内有效:\n\n ${url}\n\n[setup 2/5] 等待飞书(Lark)网页完成授权并回传凭证…`);
486
486
  say(openBrowser(url) ? "[setup] 已在默认浏览器打开授权页" : "[setup] 未能自动打开浏览器,请手动打开上面的链接");
487
487
  },
488
488
  onStatusChange: ({ status, interval }) => {
489
489
  if (status === "domain_switched")
490
490
  say("[setup] 已切换到 Lark 域名");
491
491
  if (status === "slow_down")
492
- say(`[setup] 飞书要求降低轮询频率,${interval || "稍后"}秒后继续`);
492
+ say(`[setup] 飞书(Lark)要求降低轮询频率,${interval || "稍后"}秒后继续`);
493
493
  if (status === "polling" && ++pollingCount % 12 === 0)
494
- say(`[setup] 仍在等待飞书回传凭证(约 ${pollingCount / 12} 分钟)…`);
494
+ say(`[setup] 仍在等待飞书(Lark)回传凭证(约 ${pollingCount / 12} 分钟)…`);
495
495
  },
496
496
  }).catch(() => die("网页授权失败;未执行凭证同步、文件写入或 Agent 绑定,请重试 setup"));
497
497
  const { client_id: rawId, client_secret: rawSecret, user_info: userInfo } = result || {};
@@ -559,7 +559,7 @@ export async function main() {
559
559
  throw new Error(`官方 Pi ${providerId} 登录失败或已取消;credential/config 未修改`);
560
560
  }
561
561
  if (process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN !== "1") {
562
- say("正在验证内置 Pi provider(受控单轮,不发送飞书消息)…");
562
+ say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
563
563
  try {
564
564
  await verifyOfficialPiProviderTurn(piRuntime, choice.model);
565
565
  }
@@ -39,7 +39,7 @@ const APP_ID = selected.agentId;
39
39
  if (explicitAppId && explicitAppId !== selected.feishuAppId)
40
40
  throw new Error("--app-id 必须等于所选 Agent 的 App ID");
41
41
  if (!/^cli_[A-Za-z0-9]+$/.test(APP_ID))
42
- throw new Error(`无效飞书 App ID:${APP_ID}`);
42
+ throw new Error(`无效飞书(Lark) App ID:${APP_ID}`);
43
43
  const SEND_TO = flag("--send-to", "");
44
44
  const TENANT = flag("--tenant", "feishu");
45
45
  const WAIT_MIN = Number(flag("--wait-min", "9"));
@@ -76,7 +76,7 @@ function sendUrlToChat(url, minutes) {
76
76
  const result = spawnSync(managed.command.command, [...managed.command.argsPrefix, "im", "+messages-send", "--chat-id", SEND_TO, "--text", text, "--json"], {
77
77
  encoding: "utf8", env: managed.env,
78
78
  });
79
- log(result.status === 0 ? "[grant] 更新链接已发到飞书群" : `[grant] 发链接失败: ${(result.stderr || "").trim().split("\n")[0]}`);
79
+ log(result.status === 0 ? "[grant] 更新链接已发到飞书(Lark)群" : `[grant] 发链接失败: ${(result.stderr || "").trim().split("\n")[0]}`);
80
80
  }
81
81
  export async function main() {
82
82
  log(`=== 给 bot ${APP_ID} 增补读取权限(registerApp 更新流) ===`);
@@ -94,7 +94,7 @@ export async function main() {
94
94
  },
95
95
  onQRCodeReady: (info) => {
96
96
  const minutes = Math.max(1, Math.round(info.expireIn / 60));
97
- log("\n请用飞书扫码或打开链接,确认给应用增补权限:\n");
97
+ log("\n请用飞书(Lark)扫码或打开链接,确认给应用增补权限:\n");
98
98
  qrcode.generate(info.url, { small: true });
99
99
  log(`\n链接: ${info.url}\n有效期约 ${minutes} 分钟(⚠️ 需本进程保持轮询,否则 user_code 立即失效)\n`);
100
100
  if (URL_FILE) {
@@ -87,7 +87,7 @@ async function pickProfile() {
87
87
  const profiles = listProfiles();
88
88
  if (profiles.length === 0)
89
89
  die("lark-cli 里还没有 bot profile。请运行 larkin setup 创建或连接机器人");
90
- say("\n可复用的飞书机器人(lark-cli profiles):");
90
+ say("\n可复用的飞书(Lark)机器人(lark-cli profiles):");
91
91
  profiles.forEach((profile, index) => say(` [${index + 1}] ${profile.name} appId=${profile.appId}${profile.active ? " (当前活跃,默认)" : ""}`));
92
92
  say(" (创建新机器人请退出后运行 larkin setup)");
93
93
  const activeIndex = Math.max(0, profiles.findIndex((profile) => profile.active));
@@ -298,7 +298,7 @@ export async function main() {
298
298
  }
299
299
  if (!readinessAlreadyCompleted) {
300
300
  piRuntime ??= await createOfficialPiModelRuntime(CFG_DIR, profile.appId);
301
- say("正在验证内置 Pi provider(受控单轮,不发送飞书消息)…");
301
+ say("正在验证内置 Pi provider(受控单轮,不发送飞书(Lark)消息)…");
302
302
  try {
303
303
  if (!(process.env.LARKIN_TEST_SKIP_BUILTIN_PI_PROVIDER_TURN === "1" && process.env.LARKIN_TEST_BOT_REGISTER_MODULE)) {
304
304
  await verifyOfficialPiProviderTurn(piRuntime, selection.model, authAbort.signal);
@@ -329,7 +329,7 @@ export async function main() {
329
329
  say("\n启动这个 Agent:");
330
330
  say(" larkin start # 单服务启动全部已配置 Agent");
331
331
  say(` larkin start --agent ${profile.appId} # 仅调试这个 Agent`);
332
- say("创建独立飞书机器人 + Agent:");
332
+ say("创建独立飞书(Lark)机器人 + Agent:");
333
333
  say(" larkin setup");
334
334
  }
335
335
  if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "larkin",
3
- "version": "0.2.76",
3
+ "version": "0.2.78",
4
4
  "private": false,
5
5
  "larkinPackageRole": "npm-published",
6
6
  "files": [
7
7
  "dist/",
8
8
  "assets/",
9
+ "scripts/npm/",
9
10
  "README.md",
10
11
  "LICENSE",
11
12
  "SECURITY.md",
@@ -15,9 +16,9 @@
15
16
  "packageManager": "bun@1.3.14",
16
17
  "license": "Apache-2.0",
17
18
  "type": "module",
18
- "description": "把 Codex、Claude Code 和 Pi Agent Runtime 接到飞书的本地 Runtime Host",
19
+ "description": "把 Codex、Claude Code 和 Pi Agent Runtime 接到飞书(Lark)的本地 Runtime Host",
19
20
  "bin": {
20
- "larkin": "dist/app/cli.mjs"
21
+ "larkin": "scripts/npm/larkin-bin-shim.mjs"
21
22
  },
22
23
  "repository": {
23
24
  "type": "git",
@@ -28,6 +29,7 @@
28
29
  "typecheck": "bun run scripts/typecheck.mjs",
29
30
  "build": "bun run scripts/build.mjs",
30
31
  "prepare": "bun run build",
32
+ "postinstall": "node scripts/npm/install-binary.mjs",
31
33
  "version:bump": "bun run scripts/bump-version.mjs",
32
34
  "release:check-version": "bun run scripts/check-release-version.mjs",
33
35
  "release:smoke": "bun run scripts/release/smoke.ts",
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // npm postinstall: download the matching platform standalone binary from the
3
+ // GitHub Release for this package version, verify its SHA-256, and install it
4
+ // atomically under <pkg>/larkin-bin/<platform>-<arch>/. The npm tarball itself
5
+ // never ships the binary; this runs only for registry/git installs (never in the
6
+ // source repo, which ships src/ and scripts/build.mjs).
7
+ //
8
+ // Failures are non-fatal on purpose: `bin` (scripts/npm/larkin-bin-shim.mjs)
9
+ // falls back to the Bun-compiled JS entry when the binary is unavailable.
10
+
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const PKG_ROOT = path.resolve(HERE, "..", "..");
18
+
19
+ function warn(message) {
20
+ process.stderr.write(`[larkin install] ${message}\n`);
21
+ }
22
+
23
+ function inSourceRepo() {
24
+ return fs.existsSync(path.join(PKG_ROOT, "src")) && fs.existsSync(path.join(PKG_ROOT, "scripts", "build.mjs"));
25
+ }
26
+
27
+ async function fetchWithTimeout(url, timeoutMs) {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
30
+ try {
31
+ const response = await fetch(url, { redirect: "follow", signal: controller.signal });
32
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
33
+ return Buffer.from(await response.arrayBuffer());
34
+ } finally {
35
+ clearTimeout(timer);
36
+ }
37
+ }
38
+
39
+ function expectedSha256(checksumsText, filename) {
40
+ for (const line of String(checksumsText).split(/\r?\n/)) {
41
+ const [sha, name] = line.trim().split(/\s+/);
42
+ if (name === filename && /^[a-f0-9]{64}$/.test(sha)) return sha;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ async function main() {
48
+ if (process.env.LARKIN_NPM_BINARY_DISABLE === "1") return;
49
+ if (inSourceRepo()) return;
50
+
51
+ // 延迟导入编译产物:源码仓库/CI 中 `dist/` 可能尚未构建,静态 import 会在
52
+ // 守卫之前失败(bun install 会运行根包 postinstall)。
53
+ const { RELEASE_TARGETS, artifactFilename, normalizeReleasePlatform, sha256File } = await import(
54
+ "../../dist/platform/release-artifacts.mjs"
55
+ );
56
+
57
+ const platform = normalizeReleasePlatform(os.platform());
58
+ const arch = os.arch();
59
+ const target = RELEASE_TARGETS.find((candidate) => candidate.platform === platform && candidate.arch === arch);
60
+ if (!target) return; // no standalone binary for this platform → JS fallback
61
+
62
+ let version;
63
+ try {
64
+ version = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8")).version;
65
+ } catch (error) {
66
+ warn(`无法读取包版本:${error instanceof Error ? error.message : String(error)}`);
67
+ return;
68
+ }
69
+
70
+ let filename;
71
+ try {
72
+ filename = artifactFilename(version, target.platform, target.arch);
73
+ } catch (error) {
74
+ warn(`跳过下载:${error instanceof Error ? error.message : String(error)}`);
75
+ return;
76
+ }
77
+
78
+ const baseUrl = String(process.env.LARKIN_RELEASE_BASE_URL || `https://github.com/eddiearc/larkin/releases/download/v${version}`).replace(/\/+$/, "");
79
+ const destinationDir = path.join(PKG_ROOT, "larkin-bin", `${target.platform}-${target.arch}`);
80
+ const destination = path.join(destinationDir, target.platform === "windows" ? "larkin.exe" : "larkin");
81
+
82
+ let expected;
83
+ try {
84
+ const checksums = await fetchWithTimeout(`${baseUrl}/SHA256SUMS`, 30_000);
85
+ expected = expectedSha256(checksums.toString("utf8"), filename);
86
+ } catch (error) {
87
+ if (fs.existsSync(destination)) {
88
+ warn(`SHA256SUMS 下载失败,保留已安装二进制:${error instanceof Error ? error.message : String(error)}`);
89
+ return;
90
+ }
91
+ warn(`SHA256SUMS 下载失败,将回退到 Bun JS 入口:${error instanceof Error ? error.message : String(error)}`);
92
+ return;
93
+ }
94
+ if (!expected) {
95
+ warn(`SHA256SUMS 中缺少 ${filename} 的校验和,将回退到 Bun JS 入口`);
96
+ return;
97
+ }
98
+
99
+ if (fs.existsSync(destination) && sha256File(destination) === expected) return; // already current
100
+
101
+ let bytes;
102
+ try {
103
+ bytes = await fetchWithTimeout(`${baseUrl}/${filename}`, 5 * 60_000);
104
+ } catch (error) {
105
+ warn(`二进制下载失败,将回退到 Bun JS 入口:${error instanceof Error ? error.message : String(error)}`);
106
+ return;
107
+ }
108
+
109
+ fs.mkdirSync(destinationDir, { recursive: true });
110
+ const temporary = path.join(destinationDir, `.larkin.${process.pid}.${Date.now()}.tmp`);
111
+ try {
112
+ fs.writeFileSync(temporary, bytes);
113
+ if (sha256File(temporary) !== expected) throw new Error("checksum mismatch");
114
+ fs.chmodSync(temporary, 0o755);
115
+ fs.renameSync(temporary, destination);
116
+ process.stderr.write(`[larkin install] installed ${filename}\n`);
117
+ } catch (error) {
118
+ try { fs.unlinkSync(temporary); } catch { /* best effort */ }
119
+ warn(`安装二进制失败,将回退到 Bun JS 入口:${error instanceof Error ? error.message : String(error)}`);
120
+ }
121
+ }
122
+
123
+ main().catch((error) => {
124
+ warn(`安装二进制异常,将回退到 Bun JS 入口:${error instanceof Error ? error.message : String(error)}`);
125
+ });
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // npm `bin` entry. Prefers the postinstall-downloaded standalone binary; falls
3
+ // back to the Bun-compiled JS entry when the binary is absent (unsupported
4
+ // platform or a failed/interrupted install) and Bun is on PATH.
5
+
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+ import { RELEASE_TARGETS, normalizeReleasePlatform } from "../../dist/platform/release-artifacts.mjs";
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
14
+ const PKG_ROOT = path.resolve(HERE, "..", "..");
15
+
16
+ const platform = normalizeReleasePlatform(os.platform());
17
+ const target = RELEASE_TARGETS.find((candidate) => candidate.platform === platform && candidate.arch === os.arch());
18
+ const binary = target
19
+ ? path.join(PKG_ROOT, "larkin-bin", `${target.platform}-${target.arch}`, target.platform === "windows" ? "larkin.exe" : "larkin")
20
+ : null;
21
+ const argv = process.argv.slice(2);
22
+
23
+ function run(command, args) {
24
+ const result = spawnSync(command, args, { stdio: "inherit" });
25
+ if (result.error) return null;
26
+ return result.status ?? 0;
27
+ }
28
+
29
+ if (binary && fs.existsSync(binary)) {
30
+ const status = run(binary, argv);
31
+ if (status !== null) process.exit(status);
32
+ process.stderr.write(`[larkin] 平台二进制启动失败,尝试 Bun fallback…\n`);
33
+ }
34
+
35
+ const bunEntry = path.join(PKG_ROOT, "dist", "app", "cli.mjs");
36
+ if (fs.existsSync(bunEntry)) {
37
+ const bun = process.env.LARKIN_BUN || "bun";
38
+ const status = run(bun, [bunEntry, ...argv]);
39
+ if (status !== null) process.exit(status);
40
+ }
41
+
42
+ process.stderr.write(
43
+ "[larkin] 未找到可用的平台二进制,也未找到 Bun。\n" +
44
+ " - 联网重新安装会自动下载对应平台二进制:npm install -g larkin\n" +
45
+ " - 或安装 Bun 1.3.14 后重试(会走 JS 入口 fallback)\n" +
46
+ " - 或从 GitHub Release 下载 standalone 二进制:https://github.com/eddiearc/larkin/releases\n",
47
+ );
48
+ process.exit(1);