@yijiancaotang/cli 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -2
- package/bin/caotang.cjs +15 -0
- package/dist/agent-identity.js +14 -0
- package/dist/auth.js +337 -0
- package/dist/capability-result.js +80 -0
- package/dist/cli.js +248 -0
- package/dist/command-help.js +100 -0
- package/dist/commands.js +350 -0
- package/dist/config.js +14 -0
- package/dist/connection-copy.js +40 -0
- package/dist/context.js +30 -0
- package/dist/contracts.js +1 -0
- package/dist/create-bundle.js +103 -0
- package/dist/create-file-failure.js +18 -0
- package/dist/credential-store.js +594 -0
- package/dist/doctor.js +214 -0
- package/dist/errors.js +214 -0
- package/dist/generated/knowledge-growth.js +37 -0
- package/dist/habit-day.js +58 -0
- package/dist/habit-read.js +30 -0
- package/dist/habit-wire.js +56 -0
- package/dist/http.js +201 -0
- package/dist/input-validation.js +76 -0
- package/dist/installation.js +491 -0
- package/dist/knowledge-growth.js +85 -0
- package/dist/local-commands.js +136 -0
- package/dist/local-lock.js +245 -0
- package/dist/local-types.js +3 -0
- package/dist/missing-sections.js +71 -0
- package/dist/onboarding.js +48 -0
- package/dist/output.js +52 -0
- package/dist/pending-requests.js +45 -0
- package/dist/platforms.js +200 -0
- package/dist/query-daily.js +70 -0
- package/dist/query-search.js +42 -0
- package/dist/query-validation.js +45 -0
- package/dist/skill-assets.js +136 -0
- package/dist/upload.js +130 -0
- package/dist/web-citations.js +68 -0
- package/dist/write-permissions.js +41 -0
- package/package.json +27 -4
- package/skills/caotang-context/SKILL.md +47 -0
- package/skills/caotang-first-connect/SKILL.md +62 -0
- package/skills/caotang-project-progress/SKILL.md +87 -0
- package/skills/caotang-save-knowledge/SKILL.md +40 -0
- package/skills/caotang-search/SKILL.md +68 -0
- package/skills/caotang-workday/SKILL.md +34 -0
- package/skills/manifest.json +11 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { agentIdentity } from "./agent-identity.js";
|
|
3
|
+
import { constants, lstatSync, openSync, fstatSync, readSync, writeFileSync, fsyncSync, fchmodSync, ftruncateSync, closeSync } from "node:fs";
|
|
4
|
+
import { lstat, realpath, mkdir, open, unlink } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { CliError, asRecord, validToken } from "./errors.js";
|
|
7
|
+
import { acquireLocalLock } from "./local-lock.js";
|
|
8
|
+
import { productionOrigin, legacyProductionOrigin } from "./config.js";
|
|
9
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
10
|
+
const sameVersion = (a, b) => sameFile(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
11
|
+
const missing = (error) => asRecord(error)?.code === "ENOENT";
|
|
12
|
+
const unsafe = () => new CliError("credential_unsafe", "本地凭证位置或记录未通过安全检查。", "请检查属主、符号链接、目标环境和权限,不要输出凭证内容。");
|
|
13
|
+
const unsafeLinks = () => new CliError("credential_unsafe", "本地凭据文件的链接数不是 1,已拒绝使用或报告保存成功。", "请由本人核查硬链接或路径替换;不要自动删除链接、放宽权限或输出凭据内容。", 2, { reason: "multiple_links" });
|
|
14
|
+
const unsafeSymlink = () => new CliError("credential_symlink", "本地凭证路径包含符号链接,已拒绝访问。", "请由本人改用不含符号链接的真实用户目录和凭证路径后重试;不要自动删除链接、迁移凭证或放宽权限,也不要输出凭证内容。");
|
|
15
|
+
const conflict = () => new CliError("credential_conflict", "当前环境已有另一份凭证。", "请保留原凭证,在网页核对并吊销未保存的设备后,再决定是否重新授权。");
|
|
16
|
+
const cleanupFailed = () => new CliError("credential_cleanup_failed", "本地凭据尚未确认清理完成。", "请由本人检查凭据文件的清空或删除限制;不要输出内容、绕过保护或声称断开完成。");
|
|
17
|
+
const permissionFailure = (error) => ["EPERM", "EACCES"].includes(String(asRecord(error)?.code));
|
|
18
|
+
const permissionDenied = (path, error) => new CliError("installation_permission_denied", "没有权限完成本地凭据操作。", "请由本人核对详情中目标目录的访问、清空或删除限制;不要自动提权、放宽权限或声称操作完成。", 4, { target_directory: path, system_code: String(asRecord(error)?.code) });
|
|
19
|
+
class FileCredentialStore {
|
|
20
|
+
path;
|
|
21
|
+
origin;
|
|
22
|
+
home;
|
|
23
|
+
directoryPath;
|
|
24
|
+
lockPath;
|
|
25
|
+
environment;
|
|
26
|
+
constructor(homeDir, environment, origin, isolated, fileKey, marker = false) {
|
|
27
|
+
if (!homeDir || !["production", "test"].includes(environment))
|
|
28
|
+
throw unsafe();
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(origin);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw unsafe();
|
|
35
|
+
}
|
|
36
|
+
if (url.origin !== origin || url.username || url.password || !["https:", "http:"].includes(url.protocol))
|
|
37
|
+
throw unsafe();
|
|
38
|
+
this.home = resolve(homeDir);
|
|
39
|
+
this.environment = environment;
|
|
40
|
+
this.origin = origin;
|
|
41
|
+
this.directoryPath = join(this.home, isolated ? ".caotang-credentials" : ".caotang");
|
|
42
|
+
this.path = join(this.directoryPath, marker ? `LEGACY_CLAIM.${environment}.json` : isolated ? `DO_NOT_READ.${environment}${fileKey ? `.${fileKey}` : ""}.json` : `${environment}.json`);
|
|
43
|
+
this.lockPath = join(this.directoryPath, `.${environment}${marker ? ".legacy-claim" : fileKey ? `.${fileKey}` : ""}.lock`);
|
|
44
|
+
}
|
|
45
|
+
owned(stat) {
|
|
46
|
+
return typeof process.getuid === "function" && stat.uid === process.getuid();
|
|
47
|
+
}
|
|
48
|
+
async checkHome() {
|
|
49
|
+
const stat = await lstat(this.home);
|
|
50
|
+
if (stat.isSymbolicLink())
|
|
51
|
+
throw unsafeSymlink();
|
|
52
|
+
if (!stat.isDirectory() || !this.owned(stat) || (stat.mode & 0o022) !== 0)
|
|
53
|
+
throw unsafe();
|
|
54
|
+
if (await realpath(this.home) !== this.home) {
|
|
55
|
+
// A canonical-path mismatch can also be casing/normalization. Only name
|
|
56
|
+
// a symlink cause when metadata proves it; never follow it or repair it.
|
|
57
|
+
for (let parent = dirname(this.home);; parent = dirname(parent)) {
|
|
58
|
+
if ((await lstat(parent)).isSymbolicLink())
|
|
59
|
+
throw unsafeSymlink();
|
|
60
|
+
if (dirname(parent) === parent)
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
throw unsafe();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
checkFile(stat) {
|
|
67
|
+
if (stat.isSymbolicLink())
|
|
68
|
+
throw unsafeSymlink();
|
|
69
|
+
if (!stat.isFile() || !this.owned(stat) || (stat.mode & 0o7777) !== 0o600 || stat.size > 16384)
|
|
70
|
+
throw unsafe();
|
|
71
|
+
if (stat.nlink !== 1)
|
|
72
|
+
throw unsafeLinks();
|
|
73
|
+
}
|
|
74
|
+
async directory(create) {
|
|
75
|
+
await this.checkHome();
|
|
76
|
+
if (create) {
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(this.directoryPath, { mode: 0o700 });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (asRecord(error)?.code !== "EEXIST")
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = await lstat(this.directoryPath);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (missing(error) && !create)
|
|
91
|
+
return null;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
if (stat.isSymbolicLink())
|
|
95
|
+
throw unsafeSymlink();
|
|
96
|
+
if (!stat.isDirectory() || !this.owned(stat) || (stat.mode & 0o7777) !== 0o700)
|
|
97
|
+
throw unsafe();
|
|
98
|
+
const handle = await open(this.directoryPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
99
|
+
try {
|
|
100
|
+
if (!sameFile(stat, await handle.stat()))
|
|
101
|
+
throw unsafe();
|
|
102
|
+
return { handle, stat };
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
await handle.close();
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async checkDirectory(directory) {
|
|
110
|
+
await this.checkHome();
|
|
111
|
+
const stat = await lstat(this.directoryPath);
|
|
112
|
+
if (stat.isSymbolicLink())
|
|
113
|
+
throw unsafeSymlink();
|
|
114
|
+
if (!stat.isDirectory() || !sameFile(stat, directory.stat) || !this.owned(stat) || (stat.mode & 0o7777) !== 0o700)
|
|
115
|
+
throw unsafe();
|
|
116
|
+
}
|
|
117
|
+
validate(value) {
|
|
118
|
+
const item = asRecord(value);
|
|
119
|
+
const recordOrigin = item?.origin;
|
|
120
|
+
const matchesOrigin = recordOrigin === this.origin || (this.environment === "production" && this.origin === productionOrigin && recordOrigin === legacyProductionOrigin);
|
|
121
|
+
if (!item || Object.keys(item).sort().join(",") !== "kind,origin,savedAt,scope,token,tokenId" || !validToken(item.token)
|
|
122
|
+
|| typeof recordOrigin !== "string" || !matchesOrigin || item.kind !== "device" || !["read_only", "read_write"].includes(String(item.scope))
|
|
123
|
+
|| typeof item.tokenId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(item.tokenId)
|
|
124
|
+
|| typeof item.savedAt !== "string" || !/^\d{4}-\d\d-\d\dT/.test(item.savedAt) || !Number.isFinite(Date.parse(item.savedAt)))
|
|
125
|
+
throw unsafe();
|
|
126
|
+
return { token: item.token, origin: recordOrigin, kind: "device", scope: item.scope, tokenId: item.tokenId, savedAt: item.savedAt };
|
|
127
|
+
}
|
|
128
|
+
async readBytes(directory) {
|
|
129
|
+
await this.checkDirectory(directory);
|
|
130
|
+
let stat;
|
|
131
|
+
try {
|
|
132
|
+
stat = await lstat(this.path);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (missing(error))
|
|
136
|
+
return null;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
this.checkFile(stat);
|
|
140
|
+
const handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
141
|
+
try {
|
|
142
|
+
const opened = await handle.stat();
|
|
143
|
+
this.checkFile(opened);
|
|
144
|
+
if (!sameVersion(stat, opened))
|
|
145
|
+
throw unsafe();
|
|
146
|
+
// A fixed buffer also bounds a file that grows after the initial fstat.
|
|
147
|
+
const buffer = Buffer.alloc(16385);
|
|
148
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
149
|
+
if (bytesRead > 16384 || bytesRead !== opened.size)
|
|
150
|
+
throw unsafe();
|
|
151
|
+
const after = await handle.stat();
|
|
152
|
+
this.checkFile(after);
|
|
153
|
+
const current = await lstat(this.path);
|
|
154
|
+
this.checkFile(current);
|
|
155
|
+
if (!sameVersion(opened, after) || !sameVersion(after, current))
|
|
156
|
+
throw unsafe();
|
|
157
|
+
await this.checkDirectory(directory);
|
|
158
|
+
if (bytesRead === 0)
|
|
159
|
+
return null;
|
|
160
|
+
return { bytes: buffer.subarray(0, bytesRead), stat: current };
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await handle.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async read(directory) {
|
|
167
|
+
const value = await this.readBytes(directory);
|
|
168
|
+
return value ? { record: this.validate(JSON.parse(value.bytes.toString("utf8"))), stat: value.stat } : null;
|
|
169
|
+
}
|
|
170
|
+
claim(value) {
|
|
171
|
+
const item = asRecord(value);
|
|
172
|
+
if (!item || Object.keys(item).sort().join(",") !== "owner,source_sha256,version" || item.version !== 1 || item.owner !== "命令行" || typeof item.source_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(item.source_sha256))
|
|
173
|
+
throw unsafe();
|
|
174
|
+
return { version: 1, owner: "命令行", source_sha256: item.source_sha256 };
|
|
175
|
+
}
|
|
176
|
+
async loadClaim() {
|
|
177
|
+
return this.guarded(async () => {
|
|
178
|
+
const directory = await this.directory(false);
|
|
179
|
+
if (!directory)
|
|
180
|
+
return null;
|
|
181
|
+
try {
|
|
182
|
+
const value = await this.readBytes(directory);
|
|
183
|
+
return value ? this.claim(JSON.parse(value.bytes.toString("utf8"))) : null;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await directory.handle.close();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async saveClaim(value) {
|
|
191
|
+
return this.guarded(async () => {
|
|
192
|
+
const record = this.claim(value), directory = await this.directory(true);
|
|
193
|
+
if (!directory)
|
|
194
|
+
throw unsafe();
|
|
195
|
+
try {
|
|
196
|
+
await this.locked(directory, async () => {
|
|
197
|
+
const existing = await this.readBytes(directory);
|
|
198
|
+
if (existing) {
|
|
199
|
+
if (this.claim(JSON.parse(existing.bytes.toString("utf8"))).source_sha256 !== record.source_sha256)
|
|
200
|
+
throw conflict();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await this.checkDirectory(directory);
|
|
204
|
+
this.writeWithoutDelete(directory, record);
|
|
205
|
+
await directory.handle.sync();
|
|
206
|
+
const saved = await this.readBytes(directory);
|
|
207
|
+
if (!saved || this.claim(JSON.parse(saved.bytes.toString("utf8"))).source_sha256 !== record.source_sha256)
|
|
208
|
+
throw conflict();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await directory.handle.close();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async guarded(action) {
|
|
217
|
+
try {
|
|
218
|
+
return await action();
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error instanceof CliError)
|
|
222
|
+
throw error;
|
|
223
|
+
if (asRecord(error)?.code === "ELOOP")
|
|
224
|
+
throw unsafeSymlink();
|
|
225
|
+
if (permissionFailure(error))
|
|
226
|
+
throw permissionDenied(this.directoryPath, error);
|
|
227
|
+
throw unsafe();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async locked(directory, action) {
|
|
231
|
+
await this.checkDirectory(directory);
|
|
232
|
+
const release = acquireLocalLock(this.lockPath, { path: this.directoryPath, identity: directory.stat }, {
|
|
233
|
+
unsafe,
|
|
234
|
+
busy: () => new CliError("credential_busy", "有另一项凭证操作正在进行或锁状态无法确认。", "请等待同环境命令结束;不确定时保留现状,由你人工核查,不要自动删锁或重新授权。"),
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
await action();
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
await this.checkDirectory(directory);
|
|
241
|
+
release();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
writeWithoutDelete(directory, record) {
|
|
245
|
+
const original = process.cwd();
|
|
246
|
+
let fd = -1;
|
|
247
|
+
try {
|
|
248
|
+
process.chdir(this.directoryPath);
|
|
249
|
+
const parent = lstatSync(".");
|
|
250
|
+
if (!sameFile(parent, directory.stat) || !this.owned(parent) || (parent.mode & 0o7777) !== 0o700)
|
|
251
|
+
throw unsafe();
|
|
252
|
+
const name = basename(this.path);
|
|
253
|
+
try {
|
|
254
|
+
fd = openSync(name, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (asRecord(error)?.code !== "EEXIST")
|
|
258
|
+
throw error;
|
|
259
|
+
const named = lstatSync(name);
|
|
260
|
+
this.checkFile(named);
|
|
261
|
+
if (named.size !== 0)
|
|
262
|
+
throw conflict();
|
|
263
|
+
fd = openSync(name, constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
264
|
+
const opened = fstatSync(fd);
|
|
265
|
+
this.checkFile(opened);
|
|
266
|
+
if (!sameVersion(named, opened) || !sameVersion(opened, lstatSync(name)) || opened.size !== 0)
|
|
267
|
+
throw unsafe();
|
|
268
|
+
fchmodSync(fd, 0);
|
|
269
|
+
fsyncSync(fd);
|
|
270
|
+
}
|
|
271
|
+
const initial = fstatSync(fd);
|
|
272
|
+
if (!initial.isFile() || !this.owned(initial) || initial.size !== 0 || (initial.mode & 0o7777) !== 0)
|
|
273
|
+
throw unsafe();
|
|
274
|
+
if (initial.nlink !== 1)
|
|
275
|
+
throw unsafeLinks();
|
|
276
|
+
const bytes = Buffer.from(JSON.stringify(record));
|
|
277
|
+
writeFileSync(fd, bytes);
|
|
278
|
+
fsyncSync(fd);
|
|
279
|
+
const written = fstatSync(fd), current = lstatSync(name);
|
|
280
|
+
const verify = Buffer.alloc(bytes.length + 1);
|
|
281
|
+
if (written.nlink !== 1 || current.nlink !== 1)
|
|
282
|
+
throw unsafeLinks();
|
|
283
|
+
if (!sameFile(initial, written) || !sameFile(written, current) || written.size !== bytes.length || current.size !== bytes.length
|
|
284
|
+
|| (written.mode & 0o7777) !== 0 || (current.mode & 0o7777) !== 0 || readSync(fd, verify, 0, verify.length, 0) !== bytes.length || !verify.subarray(0, bytes.length).equals(bytes))
|
|
285
|
+
throw unsafe();
|
|
286
|
+
fchmodSync(fd, 0o600);
|
|
287
|
+
fsyncSync(fd);
|
|
288
|
+
const published = lstatSync(name), final = fstatSync(fd);
|
|
289
|
+
this.checkFile(published);
|
|
290
|
+
this.checkFile(final);
|
|
291
|
+
if (!sameFile(initial, final) || !sameFile(final, published) || final.size !== bytes.length || published.size !== bytes.length || final.mtimeMs !== written.mtimeMs
|
|
292
|
+
|| readSync(fd, verify, 0, verify.length, 0) !== bytes.length || !verify.subarray(0, bytes.length).equals(bytes))
|
|
293
|
+
throw unsafe();
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (fd !== -1) {
|
|
297
|
+
try {
|
|
298
|
+
fchmodSync(fd, 0);
|
|
299
|
+
fsyncSync(fd);
|
|
300
|
+
}
|
|
301
|
+
catch { /* Never report an incomplete credential as saved. */ }
|
|
302
|
+
}
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
try {
|
|
307
|
+
if (fd !== -1)
|
|
308
|
+
closeSync(fd);
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
process.chdir(original);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
clearContents(directory, existing) {
|
|
316
|
+
const original = process.cwd();
|
|
317
|
+
let fd = -1;
|
|
318
|
+
try {
|
|
319
|
+
process.chdir(this.directoryPath);
|
|
320
|
+
const parent = lstatSync(".");
|
|
321
|
+
if (!sameFile(parent, directory.stat) || !this.owned(parent) || (parent.mode & 0o7777) !== 0o700)
|
|
322
|
+
throw unsafe();
|
|
323
|
+
const name = basename(this.path);
|
|
324
|
+
fd = openSync(name, constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
325
|
+
const opened = fstatSync(fd), named = lstatSync(name);
|
|
326
|
+
this.checkFile(opened);
|
|
327
|
+
this.checkFile(named);
|
|
328
|
+
if (!sameVersion(existing.stat, opened) || !sameVersion(opened, named))
|
|
329
|
+
throw unsafe();
|
|
330
|
+
const bytes = Buffer.alloc(16385), count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
331
|
+
if (count !== opened.size || count > 16384)
|
|
332
|
+
throw unsafe();
|
|
333
|
+
if (!sameStoredRecord(this.validate(JSON.parse(bytes.subarray(0, count).toString("utf8"))), existing.record))
|
|
334
|
+
throw conflict();
|
|
335
|
+
const checked = fstatSync(fd), current = lstatSync(name);
|
|
336
|
+
this.checkFile(checked);
|
|
337
|
+
this.checkFile(current);
|
|
338
|
+
if (!sameVersion(opened, checked) || !sameVersion(checked, current))
|
|
339
|
+
throw unsafe();
|
|
340
|
+
// Clear the pinned inode before a delete broker can move it to Trash.
|
|
341
|
+
// Keep snapshot validation and truncation in one synchronous section.
|
|
342
|
+
try {
|
|
343
|
+
ftruncateSync(fd, 0);
|
|
344
|
+
fsyncSync(fd);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (permissionFailure(error))
|
|
348
|
+
throw error;
|
|
349
|
+
throw cleanupFailed();
|
|
350
|
+
}
|
|
351
|
+
const cleared = fstatSync(fd), published = lstatSync(name);
|
|
352
|
+
this.checkFile(cleared);
|
|
353
|
+
this.checkFile(published);
|
|
354
|
+
if (!sameFile(opened, cleared) || !sameVersion(cleared, published))
|
|
355
|
+
throw unsafe();
|
|
356
|
+
if (cleared.size !== 0 || readSync(fd, bytes, 0, 1, 0) !== 0)
|
|
357
|
+
throw cleanupFailed();
|
|
358
|
+
return cleared;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
try {
|
|
362
|
+
if (fd !== -1)
|
|
363
|
+
closeSync(fd);
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
process.chdir(original);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async load() {
|
|
371
|
+
return this.guarded(async () => {
|
|
372
|
+
const directory = await this.directory(false);
|
|
373
|
+
if (!directory)
|
|
374
|
+
return null;
|
|
375
|
+
try {
|
|
376
|
+
return (await this.read(directory))?.record ?? null;
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
await directory.handle.close();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
async save(value) {
|
|
384
|
+
return this.guarded(async () => {
|
|
385
|
+
const record = this.validate(value);
|
|
386
|
+
const directory = await this.directory(true);
|
|
387
|
+
if (!directory)
|
|
388
|
+
throw unsafe();
|
|
389
|
+
try {
|
|
390
|
+
await this.locked(directory, async () => {
|
|
391
|
+
const existing = await this.read(directory);
|
|
392
|
+
if (existing) {
|
|
393
|
+
if (existing.record.token !== record.token || existing.record.tokenId !== record.tokenId || existing.record.scope !== record.scope)
|
|
394
|
+
throw conflict();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
await this.checkDirectory(directory);
|
|
398
|
+
this.writeWithoutDelete(directory, record);
|
|
399
|
+
await directory.handle.sync();
|
|
400
|
+
await this.read(directory);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
await directory.handle.close();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
async remove(expectedToken, expectedRecord) {
|
|
409
|
+
return this.guarded(async () => {
|
|
410
|
+
if (!validToken(expectedToken))
|
|
411
|
+
throw unsafe();
|
|
412
|
+
const directory = await this.directory(false);
|
|
413
|
+
if (!directory)
|
|
414
|
+
return;
|
|
415
|
+
try {
|
|
416
|
+
await this.locked(directory, async () => {
|
|
417
|
+
const existing = await this.read(directory);
|
|
418
|
+
if (!existing || existing.record.token !== expectedToken)
|
|
419
|
+
return;
|
|
420
|
+
if (expectedRecord && !sameStoredRecord(existing.record, expectedRecord))
|
|
421
|
+
throw conflict();
|
|
422
|
+
await this.checkDirectory(directory);
|
|
423
|
+
const current = await lstat(this.path);
|
|
424
|
+
this.checkFile(current);
|
|
425
|
+
if (!sameVersion(existing.stat, current))
|
|
426
|
+
throw unsafe();
|
|
427
|
+
const cleared = this.clearContents(directory, existing);
|
|
428
|
+
await this.checkDirectory(directory);
|
|
429
|
+
const beforeDelete = await lstat(this.path);
|
|
430
|
+
this.checkFile(beforeDelete);
|
|
431
|
+
if (!sameVersion(cleared, beforeDelete))
|
|
432
|
+
throw unsafe();
|
|
433
|
+
try {
|
|
434
|
+
await unlink(this.path);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (permissionFailure(error))
|
|
438
|
+
throw error;
|
|
439
|
+
throw cleanupFailed();
|
|
440
|
+
}
|
|
441
|
+
await directory.handle.sync();
|
|
442
|
+
await this.checkDirectory(directory);
|
|
443
|
+
try {
|
|
444
|
+
await lstat(this.path);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (missing(error))
|
|
448
|
+
return;
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
throw cleanupFailed();
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
await directory.handle.close();
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function sameCredential(a, b) {
|
|
461
|
+
const canonical = (origin) => origin === legacyProductionOrigin ? productionOrigin : origin;
|
|
462
|
+
return a.token === b.token && a.tokenId === b.tokenId && a.kind === b.kind && a.scope === b.scope && canonical(a.origin) === canonical(b.origin);
|
|
463
|
+
}
|
|
464
|
+
function sameStoredRecord(a, b) {
|
|
465
|
+
return sameCredential(a, b) && a.savedAt === b.savedAt;
|
|
466
|
+
}
|
|
467
|
+
export class CredentialStore {
|
|
468
|
+
path;
|
|
469
|
+
origin;
|
|
470
|
+
current;
|
|
471
|
+
legacy;
|
|
472
|
+
shared;
|
|
473
|
+
legacyClaim;
|
|
474
|
+
agent;
|
|
475
|
+
homeDir;
|
|
476
|
+
environment;
|
|
477
|
+
constructor(homeDir, environment, origin, agentName) {
|
|
478
|
+
this.agent = agentName === undefined ? undefined : agentIdentity(agentName);
|
|
479
|
+
this.homeDir = homeDir;
|
|
480
|
+
this.environment = environment;
|
|
481
|
+
this.current = new FileCredentialStore(homeDir, environment, origin, true, this.agent?.fileKey);
|
|
482
|
+
this.shared = new FileCredentialStore(homeDir, environment, origin, true);
|
|
483
|
+
this.legacyClaim = new FileCredentialStore(homeDir, environment, origin, true, undefined, true);
|
|
484
|
+
this.legacy = new FileCredentialStore(homeDir, environment, origin, false);
|
|
485
|
+
this.path = this.current.path;
|
|
486
|
+
this.origin = this.current.origin;
|
|
487
|
+
}
|
|
488
|
+
async load() {
|
|
489
|
+
if (this.agent)
|
|
490
|
+
return this.current.load();
|
|
491
|
+
const current = await this.current.load();
|
|
492
|
+
const legacy = await this.legacy.load();
|
|
493
|
+
if (current && legacy && !sameStoredRecord(current, legacy))
|
|
494
|
+
throw conflict();
|
|
495
|
+
return current ?? legacy;
|
|
496
|
+
}
|
|
497
|
+
async save(value) {
|
|
498
|
+
const record = this.current.validate(value);
|
|
499
|
+
if (this.agent) {
|
|
500
|
+
await this.current.save(record);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const legacy = await this.legacy.load();
|
|
504
|
+
if (legacy && !sameStoredRecord(legacy, record))
|
|
505
|
+
throw conflict();
|
|
506
|
+
await this.current.save(record);
|
|
507
|
+
const checked = await this.load();
|
|
508
|
+
if (!checked || !sameCredential(checked, record))
|
|
509
|
+
throw conflict();
|
|
510
|
+
}
|
|
511
|
+
// Scoped reads use only their own file. Only an explicit 命令行 setup/login
|
|
512
|
+
// claims a retained shared key; logout finalizes its tombstone before clear.
|
|
513
|
+
// The unscoped branch preserves the pre-N105 low-level compatibility API.
|
|
514
|
+
async migrate() {
|
|
515
|
+
if (this.agent) {
|
|
516
|
+
// Retired records and the existing validation API have no client name.
|
|
517
|
+
// Their deterministic owner is 命令行. Other AI setups never read them.
|
|
518
|
+
if (this.agent.key !== agentIdentity("命令行").key)
|
|
519
|
+
return { migrated: false };
|
|
520
|
+
if (await this.legacyClaim.loadClaim())
|
|
521
|
+
return { migrated: false };
|
|
522
|
+
const shared = await this.shared.load(), legacy = await this.legacy.load();
|
|
523
|
+
if (shared && legacy && !sameStoredRecord(shared, legacy))
|
|
524
|
+
throw conflict();
|
|
525
|
+
const source = shared ?? legacy;
|
|
526
|
+
if (!source)
|
|
527
|
+
return { migrated: false };
|
|
528
|
+
const previous = await this.current.load();
|
|
529
|
+
// A newer named connection supersedes the retained old source. Preserve
|
|
530
|
+
// it, and finish the old source's tombstone without ever replacing it.
|
|
531
|
+
if (!previous)
|
|
532
|
+
await this.current.save(source);
|
|
533
|
+
const copied = await this.current.load();
|
|
534
|
+
if (!copied || (!previous && !sameStoredRecord(copied, source)))
|
|
535
|
+
throw conflict();
|
|
536
|
+
const checkedShared = await this.shared.load(), checkedLegacy = await this.legacy.load();
|
|
537
|
+
if ((shared && (!checkedShared || !sameStoredRecord(shared, checkedShared))) || (legacy && (!checkedLegacy || !sameStoredRecord(legacy, checkedLegacy))))
|
|
538
|
+
throw conflict();
|
|
539
|
+
// Publish only after the target and original sources have been verified.
|
|
540
|
+
// Remove also finalizes this claim, so a crash between copy and marker
|
|
541
|
+
// cannot resurrect an old key after an explicit logout/reconnect.
|
|
542
|
+
await this.legacyClaim.saveClaim({ version: 1, owner: "命令行", source_sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex") });
|
|
543
|
+
return { migrated: !previous };
|
|
544
|
+
}
|
|
545
|
+
const legacy = await this.legacy.load();
|
|
546
|
+
const current = await this.current.load();
|
|
547
|
+
if (current && legacy && !sameStoredRecord(current, legacy))
|
|
548
|
+
throw conflict();
|
|
549
|
+
if (!legacy)
|
|
550
|
+
return { migrated: false };
|
|
551
|
+
// The retired production name is a read-only alias, not a file migration.
|
|
552
|
+
if (this.origin === productionOrigin && legacy.origin === legacyProductionOrigin)
|
|
553
|
+
return { migrated: false };
|
|
554
|
+
await this.current.save(legacy);
|
|
555
|
+
const copied = await this.current.load();
|
|
556
|
+
if (!copied || !sameStoredRecord(copied, legacy))
|
|
557
|
+
throw conflict();
|
|
558
|
+
try {
|
|
559
|
+
await this.legacy.remove(legacy.token, legacy);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
if (error instanceof CliError && error.code === "credential_conflict")
|
|
563
|
+
throw error;
|
|
564
|
+
const details = error instanceof CliError && error.code === "installation_permission_denied" ? { cause: error.code, ...error.details } : {};
|
|
565
|
+
throw new CliError("credential_migration_incomplete", "新位置的凭据已保存,但旧凭据尚未确认清理。", "请由本人处理旧凭据的访问或删除限制;不要读取其内容、绕过保护、重新授权或声称迁移完成。", 4, details);
|
|
566
|
+
}
|
|
567
|
+
if (await this.legacy.load())
|
|
568
|
+
throw conflict();
|
|
569
|
+
const verified = await this.current.load();
|
|
570
|
+
if (!verified || !sameStoredRecord(verified, legacy))
|
|
571
|
+
throw conflict();
|
|
572
|
+
return { migrated: true };
|
|
573
|
+
}
|
|
574
|
+
async remove(expectedToken) {
|
|
575
|
+
if (!validToken(expectedToken))
|
|
576
|
+
throw unsafe();
|
|
577
|
+
if (this.agent) {
|
|
578
|
+
const existing = await this.current.load();
|
|
579
|
+
if (!existing || existing.token !== expectedToken)
|
|
580
|
+
return;
|
|
581
|
+
if (this.agent.key === agentIdentity("命令行").key)
|
|
582
|
+
await this.migrate();
|
|
583
|
+
await this.current.remove(expectedToken, existing);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const existing = await this.load();
|
|
587
|
+
if (!existing || existing.token !== expectedToken)
|
|
588
|
+
return;
|
|
589
|
+
await this.current.remove(expectedToken, existing);
|
|
590
|
+
await this.legacy.remove(expectedToken, existing);
|
|
591
|
+
if (await this.load())
|
|
592
|
+
throw conflict();
|
|
593
|
+
}
|
|
594
|
+
}
|