@cjhyy/code-shell-core 0.8.9 → 0.8.11
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/dist/automation/scheduler.js +49 -0
- package/dist/automation/store.d.ts +1 -1
- package/dist/automation/store.js +184 -10
- package/dist/cli/agent-server-stdio.js +7 -0
- package/dist/credentials/store.d.ts +14 -0
- package/dist/credentials/store.js +245 -42
- package/dist/engine/engine.js +45 -6
- package/dist/engine/file-history-hook.js +24 -5
- package/dist/engine/run-types.d.ts +9 -0
- package/dist/engine/turn-loop.js +9 -8
- package/dist/goal/lifecycle.d.ts +2 -0
- package/dist/goal/lifecycle.js +56 -33
- package/dist/index.d.ts +2 -3
- package/dist/index.internal.d.ts +1 -0
- package/dist/index.internal.js +1 -0
- package/dist/index.js +2 -2
- package/dist/links/cli.d.ts +2 -0
- package/dist/links/cli.js +11 -4
- package/dist/model-catalog/index.js +19 -4
- package/dist/model-catalog/save-entry.js +122 -61
- package/dist/model-catalog/types.js +27 -23
- package/dist/panel-apps/installer.js +27 -14
- package/dist/panel-apps/registry.js +60 -12
- package/dist/plugins/installedPlugins.d.ts +4 -0
- package/dist/plugins/installedPlugins.js +70 -30
- package/dist/plugins/installer/types.d.ts +12 -12
- package/dist/plugins/installer/update.js +37 -38
- package/dist/plugins/knownMarketplaces.d.ts +7 -3
- package/dist/plugins/knownMarketplaces.js +127 -23
- package/dist/plugins/pluginCatalog.js +18 -4
- package/dist/plugins/pluginHookApproval.js +56 -60
- package/dist/plugins/pluginMcpApproval.js +50 -52
- package/dist/profile/catalog-store.js +39 -4
- package/dist/profile/catalog.js +55 -15
- package/dist/profile/store.js +51 -21
- package/dist/protocol/chat-session-manager.d.ts +9 -0
- package/dist/protocol/chat-session-manager.js +13 -0
- package/dist/protocol/chat-session.d.ts +5 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/server.d.ts +2 -0
- package/dist/protocol/server.js +75 -29
- package/dist/protocol/types.d.ts +8 -0
- package/dist/run/FileRunStore.d.ts +2 -0
- package/dist/run/FileRunStore.js +153 -18
- package/dist/run/Heartbeat.js +63 -4
- package/dist/services/auto-dream.js +39 -17
- package/dist/services/session-memory.js +107 -8
- package/dist/session/file-history.d.ts +63 -2
- package/dist/session/file-history.js +593 -86
- package/dist/session/session-manager.d.ts +1 -0
- package/dist/session/session-manager.js +52 -21
- package/dist/session/transcript.js +33 -3
- package/dist/session/undo-target.d.ts +15 -6
- package/dist/session/undo-target.js +26 -9
- package/dist/settings/manager.d.ts +22 -3
- package/dist/settings/manager.js +185 -50
- package/dist/settings/schema.d.ts +3 -3
- package/dist/sources/adapters/local-files.js +49 -4
- package/dist/sources/catalog.js +64 -18
- package/dist/sources/types.d.ts +3 -3
- package/dist/sources/types.js +7 -4
- package/dist/themes/installer.js +192 -28
- package/dist/tool-system/builtin/add-marketplace.js +21 -1
- package/dist/tool-system/builtin/cron.d.ts +2 -1
- package/dist/tool-system/builtin/cron.js +20 -6
- package/dist/tool-system/builtin/index.js +44 -0
- package/dist/tool-system/builtin/install-capability.d.ts +52 -0
- package/dist/tool-system/builtin/install-capability.js +1057 -0
- package/dist/tool-system/builtin/skill.js +3 -1
- package/dist/tool-system/executor.js +1 -0
- package/dist/tool-system/registry.js +5 -0
- package/dist/tool-system/sandbox/index.d.ts +1 -0
- package/dist/tool-system/sandbox/index.js +4 -1
- package/dist/utils/file-mutex.d.ts +2 -0
- package/dist/utils/file-mutex.js +29 -4
- package/package.json +2 -1
package/dist/settings/manager.js
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Priority: CLI flags > local > project > user > managed
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, } from "node:fs";
|
|
7
7
|
import { join, dirname, extname } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { parse as parseYaml } from "yaml";
|
|
10
10
|
import { validateSettings } from "./schema.js";
|
|
11
11
|
import { migrateModels } from "../migrate-models.js";
|
|
12
12
|
import { migrateConfig, CONFIG_VERSION_KEY } from "./migrate-config.js";
|
|
13
|
-
import { acquireFileLock } from "../utils/file-mutex.js";
|
|
13
|
+
import { acquireFileLock, writeFileAtomic } from "../utils/file-mutex.js";
|
|
14
14
|
/**
|
|
15
15
|
* Resolve the user's home directory. Prefers `process.env.HOME` so that
|
|
16
16
|
* runtime env overrides (set after process start, e.g. in tests) actually
|
|
@@ -52,6 +52,7 @@ export function isProtectedSettingKey(key) {
|
|
|
52
52
|
return PROTECTED_SETTING_ROOTS.has(root);
|
|
53
53
|
}
|
|
54
54
|
const FORBIDDEN_SETTING_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
55
|
+
const MAX_SETTINGS_FILE_BYTES = 4 * 1024 * 1024;
|
|
55
56
|
function isForbiddenSettingKeySegment(key) {
|
|
56
57
|
return FORBIDDEN_SETTING_KEY_SEGMENTS.has(key);
|
|
57
58
|
}
|
|
@@ -205,9 +206,13 @@ export class SettingsManager {
|
|
|
205
206
|
}
|
|
206
207
|
if (readProject) {
|
|
207
208
|
// 3. Project
|
|
208
|
-
this.
|
|
209
|
+
const projectPath = this.tryProjectSettingsPath(this.cwd, "settings.json");
|
|
210
|
+
if (projectPath)
|
|
211
|
+
this.loadJsonFile(projectPath, "project", 2);
|
|
209
212
|
// 4. Local
|
|
210
|
-
this.
|
|
213
|
+
const localPath = this.tryProjectSettingsPath(this.cwd, "settings.local.json");
|
|
214
|
+
if (localPath)
|
|
215
|
+
this.loadJsonFile(localPath, "local", 3);
|
|
211
216
|
}
|
|
212
217
|
// 5. CLI flags (highest priority)
|
|
213
218
|
if (flagOverrides && Object.keys(flagOverrides).length > 0) {
|
|
@@ -225,7 +230,9 @@ export class SettingsManager {
|
|
|
225
230
|
this.applyConfigMigration(join(this.userConfigDir(), "settings.json"), "user");
|
|
226
231
|
}
|
|
227
232
|
if (readProject) {
|
|
228
|
-
this.
|
|
233
|
+
const projectPath = this.tryProjectSettingsPath(this.cwd, "settings.json");
|
|
234
|
+
if (projectPath)
|
|
235
|
+
this.applyConfigMigration(projectPath, "project");
|
|
229
236
|
}
|
|
230
237
|
// Workspace-trust gate: an untrusted project must not influence execution
|
|
231
238
|
// through dangerous fields committed into its own .code-shell/settings.*.
|
|
@@ -250,15 +257,17 @@ export class SettingsManager {
|
|
|
250
257
|
// single physical file. Gated on readUser: under non-full scope we must
|
|
251
258
|
// not read — let alone rewrite — the host's ~/.code-shell/settings.json.
|
|
252
259
|
const userPath = join(this.userConfigDir(), "settings.json");
|
|
253
|
-
if (readUser &&
|
|
260
|
+
if (readUser && resolveConfigPath(userPath) === userPath) {
|
|
254
261
|
try {
|
|
255
|
-
const userRaw =
|
|
262
|
+
const userRaw = parseConfigFile(userPath);
|
|
263
|
+
if (!userRaw)
|
|
264
|
+
throw new Error("invalid user settings");
|
|
256
265
|
const result = migrateModels({
|
|
257
266
|
providers: userRaw.providers ?? [],
|
|
258
267
|
models: userRaw.models ?? [],
|
|
259
268
|
});
|
|
260
269
|
if (result.changed) {
|
|
261
|
-
|
|
270
|
+
this.writeBackup(userPath);
|
|
262
271
|
const migrated = {
|
|
263
272
|
...userRaw,
|
|
264
273
|
providers: result.providers,
|
|
@@ -295,10 +304,10 @@ export class SettingsManager {
|
|
|
295
304
|
* so this load() already sees the migrated shape.
|
|
296
305
|
*/
|
|
297
306
|
applyConfigMigration(path, sourceName) {
|
|
298
|
-
if (
|
|
307
|
+
if (resolveConfigPath(path) !== path)
|
|
299
308
|
return;
|
|
300
309
|
try {
|
|
301
|
-
const parsed =
|
|
310
|
+
const parsed = parseConfigFile(path);
|
|
302
311
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
303
312
|
return;
|
|
304
313
|
const raw = sanitizeSettingsObject(parsed);
|
|
@@ -313,7 +322,7 @@ export class SettingsManager {
|
|
|
313
322
|
};
|
|
314
323
|
if (JSON.stringify(stripStamp(raw)) === JSON.stringify(stripStamp(result.config)))
|
|
315
324
|
return;
|
|
316
|
-
|
|
325
|
+
this.writeBackup(path);
|
|
317
326
|
// Atomic write (tmp+rename) so a concurrent load can't read a half-written
|
|
318
327
|
// migrated file — matches the normal save path (atomicWriteJson). The file
|
|
319
328
|
// exists here (existsSync guard above), so the recursive mkdir is a no-op.
|
|
@@ -351,34 +360,20 @@ export class SettingsManager {
|
|
|
351
360
|
*/
|
|
352
361
|
saveUserSetting(key, value) {
|
|
353
362
|
const path = join(this.userConfigDir(), "settings.json");
|
|
354
|
-
|
|
363
|
+
assertSafeSettingsWriteTarget(path);
|
|
355
364
|
// Lock spans read → modify → write; see mutateSettingsFile for why the
|
|
356
365
|
// atomic rename alone was not enough. This path keeps its own sanitizing
|
|
357
366
|
// read (readJsonObject does not sanitize) so behaviour is unchanged apart
|
|
358
367
|
// from the added serialization.
|
|
359
368
|
const release = acquireFileLock(path);
|
|
360
369
|
try {
|
|
361
|
-
|
|
362
|
-
if (existsSync(path)) {
|
|
363
|
-
try {
|
|
364
|
-
const raw = readFileSync(path, "utf-8");
|
|
365
|
-
const parsed = JSON.parse(raw);
|
|
366
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
367
|
-
current = sanitizeSettingsObject(parsed);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
catch {
|
|
371
|
-
// Corrupt file — overwrite rather than crash.
|
|
372
|
-
}
|
|
373
|
-
}
|
|
370
|
+
const current = parseConfigFile(path) ?? {};
|
|
374
371
|
setDottedSetting(current, key, value);
|
|
375
372
|
// Atomic write: stage to .tmp, then rename, so a concurrent read can't
|
|
376
373
|
// catch a half-written file. mode 0o600 — settings.json can hold plaintext
|
|
377
374
|
// API keys, so it must be owner-only like credentials.json (store.ts:56),
|
|
378
375
|
// not world-readable (default umask leaves 0o644 otherwise).
|
|
379
|
-
|
|
380
|
-
writeFileSync(tmp, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
381
|
-
renameSync(tmp, path);
|
|
376
|
+
this.atomicWriteJson(path, current);
|
|
382
377
|
}
|
|
383
378
|
finally {
|
|
384
379
|
release();
|
|
@@ -392,14 +387,31 @@ export class SettingsManager {
|
|
|
392
387
|
* cache invalidation mirror saveUserSetting.
|
|
393
388
|
*/
|
|
394
389
|
saveProjectSetting(key, value, cwd) {
|
|
395
|
-
|
|
396
|
-
const path = this.projectSettingsPath(cwd);
|
|
390
|
+
this.validateProjectCwd(cwd, "project");
|
|
397
391
|
// Don't resurrect a deleted project root: atomicWriteJson's recursive mkdir
|
|
398
392
|
// of <cwd>/.code-shell recreates `cwd` itself as an empty shell when cwd is
|
|
399
393
|
// gone. A non-empty cwd that no longer exists means the project was deleted
|
|
400
394
|
// — skip the write rather than recreate it.
|
|
401
395
|
if (!existsSync(cwd))
|
|
402
396
|
return;
|
|
397
|
+
const path = this.projectSettingsPath(cwd);
|
|
398
|
+
this.mutateSettingsFile(path, (current) => {
|
|
399
|
+
setDottedSetting(current, key, value);
|
|
400
|
+
});
|
|
401
|
+
this.invalidate();
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Persist a machine-private setting for one project. The local layer has
|
|
405
|
+
* higher precedence than the shared project layer and lives at
|
|
406
|
+
* `${cwd}/.code-shell/settings.local.json`, matching the file already read
|
|
407
|
+
* by {@link load}. It is useful for MCP endpoints or policy that should not
|
|
408
|
+
* be shared with collaborators.
|
|
409
|
+
*/
|
|
410
|
+
saveLocalSetting(key, value, cwd) {
|
|
411
|
+
this.validateProjectCwd(cwd, "local");
|
|
412
|
+
if (!existsSync(cwd))
|
|
413
|
+
return;
|
|
414
|
+
const path = this.localSettingsPath(cwd);
|
|
403
415
|
this.mutateSettingsFile(path, (current) => {
|
|
404
416
|
setDottedSetting(current, key, value);
|
|
405
417
|
});
|
|
@@ -412,6 +424,19 @@ export class SettingsManager {
|
|
|
412
424
|
*/
|
|
413
425
|
deleteProjectSetting(key, cwd) {
|
|
414
426
|
const path = this.projectSettingsPath(cwd);
|
|
427
|
+
this.deleteSettingFromFile(path, key);
|
|
428
|
+
}
|
|
429
|
+
/** Delete one dotted key from the machine-private project settings layer. */
|
|
430
|
+
deleteLocalSetting(key, cwd) {
|
|
431
|
+
const path = this.localSettingsPath(cwd);
|
|
432
|
+
this.deleteSettingFromFile(path, key);
|
|
433
|
+
}
|
|
434
|
+
/** Delete one dotted key from the user settings layer. */
|
|
435
|
+
deleteUserSetting(key) {
|
|
436
|
+
const path = join(this.userConfigDir(), "settings.json");
|
|
437
|
+
this.deleteSettingFromFile(path, key);
|
|
438
|
+
}
|
|
439
|
+
deleteSettingFromFile(path, key) {
|
|
415
440
|
// Must be YAML-aware, symmetric with saveProjectSetting: a project with only
|
|
416
441
|
// settings.yaml has no .json, so the old `existsSync(path)` guard returned
|
|
417
442
|
// here and the override survived (read/merge ARE yaml-aware → UI shows
|
|
@@ -442,13 +467,18 @@ export class SettingsManager {
|
|
|
442
467
|
* overlay math needs the project overlay and the user/global baseline
|
|
443
468
|
* separately — the merged get() collapses provenance and can't express
|
|
444
469
|
* tri-state inheritance. user → ~/.code-shell/settings.json, project →
|
|
445
|
-
* ${cwd}/.code-shell/settings.json
|
|
446
|
-
*
|
|
470
|
+
* ${cwd}/.code-shell/settings.json, local →
|
|
471
|
+
* ${cwd}/.code-shell/settings.local.json. Only keys actually present in the
|
|
472
|
+
* file are returned (defaults are not synthesized), so an absent file → {}.
|
|
447
473
|
*/
|
|
448
474
|
getForScope(scope, cwd) {
|
|
449
475
|
const path = scope === "user"
|
|
450
476
|
? join(this.userConfigDir(), "settings.json")
|
|
451
|
-
:
|
|
477
|
+
: scope === "local"
|
|
478
|
+
? this.tryProjectSettingsPath(cwd ?? this.cwd, "settings.local.json")
|
|
479
|
+
: this.tryProjectSettingsPath(cwd ?? this.cwd, "settings.json");
|
|
480
|
+
if (!path)
|
|
481
|
+
return {};
|
|
452
482
|
const raw = this.readJsonObject(path);
|
|
453
483
|
// validateSettings applies defaults; for a scope view we want only the
|
|
454
484
|
// file's own keys, so validate then project back the present keys.
|
|
@@ -456,21 +486,55 @@ export class SettingsManager {
|
|
|
456
486
|
const out = {};
|
|
457
487
|
for (const k of Object.keys(raw))
|
|
458
488
|
out[k] = validated[k];
|
|
459
|
-
// Same workspace-trust gate as load():
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
if (scope === "project" && !this.projectTrusted) {
|
|
489
|
+
// Same workspace-trust gate as load(): project/local scope reads bypass the
|
|
490
|
+
// merge, so an untrusted project's dangerous fields (for example an MCP
|
|
491
|
+
// server or setup script) must be stripped here too.
|
|
492
|
+
if ((scope === "project" || scope === "local") && !this.projectTrusted) {
|
|
464
493
|
for (const field of DANGEROUS_PROJECT_FIELDS)
|
|
465
494
|
delete out[field];
|
|
466
495
|
}
|
|
467
496
|
return out;
|
|
468
497
|
}
|
|
469
498
|
projectSettingsPath(cwd) {
|
|
499
|
+
const path = this.tryProjectSettingsPath(cwd, "settings.json", true);
|
|
500
|
+
if (!path)
|
|
501
|
+
throw new Error("unsafe project settings directory");
|
|
502
|
+
return path;
|
|
503
|
+
}
|
|
504
|
+
localSettingsPath(cwd) {
|
|
505
|
+
const path = this.tryProjectSettingsPath(cwd, "settings.local.json", true);
|
|
506
|
+
if (!path)
|
|
507
|
+
throw new Error("unsafe local settings directory");
|
|
508
|
+
return path;
|
|
509
|
+
}
|
|
510
|
+
validateProjectCwd(cwd, layer) {
|
|
470
511
|
if (!cwd || cwd.trim().length === 0) {
|
|
471
|
-
throw new Error(
|
|
512
|
+
throw new Error(`${layer} setting write requires a non-empty cwd`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** Resolve the project root once and refuse a linked/non-directory state root. */
|
|
516
|
+
tryProjectSettingsPath(cwd, filename, strict = false) {
|
|
517
|
+
this.validateProjectCwd(cwd, filename === "settings.json" ? "project" : "local");
|
|
518
|
+
if (!existsSync(cwd))
|
|
519
|
+
return join(cwd, ".code-shell", filename);
|
|
520
|
+
try {
|
|
521
|
+
const root = realpathSync(cwd);
|
|
522
|
+
if (!lstatSync(root).isDirectory())
|
|
523
|
+
throw new Error("project root is not a directory");
|
|
524
|
+
const stateDir = join(root, ".code-shell");
|
|
525
|
+
if (existsSync(stateDir)) {
|
|
526
|
+
const info = lstatSync(stateDir);
|
|
527
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
528
|
+
throw new Error("project .code-shell must be a real directory");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return join(stateDir, filename);
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (strict)
|
|
535
|
+
throw error;
|
|
536
|
+
return null;
|
|
472
537
|
}
|
|
473
|
-
return join(cwd, ".code-shell", "settings.json");
|
|
474
538
|
}
|
|
475
539
|
readJsonObject(path) {
|
|
476
540
|
// Resolve to a sibling .yaml/.yml when the .json layer is absent so
|
|
@@ -481,12 +545,22 @@ export class SettingsManager {
|
|
|
481
545
|
return parseConfigFile(resolved) ?? {};
|
|
482
546
|
}
|
|
483
547
|
atomicWriteJson(path, data) {
|
|
484
|
-
|
|
548
|
+
assertSafeSettingsWriteTarget(path);
|
|
549
|
+
const serialized = JSON.stringify(data, null, 2);
|
|
550
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_SETTINGS_FILE_BYTES) {
|
|
551
|
+
throw new Error(`settings file exceeds ${MAX_SETTINGS_FILE_BYTES} bytes`);
|
|
552
|
+
}
|
|
485
553
|
// mode 0o600: settings.json may hold plaintext API keys — owner-only, see
|
|
486
554
|
// saveUserSetting above and credentials/store.ts.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
555
|
+
writeFileAtomic(path, serialized, 0o600);
|
|
556
|
+
}
|
|
557
|
+
writeBackup(path) {
|
|
558
|
+
const content = readBoundedRegularFile(path);
|
|
559
|
+
if (content === null)
|
|
560
|
+
throw new Error("settings backup source is unsafe");
|
|
561
|
+
const backupPath = `${path}.bak`;
|
|
562
|
+
assertSafeSettingsWriteTarget(backupPath);
|
|
563
|
+
writeFileAtomic(backupPath, content, 0o600);
|
|
490
564
|
}
|
|
491
565
|
/**
|
|
492
566
|
* Read-modify-write a settings file under a cross-process lock.
|
|
@@ -506,9 +580,10 @@ export class SettingsManager {
|
|
|
506
580
|
* mutates it in place; returning false skips the write.
|
|
507
581
|
*/
|
|
508
582
|
mutateSettingsFile(path, mutate) {
|
|
509
|
-
|
|
583
|
+
assertSafeSettingsWriteTarget(path);
|
|
510
584
|
const release = acquireFileLock(path);
|
|
511
585
|
try {
|
|
586
|
+
assertSafeSettingsWriteTarget(path);
|
|
512
587
|
// Re-read INSIDE the lock: a snapshot taken before acquiring it would be
|
|
513
588
|
// exactly the stale value that drops the other writer's key.
|
|
514
589
|
const current = this.readJsonObject(path);
|
|
@@ -577,10 +652,10 @@ export class SettingsManager {
|
|
|
577
652
|
* what an absent/empty layer means.
|
|
578
653
|
*/
|
|
579
654
|
function parseConfigFile(path) {
|
|
580
|
-
if (!existsSync(path))
|
|
581
|
-
return null;
|
|
582
655
|
try {
|
|
583
|
-
const content =
|
|
656
|
+
const content = readBoundedRegularFile(path);
|
|
657
|
+
if (content === null)
|
|
658
|
+
return null;
|
|
584
659
|
const ext = extname(path).toLowerCase();
|
|
585
660
|
const parsed = ext === ".yaml" || ext === ".yml" ? parseYaml(content) : JSON.parse(content);
|
|
586
661
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
@@ -592,6 +667,49 @@ function parseConfigFile(path) {
|
|
|
592
667
|
}
|
|
593
668
|
return null;
|
|
594
669
|
}
|
|
670
|
+
/** Read through a no-follow descriptor so a settings-file symlink cannot escape its layer. */
|
|
671
|
+
function readBoundedRegularFile(path) {
|
|
672
|
+
let fd;
|
|
673
|
+
try {
|
|
674
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
675
|
+
const info = fstatSync(fd);
|
|
676
|
+
if (!info.isFile() || info.size > MAX_SETTINGS_FILE_BYTES)
|
|
677
|
+
return null;
|
|
678
|
+
return readFileSync(fd, "utf8");
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
finally {
|
|
684
|
+
if (fd !== undefined)
|
|
685
|
+
closeSync(fd);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Writers must never follow a linked settings directory or replace an unusual
|
|
690
|
+
* filesystem object. Atomic rename protects the file contents, but without
|
|
691
|
+
* this boundary check a project-controlled `.code-shell` directory symlink
|
|
692
|
+
* redirects the entire write outside the workspace.
|
|
693
|
+
*/
|
|
694
|
+
function assertSafeSettingsWriteTarget(path) {
|
|
695
|
+
const parent = dirname(path);
|
|
696
|
+
if (!existsSync(parent))
|
|
697
|
+
mkdirSync(parent, { recursive: true });
|
|
698
|
+
const parentInfo = lstatSync(parent);
|
|
699
|
+
if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
|
|
700
|
+
throw new Error("settings directory must be a real directory");
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
const targetInfo = lstatSync(path);
|
|
704
|
+
if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) {
|
|
705
|
+
throw new Error("settings target must be a regular file");
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
if (error.code !== "ENOENT")
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
595
713
|
/**
|
|
596
714
|
* Given the JSON path for a settings layer (e.g. .../settings.json or
|
|
597
715
|
* .../settings.local.json), return the path that should actually be read:
|
|
@@ -600,16 +718,33 @@ function parseConfigFile(path) {
|
|
|
600
718
|
* hand-written read-only alternative. Returns null when no layer file exists.
|
|
601
719
|
*/
|
|
602
720
|
function resolveConfigPath(jsonPath) {
|
|
603
|
-
|
|
721
|
+
const jsonStatus = configCandidateStatus(jsonPath);
|
|
722
|
+
if (jsonStatus === "safe")
|
|
604
723
|
return jsonPath;
|
|
724
|
+
if (jsonStatus === "unsafe")
|
|
725
|
+
return null;
|
|
605
726
|
const base = jsonPath.replace(/\.json$/, "");
|
|
606
727
|
for (const ext of [".yaml", ".yml"]) {
|
|
607
728
|
const candidate = `${base}${ext}`;
|
|
608
|
-
|
|
729
|
+
const status = configCandidateStatus(candidate);
|
|
730
|
+
if (status === "safe")
|
|
609
731
|
return candidate;
|
|
732
|
+
if (status === "unsafe")
|
|
733
|
+
return null;
|
|
610
734
|
}
|
|
611
735
|
return null;
|
|
612
736
|
}
|
|
737
|
+
function configCandidateStatus(path) {
|
|
738
|
+
try {
|
|
739
|
+
const info = lstatSync(path);
|
|
740
|
+
return !info.isSymbolicLink() && info.isFile() && info.size <= MAX_SETTINGS_FILE_BYTES
|
|
741
|
+
? "safe"
|
|
742
|
+
: "unsafe";
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
return error.code === "ENOENT" ? "missing" : "unsafe";
|
|
746
|
+
}
|
|
747
|
+
}
|
|
613
748
|
function merge(base, override) {
|
|
614
749
|
const result = {};
|
|
615
750
|
for (const [key, value] of Object.entries(base)) {
|
|
@@ -681,7 +681,7 @@ export declare const SettingsSchema: z.ZodObject<{
|
|
|
681
681
|
*/
|
|
682
682
|
sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
683
683
|
sourceId: z.ZodString;
|
|
684
|
-
scopes: z.ZodArray<z.ZodString, "many">;
|
|
684
|
+
scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
|
|
685
685
|
readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
|
|
686
686
|
}, "strip", z.ZodTypeAny, {
|
|
687
687
|
sourceId: string;
|
|
@@ -1705,7 +1705,7 @@ export declare const SettingsSchema: z.ZodObject<{
|
|
|
1705
1705
|
*/
|
|
1706
1706
|
sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
1707
1707
|
sourceId: z.ZodString;
|
|
1708
|
-
scopes: z.ZodArray<z.ZodString, "many">;
|
|
1708
|
+
scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
|
|
1709
1709
|
readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
|
|
1710
1710
|
}, "strip", z.ZodTypeAny, {
|
|
1711
1711
|
sourceId: string;
|
|
@@ -2729,7 +2729,7 @@ export declare const SettingsSchema: z.ZodObject<{
|
|
|
2729
2729
|
*/
|
|
2730
2730
|
sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
2731
2731
|
sourceId: z.ZodString;
|
|
2732
|
-
scopes: z.ZodArray<z.ZodString, "many">;
|
|
2732
|
+
scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
|
|
2733
2733
|
readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
|
|
2734
2734
|
}, "strip", z.ZodTypeAny, {
|
|
2735
2735
|
sourceId: string;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 文件在 ${cwd}/.code-shell/uploads/ 内;读取前规范化 resourceId,
|
|
4
4
|
* 并校验消解 symlink 后的真实路径仍在 uploads 目录内。
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { truncateUtf8Bytes } from "../truncate-utf8.js";
|
|
9
9
|
export const LOCAL_FILES_SOURCE_ID = "project-uploads";
|
|
@@ -76,9 +76,32 @@ function canonicalResourceId(resourceId) {
|
|
|
76
76
|
return canonical.split(sep).join("/");
|
|
77
77
|
}
|
|
78
78
|
function resolveInsideUploads(cwd, resourceId) {
|
|
79
|
-
const
|
|
79
|
+
const workspace = realpathSync(resolve(cwd));
|
|
80
|
+
const stateDir = join(workspace, ".code-shell");
|
|
81
|
+
const stateInfo = lstatSync(stateDir);
|
|
82
|
+
if (stateInfo.isSymbolicLink() || !stateInfo.isDirectory()) {
|
|
83
|
+
throw new Error("project state directory must be a regular directory");
|
|
84
|
+
}
|
|
85
|
+
const stateReal = realpathSync(stateDir);
|
|
86
|
+
if (relative(workspace, stateReal).startsWith(`..${sep}`) || relative(workspace, stateReal) === "..") {
|
|
87
|
+
throw new Error("project state directory escapes cwd");
|
|
88
|
+
}
|
|
89
|
+
const uploadPath = join(stateReal, "uploads");
|
|
90
|
+
const uploadInfo = lstatSync(uploadPath);
|
|
91
|
+
if (uploadInfo.isSymbolicLink() || !uploadInfo.isDirectory()) {
|
|
92
|
+
throw new Error("uploads directory must be a regular directory");
|
|
93
|
+
}
|
|
94
|
+
const root = realpathSync(uploadPath);
|
|
95
|
+
const uploadRel = relative(stateReal, root);
|
|
96
|
+
if (uploadRel === ".." || uploadRel.startsWith(`..${sep}`) || isAbsolute(uploadRel)) {
|
|
97
|
+
throw new Error("uploads directory escapes cwd");
|
|
98
|
+
}
|
|
80
99
|
const canonicalId = canonicalResourceId(resourceId);
|
|
81
100
|
const candidate = resolve(root, ...canonicalId.split("/"));
|
|
101
|
+
const candidateInfo = lstatSync(candidate);
|
|
102
|
+
if (candidateInfo.isSymbolicLink() || !candidateInfo.isFile()) {
|
|
103
|
+
throw new Error(`resource escapes uploads or is not a regular file: ${resourceId}`);
|
|
104
|
+
}
|
|
82
105
|
const real = realpathSync(candidate);
|
|
83
106
|
if (real !== root && !real.startsWith(`${root}${sep}`)) {
|
|
84
107
|
throw new Error(`resource escapes uploads dir: ${resourceId}`);
|
|
@@ -115,13 +138,35 @@ export function listLocalFiles(cwd) {
|
|
|
115
138
|
const directory = uploadsDir(cwd);
|
|
116
139
|
if (!existsSync(directory))
|
|
117
140
|
return [];
|
|
118
|
-
|
|
141
|
+
let workspace;
|
|
142
|
+
let directoryReal;
|
|
143
|
+
try {
|
|
144
|
+
workspace = realpathSync(resolve(cwd));
|
|
145
|
+
const stateDir = join(workspace, ".code-shell");
|
|
146
|
+
const stateInfo = lstatSync(stateDir);
|
|
147
|
+
const directoryInfo = lstatSync(directory);
|
|
148
|
+
if (stateInfo.isSymbolicLink() ||
|
|
149
|
+
!stateInfo.isDirectory() ||
|
|
150
|
+
directoryInfo.isSymbolicLink() ||
|
|
151
|
+
!directoryInfo.isDirectory()) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
directoryReal = realpathSync(directory);
|
|
155
|
+
const rel = relative(workspace, directoryReal);
|
|
156
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return readdirSync(directoryReal, { withFileTypes: true })
|
|
119
163
|
.filter((entry) => entry.isFile())
|
|
164
|
+
.slice(0, 10_000)
|
|
120
165
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
121
166
|
.map((entry) => ({
|
|
122
167
|
id: entry.name,
|
|
123
168
|
scopeId: "uploads",
|
|
124
169
|
name: entry.name,
|
|
125
|
-
sizeBytes: statSync(join(
|
|
170
|
+
sizeBytes: statSync(join(directoryReal, entry.name)).size,
|
|
126
171
|
}));
|
|
127
172
|
}
|
package/dist/sources/catalog.js
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
/** 全局数据源目录:codeShellHome()/sources.json。损坏条目隔离,原子写。 */
|
|
2
|
-
import {
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { logger } from "../logging/logger.js";
|
|
5
5
|
import { codeShellHome } from "../session/session-manager.js";
|
|
6
|
+
import { mutateJsonFile } from "../utils/file-mutex.js";
|
|
6
7
|
import { SourceDefinitionSchema } from "./types.js";
|
|
8
|
+
const MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
|
9
|
+
const MAX_CATALOG_SOURCES = 1_000;
|
|
10
|
+
const MAX_SOURCE_DEFINITION_BYTES = 1024 * 1024;
|
|
7
11
|
export function sourceCatalogPath() {
|
|
8
12
|
return join(codeShellHome(), "sources.json");
|
|
9
13
|
}
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
function parseCatalog(rawText) {
|
|
15
|
+
if (rawText === undefined)
|
|
16
|
+
return [];
|
|
17
|
+
if (Buffer.byteLength(rawText, "utf8") > MAX_CATALOG_BYTES)
|
|
13
18
|
return [];
|
|
14
19
|
try {
|
|
15
|
-
const raw = JSON.parse(
|
|
20
|
+
const raw = JSON.parse(rawText);
|
|
16
21
|
if (raw.version !== 1 || !Array.isArray(raw.sources))
|
|
17
22
|
return [];
|
|
18
|
-
const sources =
|
|
19
|
-
for (const entry of raw.sources) {
|
|
23
|
+
const sources = new Map();
|
|
24
|
+
for (const entry of raw.sources.slice(0, MAX_CATALOG_SOURCES)) {
|
|
20
25
|
const parsed = SourceDefinitionSchema.safeParse(entry);
|
|
21
26
|
if (parsed.success) {
|
|
22
|
-
|
|
27
|
+
let encoded;
|
|
28
|
+
try {
|
|
29
|
+
encoded = JSON.stringify(parsed.data);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (Buffer.byteLength(encoded, "utf8") <= MAX_SOURCE_DEFINITION_BYTES) {
|
|
35
|
+
sources.set(parsed.data.id, parsed.data);
|
|
36
|
+
}
|
|
23
37
|
}
|
|
24
38
|
else {
|
|
25
39
|
logger.warn("sources.catalog_entry_invalid", {
|
|
@@ -28,7 +42,7 @@ function load() {
|
|
|
28
42
|
});
|
|
29
43
|
}
|
|
30
44
|
}
|
|
31
|
-
return sources;
|
|
45
|
+
return [...sources.values()];
|
|
32
46
|
}
|
|
33
47
|
catch (error) {
|
|
34
48
|
logger.warn("sources.catalog_unreadable", {
|
|
@@ -38,15 +52,32 @@ function load() {
|
|
|
38
52
|
return [];
|
|
39
53
|
}
|
|
40
54
|
}
|
|
41
|
-
function
|
|
55
|
+
function load() {
|
|
42
56
|
const path = sourceCatalogPath();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return [];
|
|
59
|
+
try {
|
|
60
|
+
const info = lstatSync(path);
|
|
61
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_CATALOG_BYTES)
|
|
62
|
+
return [];
|
|
63
|
+
return parseCatalog(readFileSync(path, "utf-8"));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function mutateCatalog(mutation) {
|
|
70
|
+
const path = sourceCatalogPath();
|
|
71
|
+
const directory = dirname(path);
|
|
72
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
73
|
+
if (process.platform !== "win32")
|
|
74
|
+
chmodSync(directory, 0o700);
|
|
75
|
+
mutateJsonFile(path, {
|
|
76
|
+
parse: parseCatalog,
|
|
77
|
+
serialize: (sources) => `${JSON.stringify({ version: 1, sources }, null, 2)}\n`,
|
|
78
|
+
mutation: (current) => ({ value: mutation(current) }),
|
|
47
79
|
mode: 0o600,
|
|
48
80
|
});
|
|
49
|
-
renameSync(tmp, path);
|
|
50
81
|
}
|
|
51
82
|
export function listSourceDefinitions() {
|
|
52
83
|
return load().sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -56,9 +87,24 @@ export function readSourceDefinition(id) {
|
|
|
56
87
|
}
|
|
57
88
|
export function saveSourceDefinition(definition) {
|
|
58
89
|
const parsed = SourceDefinitionSchema.parse(definition);
|
|
59
|
-
|
|
60
|
-
|
|
90
|
+
let encoded;
|
|
91
|
+
try {
|
|
92
|
+
encoded = JSON.stringify(parsed);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error("source definition must be JSON-serializable");
|
|
96
|
+
}
|
|
97
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_SOURCE_DEFINITION_BYTES) {
|
|
98
|
+
throw new Error("source definition exceeds the size limit");
|
|
99
|
+
}
|
|
100
|
+
mutateCatalog((current) => {
|
|
101
|
+
const rest = current.filter((source) => source.id !== parsed.id);
|
|
102
|
+
if (rest.length >= MAX_CATALOG_SOURCES)
|
|
103
|
+
throw new Error("source catalog is full");
|
|
104
|
+
return [...rest, parsed];
|
|
105
|
+
});
|
|
61
106
|
}
|
|
62
107
|
export function deleteSourceDefinition(id) {
|
|
63
|
-
|
|
108
|
+
SourceDefinitionSchema.shape.id.parse(id);
|
|
109
|
+
mutateCatalog((current) => current.filter((source) => source.id !== id));
|
|
64
110
|
}
|
package/dist/sources/types.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export type SourceKind = (typeof SOURCE_KINDS)[number];
|
|
|
10
10
|
export declare const SourceDefinitionSchema: z.ZodObject<{
|
|
11
11
|
id: z.ZodString;
|
|
12
12
|
kind: z.ZodEnum<["mock", "mcp-resource", "local-files"]>;
|
|
13
|
-
label: z.ZodString
|
|
14
|
-
description: z.ZodOptional<z.ZodString
|
|
13
|
+
label: z.ZodEffects<z.ZodString, string, string>;
|
|
14
|
+
description: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
15
15
|
/** 按 kind 的 adapter 配置(如 mcp-resource: { server })。 */
|
|
16
16
|
adapterConfig: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
17
17
|
/** 指向全局 CredentialStore 的 id;local-files/mock 不需要。 */
|
|
@@ -38,7 +38,7 @@ export type SourceDefinition = z.infer<typeof SourceDefinitionSchema>;
|
|
|
38
38
|
export declare const WorkspaceSourceBindingSchema: z.ZodObject<{
|
|
39
39
|
sourceId: z.ZodString;
|
|
40
40
|
/** 显式勾选的 scope id;空数组 = 什么都不可见(不是"全部")。 */
|
|
41
|
-
scopes: z.ZodArray<z.ZodString, "many">;
|
|
41
|
+
scopes: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
|
|
42
42
|
/** ask(默认,ReadSource 每次审批)| deny(只许 list metadata,禁读内容)。无 allow 档(ADR §1.2)。 */
|
|
43
43
|
readPolicy: z.ZodDefault<z.ZodEnum<["ask", "deny"]>>;
|
|
44
44
|
}, "strip", z.ZodTypeAny, {
|