feishu-codex-console 1.0.0-beta.6

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.
Files changed (113) hide show
  1. package/.env.example +101 -0
  2. package/.feishu-codex-policy.example.json +11 -0
  3. package/.feishu-codex-runbooks.example.json +36 -0
  4. package/CHANGELOG.md +129 -0
  5. package/CODE_OF_CONDUCT.md +7 -0
  6. package/CONTRIBUTING.md +52 -0
  7. package/LICENSE +21 -0
  8. package/README.en.md +81 -0
  9. package/README.md +398 -0
  10. package/ROADMAP.md +40 -0
  11. package/SECURITY.md +53 -0
  12. package/dist/account-quota-card.js +233 -0
  13. package/dist/account-quota.js +125 -0
  14. package/dist/app-server-client.js +281 -0
  15. package/dist/card-session.js +166 -0
  16. package/dist/codex-events.js +1 -0
  17. package/dist/codex-runner.js +875 -0
  18. package/dist/config.js +198 -0
  19. package/dist/confirmation-card.js +135 -0
  20. package/dist/control-card.js +345 -0
  21. package/dist/conversation-turn-session.js +209 -0
  22. package/dist/data-maintenance.js +71 -0
  23. package/dist/device-card.js +460 -0
  24. package/dist/device-health.js +94 -0
  25. package/dist/diagnostics.js +253 -0
  26. package/dist/doctor.js +250 -0
  27. package/dist/fallback-card-session.js +37 -0
  28. package/dist/health-file.js +75 -0
  29. package/dist/index.js +4330 -0
  30. package/dist/lark-cli.js +558 -0
  31. package/dist/lark-retry.js +34 -0
  32. package/dist/maintenance.js +140 -0
  33. package/dist/model-capabilities.js +31 -0
  34. package/dist/onboarding-card.js +312 -0
  35. package/dist/permission-lease.js +22 -0
  36. package/dist/policy.js +506 -0
  37. package/dist/progress.js +267 -0
  38. package/dist/project-card.js +303 -0
  39. package/dist/project-overview-card.js +182 -0
  40. package/dist/project-overview.js +278 -0
  41. package/dist/project-policy.js +160 -0
  42. package/dist/project-registry.js +259 -0
  43. package/dist/project-status.js +45 -0
  44. package/dist/project-workspace.js +55 -0
  45. package/dist/quota-card.js +94 -0
  46. package/dist/recovery-policy.js +26 -0
  47. package/dist/redaction.js +67 -0
  48. package/dist/remote-ready.js +112 -0
  49. package/dist/response-card.js +139 -0
  50. package/dist/result-card.js +166 -0
  51. package/dist/review-card.js +452 -0
  52. package/dist/runbook-card.js +272 -0
  53. package/dist/runbooks.js +191 -0
  54. package/dist/runtime-card.js +337 -0
  55. package/dist/session-card.js +128 -0
  56. package/dist/session-naming.js +14 -0
  57. package/dist/smoke.js +28 -0
  58. package/dist/state-backup.js +302 -0
  59. package/dist/state-store.js +874 -0
  60. package/dist/task-card.js +640 -0
  61. package/dist/task-center-card.js +176 -0
  62. package/dist/task-failure.js +43 -0
  63. package/dist/task-intent.js +76 -0
  64. package/dist/task-queue.js +187 -0
  65. package/dist/task-reconciliation.js +80 -0
  66. package/dist/task-review.js +497 -0
  67. package/dist/team-card.js +275 -0
  68. package/dist/team-directory.js +54 -0
  69. package/dist/team-policy.js +93 -0
  70. package/dist/types.js +1 -0
  71. package/dist/version.js +9 -0
  72. package/dist/workspace-session.js +64 -0
  73. package/docs/ARCHITECTURE.md +54 -0
  74. package/docs/COMPATIBILITY.md +55 -0
  75. package/docs/CONFIGURATION.md +88 -0
  76. package/docs/DEMO.md +45 -0
  77. package/docs/GOOD_FIRST_ISSUES.md +23 -0
  78. package/docs/INSTALLATION.md +207 -0
  79. package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
  80. package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
  81. package/docs/RELEASE_CHECKLIST.md +65 -0
  82. package/docs/TEAM_DEPLOYMENT.md +35 -0
  83. package/docs/TROUBLESHOOTING.md +130 -0
  84. package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
  85. package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
  86. package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
  87. package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
  88. package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
  89. package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
  90. package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
  91. package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
  92. package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
  93. package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
  94. package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
  95. package/package.json +76 -0
  96. package/scripts/capability-probe.mjs +113 -0
  97. package/scripts/cli.mjs +919 -0
  98. package/scripts/config-file.mjs +137 -0
  99. package/scripts/discovery-lib.mjs +78 -0
  100. package/scripts/install-card.mjs +37 -0
  101. package/scripts/install-detection.mjs +126 -0
  102. package/scripts/install-state.mjs +107 -0
  103. package/scripts/launchd.mjs +161 -0
  104. package/scripts/migrate-legacy.mjs +97 -0
  105. package/scripts/package-smoke.mjs +163 -0
  106. package/scripts/release-dist-tag.mjs +7 -0
  107. package/scripts/runbook-template.mjs +36 -0
  108. package/scripts/service-health.mjs +110 -0
  109. package/scripts/service.mjs +24 -0
  110. package/scripts/setup-lib.mjs +118 -0
  111. package/scripts/systemd.mjs +96 -0
  112. package/scripts/upgrade-lib.mjs +99 -0
  113. package/scripts/verify-release.mjs +37 -0
