@cjhyy/code-shell-core 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +81 -27
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-finalize.js +8 -6
  10. package/dist/engine/run-session-open.js +1 -1
  11. package/dist/engine/run-setup.d.ts +4 -0
  12. package/dist/engine/run-setup.js +5 -1
  13. package/dist/engine/run-tooling.js +7 -1
  14. package/dist/engine/run-types.d.ts +33 -0
  15. package/dist/engine/run-types.js +21 -0
  16. package/dist/engine/run-workspace.js +11 -5
  17. package/dist/engine/turn-loop.js +9 -8
  18. package/dist/goal/lifecycle.d.ts +2 -0
  19. package/dist/goal/lifecycle.js +56 -33
  20. package/dist/index.d.ts +2 -3
  21. package/dist/index.internal.d.ts +1 -0
  22. package/dist/index.internal.js +1 -0
  23. package/dist/index.js +2 -2
  24. package/dist/links/cli.d.ts +2 -0
  25. package/dist/links/cli.js +11 -4
  26. package/dist/model-catalog/index.js +19 -4
  27. package/dist/model-catalog/save-entry.js +122 -61
  28. package/dist/model-catalog/types.js +27 -23
  29. package/dist/panel-apps/installer.js +27 -14
  30. package/dist/panel-apps/manifest.d.ts +11 -11
  31. package/dist/panel-apps/manifest.js +3 -1
  32. package/dist/panel-apps/registry.js +60 -12
  33. package/dist/plugins/installedPlugins.d.ts +4 -0
  34. package/dist/plugins/installedPlugins.js +70 -30
  35. package/dist/plugins/installer/types.d.ts +12 -12
  36. package/dist/plugins/installer/update.js +37 -38
  37. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  38. package/dist/plugins/knownMarketplaces.js +127 -23
  39. package/dist/plugins/pluginCatalog.js +18 -4
  40. package/dist/plugins/pluginHookApproval.js +56 -60
  41. package/dist/plugins/pluginMcpApproval.js +50 -52
  42. package/dist/profile/catalog-store.js +39 -4
  43. package/dist/profile/catalog.js +55 -15
  44. package/dist/profile/store.js +51 -21
  45. package/dist/prompt/composer.d.ts +8 -0
  46. package/dist/prompt/composer.js +8 -0
  47. package/dist/protocol/chat-session-manager.d.ts +9 -0
  48. package/dist/protocol/chat-session-manager.js +22 -0
  49. package/dist/protocol/chat-session.d.ts +5 -0
  50. package/dist/protocol/chat-session.js +1 -0
  51. package/dist/protocol/server.d.ts +2 -0
  52. package/dist/protocol/server.js +112 -29
  53. package/dist/protocol/types.d.ts +20 -0
  54. package/dist/run/FileRunStore.d.ts +2 -0
  55. package/dist/run/FileRunStore.js +153 -18
  56. package/dist/run/Heartbeat.js +63 -4
  57. package/dist/services/auto-dream.js +39 -17
  58. package/dist/services/session-memory.js +107 -8
  59. package/dist/session/file-history.d.ts +63 -2
  60. package/dist/session/file-history.js +593 -86
  61. package/dist/session/session-manager.d.ts +4 -1
  62. package/dist/session/session-manager.js +94 -47
  63. package/dist/session/transcript.js +33 -3
  64. package/dist/session/undo-target.d.ts +15 -6
  65. package/dist/session/undo-target.js +26 -9
  66. package/dist/settings/manager.d.ts +22 -3
  67. package/dist/settings/manager.js +185 -50
  68. package/dist/settings/schema.d.ts +3 -3
  69. package/dist/sources/adapters/local-files.js +49 -4
  70. package/dist/sources/catalog.js +64 -18
  71. package/dist/sources/types.d.ts +3 -3
  72. package/dist/sources/types.js +7 -4
  73. package/dist/themes/installer.js +192 -28
  74. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  75. package/dist/tool-system/builtin/cron.d.ts +2 -1
  76. package/dist/tool-system/builtin/cron.js +20 -6
  77. package/dist/tool-system/builtin/index.js +48 -1
  78. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  79. package/dist/tool-system/builtin/install-capability.js +1057 -0
  80. package/dist/tool-system/builtin/skill.js +3 -1
  81. package/dist/tool-system/executor.js +1 -0
  82. package/dist/tool-system/path-policy.js +25 -33
  83. package/dist/tool-system/registry.js +5 -0
  84. package/dist/utils/file-mutex.d.ts +2 -0
  85. package/dist/utils/file-mutex.js +29 -4
  86. package/package.json +2 -1
