@lark-codem/codem-cli 0.1.234-dev-browser-use-review.100102657797.0.g7a31979 → 0.1.234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ // 设备身份(device_id / device_secret)的唯一读取与归一实现(叶子模块,无内部依赖)。
2
+ //
3
+ // 为什么要有这个模块:在此之前 device_id 的读取被手写了四份(index.js 绑定路径、
4
+ // login-state.js 的 isLoggedInConfig、config-store.js 的 logout 墓碑、
5
+ // daemon-post-client.ts 的自动重绑),各自认的形状还不一样——绑定路径认四个位置,
6
+ // 墓碑只认两个。这种漂移是必然的:读侧每遇到一次「读不到」就加宽一次容忍度,
7
+ // 写侧却从没收敛,于是容忍度成了口头约定而不是一个函数。
8
+ //
9
+ // 事实核对(git 考古):config.json 里 device_id 的**唯一**真实写入形状是嵌套
10
+ // camelCase `device.deviceId`,自 initial commit 起从未变过。snake_case 只存在于
11
+ // 服务端 JSON 响应,在 API 边界就被归一成字符串了,从未整体落盘;顶层
12
+ // `config.deviceId` / `config.device_id` 更是从来没有任何写入代码产生过——它们是
13
+ // 防御性代码的化石。所以这里只保留嵌套两种:camelCase 是真实形状,snake_case 是
14
+ // 对「服务端响应被整体 spread 进配置」这一真实可能性的收口。顶层形状已删除。
15
+ //
16
+ // 新增形状请改这里,不要在调用点就地手写回退链。
17
+
18
+ function firstString(...values) {
19
+ for (const value of values) {
20
+ if (typeof value === "string" && value.trim()) {
21
+ return value;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ function deviceOf(config) {
28
+ return config?.device && typeof config.device === "object" && !Array.isArray(config.device)
29
+ ? config.device
30
+ : null;
31
+ }
32
+
33
+ /** 从 config 读设备 ID;读不到返回 null("本机确实没有设备",不表示"读失败")。 */
34
+ export function readDeviceId(config = null) {
35
+ const device = deviceOf(config);
36
+ return device ? firstString(device.deviceId, device.device_id) : null;
37
+ }
38
+
39
+ /** 从 config 读设备密钥。logout 墓碑只留 deviceId,故此处会返回 null。 */
40
+ export function readDeviceSecret(config = null) {
41
+ const device = deviceOf(config);
42
+ return device ? firstString(device.deviceSecret, device.device_secret) : null;
43
+ }
44
+
45
+ /**
46
+ * 判定本次设备绑定的意图,供 bind_reason 上报与用户提示使用。
47
+ *
48
+ * rekey 读到了本地锚点,期望服务端复用同一条设备记录
49
+ * first_bind 本机从未绑定过设备,新建是预期行为
50
+ * anchor_missing ⚠️ 有登录过的痕迹、却读不到 device_id:不该发生
51
+ *
52
+ * 判据是「**曾登录过的痕迹**」而不是「配置文件是否存在」。后者会把合法首次登录误
53
+ * 判成异常:`codem channel test` 等命令在登录前就会写出一份合法配置(device 为
54
+ * null),此时报「历史工作目录仍挂在原设备下」纯属虚惊。
55
+ *
56
+ * 用 lastLoginUser / auth.oauth 而不是新加一个持久标记,是因为它们对存量用户天然
57
+ * 成立、无需迁移,且都由 buildBaseConfig 透传保留 —— 包括 logout(clearLoginIdentity
58
+ * 只清 user 和 auth.oauth,lastLoginUser 保留)。所以「device 被误抹成 null」这个
59
+ * 事故形态仍会被正确判成 anchor_missing,不会被这次修正掩盖掉。
60
+ */
61
+ export function resolveBindReason(config = null) {
62
+ if (readDeviceId(config)) {
63
+ return "rekey";
64
+ }
65
+ const everLoggedIn = Boolean(
66
+ config?.lastLoginUser || config?.auth?.oauth
67
+ );
68
+ return everLoggedIn ? "anchor_missing" : "first_bind";
69
+ }
70
+
71
+ /**
72
+ * 把 device 子树归一到唯一的存储形状(camelCase),供 buildBaseConfig 在每次写盘
73
+ * 时收敛。device 曾是 buildBaseConfig 里唯一被跳过归一化的字段(纯透传),这正是
74
+ * 读侧兜底永远删不掉的原因——没有 normalize 就无法证明删掉兜底是安全的。
75
+ */
76
+ export function normalizeDeviceConfig(device = null) {
77
+ if (!device || typeof device !== "object" || Array.isArray(device)) {
78
+ return null;
79
+ }
80
+ const { device_id, device_secret, ...rest } = device;
81
+ const deviceId = firstString(device.deviceId, device_id);
82
+ const deviceSecret = firstString(device.deviceSecret, device_secret);
83
+ const normalized = { ...rest };
84
+ if (deviceId) {
85
+ normalized.deviceId = deviceId;
86
+ } else {
87
+ delete normalized.deviceId;
88
+ }
89
+ if (deviceSecret) {
90
+ normalized.deviceSecret = deviceSecret;
91
+ } else {
92
+ delete normalized.deviceSecret;
93
+ }
94
+ return normalized;
95
+ }
@@ -0,0 +1,47 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const VALID = /^cm-[a-f0-9]{16}$/u;
7
+ let cached;
8
+
9
+ // A local support identifier, never an authentication/device-binding identity.
10
+ // Exclusive creation avoids overwriting another process's ID. A failed or
11
+ // interrupted write only loses persistence; diagnostics never gate startup.
12
+ export function resolveDiagnosticId({ env = process.env, io = fs, home = os.homedir, random = randomBytes } = {}) {
13
+ try {
14
+ const inherited = env.CODEM_DIAGNOSTIC_ID;
15
+ if (VALID.test(inherited ?? "")) return inherited;
16
+ } catch { /* Unavailable environment must not gate startup. */ }
17
+ let generated;
18
+ try { generated = `cm-${random(8).toString("hex")}`; }
19
+ catch {
20
+ // Correlation only: entropy failure must not become a startup failure.
21
+ generated = `cm-${Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("")}`;
22
+ }
23
+ try {
24
+ const root = env.CODEM_HOME || env.LINCO_HOME || path.join(home(), ".codem");
25
+ const file = path.join(root, "diagnostic-id");
26
+ const read = () => {
27
+ if (io.statSync(file).size > 64) return undefined;
28
+ const value = io.readFileSync(file, "utf8").trim();
29
+ return VALID.test(value) ? value : undefined;
30
+ };
31
+ try { const existing = read(); if (existing) return existing; } catch { /* first run */ }
32
+ io.mkdirSync(root, { recursive: true });
33
+ try { io.writeFileSync(file, `${generated}\n`, { flag: "wx", mode: 0o600 }); }
34
+ catch { return read() ?? generated; }
35
+ } catch { /* Read-only or unavailable local storage: process-local fallback. */ }
36
+ return generated;
37
+ }
38
+
39
+ export function diagnosticId() {
40
+ if (!cached) {
41
+ cached = resolveDiagnosticId();
42
+ // Children, including a restarted CLI and Core, inherit the same fallback
43
+ // even when local storage is unavailable.
44
+ try { process.env.CODEM_DIAGNOSTIC_ID = cached; } catch { /* Child spawn also receives the cached ID explicitly. */ }
45
+ }
46
+ return cached;
47
+ }
@@ -0,0 +1,61 @@
1
+ export interface ProfileAccount {
2
+ readonly userId: string
3
+ readonly tenantId: string
4
+ readonly displayName: string | null
5
+ readonly avatarUrl: string | null
6
+ }
7
+ export interface ProfileSnapshot {
8
+ readonly schemaVersion: 1
9
+ readonly user: { readonly userId: string | null; readonly tenantId: string | null; readonly displayName: string | null; readonly avatarUrl: string | null }
10
+ readonly serverUrl: string | null
11
+ readonly account: ProfileAccount | null
12
+ readonly device: { readonly id: string; readonly name: string | null; readonly binding: 'ready' | 'missing' } | null
13
+ readonly identityRevision: string | null
14
+ }
15
+ export interface ProfileWriterBlocker {
16
+ readonly kind: 'app-process' | 'cli-process' | 'codem-process' | 'session-handle'
17
+ readonly processId: number | null
18
+ readonly processName: string | null
19
+ }
20
+ export type ProfileSetupChoice = { readonly kind: 'migrate'; readonly home: string } | { readonly kind: 'new' | 'defer' | 'resume' }
21
+ export interface ProfileSetupSnapshot {
22
+ readonly stage: 'choice' | 'welcome' | 'working' | 'blocked'
23
+ readonly situation: 'accounts' | 'devices' | 'recovery' | 'desktop-data'
24
+ readonly targetHome: string
25
+ readonly environment: string
26
+ readonly backupHome: string
27
+ readonly profiles: {
28
+ readonly home: string
29
+ readonly accountKey: string | null
30
+ readonly accountLabel: string
31
+ readonly deviceId: string | null
32
+ readonly status: 'available' | 'signed-out' | 'recovery' | 'other-environment'
33
+ readonly issue: string | null
34
+ }[]
35
+ readonly recommendedHome: string
36
+ readonly busy: boolean
37
+ readonly blockers: ProfileWriterBlocker[]
38
+ readonly notice: string | null
39
+ readonly diagnosticId: string | null
40
+ }
41
+ export interface ProfileMigrationOptions {
42
+ readonly mode: 'automatic' | 'maintenance'
43
+ readonly targetHome: string
44
+ readonly legacyHomes: readonly string[]
45
+ readonly backupHome: string
46
+ readonly serverOrigin: string
47
+ readonly migrateSessions: (sessionRoots: readonly string[]) => Promise<void>
48
+ readonly choose: (snapshot: ProfileSetupSnapshot) => Promise<ProfileSetupChoice>
49
+ }
50
+ export type ProfileMigrationResult = { readonly kind: 'ready'; readonly backupHome: string | null; readonly legacyCleanupPending: boolean } | { readonly kind: 'defer' }
51
+ export class CodeMProfileMigrationError extends Error {}
52
+ export class CodeMProfileMigrationBlockedError extends CodeMProfileMigrationError {
53
+ readonly code: 'CODEM_PROFILE_MIGRATION_BLOCKED'
54
+ readonly reason: 'daemon-active' | 'session-writer-active' | 'writer-inspection-failed'
55
+ readonly blockers: readonly ProfileWriterBlocker[]
56
+ constructor(detail: string, reason: CodeMProfileMigrationBlockedError['reason'], blockers: readonly ProfileWriterBlocker[], options?: ErrorOptions)
57
+ }
58
+ export function readProfileSnapshot(home: string): Promise<ProfileSnapshot>
59
+ export function canonicalCodeMProfileHome(home: string): Promise<string>
60
+ export function migrateCodeMProfile(options: ProfileMigrationOptions): Promise<ProfileMigrationResult>
61
+ export function withProfileSessionMaintenance<T>(home: string, operation: () => Promise<T>): Promise<T>
@@ -0,0 +1,17 @@
1
+ export { readProfileSnapshot } from "./profile-snapshot.js";
2
+ export {
3
+ canonicalCodeMProfileHome,
4
+ migrateCodeMProfile,
5
+ CodeMProfileMigrationError,
6
+ CodeMProfileMigrationBlockedError
7
+ } from "./profile-migration.js";
8
+ import { join } from "node:path";
9
+ import { withCodeMConfigLock } from "./profile-io.js";
10
+ import { requireIdleProfileSessions } from "./profile-writers.js";
11
+
12
+ export async function withProfileSessionMaintenance(home, operation) {
13
+ return withCodeMConfigLock(join(home, "config.json"), async () => {
14
+ await requireIdleProfileSessions(home);
15
+ return operation();
16
+ });
17
+ }
@@ -0,0 +1,70 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, open, rename, rm } from "node:fs/promises";
4
+ import { basename, dirname, join, posix, win32 } from "node:path";
5
+ import { acquireConfigLock } from "./config-lock.js";
6
+
7
+ export async function withCodeMConfigLock(path, operation) {
8
+ const release = await acquireConfigLock(path);
9
+ try { return await operation(); } finally { await release(); }
10
+ }
11
+
12
+ export function probeProcessLiveness(pid) {
13
+ try { process.kill(pid, 0); return { kind: "alive" }; }
14
+ catch (cause) { return cause.code === "ESRCH" ? { kind: "missing" } : { kind: "unavailable", cause }; }
15
+ }
16
+
17
+ export function samePathForPlatform(left, right, platform) {
18
+ const api = platform === "win32" ? win32 : posix;
19
+ const normalize = value => platform === "win32" ? api.resolve(value).toLowerCase() : api.resolve(value);
20
+ return normalize(left) === normalize(right);
21
+ }
22
+
23
+ export async function writeFileAtomically(path, contents, label) {
24
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
25
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
26
+ try {
27
+ const file = await open(temporary, "wx", 0o600);
28
+ try { await file.writeFile(contents); await file.sync(); }
29
+ finally { await file.close(); }
30
+ await rename(temporary, path);
31
+ } catch (cause) {
32
+ await rm(temporary, { force: true }).catch(() => undefined);
33
+ throw new Error(`Cannot persist ${label}`, { cause });
34
+ }
35
+ }
36
+
37
+ export function writeJsonFileAtomically(path, value, label) {
38
+ return writeFileAtomically(path, `${JSON.stringify(value, null, 2)}\n`, label);
39
+ }
40
+
41
+ export function runProcessResult(options) {
42
+ return new Promise(resolve => {
43
+ execFile(options.executable, options.arguments, {
44
+ timeout: options.timeoutMilliseconds,
45
+ maxBuffer: options.maximumOutputBytes,
46
+ windowsHide: true,
47
+ encoding: "utf8"
48
+ }, (error, stdout, stderr) => resolve({
49
+ stdout, stderr,
50
+ termination: error
51
+ ? typeof error.code === "number"
52
+ ? { kind: "exit", exitCode: error.code }
53
+ : { kind: "failure" }
54
+ : { kind: "exit", exitCode: 0 }
55
+ }));
56
+ });
57
+ }
58
+
59
+ export function isProcessExit(outcome, code) {
60
+ return outcome.termination.kind === "exit" && outcome.termination.exitCode === code;
61
+ }
62
+
63
+ export function requireProcessSuccess(outcome) {
64
+ if (!isProcessExit(outcome, 0)) throw new Error("Profile writer inspection failed");
65
+ return outcome;
66
+ }
67
+
68
+ export async function runProcess(options) {
69
+ return requireProcessSuccess(await runProcessResult(options));
70
+ }