@@ -0,0 +1,253 @@
1
+ import { chmod, lstat, mkdir, open, writeFile } from "node:fs/promises";
2
+ import { homedir, hostname, platform, release } from "node:os";
3
+ import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { readBridgeHealth, removeBridgeHealth } from "./health-file.js";
6
+ import { trimRuntimeLogs } from "./maintenance.js";
7
+ import { redactDiagnosticText, safeErrorText } from "./redaction.js";
8
+ export async function repairRuntime(config) {
9
+ const changed = [];
10
+ const warnings = [];
11
+ await ensurePrivateDirectory(config.dataDir, changed, "运行数据目录");
12
+ await ensurePrivateDirectory(path.join(config.dataDir, "log"), changed, "日志目录");
13
+ for (const file of [
14
+ config.databaseFile,
15
+ `${config.databaseFile}-wal`,
16
+ `${config.databaseFile}-shm`,
17
+ config.stateFile,
18
+ path.join(config.dataDir, "bridge-health.json"),
19
+ path.join(config.dataDir, "log", "bridge.log"),
20
+ path.join(config.dataDir, "log", "bridge.error.log"),
21
+ ]) {
22
+ await repairPrivateFile(file, changed, warnings);
23
+ }
24
+ try {
25
+ const health = await readBridgeHealth(config.dataDir);
26
+ if (health && !processIsAlive(health.pid) && Date.now() - Date.parse(health.updatedAt) > 45_000) {
27
+ await removeBridgeHealth(config.dataDir, health.pid);
28
+ changed.push("移除失效的服务健康标记");
29
+ }
30
+ }
31
+ catch (error) {
32
+ warnings.push(`健康标记未修复:${safeErrorText(error)}`);
33
+ }
34
+ try {
35
+ const trimmed = await trimRuntimeLogs(path.join(config.dataDir, "log"), config.maxLogBytes);
36
+ if (trimmed > 0)
37
+ changed.push(`轮转 ${trimmed} 个超限日志`);
38
+ }
39
+ catch (error) {
40
+ warnings.push(`日志轮转失败:${safeErrorText(error)}`);
41
+ }
42
+ return { changed, warnings };
43
+ }
44
+ export async function createDiagnosticBundle(config, checks, requestedFile) {
45
+ const directory = requestedFile
46
+ ? path.dirname(path.resolve(requestedFile))
47
+ : path.join(config.dataDir, "diagnostics");
48
+ await ensureDiagnosticDirectory(directory);
49
+ const file = requestedFile
50
+ ? path.resolve(requestedFile)
51
+ : path.join(directory, `diagnostic-${timestamp(new Date())}.json`);
52
+ await assertSafeOptionalFile(file);
53
+ const health = await readBridgeHealth(config.dataDir).catch((error) => ({
54
+ unavailable: safeErrorText(error),
55
+ }));
56
+ const database = await databaseSummary(config.databaseFile);
57
+ const logs = {
58
+ stdout: await readTail(path.join(config.dataDir, "log", "bridge.log"), 32 * 1024),
59
+ stderr: await readTail(path.join(config.dataDir, "log", "bridge.error.log"), 32 * 1024),
60
+ };
61
+ const report = sanitizeDiagnosticValue({
62
+ version: 1,
63
+ createdAt: new Date().toISOString(),
64
+ product: "feishu-codex-console",
65
+ runtime: {
66
+ node: process.versions.node,
67
+ platform: `${platform()} ${release()}`,
68
+ hostname: hostname(),
69
+ pid: process.pid,
70
+ },
71
+ config: {
72
+ configVersion: config.configVersion,
73
+ instanceId: config.instanceId,
74
+ dataDir: config.dataDir,
75
+ workdir: config.workdir,
76
+ projectRoots: config.projectRoots,
77
+ sandboxMaximum: config.sandboxMode,
78
+ operatorSandboxMaximum: config.operatorSandboxMode,
79
+ networkEnabled: config.networkAccessEnabled,
80
+ webSearchMode: config.webSearchMode,
81
+ administrators: config.adminSenderIds.size,
82
+ operators: config.allowedSenderIds.size,
83
+ viewers: config.viewerSenderIds.size,
84
+ chatAllowlistEntries: config.allowedChatIds.size,
85
+ projectAclEntries: config.projectAcl.size,
86
+ maxConcurrentTasks: config.maxConcurrentTasks,
87
+ },
88
+ checks,
89
+ health,
90
+ database,
91
+ logs,
92
+ });
93
+ await writeFile(file, `${JSON.stringify(report, null, 2)}\n`, {
94
+ encoding: "utf8",
95
+ mode: 0o600,
96
+ flag: "wx",
97
+ });
98
+ await chmod(file, 0o600);
99
+ return file;
100
+ }
101
+ async function databaseSummary(file) {
102
+ try {
103
+ const details = await lstat(file);
104
+ if (!details.isFile() || details.isSymbolicLink()) {
105
+ return { exists: true, error: "unsafe database path" };
106
+ }
107
+ const database = new DatabaseSync(file, { readOnly: true });
108
+ try {
109
+ const integrity = database.prepare("PRAGMA quick_check").get();
110
+ const row = database.prepare("SELECT payload FROM bridge_state WHERE id = 1").get();
111
+ const state = row?.payload ? JSON.parse(row.payload) : {};
112
+ const tasks = Object.values(state.tasks ?? {});
113
+ const statuses = {};
114
+ for (const task of tasks) {
115
+ const status = task.status ?? "unknown";
116
+ statuses[status] = (statuses[status] ?? 0) + 1;
117
+ }
118
+ return {
119
+ exists: true,
120
+ sizeBytes: details.size,
121
+ integrity: integrity?.quick_check ?? "unknown",
122
+ stateVersion: state.version ?? "unknown",
123
+ taskStatuses: statuses,
124
+ };
125
+ }
126
+ finally {
127
+ database.close();
128
+ }
129
+ }
130
+ catch (error) {
131
+ if (error.code === "ENOENT")
132
+ return { exists: false };
133
+ return { exists: true, error: safeErrorText(error) };
134
+ }
135
+ }
136
+ async function readTail(file, maxBytes) {
137
+ try {
138
+ const details = await lstat(file);
139
+ if (!details.isFile() || details.isSymbolicLink())
140
+ return "[unsafe log path]";
141
+ const length = Math.min(details.size, maxBytes);
142
+ const buffer = Buffer.alloc(length);
143
+ const handle = await open(file, "r");
144
+ try {
145
+ await handle.read(buffer, 0, length, Math.max(0, details.size - length));
146
+ }
147
+ finally {
148
+ await handle.close();
149
+ }
150
+ return buffer.toString("utf8");
151
+ }
152
+ catch (error) {
153
+ if (error.code === "ENOENT")
154
+ return "";
155
+ return `[unavailable: ${safeErrorText(error)}]`;
156
+ }
157
+ }
158
+ async function ensurePrivateDirectory(directory, changed, label) {
159
+ let created = false;
160
+ try {
161
+ const details = await lstat(directory);
162
+ if (!details.isDirectory() || details.isSymbolicLink()) {
163
+ throw new Error(`${label}不是安全的普通目录:${directory}`);
164
+ }
165
+ }
166
+ catch (error) {
167
+ if (error.code !== "ENOENT")
168
+ throw error;
169
+ await mkdir(directory, { recursive: true, mode: 0o700 });
170
+ created = true;
171
+ }
172
+ const details = await lstat(directory);
173
+ if ((details.mode & 0o777) !== 0o700) {
174
+ await chmod(directory, 0o700);
175
+ changed.push(`修复 ${label} 权限为 0700`);
176
+ }
177
+ else if (created) {
178
+ changed.push(`创建${label}`);
179
+ }
180
+ }
181
+ async function repairPrivateFile(file, changed, warnings) {
182
+ try {
183
+ const details = await lstat(file);
184
+ if (!details.isFile() || details.isSymbolicLink()) {
185
+ warnings.push(`跳过不安全路径:${file}`);
186
+ return;
187
+ }
188
+ if ((details.mode & 0o777) !== 0o600) {
189
+ await chmod(file, 0o600);
190
+ changed.push(`修复 ${path.basename(file)} 权限为 0600`);
191
+ }
192
+ }
193
+ catch (error) {
194
+ if (error.code !== "ENOENT") {
195
+ warnings.push(`无法检查 ${path.basename(file)}:${safeErrorText(error)}`);
196
+ }
197
+ }
198
+ }
199
+ async function ensureDiagnosticDirectory(directory) {
200
+ try {
201
+ const details = await lstat(directory);
202
+ if (!details.isDirectory() || details.isSymbolicLink()) {
203
+ throw new Error(`诊断目录不是安全的普通目录:${directory}`);
204
+ }
205
+ }
206
+ catch (error) {
207
+ if (error.code !== "ENOENT")
208
+ throw error;
209
+ await mkdir(directory, { recursive: true, mode: 0o700 });
210
+ }
211
+ await chmod(directory, 0o700);
212
+ }
213
+ async function assertSafeOptionalFile(file) {
214
+ try {
215
+ const details = await lstat(file);
216
+ if (!details.isFile() || details.isSymbolicLink()) {
217
+ throw new Error(`诊断文件路径不安全:${file}`);
218
+ }
219
+ throw new Error(`诊断文件已存在,请选择新路径:${file}`);
220
+ }
221
+ catch (error) {
222
+ if (error.code !== "ENOENT")
223
+ throw error;
224
+ }
225
+ }
226
+ function sanitizeDiagnosticValue(value) {
227
+ if (typeof value === "string") {
228
+ return redactDiagnosticText(value, { homeDirectory: homedir(), maxChars: 64_000 });
229
+ }
230
+ if (Array.isArray(value))
231
+ return value.map(sanitizeDiagnosticValue);
232
+ if (typeof value === "object" && value !== null) {
233
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
234
+ key,
235
+ sanitizeDiagnosticValue(entry),
236
+ ]));
237
+ }
238
+ return value;
239
+ }
240
+ function processIsAlive(pid) {
241
+ if (!Number.isSafeInteger(pid) || pid <= 0)
242
+ return false;
243
+ try {
244
+ process.kill(pid, 0);
245
+ return true;
246
+ }
247
+ catch (error) {
248
+ return error.code === "EPERM";
249
+ }
250
+ }
251
+ function timestamp(value) {
252
+ return value.toISOString().replace(/[:.]/g, "-");
253
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,250 @@
1
+ import "dotenv/config";
2
+ import { spawn } from "node:child_process";
3
+ import { access } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ import { loadConfig } from "./config.js";
7
+ import { createDiagnosticBundle, repairRuntime, } from "./diagnostics.js";
8
+ import { ProjectRegistry } from "./project-registry.js";
9
+ import { installConsoleRedaction, redactDiagnosticText, safeErrorText, } from "./redaction.js";
10
+ installConsoleRedaction();
11
+ async function main() {
12
+ const options = parseDoctorOptions(process.argv.slice(2));
13
+ const results = [];
14
+ let config;
15
+ try {
16
+ config = loadConfig();
17
+ results.push({
18
+ label: "配置",
19
+ status: "ok",
20
+ detail: `${config.sandboxMode} · 并发 ${config.maxConcurrentTasks} · 附件保留 ${config.attachmentRetentionHours} 小时 · 新手引导${config.autoOnboarding ? "自动" : "手动"}`,
21
+ });
22
+ if (config.sandboxMode === "danger-full-access" && config.allowedChatIds.size === 0) {
23
+ results.push({
24
+ label: "群聊门禁",
25
+ status: "warning",
26
+ detail: "完全访问模式未配置聊天白名单;私聊可用,群聊将自动拒绝",
27
+ });
28
+ }
29
+ else {
30
+ results.push({
31
+ label: "群聊门禁",
32
+ status: "ok",
33
+ detail: config.allowedChatIds.size > 0 ? "已配置聊天白名单" : "当前非完全访问模式",
34
+ });
35
+ }
36
+ results.push({
37
+ label: "团队权限",
38
+ status: "ok",
39
+ detail: `管理员 ${config.adminSenderIds.size} · 操作者 ${config.allowedSenderIds.size} · 只读 ${config.viewerSenderIds.size} · 友好名称 ${config.memberLabels.size} · 群聊按${config.groupSessionScope === "member" ? "成员" : "整群"}隔离`,
40
+ });
41
+ results.push({
42
+ label: "项目 ACL",
43
+ status: config.projectAcl.size > 0 ? "ok" : "warning",
44
+ detail: config.projectAcl.size > 0
45
+ ? `已配置 ${config.projectAcl.size} 条项目访问规则`
46
+ : "未配置时,所有授权成员可看到全部已发现项目",
47
+ });
48
+ results.push({
49
+ label: "权限上限",
50
+ status: "ok",
51
+ detail: `管理员 ${config.sandboxMode} · 普通操作者 ${config.operatorSandboxMode}`,
52
+ });
53
+ results.push({
54
+ label: "环境隔离",
55
+ status: "ok",
56
+ detail: `敏感环境变量默认不传给 Codex;额外放行 ${config.codexAllowedEnvVars.length} 个`,
57
+ });
58
+ }
59
+ catch (error) {
60
+ results.push({ label: "配置", status: "failed", detail: safeErrorText(error) });
61
+ print(results);
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ if (options.fix) {
66
+ try {
67
+ const repair = await repairRuntime(config);
68
+ results.push({
69
+ label: "自动修复",
70
+ status: repair.warnings.length > 0 ? "warning" : "ok",
71
+ detail: [
72
+ repair.changed.length > 0 ? repair.changed.join(";") : "没有发现可自动修复的问题",
73
+ ...repair.warnings,
74
+ ].join(";"),
75
+ });
76
+ }
77
+ catch (error) {
78
+ results.push({ label: "自动修复", status: "failed", detail: safeErrorText(error) });
79
+ }
80
+ }
81
+ const lark = await run(config.larkCliPath, ["whoami", "--as", "bot"]);
82
+ results.push({
83
+ label: "飞书 Bot",
84
+ status: lark.code === 0 ? "ok" : "failed",
85
+ detail: lark.code === 0 ? "应用身份可用" : "lark-cli Bot 身份不可用",
86
+ });
87
+ const localCodex = config.codexCliPath ?? path.join(config.projectDir, "node_modules", ".bin", "codex");
88
+ const codex = await run(localCodex, ["login", "status"]);
89
+ results.push({
90
+ label: "Codex",
91
+ status: codex.code === 0 ? "ok" : "failed",
92
+ detail: codex.code === 0 ? codex.stdout.trim() || "登录态可用" : "本机 Codex 登录态不可用",
93
+ });
94
+ try {
95
+ await access(path.dirname(config.databaseFile));
96
+ results.push({
97
+ label: "可靠状态",
98
+ status: "ok",
99
+ detail: `SQLite WAL · ${path.relative(config.projectDir, config.databaseFile)}`,
100
+ });
101
+ }
102
+ catch {
103
+ results.push({
104
+ label: "可靠状态",
105
+ status: "warning",
106
+ detail: "数据库目录尚未创建,首次启动时会自动创建",
107
+ });
108
+ }
109
+ if (process.platform === "darwin") {
110
+ try {
111
+ await access("/usr/bin/caffeinate");
112
+ results.push({
113
+ label: "远程就绪",
114
+ status: "ok",
115
+ detail: "macOS caffeinate 可用,可从飞书设备卡开启",
116
+ });
117
+ }
118
+ catch {
119
+ results.push({
120
+ label: "远程就绪",
121
+ status: "warning",
122
+ detail: "未找到 /usr/bin/caffeinate",
123
+ });
124
+ }
125
+ }
126
+ try {
127
+ await access(config.workdir);
128
+ const registry = new ProjectRegistry(config.projectRoots, config.workdir, config.projectScanDepth, config.maxProjects, config.syncSavedProjects ? config.codexProjectStateFile : undefined);
129
+ const projects = await registry.refresh();
130
+ results.push({
131
+ label: "项目",
132
+ status: projects.length > 0 ? "ok" : "failed",
133
+ detail: `发现 ${projects.length} 个可用项目`,
134
+ });
135
+ }
136
+ catch (error) {
137
+ results.push({ label: "项目", status: "failed", detail: safeErrorText(error) });
138
+ }
139
+ if (process.platform === "darwin") {
140
+ const service = await run("launchctl", [
141
+ "print",
142
+ `gui/${process.getuid?.() ?? 0}/${config.serviceLabel}`,
143
+ ]);
144
+ results.push({
145
+ label: "后台守护",
146
+ status: service.code === 0 ? "ok" : "warning",
147
+ detail: service.code === 0 ? "LaunchAgent 正在运行" : "尚未安装,可运行 npm run service:install",
148
+ });
149
+ }
150
+ else if (process.platform === "linux") {
151
+ const service = await run("systemctl", [
152
+ "--user",
153
+ "is-active",
154
+ `feishu-codex-bridge-${config.instanceId}.service`,
155
+ ]);
156
+ results.push({
157
+ label: "后台守护",
158
+ status: service.code === 0 ? "ok" : "warning",
159
+ detail: service.code === 0 ? "systemd user service 正在运行" : "尚未安装,可运行 npm run service:install",
160
+ });
161
+ }
162
+ if (options.diagnostics) {
163
+ try {
164
+ const diagnosticFile = await createDiagnosticBundle(config, results, options.diagnostics);
165
+ results.push({
166
+ label: "诊断包",
167
+ status: "ok",
168
+ detail: `已生成脱敏诊断包:${diagnosticFile}`,
169
+ });
170
+ }
171
+ catch (error) {
172
+ results.push({ label: "诊断包", status: "failed", detail: safeErrorText(error) });
173
+ }
174
+ }
175
+ print(results);
176
+ if (results.some((result) => result.status === "failed"))
177
+ process.exitCode = 1;
178
+ }
179
+ function run(command, args, timeoutMs = 15_000) {
180
+ return new Promise((resolve) => {
181
+ const child = spawn(command, args, {
182
+ cwd: process.cwd(),
183
+ env: process.env,
184
+ shell: false,
185
+ stdio: ["ignore", "pipe", "ignore"],
186
+ });
187
+ let stdout = "";
188
+ let settled = false;
189
+ let forceKillTimer;
190
+ const finish = (code) => {
191
+ if (settled)
192
+ return;
193
+ settled = true;
194
+ clearTimeout(timeout);
195
+ if (forceKillTimer)
196
+ clearTimeout(forceKillTimer);
197
+ resolve({ code, stdout });
198
+ };
199
+ const timeout = setTimeout(() => {
200
+ child.kill("SIGTERM");
201
+ forceKillTimer = setTimeout(() => {
202
+ child.kill("SIGKILL");
203
+ finish(null);
204
+ }, 2_000);
205
+ forceKillTimer.unref();
206
+ }, timeoutMs);
207
+ timeout.unref();
208
+ child.stdout.setEncoding("utf8");
209
+ child.stdout.on("data", (chunk) => {
210
+ stdout += chunk;
211
+ });
212
+ child.once("error", () => finish(null));
213
+ child.once("exit", (code) => finish(code));
214
+ });
215
+ }
216
+ function print(results) {
217
+ console.log("Feishu Codex Bridge V5 自检");
218
+ for (const result of results) {
219
+ const marker = result.status === "ok" ? "通过" : result.status === "warning" ? "提醒" : "失败";
220
+ console.log(`${marker.padEnd(4)} ${result.label}:${redactDiagnosticText(result.detail, {
221
+ homeDirectory: homedir(),
222
+ maxChars: 2_000,
223
+ })}`);
224
+ }
225
+ }
226
+ function parseDoctorOptions(args) {
227
+ const options = { fix: false };
228
+ for (let index = 0; index < args.length; index += 1) {
229
+ const value = args[index];
230
+ if (value === "--fix") {
231
+ options.fix = true;
232
+ continue;
233
+ }
234
+ if (value === "--diagnostics") {
235
+ const target = args[index + 1];
236
+ if (!target || target.startsWith("--")) {
237
+ throw new Error("--diagnostics 需要指定输出 JSON 文件");
238
+ }
239
+ options.diagnostics = target;
240
+ index += 1;
241
+ continue;
242
+ }
243
+ throw new Error(`无法识别 doctor 参数:${value}`);
244
+ }
245
+ return options;
246
+ }
247
+ void main().catch((error) => {
248
+ console.error(`doctor 无法启动:${safeErrorText(error)}`);
249
+ process.exitCode = 2;
250
+ });
@@ -0,0 +1,37 @@
1
+ import { renderResponseCard } from "./response-card.js";
2
+ export class FallbackCardSession {
3
+ lark;
4
+ cardId;
5
+ messageId;
6
+ sequence;
7
+ constructor(lark, cardId, messageId, sequence = 0) {
8
+ this.lark = lark;
9
+ this.cardId = cardId;
10
+ this.messageId = messageId;
11
+ this.sequence = sequence;
12
+ }
13
+ static async create(lark, replyToMessageId, text, phase, idempotencyKey) {
14
+ const cardId = await lark.createCard(renderResponseCard(text, phase));
15
+ const messageId = await lark.replyCard(replyToMessageId, cardId, idempotencyKey);
16
+ if (!messageId)
17
+ throw new Error("飞书没有返回产品回复卡消息 ID");
18
+ return new FallbackCardSession(lark, cardId, messageId);
19
+ }
20
+ static restore(lark, cardId, messageId, sequence = 0) {
21
+ return new FallbackCardSession(lark, cardId, messageId, sequence);
22
+ }
23
+ get sequenceNumber() {
24
+ return this.sequence;
25
+ }
26
+ async update(text, phase) {
27
+ try {
28
+ this.sequence += 1;
29
+ await this.lark.updateCard(this.cardId, renderResponseCard(text, phase), this.sequence);
30
+ return true;
31
+ }
32
+ catch (error) {
33
+ console.error(`[card] fallback update failed card=${this.cardId}`, error);
34
+ return false;
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,75 @@
1
+ import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const BRIDGE_HEALTH_FILE = "bridge-health.json";
4
+ export function bridgeHealthPath(dataDir) {
5
+ return path.join(dataDir, BRIDGE_HEALTH_FILE);
6
+ }
7
+ export async function writeBridgeHealth(dataDir, input) {
8
+ await ensurePrivateDirectory(dataDir);
9
+ const target = bridgeHealthPath(dataDir);
10
+ await assertSafeOptionalFile(target);
11
+ const snapshot = {
12
+ version: 1,
13
+ ...input,
14
+ updatedAt: new Date().toISOString(),
15
+ };
16
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}`;
17
+ await writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, {
18
+ encoding: "utf8",
19
+ mode: 0o600,
20
+ flag: "wx",
21
+ });
22
+ await chmod(temporary, 0o600);
23
+ await rename(temporary, target);
24
+ await chmod(target, 0o600);
25
+ return snapshot;
26
+ }
27
+ export async function readBridgeHealth(dataDir) {
28
+ const target = bridgeHealthPath(dataDir);
29
+ try {
30
+ const details = await lstat(target);
31
+ if (!details.isFile() || details.isSymbolicLink()) {
32
+ throw new Error(`Unsafe bridge health file: ${target}`);
33
+ }
34
+ return JSON.parse(await readFile(target, "utf8"));
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return null;
39
+ throw error;
40
+ }
41
+ }
42
+ export async function removeBridgeHealth(dataDir, ownerPid) {
43
+ if (ownerPid !== undefined) {
44
+ const current = await readBridgeHealth(dataDir);
45
+ if (current && current.pid !== ownerPid)
46
+ return;
47
+ }
48
+ await rm(bridgeHealthPath(dataDir), { force: true });
49
+ }
50
+ async function ensurePrivateDirectory(directory) {
51
+ try {
52
+ const details = await lstat(directory);
53
+ if (!details.isDirectory() || details.isSymbolicLink()) {
54
+ throw new Error(`Unsafe bridge data directory: ${directory}`);
55
+ }
56
+ }
57
+ catch (error) {
58
+ if (error.code !== "ENOENT")
59
+ throw error;
60
+ await mkdir(directory, { recursive: true, mode: 0o700 });
61
+ }
62
+ await chmod(directory, 0o700);
63
+ }
64
+ async function assertSafeOptionalFile(file) {
65
+ try {
66
+ const details = await lstat(file);
67
+ if (!details.isFile() || details.isSymbolicLink()) {
68
+ throw new Error(`Unsafe bridge health file: ${file}`);
69
+ }
70
+ }
71
+ catch (error) {
72
+ if (error.code !== "ENOENT")
73
+ throw error;
74
+ }
75
+ }