@@ -6,10 +6,91 @@
6
6
  * here (those go through the user's own Edit, by design).
7
7
  * See docs/superpowers/specs/2026-06-15-unified-model-catalog-design.md §7.
8
8
  */
9
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from "node:fs";
9
+ import { randomUUID } from "node:crypto";
10
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
10
11
  import { dirname } from "node:path";
11
12
  import { catalogEntrySchema, userCatalogFileSchema } from "./types.js";
12
13
  import { upsertCatalogEntry } from "./upsert.js";
14
+ import { acquireFileLock } from "../utils/file-mutex.js";
15
+ const MAX_USER_CATALOG_BYTES = 4 * 1024 * 1024;
16
+ const BACKUP_STAMP_RE = /^[A-Za-z0-9._-]{1,128}$/;
17
+ function pathEntry(path) {
18
+ try {
19
+ return lstatSync(path);
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return undefined;
24
+ throw error;
25
+ }
26
+ }
27
+ function readExistingCatalog(path) {
28
+ const metadata = pathEntry(path);
29
+ if (!metadata)
30
+ return undefined;
31
+ if (metadata.isSymbolicLink() ||
32
+ !metadata.isFile() ||
33
+ metadata.size > MAX_USER_CATALOG_BYTES) {
34
+ throw new Error("catalog must be a bounded regular file");
35
+ }
36
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
37
+ try {
38
+ const opened = fstatSync(descriptor);
39
+ if (!opened.isFile() || opened.size > MAX_USER_CATALOG_BYTES) {
40
+ throw new Error("catalog must be a bounded regular file");
41
+ }
42
+ const raw = readFileSync(descriptor, "utf8");
43
+ try {
44
+ const parsed = userCatalogFileSchema.safeParse(JSON.parse(raw));
45
+ return { raw, entries: parsed.success ? parsed.data : [] };
46
+ }
47
+ catch {
48
+ return { raw, entries: [] };
49
+ }
50
+ }
51
+ finally {
52
+ closeSync(descriptor);
53
+ }
54
+ }
55
+ function writeBackup(path, raw) {
56
+ try {
57
+ writeFileSync(path, raw, { encoding: "utf8", mode: 0o600, flag: "wx" });
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ function writeCatalogAtomic(path, entries) {
65
+ const serialized = `${JSON.stringify(userCatalogFileSchema.parse(entries), null, 2)}\n`;
66
+ if (Buffer.byteLength(serialized, "utf8") > MAX_USER_CATALOG_BYTES) {
67
+ throw new Error("catalog exceeds its size limit");
68
+ }
69
+ const parent = dirname(path);
70
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
71
+ const parentInfo = lstatSync(parent);
72
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
73
+ throw new Error("catalog parent must be a real directory");
74
+ }
75
+ const target = pathEntry(path);
76
+ if (target && (target.isSymbolicLink() || !target.isFile())) {
77
+ throw new Error("catalog target must be a regular file");
78
+ }
79
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
80
+ try {
81
+ writeFileSync(temporary, serialized, {
82
+ encoding: "utf8",
83
+ mode: 0o600,
84
+ flag: "wx",
85
+ });
86
+ renameSync(temporary, path);
87
+ if (process.platform !== "win32")
88
+ chmodSync(path, 0o600);
89
+ }
90
+ finally {
91
+ rmSync(temporary, { force: true });
92
+ }
93
+ }
13
94
  /**
14
95
  * @param stamp caller-supplied unique suffix for the backup filename. Pass a
15
96
  * timestamp/counter from the caller — core forbids Date.now() in some paths
@@ -22,47 +103,27 @@ export function saveCatalogEntry(entry, opts) {
22
103
  return { ok: false, error: `invalid catalog entry: ${parsed.error.issues.map((i) => i.message).join("; ")}` };
23
104
  }
24
105
  const valid = parsed.data;
25
- // Read + back up any existing file (even if corrupt — never silently lose it).
26
- let current = [];
27
- let backup;
28
- if (existsSync(opts.path)) {
29
- backup = `${opts.path}.bak-${opts.stamp}`;
30
- try {
31
- copyFileSync(opts.path, backup);
32
- }
33
- catch {
34
- backup = undefined; // best-effort; don't abort the write on backup failure
35
- }
36
- try {
37
- const raw = JSON.parse(readFileSync(opts.path, "utf-8"));
38
- const safe = userCatalogFileSchema.safeParse(raw);
39
- current = safe.success ? safe.data : [];
40
- }
41
- catch {
42
- current = []; // corrupt → start fresh (original preserved in backup)
43
- }
44
- }
45
- const action = current.some((e) => e.id === valid.id) ? "updated" : "added";
46
- const next = upsertCatalogEntry(current, valid);
47
- // Ensure the parent dir exists — a first-ever catalog write on a machine
48
- // whose ~/.code-shell hasn't been created yet would otherwise throw ENOENT
49
- // (the agent-facing tool wants a clean {ok:false} or a real write, not a crash).
106
+ if (!BACKUP_STAMP_RE.test(opts.stamp))
107
+ return { ok: false, error: "invalid backup stamp" };
108
+ let release;
50
109
  try {
51
- mkdirSync(dirname(opts.path), { recursive: true });
110
+ release = acquireFileLock(opts.path);
111
+ const existing = readExistingCatalog(opts.path);
112
+ let backup = existing ? `${opts.path}.bak-${opts.stamp}` : undefined;
113
+ if (existing && !writeBackup(backup, existing.raw))
114
+ backup = undefined;
115
+ const action = existing?.entries.some((e) => e.id === valid.id)
116
+ ? "updated"
117
+ : "added";
118
+ writeCatalogAtomic(opts.path, upsertCatalogEntry(existing?.entries ?? [], valid));
119
+ return { ok: true, action, backup };
52
120
  }
53
121
  catch (e) {
54
- return { ok: false, error: `could not create catalog directory: ${e instanceof Error ? e.message : String(e)}`, backup };
122
+ return { ok: false, error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}` };
55
123
  }
56
- try {
57
- writeFileSync(opts.path, JSON.stringify(next, null, 2));
58
- }
59
- catch (e) {
60
- // IO error (perms / disk full / bad path): return a clean {ok:false} with the
61
- // backup filename preserved, never let the throw escape past the tool's
62
- // expected result shape (the original file is intact — we only upsert-wrote).
63
- return { ok: false, error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}`, backup };
124
+ finally {
125
+ release?.();
64
126
  }
65
- return { ok: true, action, backup };
66
127
  }
67
128
  /**
68
129
  * Remove the entry with `id` from the user catalog file. Mirrors saveCatalogEntry's
@@ -72,33 +133,33 @@ export function saveCatalogEntry(entry, opts) {
72
133
  * built-in version ("reset" semantics).
73
134
  */
74
135
  export function deleteUserCatalogEntry(id, opts) {
75
- if (!existsSync(opts.path))
76
- return { ok: true, removed: false };
77
- let backup = `${opts.path}.bak-${opts.stamp}`;
78
- try {
79
- copyFileSync(opts.path, backup);
80
- }
81
- catch {
82
- backup = undefined;
136
+ if (!BACKUP_STAMP_RE.test(opts.stamp)) {
137
+ return { ok: false, removed: false, error: "invalid backup stamp" };
83
138
  }
84
- let current = [];
139
+ let release;
85
140
  try {
86
- const raw = JSON.parse(readFileSync(opts.path, "utf-8"));
87
- const safe = userCatalogFileSchema.safeParse(raw);
88
- current = safe.success ? safe.data : [];
89
- }
90
- catch {
91
- current = [];
92
- }
93
- const next = current.filter((e) => e.id !== id);
94
- const removed = next.length !== current.length;
95
- if (!removed)
96
- return { ok: true, removed: false, backup };
97
- try {
98
- writeFileSync(opts.path, JSON.stringify(next, null, 2));
141
+ release = acquireFileLock(opts.path);
142
+ const existing = readExistingCatalog(opts.path);
143
+ if (!existing)
144
+ return { ok: true, removed: false };
145
+ let backup = `${opts.path}.bak-${opts.stamp}`;
146
+ if (!writeBackup(backup, existing.raw))
147
+ backup = undefined;
148
+ const next = existing.entries.filter((e) => e.id !== id);
149
+ const removed = next.length !== existing.entries.length;
150
+ if (!removed)
151
+ return { ok: true, removed: false, backup };
152
+ writeCatalogAtomic(opts.path, next);
153
+ return { ok: true, removed: true, backup };
99
154
  }
100
155
  catch (e) {
101
- return { ok: false, removed: false, error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}`, backup };
156
+ return {
157
+ ok: false,
158
+ removed: false,
159
+ error: `could not write catalog: ${e instanceof Error ? e.message : String(e)}`,
160
+ };
161
+ }
162
+ finally {
163
+ release?.();
102
164
  }
103
- return { ok: true, removed: true, backup };
104
165
  }
@@ -12,6 +12,10 @@
12
12
  * ("openai"/"google"/"fal"). `shape` is documentation/future only.
13
13
  */
14
14
  import { z } from "zod";
15
+ const id = z.string().min(1).max(128);
16
+ const shortText = z.string().max(256);
17
+ const longText = z.string().max(32_768);
18
+ const boundedNumber = z.number().finite().min(0).max(1_000_000_000);
15
19
  /**
16
20
  * How the param maps onto the outgoing request body. `field` is the request
17
21
  * field this param lands on — same param name can land differently per
@@ -20,7 +24,7 @@ import { z } from "zod";
20
24
  * in `if (kind === ...)` branches in the engine. Minimal v1: field only.
21
25
  */
22
26
  export const wireSpecSchema = z.object({
23
- field: z.string(),
27
+ field: id,
24
28
  });
25
29
  /**
26
30
  * One **generic** declarative param — no special-casing per param. reasoning
@@ -32,18 +36,18 @@ export const wireSpecSchema = z.object({
32
36
  */
33
37
  export const paramSpecSchema = z.object({
34
38
  /** Logical name, e.g. "reasoning" / "size" / "quality" / "temperature". */
35
- name: z.string(),
39
+ name: id,
36
40
  /** UI control label (falls back to `name`). */
37
- label: z.string().optional(),
41
+ label: shortText.optional(),
38
42
  control: z.enum(["enum", "number", "toggle", "text"]),
39
43
  /** control=enum allowed values, e.g. ["low","medium","high","xhigh"]. */
40
- options: z.array(z.string()).optional(),
44
+ options: z.array(shortText).max(64).optional(),
41
45
  /** control=number bounds. */
42
- min: z.number().optional(),
43
- max: z.number().optional(),
44
- default: z.union([z.string(), z.number(), z.boolean()]).optional(),
46
+ min: boundedNumber.optional(),
47
+ max: boundedNumber.optional(),
48
+ default: z.union([shortText, boundedNumber, z.boolean()]).optional(),
45
49
  /** Natural-language usage note → injected into the tool description. */
46
- doc: z.string().optional(),
50
+ doc: z.string().max(4_096).optional(),
47
51
  /** How this param lands on the request body. */
48
52
  wire: wireSpecSchema.optional(),
49
53
  });
@@ -53,17 +57,17 @@ export const paramSpecSchema = z.object({
53
57
  * can expose different params, since gateways normalize differently).
54
58
  */
55
59
  export const modelPresetSchema = z.object({
56
- value: z.string(),
57
- label: z.string().optional(),
58
- maxContextTokens: z.number().optional(),
59
- maxOutputTokens: z.number().optional(),
60
+ value: id,
61
+ label: shortText.optional(),
62
+ maxContextTokens: boundedNumber.optional(),
63
+ maxOutputTokens: boundedNumber.optional(),
60
64
  supportsVision: z.boolean().optional(),
61
65
  /** Params this model supports; absent → no adjustable knobs. */
62
- params: z.array(paramSpecSchema).optional(),
66
+ params: z.array(paramSpecSchema).max(64).optional(),
63
67
  });
64
68
  export const catalogEntrySchema = z.object({
65
69
  /** Template id, e.g. "openai" / "openai-images" / "fal-video". */
66
- id: z.string(),
70
+ id,
67
71
  /** Which 连接 page group this lands in. (audio = speech-to-text / voice input.) */
68
72
  tag: z.enum(["text", "image", "video", "audio"]),
69
73
  /**
@@ -71,15 +75,15 @@ export const catalogEntrySchema = z.object({
71
75
  * runtime adapter for image/video. OpenRouter is `openrouter` even though its
72
76
  * text protocol is `openai-compat`.
73
77
  */
74
- adapterKind: z.string(),
78
+ adapterKind: id,
75
79
  /** LLM client protocol (text entries). */
76
80
  protocol: z.enum(["openai-compat", "anthropic-style"]).optional(),
77
81
  /** HTTP shape — documentation/future only; runtime dispatches on adapterKind. */
78
82
  shape: z.enum(["generic-sync", "fal-queue"]).optional(),
79
- displayName: z.string(),
80
- description: z.string(),
81
- defaultBaseUrl: z.string(),
82
- defaultModel: z.string().optional(),
83
+ displayName: z.string().min(1).max(256),
84
+ description: z.string().max(4_096),
85
+ defaultBaseUrl: z.string().max(4_096),
86
+ defaultModel: shortText.optional(),
83
87
  /** Whether this provider needs an API key (ollama/local = false). */
84
88
  needsKey: z.boolean().optional(),
85
89
  /**
@@ -89,8 +93,8 @@ export const catalogEntrySchema = z.object({
89
93
  * `value`, so future built-in models remain visible.
90
94
  */
91
95
  modelPresetsMode: z.enum(["replace", "merge"]).optional(),
92
- modelPresets: z.array(modelPresetSchema).optional(),
93
- signupUrl: z.string().optional(),
96
+ modelPresets: z.array(modelPresetSchema).max(512).optional(),
97
+ signupUrl: z.string().max(4_096).optional(),
94
98
  /** Whether the 连接 card offers a "测试" button (image=true, video=false). */
95
99
  test: z.boolean().optional(),
96
100
  /**
@@ -98,7 +102,7 @@ export const catalogEntrySchema = z.object({
98
102
  * the agent via the dynamic GenerateImage/GenerateVideo tool description so it
99
103
  * knows what a configured model accepts (different models differ).
100
104
  */
101
- paramsDoc: z.string().optional(),
105
+ paramsDoc: longText.optional(),
102
106
  });
103
107
  /** A user-catalog file is just an array of entries (zod-validated on load). */
104
- export const userCatalogFileSchema = z.array(catalogEntrySchema);
108
+ export const userCatalogFileSchema = z.array(catalogEntrySchema).max(256);
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync } from "node:fs";
3
- import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
2
+ import { constants, existsSync } from "node:fs";
3
+ import { cp, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, extname, join, posix, relative, resolve, sep } from "node:path";
6
6
  import { extractZip, extractZipSubdirectory } from "../plugins/installer/unzip.js";
@@ -279,22 +279,34 @@ async function walkBoundedTree(root, directory, depth, budget, files, directorie
279
279
  }
280
280
  }
281
281
  async function readManifest(sourceRoot) {
282
- const file = join(sourceRoot, PANEL_APP_MANIFEST_FILE);
283
- let info;
284
282
  try {
285
- info = await stat(file);
283
+ const raw = await readBoundedPackageFile(sourceRoot, PANEL_APP_MANIFEST_FILE, MAX_MANIFEST_BYTES);
284
+ return PanelAppManifest.parse(JSON.parse(raw.toString("utf8")));
286
285
  }
287
- catch {
288
- throw new PanelAppInstallError(`missing ${PANEL_APP_MANIFEST_FILE}`);
286
+ catch (error) {
287
+ throw new PanelAppInstallError(`invalid Panel App manifest: ${error instanceof Error ? error.message : String(error)}`);
288
+ }
289
+ }
290
+ async function readBoundedPackageFile(root, relativePath, maxBytes) {
291
+ const candidate = join(root, ...relativePath.split("/"));
292
+ const metadata = await lstat(candidate);
293
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > maxBytes) {
294
+ throw new PanelAppInstallError(`Panel App file is not a bounded regular file: ${relativePath}`);
289
295
  }
290
- if (!info.isFile() || info.size > MAX_MANIFEST_BYTES) {
291
- throw new PanelAppInstallError(`${PANEL_APP_MANIFEST_FILE} must be a regular file no larger than 1 MiB`);
296
+ const physical = await realpath(candidate);
297
+ if (physical !== root && !physical.startsWith(`${root}${sep}`)) {
298
+ throw new PanelAppInstallError(`Panel App file escapes its package: ${relativePath}`);
292
299
  }
300
+ const handle = await open(candidate, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
293
301
  try {
294
- return PanelAppManifest.parse(JSON.parse(await readFile(file, "utf-8")));
302
+ const opened = await handle.stat();
303
+ if (!opened.isFile() || opened.size > maxBytes) {
304
+ throw new PanelAppInstallError(`Panel App file is not a bounded regular file: ${relativePath}`);
305
+ }
306
+ return await handle.readFile();
295
307
  }
296
- catch (error) {
297
- throw new PanelAppInstallError(`invalid Panel App manifest: ${error instanceof Error ? error.message : String(error)}`);
308
+ finally {
309
+ await handle.close();
298
310
  }
299
311
  }
300
312
  async function inspectPanelAppSource(sourceRoot) {
@@ -323,10 +335,11 @@ async function inspectPanelAppSource(sourceRoot) {
323
335
  if (!files.includes(skillEntry)) {
324
336
  throw new PanelAppInstallError(`declared Panel App skill is missing: ${skillEntry}`);
325
337
  }
326
- const skillInfo = await stat(join(root, ...skillEntry.split("/")));
338
+ const skillInfo = await lstat(join(root, ...skillEntry.split("/")));
327
339
  if (!skillInfo.isFile() || skillInfo.size > MAX_AGENT_SKILL_BYTES) {
328
340
  throw new PanelAppInstallError(`declared Panel App skill must be a file no larger than 256 KiB: ${skillEntry}`);
329
341
  }
342
+ await readBoundedPackageFile(root, skillEntry, MAX_AGENT_SKILL_BYTES);
330
343
  }
331
344
  for (const file of files) {
332
345
  if (file === PANEL_APP_MANIFEST_FILE || file === PANEL_APP_META_FILE)
@@ -352,7 +365,7 @@ async function inspectPanelAppSource(sourceRoot) {
352
365
  hash
353
366
  .update(file)
354
367
  .update("\0")
355
- .update(await readFile(join(root, ...file.split("/"))));
368
+ .update(await readBoundedPackageFile(root, file, file === PANEL_APP_MANIFEST_FILE ? MAX_MANIFEST_BYTES : MAX_FILE_BYTES));
356
369
  }
357
370
  return { manifest, files, digest: hash.digest("hex") };
358
371
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export declare const PANEL_APP_MANIFEST_FILE = ".codeshell-panel/panel.json";
3
- export declare const PANEL_APP_PERMISSIONS: readonly ["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage"];
3
+ export declare const PANEL_APP_PERMISSIONS: readonly ["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "agent.task", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage", "process"];
4
4
  export declare const PANEL_APP_ICONS: readonly ["panel", "activity", "bar-chart-3", "chart", "file-text", "globe", "image", "layout-dashboard", "line-chart", "palette", "pie-chart", "table", "terminal"];
5
5
  export declare const PanelAppAgentTool: z.ZodEffects<z.ZodObject<{
6
6
  name: z.ZodString;
@@ -116,12 +116,12 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
116
116
  icon: z.ZodDefault<z.ZodEnum<["panel", "activity", "bar-chart-3", "chart", "file-text", "globe", "image", "layout-dashboard", "line-chart", "palette", "pie-chart", "table", "terminal"]>>;
117
117
  placement: z.ZodDefault<z.ZodLiteral<"right-dock">>;
118
118
  singleton: z.ZodDefault<z.ZodBoolean>;
119
- permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage"]>, "many">>;
119
+ permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "agent.task", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage", "process"]>, "many">>;
120
120
  schemaVersion: z.ZodLiteral<1>;
121
121
  }, "strict", z.ZodTypeAny, {
122
122
  id: string;
123
123
  version: string;
124
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
124
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[];
125
125
  entry: string;
126
126
  title: {
127
127
  default: string;
@@ -144,7 +144,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
144
144
  };
145
145
  schemaVersion: 1;
146
146
  description?: string | undefined;
147
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
147
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[] | undefined;
148
148
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
149
149
  placement?: "right-dock" | undefined;
150
150
  singleton?: boolean | undefined;
@@ -230,12 +230,12 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
230
230
  icon: z.ZodDefault<z.ZodEnum<["panel", "activity", "bar-chart-3", "chart", "file-text", "globe", "image", "layout-dashboard", "line-chart", "palette", "pie-chart", "table", "terminal"]>>;
231
231
  placement: z.ZodDefault<z.ZodLiteral<"right-dock">>;
232
232
  singleton: z.ZodDefault<z.ZodBoolean>;
233
- permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage"]>, "many">>;
233
+ permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "agent.task", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage", "process"]>, "many">>;
234
234
  schemaVersion: z.ZodLiteral<2>;
235
235
  }, "strict", z.ZodTypeAny, {
236
236
  id: string;
237
237
  version: string;
238
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
238
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[];
239
239
  entry: string;
240
240
  title: {
241
241
  default: string;
@@ -276,14 +276,14 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
276
276
  }[] | undefined;
277
277
  } | undefined;
278
278
  description?: string | undefined;
279
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
279
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[] | undefined;
280
280
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
281
281
  placement?: "right-dock" | undefined;
282
282
  singleton?: boolean | undefined;
283
283
  }>]>, {
284
284
  id: string;
285
285
  version: string;
286
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
286
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[];
287
287
  entry: string;
288
288
  title: {
289
289
  default: string;
@@ -298,7 +298,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
298
298
  } | {
299
299
  id: string;
300
300
  version: string;
301
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
301
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[];
302
302
  entry: string;
303
303
  title: {
304
304
  default: string;
@@ -330,7 +330,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
330
330
  };
331
331
  schemaVersion: 1;
332
332
  description?: string | undefined;
333
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
333
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[] | undefined;
334
334
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
335
335
  placement?: "right-dock" | undefined;
336
336
  singleton?: boolean | undefined;
@@ -354,7 +354,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
354
354
  }[] | undefined;
355
355
  } | undefined;
356
356
  description?: string | undefined;
357
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
357
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "agent.task" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage" | "process")[] | undefined;
358
358
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
359
359
  placement?: "right-dock" | undefined;
360
360
  singleton?: boolean | undefined;
@@ -7,6 +7,7 @@ export const PANEL_APP_PERMISSIONS = [
7
7
  "storage",
8
8
  "external.open",
9
9
  "agent.submitPrompt",
10
+ "agent.task",
10
11
  "workspace.info",
11
12
  "workspace.read",
12
13
  "workspace.write",
@@ -14,6 +15,7 @@ export const PANEL_APP_PERMISSIONS = [
14
15
  "audio.transcribe",
15
16
  "credentials.cookies",
16
17
  "automations.manage",
18
+ "process",
17
19
  ];
18
20
  export const PANEL_APP_ICONS = [
19
21
  "panel",
@@ -139,7 +141,7 @@ const PanelAppManifestFields = {
139
141
  icon: z.enum(PANEL_APP_ICONS).default("panel"),
140
142
  placement: z.literal("right-dock").default("right-dock"),
141
143
  singleton: z.boolean().default(true),
142
- permissions: z.array(z.enum(PANEL_APP_PERMISSIONS)).max(12).default([]),
144
+ permissions: z.array(z.enum(PANEL_APP_PERMISSIONS)).max(16).default([]),
143
145
  };
144
146
  /**
145
147
  * Schema v2 keeps one installable Panel App identity while allowing it to
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync } from "node:fs";
3
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import { z } from "zod";
6
6
  import { panelAppsRegistryPath } from "./paths.js";
@@ -28,47 +28,95 @@ const Registry = z
28
28
  apps: z.array(RegistryEntry).max(1_024),
29
29
  })
30
30
  .strict();
31
- async function readRegistryUnlocked() {
31
+ const MAX_PANEL_APP_REGISTRY_BYTES = 4 * 1024 * 1024;
32
+ async function registryEntry(path) {
33
+ try {
34
+ return await lstat(path);
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return null;
39
+ throw error;
40
+ }
41
+ }
42
+ async function checkedRegistryDirectory() {
43
+ const directory = dirname(panelAppsRegistryPath());
44
+ await mkdir(directory, { recursive: true, mode: 0o700 });
45
+ const metadata = await lstat(directory);
46
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
47
+ throw new Error("Panel App registry directory must be a real directory");
48
+ }
49
+ return directory;
50
+ }
51
+ async function readRegistryUnlocked(strict = false) {
32
52
  const path = panelAppsRegistryPath();
33
- if (!existsSync(path))
34
- return [];
53
+ let handle;
35
54
  try {
36
- return Registry.parse(JSON.parse(await readFile(path, "utf-8"))).apps;
55
+ const metadata = await registryEntry(path);
56
+ if (!metadata)
57
+ return [];
58
+ if (metadata.isSymbolicLink() ||
59
+ !metadata.isFile() ||
60
+ metadata.size > MAX_PANEL_APP_REGISTRY_BYTES) {
61
+ throw new Error("Panel App registry must be a bounded regular file");
62
+ }
63
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
64
+ const opened = await handle.stat();
65
+ if (!opened.isFile() || opened.size > MAX_PANEL_APP_REGISTRY_BYTES) {
66
+ throw new Error("Panel App registry must be a bounded regular file");
67
+ }
68
+ return Registry.parse(JSON.parse(await handle.readFile("utf8"))).apps;
37
69
  }
38
- catch {
70
+ catch (error) {
71
+ if (strict) {
72
+ const detail = error instanceof Error ? `: ${error.message}` : "";
73
+ throw new Error(`Panel App registry is corrupt${detail}`, { cause: error });
74
+ }
39
75
  return [];
40
76
  }
77
+ finally {
78
+ await handle?.close().catch(() => undefined);
79
+ }
41
80
  }
42
81
  async function writeRegistryUnlocked(apps) {
43
82
  const path = panelAppsRegistryPath();
44
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
83
+ await checkedRegistryDirectory();
84
+ const target = await registryEntry(path);
85
+ if (target && (target.isSymbolicLink() || !target.isFile())) {
86
+ throw new Error("Panel App registry target must be a regular file");
87
+ }
45
88
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
46
89
  try {
47
90
  const registry = Registry.parse({
48
91
  version: 1,
49
92
  apps: [...apps].sort((left, right) => left.id.localeCompare(right.id)),
50
93
  });
51
- await writeFile(tmp, `${JSON.stringify(registry, null, 2)}\n`, {
94
+ const serialized = `${JSON.stringify(registry, null, 2)}\n`;
95
+ if (Buffer.byteLength(serialized, "utf8") > MAX_PANEL_APP_REGISTRY_BYTES) {
96
+ throw new Error("Panel App registry is too large");
97
+ }
98
+ await writeFile(tmp, serialized, {
52
99
  encoding: "utf-8",
53
100
  flag: "wx",
54
101
  mode: 0o600,
55
102
  });
56
103
  await rename(tmp, path);
104
+ if (process.platform !== "win32")
105
+ await chmod(path, 0o600);
57
106
  }
58
107
  finally {
59
108
  await rm(tmp, { force: true });
60
109
  }
61
110
  }
62
111
  async function mutateRegistry(mutation) {
63
- const directory = dirname(panelAppsRegistryPath());
64
- await mkdir(directory, { recursive: true, mode: 0o700 });
112
+ const directory = await checkedRegistryDirectory();
65
113
  const release = await lock(directory, {
66
114
  realpath: true,
67
115
  stale: 10_000,
68
116
  retries: { retries: 8, minTimeout: 10, maxTimeout: 120, factor: 1.5 },
69
117
  });
70
118
  try {
71
- const next = mutation(await readRegistryUnlocked());
119
+ const next = mutation(await readRegistryUnlocked(true));
72
120
  await writeRegistryUnlocked(next.apps);
73
121
  return next.result;
74
122
  }
@@ -7,6 +7,10 @@ import type { InstalledPluginsV2, PluginInstallEntry } from "./types.js";
7
7
  export declare function installedPluginsPath(): string;
8
8
  export declare function readInstalledPlugins(): InstalledPluginsV2;
9
9
  export declare function writeInstalledPlugins(data: InstalledPluginsV2): void;
10
+ export declare function mutateInstalledPlugins<R>(mutation: (current: InstalledPluginsV2) => {
11
+ value?: InstalledPluginsV2;
12
+ result?: R;
13
+ }): R | undefined;
10
14
  /**
11
15
  * Append an install entry for `<plugin>@<marketplace>`. Multiple entries
12
16
  * for the same key are allowed (different scopes); MVP only writes scope:"user".