@yijiancaotang/cli 1.1.0 → 1.2.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.
@@ -0,0 +1,370 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ import { asRecord, CliError } from "./errors.js";
5
+ import { OwnedTree } from "./installation.js";
6
+ import { acquireLocalLock } from "./local-lock.js";
7
+ const serviceBase = "/api/v1/agent";
8
+ const maxMapBytes = 256 * 1024;
9
+ const maxEntries = 256;
10
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
11
+ const uuid = (value) => typeof value === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
12
+ const ownerID = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
13
+ const hex = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
14
+ const keys = (value, expected) => Object.keys(value).sort().join(",") === [...expected].sort().join(",");
15
+ const safePath = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && isAbsolute(value) && resolve(value) === value && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value);
16
+ const within = (parent, child) => { const part = relative(parent, child); return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part); };
17
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino && a.uid === b.uid;
18
+ const sameFacts = (a, b) => a.canonical_path === b.canonical_path && sameIdentity(a, b) && a.mode === b.mode;
19
+ class StateError extends Error {
20
+ reason;
21
+ constructor(reason) {
22
+ super("Local project binding metadata is unavailable or unsafe");
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ function result(operation, status, reason) { return { schema_version: 1, operation, status, ...(reason ? { reason } : {}) }; }
27
+ function changedPreview() { throw new CliError("project_binding_preview_changed", "本机项目关联的确认范围已变化,未写入。", "重新查看当前目录、账号、服务和项目的预览;摘要不能替代本人明确同意。", 2); }
28
+ function projectOptions(options) {
29
+ if (!options || !uuid(options.projectId) || options.includeSubdirs !== undefined && typeof options.includeSubdirs !== "boolean")
30
+ throw new CliError("invalid_arguments", "项目关联参数无效。", "指定真实项目ID;子目录范围须单独预览确认,不扫描目录或猜测同名项目。", 2);
31
+ return { projectId: options.projectId.toLowerCase(), includeSubdirs: options.includeSubdirs === true };
32
+ }
33
+ function identity(ctx) {
34
+ if (!ownerID(ctx.verifiedOwnerId) || ctx.serviceBase !== serviceBase || typeof ctx.apiBaseUrl !== "string" || ctx.apiBaseUrl.trim() !== ctx.apiBaseUrl)
35
+ return;
36
+ try {
37
+ const url = new URL(ctx.apiBaseUrl);
38
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))))
39
+ return;
40
+ return { origin: url.origin, owner: ctx.verifiedOwnerId };
41
+ }
42
+ catch {
43
+ return;
44
+ }
45
+ }
46
+ async function directoryFacts(path) {
47
+ if (!safePath(path) || typeof process.getuid !== "function")
48
+ throw new StateError("directory_unsafe");
49
+ const uid = process.getuid(), root = parse(path).root, ancestors = [];
50
+ let current = root;
51
+ for (const part of [null, ...relative(root, path).split(sep).filter(Boolean)]) {
52
+ if (part !== null)
53
+ current = join(current, part);
54
+ const info = await lstat(current, { bigint: true }), mode = Number(info.mode & 4095n), owner = Number(info.uid), final = current === path;
55
+ const stickySystemAncestor = !final && owner === 0 && (mode & 0o1000) !== 0;
56
+ if (!info.isDirectory() || (final ? owner !== uid : owner !== uid && owner !== 0) || (mode & 0o022) !== 0 && !stickySystemAncestor)
57
+ throw new StateError("directory_unsafe");
58
+ ancestors.push({ canonical_path: current, dev: info.dev.toString(), ino: info.ino.toString(), uid: owner, mode });
59
+ }
60
+ if (await realpath(path) !== path)
61
+ throw new StateError("directory_unsafe");
62
+ return { facts: ancestors.at(-1), ancestors };
63
+ }
64
+ async function base(ctx, operation, requireLocal) {
65
+ if (requireLocal && ctx.localScopeConfirmed !== true)
66
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
67
+ const who = identity(ctx);
68
+ if (!who || typeof ctx.readProject !== "function")
69
+ return result(operation, "unknown", "identity_or_service_unverified");
70
+ try {
71
+ const home = await directoryFacts(ctx.homeDir), directory = await directoryFacts(ctx.cwd), tree = new OwnedTree(ctx.homeDir);
72
+ await tree.directory(ctx.homeDir, false, async () => true);
73
+ return { tree, home: home.facts, directory: directory.facts, ancestors: directory.ancestors, uid: process.getuid(), ...who, root: join(ctx.homeDir, ".caotang", "project-bindings") };
74
+ }
75
+ catch {
76
+ return result(operation, "invalid", "directory_unsafe_or_changed");
77
+ }
78
+ }
79
+ function isBase(value) { return "tree" in value; }
80
+ function metadata(b) { return { service: { origin: b.origin, service_base: serviceBase }, owner_id: b.owner }; }
81
+ async function verifyBase(ctx, b) {
82
+ const who = identity(ctx);
83
+ if (ctx.localScopeConfirmed !== true || !who || who.origin !== b.origin || who.owner !== b.owner
84
+ || !sameFacts((await directoryFacts(ctx.homeDir)).facts, b.home) || !sameFacts((await directoryFacts(ctx.cwd)).facts, b.directory))
85
+ throw new StateError("scope_changed");
86
+ }
87
+ async function checkedProject(ctx, id, operation) {
88
+ let value;
89
+ try {
90
+ value = await ctx.readProject(id);
91
+ }
92
+ catch {
93
+ return result(operation, "unknown", "project_validation_unavailable");
94
+ }
95
+ if (!value)
96
+ return result(operation, "invalid", "project_not_accessible");
97
+ if (["hidden", "archived", "deleted"].includes(value.status))
98
+ return result(operation, "invalid", "project_not_eligible");
99
+ if (!uuid(value.id) || value.id.toLowerCase() !== id || !["active", "paused", "draft"].includes(value.status)
100
+ || typeof value.title !== "string" || !value.title.trim() || [...value.title].length > 80 || /[\p{Cc}\uD800-\uDFFF]/u.test(value.title))
101
+ return result(operation, "unknown", "project_metadata_unverified");
102
+ return { id, title: value.title, status: value.status };
103
+ }
104
+ function isProject(value) { return "id" in value; }
105
+ function bindDigest(b, project, includeSubdirs) {
106
+ return hash(JSON.stringify({ schema_version: 1, operation: "bind", home: b.home, directory: b.directory, uid: b.uid, origin: b.origin, service_base: serviceBase, owner_id: b.owner, project, include_subdirs: includeSubdirs, scope: "independent_local_only" }));
107
+ }
108
+ async function bindingPreview(ctx, options) {
109
+ const wanted = projectOptions(options), operation = "project_bind_preview", b = await base(ctx, operation, false);
110
+ if (!isBase(b))
111
+ return b;
112
+ const project = await checkedProject(ctx, wanted.projectId, operation);
113
+ if (!isProject(project))
114
+ return project;
115
+ try {
116
+ if (!sameFacts((await directoryFacts(ctx.cwd)).facts, b.directory) || !sameFacts((await directoryFacts(ctx.homeDir)).facts, b.home))
117
+ return result(operation, "invalid", "directory_changed_during_preview");
118
+ }
119
+ catch {
120
+ return result(operation, "invalid", "directory_changed_during_preview");
121
+ }
122
+ return { b, project, includeSubdirs: wanted.includeSubdirs, preview: {
123
+ ...result(operation, "confirmation_required"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: wanted.includeSubdirs }, project,
124
+ preview_digest: bindDigest(b, project, wanted.includeSubdirs), requires_local_scope_confirmation: true, local_write_only: true,
125
+ } };
126
+ }
127
+ export async function previewProjectBinding(ctx, options) {
128
+ const outcome = await bindingPreview(ctx, options);
129
+ return "preview" in outcome ? outcome.preview : outcome;
130
+ }
131
+ function storedFacts(value) {
132
+ const v = asRecord(value);
133
+ if (!v || !keys(v, ["canonical_path", "dev", "ino", "uid", "mode"]) || !safePath(v.canonical_path)
134
+ || typeof v.dev !== "string" || !/^\d{1,40}$/.test(v.dev) || typeof v.ino !== "string" || !/^\d{1,40}$/.test(v.ino)
135
+ || typeof v.uid !== "number" || !Number.isSafeInteger(v.uid) || v.uid < 0 || typeof v.mode !== "number" || !Number.isInteger(v.mode) || v.mode < 0 || v.mode > 0o7777)
136
+ throw new StateError("local_metadata_malformed");
137
+ return { canonical_path: v.canonical_path, dev: v.dev, ino: v.ino, uid: v.uid, mode: v.mode };
138
+ }
139
+ function json(bytes) {
140
+ try {
141
+ const raw = bytes.toString("utf8");
142
+ if (!Buffer.from(raw, "utf8").equals(bytes))
143
+ throw Error();
144
+ const value = asRecord(JSON.parse(raw));
145
+ if (!value)
146
+ throw Error();
147
+ return value;
148
+ }
149
+ catch {
150
+ throw new StateError("local_metadata_malformed");
151
+ }
152
+ }
153
+ async function localState(b, create) {
154
+ const path = join(b.root, "local-state.json");
155
+ let bytes = await b.tree.read(path, 16384, 0o600);
156
+ if (!bytes && create) {
157
+ const fresh = { schema_version: 1, scope: "user_confirmed_independent_local", local_state_id: randomBytes(32).toString("hex"), home: b.home };
158
+ try {
159
+ await b.tree.writeNew(path, Buffer.from(JSON.stringify(fresh)), 0o600);
160
+ }
161
+ catch (error) {
162
+ if (!(error instanceof CliError) || error.code !== "installation_conflict")
163
+ throw error;
164
+ }
165
+ bytes = await b.tree.read(path, 16384, 0o600);
166
+ if (!bytes)
167
+ throw new StateError("local_state_unconfirmed");
168
+ }
169
+ if (!bytes)
170
+ return null;
171
+ const value = json(bytes);
172
+ if (!keys(value, ["schema_version", "scope", "local_state_id", "home"]) || value.schema_version !== 1 || value.scope !== "user_confirmed_independent_local" || !hex(value.local_state_id))
173
+ throw new StateError("local_metadata_malformed");
174
+ const home = storedFacts(value.home);
175
+ if (!sameFacts(home, b.home))
176
+ throw new StateError("local_home_identity_changed");
177
+ return { schema_version: 1, scope: "user_confirmed_independent_local", local_state_id: value.local_state_id, home };
178
+ }
179
+ function namespace(b, state) {
180
+ return hash(JSON.stringify({ schema_version: 1, local_state_id: state.local_state_id, home: state.home, uid: b.uid, origin: b.origin, service_base: serviceBase, owner_id: b.owner }));
181
+ }
182
+ async function storedMap(b, state) {
183
+ const ns = namespace(b, state), directory = join(b.root, "namespaces", ns), path = join(directory, "bindings.json"), bytes = await b.tree.read(path, maxMapBytes, 0o600);
184
+ if (!bytes)
185
+ return { namespace: ns, path, directory, bytes: null, value: { schema_version: 1, namespace: ns, entries: [] } };
186
+ const value = json(bytes);
187
+ if (!keys(value, ["schema_version", "namespace", "entries"]) || value.schema_version !== 1 || value.namespace !== ns || !Array.isArray(value.entries) || value.entries.length > maxEntries)
188
+ throw new StateError("local_metadata_malformed");
189
+ const entries = value.entries.map((raw) => {
190
+ const item = asRecord(raw);
191
+ if (!item || !keys(item, ["directory", "project_id", "include_subdirs"]) || !uuid(item.project_id) || typeof item.include_subdirs !== "boolean")
192
+ throw new StateError("local_metadata_malformed");
193
+ const directory = storedFacts(item.directory);
194
+ if (directory.uid !== b.uid)
195
+ throw new StateError("local_metadata_malformed");
196
+ return { directory, project_id: item.project_id.toLowerCase(), include_subdirs: item.include_subdirs };
197
+ });
198
+ if (new Set(entries.map(entry => entry.directory.canonical_path)).size !== entries.length)
199
+ throw new StateError("local_metadata_malformed");
200
+ return { namespace: ns, path, directory, bytes, value: { schema_version: 1, namespace: ns, entries } };
201
+ }
202
+ function selection(b, entries) {
203
+ const candidates = entries.filter(entry => entry.directory.canonical_path === b.directory.canonical_path || entry.include_subdirs && within(entry.directory.canonical_path, b.directory.canonical_path))
204
+ .sort((a, z) => z.directory.canonical_path.length - a.directory.canonical_path.length);
205
+ if (candidates.length)
206
+ return { entry: candidates[0], inherited: candidates[0].directory.canonical_path !== b.directory.canonical_path };
207
+ const moved = entries.some(entry => sameIdentity(entry.directory, b.directory) || entry.include_subdirs && b.ancestors.some(ancestor => sameIdentity(entry.directory, ancestor)));
208
+ return { moved };
209
+ }
210
+ function stateFailure(operation, error) {
211
+ if (error instanceof StateError)
212
+ return result(operation, "invalid", error.reason);
213
+ if (error instanceof CliError && ["project_binding_busy", "project_binding_update_unknown"].includes(error.code))
214
+ return result(operation, "unknown", error.code);
215
+ return result(operation, "invalid", "local_state_unsafe_or_unavailable");
216
+ }
217
+ async function changeMap(b, map, entries) {
218
+ if (entries.length > maxEntries)
219
+ throw new StateError("local_binding_capacity_exceeded");
220
+ const value = { schema_version: 1, namespace: map.namespace, entries: [...entries].sort((a, z) => a.directory.canonical_path.localeCompare(z.directory.canonical_path)) };
221
+ const after = Buffer.from(JSON.stringify(value));
222
+ if (after.length > maxMapBytes)
223
+ throw new StateError("local_binding_capacity_exceeded");
224
+ if (map.bytes?.equals(after))
225
+ return false;
226
+ let writeAttempted = false;
227
+ try {
228
+ if (!map.bytes) {
229
+ writeAttempted = true;
230
+ await b.tree.writeNew(map.path, after, 0o600);
231
+ }
232
+ else {
233
+ const journal = Buffer.from(JSON.stringify({ schema_version: 1, before_sha256: hash(map.bytes), after_sha256: hash(after), before_base64: map.bytes.toString("base64"), after_base64: after.toString("base64") }));
234
+ await b.tree.writeNew(join(map.directory, "history", `${hash(map.bytes)}.${hash(after)}.json`), journal, 0o600);
235
+ const entry = { path: map.path, before: map.bytes, after, mode: 0o600, label: "project_bindings" };
236
+ writeAttempted = true;
237
+ try {
238
+ await b.tree.replaceManaged(entry);
239
+ }
240
+ catch {
241
+ try {
242
+ await b.tree.replaceManaged(entry, true);
243
+ }
244
+ catch { /* Keep recovery bytes; never reset an unverified inode. */ }
245
+ throw new StateError("local_update_unconfirmed");
246
+ }
247
+ }
248
+ if (!(await b.tree.read(map.path, maxMapBytes, 0o600))?.equals(after))
249
+ throw new StateError("local_update_unconfirmed");
250
+ }
251
+ catch (error) {
252
+ if (!writeAttempted)
253
+ throw error;
254
+ throw new CliError("project_binding_update_unknown", "本机关联更新未完成最终核对。", "保留现有元数据和恢复记录,由本人核对;不要自动重写或影响草堂项目。", 6);
255
+ }
256
+ return true;
257
+ }
258
+ async function locked(b, state, action) {
259
+ const ns = namespace(b, state), directory = join(b.root, "namespaces", ns), lockPath = join(directory, ".binding.lock");
260
+ const release = await b.tree.directory(directory, true, async (parents) => acquireLocalLock(lockPath, parents.at(-1), {
261
+ unsafe: () => new StateError("local_lock_unsafe"),
262
+ busy: () => new CliError("project_binding_busy", "另一项本机项目关联操作未完成。", "先等待本机操作结束;状态未知时不要删锁或强制覆盖。", 6),
263
+ }));
264
+ if (!release)
265
+ throw new StateError("local_lock_unavailable");
266
+ try {
267
+ return await action(await storedMap(b, state));
268
+ }
269
+ finally {
270
+ release();
271
+ }
272
+ }
273
+ export async function confirmProjectBinding(ctx, options) {
274
+ const operation = "project_bind";
275
+ if (ctx.localScopeConfirmed !== true || options.localScopeConfirmed !== true)
276
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
277
+ const outcome = await bindingPreview(ctx, options);
278
+ if (!("preview" in outcome))
279
+ return { ...outcome, operation };
280
+ if (!hex(options.previewDigest) || options.previewDigest !== outcome.preview.preview_digest)
281
+ changedPreview();
282
+ const { b, project, includeSubdirs } = outcome;
283
+ try {
284
+ await verifyBase(ctx, b);
285
+ const state = await localState(b, true);
286
+ if (!state)
287
+ throw new StateError("local_state_unconfirmed");
288
+ const changed = await locked(b, state, async (map) => {
289
+ await verifyBase(ctx, b);
290
+ const entries = map.value.entries.filter(entry => entry.directory.canonical_path !== b.directory.canonical_path);
291
+ entries.push({ directory: b.directory, project_id: project.id, include_subdirs: includeSubdirs });
292
+ return changeMap(b, map, entries);
293
+ });
294
+ return { ...result(operation, "bound"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: includeSubdirs }, project, inherited: false, changed, local_write_only: true };
295
+ }
296
+ catch (error) {
297
+ return stateFailure(operation, error);
298
+ }
299
+ }
300
+ export async function readProjectBinding(ctx) {
301
+ const operation = "project_binding", b = await base(ctx, operation, true);
302
+ if (!isBase(b))
303
+ return b;
304
+ try {
305
+ const state = await localState(b, false);
306
+ if (!state)
307
+ return result(operation, "not_bound", "no_local_state_in_current_scope");
308
+ const map = await storedMap(b, state), chosen = selection(b, map.value.entries);
309
+ if (!chosen.entry)
310
+ return result(operation, chosen.moved ? "invalid" : "not_bound", chosen.moved ? "directory_moved" : "no_binding_in_current_scope");
311
+ const entry = chosen.entry;
312
+ if (!sameFacts((await directoryFacts(entry.directory.canonical_path)).facts, entry.directory))
313
+ return result(operation, "invalid", "directory_identity_changed");
314
+ const project = await checkedProject(ctx, entry.project_id, operation);
315
+ if (!isProject(project))
316
+ return project;
317
+ await verifyBase(ctx, b);
318
+ // A different local operation may have changed the map while the project
319
+ // validation was awaiting the server. Never label that old selection current.
320
+ const currentState = await localState(b, false);
321
+ if (!currentState || currentState.local_state_id !== state.local_state_id || !map.bytes
322
+ || !(await b.tree.read(map.path, maxMapBytes, 0o600))?.equals(map.bytes))
323
+ return result(operation, "unknown", "binding_changed_during_validation");
324
+ return { ...result(operation, "bound"), ...metadata(b), directory: { canonical_path: entry.directory.canonical_path, include_subdirs: entry.include_subdirs }, project, inherited: chosen.inherited === true };
325
+ }
326
+ catch (error) {
327
+ return stateFailure(operation, error);
328
+ }
329
+ }
330
+ function unbindDigest(b, map, entry) {
331
+ return hash(JSON.stringify({ schema_version: 1, operation: "unbind_exact", home: b.home, directory: b.directory, origin: b.origin, service_base: serviceBase, owner_id: b.owner, namespace: map.namespace, map_sha256: hash(map.bytes), entry }));
332
+ }
333
+ export async function unbindProjectBinding(ctx, options = {}) {
334
+ const operation = options.previewDigest === undefined ? "project_unbind_preview" : "project_unbind", b = await base(ctx, operation, true);
335
+ if (!isBase(b))
336
+ return b;
337
+ if (options.previewDigest !== undefined && options.localScopeConfirmed !== true)
338
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
339
+ try {
340
+ const state = await localState(b, false);
341
+ if (!state)
342
+ return result(operation, "not_bound", "no_local_state_in_current_scope");
343
+ const map = await storedMap(b, state), exact = map.value.entries.find(entry => entry.directory.canonical_path === b.directory.canonical_path);
344
+ if (!exact) {
345
+ const inherited = selection(b, map.value.entries).entry;
346
+ return result(operation, inherited ? "inherited_binding_requires_root" : "not_bound", inherited ? "confirm_at_exact_binding_directory" : "no_binding_in_current_scope");
347
+ }
348
+ if (!map.bytes)
349
+ throw new StateError("local_metadata_malformed");
350
+ const digest = unbindDigest(b, map, exact);
351
+ if (options.previewDigest === undefined)
352
+ return { ...result(operation, "confirmation_required"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: exact.include_subdirs }, preview_digest: digest, requires_local_scope_confirmation: true, local_write_only: true };
353
+ if (!hex(options.previewDigest) || options.previewDigest !== digest)
354
+ changedPreview();
355
+ await verifyBase(ctx, b);
356
+ const changed = await locked(b, state, async (current) => {
357
+ const row = current.value.entries.find(entry => entry.directory.canonical_path === b.directory.canonical_path);
358
+ if (!row || !current.bytes || unbindDigest(b, current, row) !== options.previewDigest)
359
+ changedPreview();
360
+ await verifyBase(ctx, b);
361
+ return changeMap(b, current, current.value.entries.filter(entry => entry.directory.canonical_path !== b.directory.canonical_path));
362
+ });
363
+ return { ...result(operation, "unbound"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: exact.include_subdirs }, changed, local_write_only: true };
364
+ }
365
+ catch (error) {
366
+ if (error instanceof CliError && error.code === "project_binding_preview_changed")
367
+ throw error;
368
+ return stateFailure(operation, error);
369
+ }
370
+ }
@@ -0,0 +1,40 @@
1
+ import { productionOrigin } from "./config.js";
2
+ import { getAgentOperations } from "./generated/agent-operations.js";
3
+ // Match the existing closed config origins, not an arbitrary apiFactory URL.
4
+ // This reads public metadata only; it never loads a credential or probes an API.
5
+ function currentOrigin(input) {
6
+ const expected = input.environment === "production" ? productionOrigin : input.environment === "test" ? "https://test.gtcai.cn" : undefined;
7
+ if (!expected || typeof input.baseUrl !== "string" || input.credentialOrigin !== expected || input.baseUrl.trim() !== input.baseUrl)
8
+ return undefined;
9
+ try {
10
+ const url = new URL(input.baseUrl);
11
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.origin !== expected)
12
+ return undefined;
13
+ return url.origin;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ export function setupGuidance(input) {
20
+ const operations = getAgentOperations(), origin = currentOrigin(input);
21
+ const verified = input.connectionVerified === true && origin !== undefined;
22
+ const name = typeof input.agentName === "string" && input.agentName.trim() === input.agentName && [...input.agentName].length >= 1 && [...input.agentName].length <= 80
23
+ && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(input.agentName) ? input.agentName : undefined;
24
+ const scope = verified && (input.scope === "read_only" || input.scope === "read_write") ? input.scope : "unknown";
25
+ return {
26
+ schema_version: 1,
27
+ user_experience: { state: "unknown", basis: "not_recorded" },
28
+ agent_preparation: {
29
+ status: "required",
30
+ body_obtained: "unknown",
31
+ evidence: "not_observable_by_cli",
32
+ operations: { version: operations.version, sha256: operations.sha256, source: "package_generated", command: "caotang guide", manual_path: operations.manual_path },
33
+ },
34
+ presentation_reason: input.ready === true && verified ? "requested" : "not_ready",
35
+ identity: { verified, ...(verified && name !== undefined ? { agent_name: name } : {}), ...(origin ? { environment: input.environment, origin } : {}) },
36
+ permissions: { scope, account_capabilities: "unknown", effective_capabilities: "unknown" },
37
+ entry: { transport: "cli", skill_support: "unknown" },
38
+ ...(origin ? { manual_url: new URL(operations.manual_path, origin).href } : {}),
39
+ };
40
+ }
@@ -0,0 +1,206 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, closeSync, fstatSync, fsyncSync, linkSync, lstatSync, openSync, readSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ import { asRecord, CliError } from "./errors.js";
5
+ import { getAgentOperations } from "./generated/agent-operations.js";
6
+ import { loadSkillAssets } from "./skill-assets.js";
7
+ const unsafe = () => new CliError("skills_export_path_unsafe", "导出位置未通过安全检查。", "请明确指定一个新ZIP文件,父目录须已存在、属于本人且不是符号链接;不会自动创建目录、改权限或覆盖文件。", 2);
8
+ const exists = () => new CliError("skills_export_exists", "导出目标已存在,未覆盖。", "保留原文件;请本人明确选择一个新的ZIP文件路径。", 2);
9
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
10
+ const missing = (error) => asRecord(error)?.code === "ENOENT";
11
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
12
+ const crcTable = Uint32Array.from({ length: 256 }, (_, index) => {
13
+ let value = index;
14
+ for (let bit = 0; bit < 8; bit++)
15
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
16
+ return value >>> 0;
17
+ });
18
+ function crc32(bytes) {
19
+ let crc = 0xffffffff;
20
+ for (const byte of bytes)
21
+ crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
22
+ return (crc ^ 0xffffffff) >>> 0;
23
+ }
24
+ function zip(entries) {
25
+ if (entries.length > 32 || entries.reduce((total, entry) => total + entry.content.length, 0) > 1048576)
26
+ throw unsafe();
27
+ const local = [], central = [];
28
+ let offset = 0;
29
+ for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name, "en"))) {
30
+ if (!/^(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_.-]+$/.test(entry.name) || entry.name.split("/").some(part => part === "." || part === ".."))
31
+ throw unsafe();
32
+ const name = Buffer.from(entry.name, "utf8"), crc = crc32(entry.content);
33
+ const header = Buffer.alloc(30);
34
+ header.writeUInt32LE(0x04034b50);
35
+ header.writeUInt16LE(20, 4);
36
+ header.writeUInt16LE(0x0800, 6);
37
+ header.writeUInt16LE(0x0021, 12); // Fixed DOS 1980-01-01; no local identity/time metadata.
38
+ header.writeUInt32LE(crc, 14);
39
+ header.writeUInt32LE(entry.content.length, 18);
40
+ header.writeUInt32LE(entry.content.length, 22);
41
+ header.writeUInt16LE(name.length, 26);
42
+ const directory = Buffer.alloc(46);
43
+ directory.writeUInt32LE(0x02014b50);
44
+ directory.writeUInt16LE(0x0314, 4);
45
+ directory.writeUInt16LE(20, 6);
46
+ directory.writeUInt16LE(0x0800, 8);
47
+ directory.writeUInt16LE(0x0021, 14);
48
+ directory.writeUInt32LE(crc, 16);
49
+ directory.writeUInt32LE(entry.content.length, 20);
50
+ directory.writeUInt32LE(entry.content.length, 24);
51
+ directory.writeUInt16LE(name.length, 28);
52
+ directory.writeUInt32LE((0o100600 << 16) >>> 0, 38);
53
+ directory.writeUInt32LE(offset, 42);
54
+ local.push(header, name, entry.content);
55
+ central.push(directory, name);
56
+ offset += header.length + name.length + entry.content.length;
57
+ }
58
+ const directory = Buffer.concat(central), end = Buffer.alloc(22);
59
+ end.writeUInt32LE(0x06054b50);
60
+ end.writeUInt16LE(entries.length, 8);
61
+ end.writeUInt16LE(entries.length, 10);
62
+ end.writeUInt32LE(directory.length, 12);
63
+ end.writeUInt32LE(offset, 16);
64
+ return Buffer.concat([...local, directory, end]);
65
+ }
66
+ function outputPath(value) {
67
+ if (typeof value !== "string" || !value.trim() || value.length > 4096 || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value))
68
+ throw unsafe();
69
+ const target = resolve(value);
70
+ if (!isAbsolute(target) || target === parse(target).root || !basename(target).toLowerCase().endsWith(".zip"))
71
+ throw unsafe();
72
+ return target;
73
+ }
74
+ // Public ZIP output is not restricted to HOME. All existing ancestors must be
75
+ // real directories; only trusted root/sticky system ancestors may precede the
76
+ // user's private final parent. No directory contents are scanned or modified.
77
+ function checkedParent(path) {
78
+ if (typeof process.getuid !== "function")
79
+ throw unsafe();
80
+ const uid = process.getuid(), parent = dirname(path), root = parse(parent).root;
81
+ let current = root, result;
82
+ for (const part of [null, ...relative(root, parent).split(sep).filter(Boolean)]) {
83
+ if (part !== null)
84
+ current = join(current, part);
85
+ const info = lstatSync(current), final = current === parent;
86
+ const stickyRootAncestor = !final && info.uid === 0 && (info.mode & 0o1000) !== 0;
87
+ if (!info.isDirectory() || (final ? info.uid !== uid : info.uid !== uid && info.uid !== 0)
88
+ || (info.mode & 0o022) !== 0 && !stickyRootAncestor)
89
+ throw unsafe();
90
+ result = info;
91
+ }
92
+ if (!result || realpathSync(parent) !== parent)
93
+ throw unsafe();
94
+ return result;
95
+ }
96
+ function readDescriptor(fd, length) {
97
+ const bytes = Buffer.alloc(length);
98
+ let offset = 0;
99
+ while (offset < length) {
100
+ const count = readSync(fd, bytes, offset, length - offset, offset);
101
+ if (!count)
102
+ break;
103
+ offset += count;
104
+ }
105
+ return bytes.subarray(0, offset);
106
+ }
107
+ function publish(path, bytes) {
108
+ const parent = dirname(path), identity = checkedParent(path), original = process.cwd();
109
+ let fd, stagedIdentity, temporary, published = false;
110
+ try {
111
+ // Keep all operations synchronous while pinned to the verified parent inode.
112
+ process.chdir(parent);
113
+ const current = lstatSync(".");
114
+ if (!same(current, identity) || current.uid !== identity.uid || current.mode !== identity.mode)
115
+ throw unsafe();
116
+ const name = basename(path);
117
+ try {
118
+ lstatSync(name);
119
+ throw exists();
120
+ }
121
+ catch (error) {
122
+ if (!missing(error))
123
+ throw error;
124
+ }
125
+ temporary = `.caotang-skills-export-${randomUUID()}.tmp`;
126
+ fd = openSync(temporary, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
127
+ stagedIdentity = fstatSync(fd);
128
+ const valid = (info, links) => info.isFile() && info.uid === identity.uid && info.nlink === links && (info.mode & 0o7777) === 0o600 && same(info, stagedIdentity);
129
+ if (!valid(stagedIdentity, 1) || stagedIdentity.size !== 0)
130
+ throw unsafe();
131
+ writeFileSync(fd, bytes);
132
+ fsyncSync(fd);
133
+ const written = fstatSync(fd);
134
+ if (!valid(written, 1) || written.size !== bytes.length || !same(written, lstatSync(temporary)) || !readDescriptor(fd, bytes.length).equals(bytes))
135
+ throw unsafe();
136
+ try {
137
+ linkSync(temporary, name);
138
+ published = true;
139
+ }
140
+ catch (error) {
141
+ if (asRecord(error)?.code === "EEXIST")
142
+ throw exists();
143
+ throw error;
144
+ }
145
+ if (!valid(lstatSync(name), 2) || !same(lstatSync(temporary), stagedIdentity))
146
+ throw unsafe();
147
+ unlinkSync(temporary);
148
+ temporary = undefined;
149
+ const final = lstatSync(name);
150
+ if (!valid(final, 1) || final.size !== bytes.length || !readDescriptor(fd, bytes.length).equals(bytes))
151
+ throw unsafe();
152
+ const after = lstatSync(".");
153
+ if (!same(after, identity) || after.uid !== identity.uid || after.mode !== identity.mode)
154
+ throw unsafe();
155
+ // cwd remains the old inode after a concurrent rename. Also verify the
156
+ // absolute location promised to the caller, including its safe ancestry.
157
+ const promisedParent = checkedParent(path), promisedTarget = lstatSync(path);
158
+ if (!same(promisedParent, identity) || promisedParent.uid !== identity.uid || promisedParent.mode !== identity.mode
159
+ || !valid(promisedTarget, 1) || promisedTarget.size !== bytes.length || !same(promisedTarget, final)
160
+ || !readDescriptor(fd, bytes.length).equals(bytes))
161
+ throw unsafe();
162
+ }
163
+ catch (error) {
164
+ if (published)
165
+ throw new CliError("skills_export_unconfirmed", "已创建导出目标,但最终核对未完成。", "保留现有ZIP并由本人核对文件;不要自动删除、覆盖或重新导出到同一路径。", 6);
166
+ if (error instanceof CliError)
167
+ throw error;
168
+ throw unsafe();
169
+ }
170
+ finally {
171
+ if (fd !== undefined)
172
+ closeSync(fd);
173
+ if (temporary && stagedIdentity) {
174
+ try {
175
+ if (same(lstatSync(temporary), stagedIdentity))
176
+ unlinkSync(temporary);
177
+ }
178
+ catch { /* Preserve an unverified pathname; never remove another inode. */ }
179
+ }
180
+ process.chdir(original);
181
+ }
182
+ }
183
+ export async function exportSkillsBundle(options) {
184
+ const path = outputPath(options.outputPath);
185
+ const assets = await loadSkillAssets(options.packageRoot);
186
+ if (assets.some(asset => asset.content.includes("本技能使用的 AI 名字是")))
187
+ throw new CliError("skills_export_source_invalid", "本包技能含绑定身份,未导出。", "只使用官方未绑定个人身份的技能包,不从已安装个人目录导出。", 7);
188
+ const operations = getAgentOperations();
189
+ const manifest = { formatVersion: 1, skills: assets.map(({ name, description }) => ({ name, description })) };
190
+ const entries = [
191
+ { name: "skills/manifest.json", content: Buffer.from(JSON.stringify(manifest, null, 2) + "\n") },
192
+ ...assets.map(asset => ({ name: `skills/${asset.name}/SKILL.md`, content: Buffer.from(asset.content) })),
193
+ { name: "operations.md", content: Buffer.from(operations.content) },
194
+ { name: "README.md", content: Buffer.from(`# 一间草堂官方公共技能包\n\n七项技能来自本CLI包内官方资源,未绑定个人身份;不含账号、令牌、同意或个人配置。\n公共短合同版本:${operations.version};正文SHA256:${operations.sha256}。\n人用手册:当前服务的 ${operations.manual_path}。\n\n导出不等于安装、授权、当前会话加载或行为通过。请按目标宿主实际支持的导入方式使用,核对共享范围;不会自动上传共享库、启动Agent或改个人配置。\n能执行CLI但无Skill的入口仍可按公共说明使用完整业务能力;纯网页无外部工具的AI尚未接通。\n`) },
195
+ ];
196
+ const bytes = zip(entries);
197
+ try {
198
+ publish(path, bytes);
199
+ }
200
+ catch (error) {
201
+ if (error instanceof CliError)
202
+ throw error;
203
+ throw unsafe();
204
+ }
205
+ return { status: 200, body: { operation: "skills_export", output_path: path, format: "zip", compression: "store", size_bytes: bytes.length, sha256: digest(bytes), entry_count: entries.length, skill_count: assets.length, uploaded: false } };
206
+ }