@cjhyy/code-shell-server 0.9.22 → 0.9.25

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 (109) hide show
  1. package/README.md +45 -1
  2. package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
  3. package/dist/bin/code-shell-settings-recovery.js +4 -0
  4. package/dist/desktop-web/http-api.js +14 -0
  5. package/dist/environment-identity.d.ts +4 -0
  6. package/dist/environment-identity.js +67 -0
  7. package/dist/hub/auth-store.d.ts +2 -0
  8. package/dist/hub/auth-store.js +7 -0
  9. package/dist/index.auth.d.ts +3 -0
  10. package/dist/index.auth.js +2 -0
  11. package/dist/index.links.d.ts +1 -0
  12. package/dist/index.links.js +1 -0
  13. package/dist/index.panels.d.ts +7 -0
  14. package/dist/index.panels.js +7 -0
  15. package/dist/index.remote-relay.d.ts +5 -0
  16. package/dist/index.remote-relay.js +6 -0
  17. package/dist/index.serve.d.ts +2 -0
  18. package/dist/index.serve.js +2 -0
  19. package/dist/links/http.d.ts +3 -0
  20. package/dist/links/http.js +11 -2
  21. package/dist/links/remote-configuration.d.ts +4 -0
  22. package/dist/links/remote-configuration.js +32 -0
  23. package/dist/links/service.d.ts +6 -1
  24. package/dist/links/service.js +241 -19
  25. package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
  26. package/dist/mobile-remote/mobile-upload-service.js +69 -11
  27. package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
  28. package/dist/mobile-remote/remote-host-manager.js +53 -5
  29. package/dist/panels/agent-task-service.js +9 -2
  30. package/dist/panels/automations.d.ts +49 -0
  31. package/dist/panels/automations.js +134 -0
  32. package/dist/panels/bridge-contract.d.ts +5 -3
  33. package/dist/panels/bridge-contract.js +11 -1
  34. package/dist/panels/directory-bookmarks.d.ts +9 -1
  35. package/dist/panels/directory-bookmarks.js +64 -5
  36. package/dist/panels/execution-gate.d.ts +26 -0
  37. package/dist/panels/execution-gate.js +70 -0
  38. package/dist/panels/http.d.ts +26 -2
  39. package/dist/panels/http.js +7 -1
  40. package/dist/panels/hub-automations.d.ts +31 -0
  41. package/dist/panels/hub-automations.js +352 -0
  42. package/dist/panels/management-http.d.ts +20 -2
  43. package/dist/panels/management-http.js +15 -2
  44. package/dist/panels/management.d.ts +26 -4
  45. package/dist/panels/management.js +382 -39
  46. package/dist/panels/package-mutation.d.ts +8 -0
  47. package/dist/panels/package-mutation.js +23 -0
  48. package/dist/panels/process-service.d.ts +3 -0
  49. package/dist/panels/process-service.js +77 -4
  50. package/dist/panels/resources/clone.d.ts +13 -0
  51. package/dist/panels/resources/clone.js +99 -0
  52. package/dist/panels/resources/http.d.ts +11 -0
  53. package/dist/panels/resources/http.js +74 -0
  54. package/dist/panels/resources/references.d.ts +5 -0
  55. package/dist/panels/resources/references.js +26 -0
  56. package/dist/panels/resources/service.js +63 -27
  57. package/dist/panels/runtime-services.js +16 -3
  58. package/dist/panels/runtime.d.ts +16 -4
  59. package/dist/panels/runtime.js +522 -64
  60. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  61. package/dist/panels/shared-tool-jobs.js +37 -0
  62. package/dist/panels/storage-store.d.ts +21 -0
  63. package/dist/panels/storage-store.js +43 -0
  64. package/dist/panels/task-cookie-host.d.ts +26 -0
  65. package/dist/panels/task-cookie-host.js +145 -0
  66. package/dist/panels/task-cookies.d.ts +51 -0
  67. package/dist/panels/task-cookies.js +183 -0
  68. package/dist/panels/tool-executor.d.ts +21 -5
  69. package/dist/panels/tool-executor.js +171 -30
  70. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  71. package/dist/panels/tool-jobs-storage.js +34 -0
  72. package/dist/panels/tool-jobs.d.ts +49 -3
  73. package/dist/panels/tool-jobs.js +270 -15
  74. package/dist/panels/types.d.ts +43 -0
  75. package/dist/project-runtime/backup.d.ts +26 -0
  76. package/dist/project-runtime/backup.js +500 -0
  77. package/dist/project-runtime/control-server.d.ts +4 -0
  78. package/dist/project-runtime/control-server.js +19 -1
  79. package/dist/project-runtime/docker-provider.d.ts +4 -0
  80. package/dist/project-runtime/docker-provider.js +16 -1
  81. package/dist/project-runtime/managed-entry.js +4 -0
  82. package/dist/project-runtime/registry.d.ts +2 -0
  83. package/dist/project-runtime/registry.js +15 -0
  84. package/dist/project-runtime/seccomp.d.ts +8 -0
  85. package/dist/project-runtime/seccomp.js +72 -0
  86. package/dist/project-runtime/types.d.ts +2 -0
  87. package/dist/remote-relay/byte-stream.d.ts +9 -0
  88. package/dist/remote-relay/byte-stream.js +71 -0
  89. package/dist/remote-relay/connector.d.ts +27 -0
  90. package/dist/remote-relay/connector.js +197 -0
  91. package/dist/remote-relay/local-stream.d.ts +10 -0
  92. package/dist/remote-relay/local-stream.js +120 -0
  93. package/dist/remote-relay/protocol.d.ts +39 -0
  94. package/dist/remote-relay/protocol.js +64 -0
  95. package/dist/serve/automation-worker.d.ts +16 -0
  96. package/dist/serve/automation-worker.js +126 -0
  97. package/dist/serve/cli.d.ts +3 -0
  98. package/dist/serve/cli.js +17 -0
  99. package/dist/serve/headless-server.d.ts +3 -0
  100. package/dist/serve/headless-server.js +140 -10
  101. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  102. package/dist/serve/settings-recovery-cli.js +128 -0
  103. package/dist/session-http-responses.d.ts +7 -0
  104. package/dist/session-http-responses.js +40 -0
  105. package/dist/sessions-service.d.ts +6 -2
  106. package/dist/sessions-service.js +23 -4
  107. package/dist/worker-bridge-core.d.ts +5 -0
  108. package/dist/worker-bridge-core.js +23 -0
  109. package/package.json +15 -6
@@ -1,7 +1,9 @@
1
+ import { panelExecutionGate } from "./execution-gate.js";
2
+ import { panelPackageMutationMatches } from "./package-mutation.js";
1
3
  import { createHash, randomBytes } from "node:crypto";
2
- import { lstatSync, mkdirSync } from "node:fs";
3
- import { basename, join, resolve } from "node:path";
4
- import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, userHome, panelAppInstallDir, panelAppsRoot, previewLocalPanelApp, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
4
+ import { lstatSync, mkdirSync, statSync } from "node:fs";
5
+ import { basename, extname, isAbsolute, join, resolve } from "node:path";
6
+ import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
5
7
  import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
6
8
  import { getRefCommit, parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
7
9
  const REVIEW_TTL = 8 * 60_000;
@@ -154,6 +156,7 @@ export function createPanelManagement(options) {
154
156
  const workspace = resolve(options.bindingCwd ?? options.cwd);
155
157
  const hasProject = workspace !== resolve(join(userHome(), ".code-shell", "no-repo"));
156
158
  const reviews = new Map();
159
+ const restores = new Map();
157
160
  const generations = new Map();
158
161
  let closed = false;
159
162
  let networkBusy = 0;
@@ -174,22 +177,49 @@ export function createPanelManagement(options) {
174
177
  .update(JSON.stringify({
175
178
  app,
176
179
  digest,
180
+ ...(options.projectPackages
181
+ ? { pin: parsePanelAppPackagePins(project ?? projectSettings())[app.id] ?? null }
182
+ : {}),
177
183
  bound: current.boundApps.has(app.id),
178
184
  disabled: current.globalDisabledApps.has(app.id),
179
185
  }))
180
186
  .digest("hex");
181
187
  }
182
- async function catalogApp(id) {
183
- const app = (await listInstalledPanelApps()).find((item) => item.id === id);
184
- if (!app)
185
- throw new PanelManagementError(404, "not_found", "找不到这个面板,请刷新列表。");
186
- const target = panelAppInstallDir(id);
187
- const info = lstatSync(target);
188
+ function projectSettings() {
189
+ return new SettingsManager(workspace, "full").getRawForScope("project", workspace, {
190
+ strict: true,
191
+ });
192
+ }
193
+ // Compare the app-specific state under the project settings lock after package
194
+ // installation. Installing bytes must never overwrite another device's binding.
195
+ function projectState(id, project = projectSettings()) {
196
+ const current = policy(project);
197
+ return JSON.stringify({
198
+ bound: current.boundApps.has(id),
199
+ disabled: current.globalDisabledApps.has(id),
200
+ pin: options.projectPackages ? (parsePanelAppPackagePins(project)[id] ?? null) : null,
201
+ });
202
+ }
203
+ const listApps = () => options.projectPackages
204
+ ? inspectProjectPanelApps(workspace).then((result) => result.apps)
205
+ : listInstalledPanelApps();
206
+ async function inspectApp(app) {
207
+ const info = lstatSync(app.installPath);
188
208
  if (info.isSymbolicLink() || !info.isDirectory())
189
209
  throw new PanelManagementError(403, "unsafe_path", "面板安装目录不支持链接。");
190
- const preview = await previewLocalPanelApp({ kind: "dir", path: target });
210
+ const preview = await previewLocalPanelApp({ kind: "dir", path: app.installPath });
191
211
  return { app, digest: preview.reviewToken };
192
212
  }
213
+ async function selectedApp(id) {
214
+ const app = (await listApps()).find((item) => item.id === id);
215
+ if (!app)
216
+ throw new PanelManagementError(404, "not_found", "找不到这个面板,请刷新列表。");
217
+ return inspectApp(app);
218
+ }
219
+ async function catalogState(id) {
220
+ const app = (await listInstalledPanelApps()).find((item) => item.id === id);
221
+ return JSON.stringify(app ? await inspectApp(app) : null);
222
+ }
193
223
  function savedSource(app, saved) {
194
224
  if (typeof app.source === "string")
195
225
  return;
@@ -207,19 +237,41 @@ export function createPanelManagement(options) {
207
237
  commit: SHA.test(parsed.source.ref ?? "") ? parsed.source.ref : "",
208
238
  };
209
239
  }
210
- async function snapshot() {
240
+ async function snapshot(appId) {
241
+ if (appId !== undefined)
242
+ assertSafePanelAppId(appId);
243
+ options.assertBinding?.();
244
+ const inspected = options.projectPackages
245
+ ? await inspectProjectPanelApps(workspace, appId)
246
+ : undefined;
247
+ const apps = inspected?.apps ?? (await listInstalledPanelApps(appId));
211
248
  options.assertBinding?.();
212
- const current = policy();
249
+ const project = projectSettings();
250
+ const current = policy(project);
213
251
  const saved = origins();
214
252
  const panels = [];
215
- for (const listed of await listInstalledPanelApps()) {
216
- const { app, digest } = await catalogApp(listed.id);
253
+ const issues = (inspected?.issues ?? []).map((issue) => ({
254
+ id: issue.id,
255
+ code: issue.code,
256
+ version: issue.pin?.version,
257
+ packageDigest: issue.pin?.packageDigest,
258
+ bound: current.boundApps.has(issue.id),
259
+ globalDisabled: current.globalDisabledApps.has(issue.id),
260
+ revision: createHash("sha256")
261
+ .update(JSON.stringify({
262
+ unavailable: issue.selectionKey,
263
+ state: projectState(issue.id, project),
264
+ }))
265
+ .digest("hex"),
266
+ }));
267
+ for (const listed of apps) {
268
+ const { app, digest } = await inspectApp(listed);
217
269
  const source = savedSource(app, saved);
218
270
  const { installPath: _path, source: _source, installedAt: _installedAt, lastUpdated: _lastUpdated, ...publicApp } = app;
219
271
  const support = compatibility(app);
220
272
  panels.push({
221
273
  ...publicApp,
222
- revision: revision(app, digest),
274
+ revision: revision(app, digest, project),
223
275
  bound: current.boundApps.has(app.id),
224
276
  globalDisabled: current.globalDisabledApps.has(app.id),
225
277
  enabled: current.hasProject &&
@@ -236,7 +288,48 @@ export function createPanelManagement(options) {
236
288
  compatibility: support,
237
289
  });
238
290
  }
239
- return { panels, workspace, hasProject };
291
+ // Never attach a new revision to stale displayed binding state. File and
292
+ // package inspection above can yield to another device's mutation.
293
+ for (const app of [...panels, ...issues]) {
294
+ const pin = options.projectPackages ? parsePanelAppPackagePins(project)[app.id] : undefined;
295
+ if (projectState(app.id, project) !== projectState(app.id) ||
296
+ current.globalDisabledApps.has(app.id) !== policy().globalDisabledApps.has(app.id) ||
297
+ (pin && (pin.packageDigest !== app.packageDigest || pin.version !== app.version)))
298
+ throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请刷新后重试。");
299
+ }
300
+ return {
301
+ panels,
302
+ ...(issues.length ? { issues } : {}),
303
+ workspace,
304
+ hasProject,
305
+ canRestorePackages: !!options.projectPackages && hasProject,
306
+ };
307
+ }
308
+ // A fault row is diagnostic only: it never supplies executable descriptors or permissions.
309
+ // Restoring it explicitly reviews every target permission because its old manifest is unavailable.
310
+ async function restorationSelection(id) {
311
+ const value = await snapshot();
312
+ const app = value.panels.find((item) => item.id === id);
313
+ if (app)
314
+ return {
315
+ title: app.title,
316
+ revision: app.revision,
317
+ permissions: app.permissions,
318
+ current: { version: app.version, packageDigest: app.packageDigest },
319
+ };
320
+ const issue = value.issues?.find((item) => item.id === id);
321
+ if (!issue)
322
+ throw new PanelManagementError(404, "not_found", "找不到这个面板,请刷新列表。");
323
+ return {
324
+ title: { default: issue.id },
325
+ revision: issue.revision,
326
+ permissions: [],
327
+ current: {
328
+ version: issue.version ?? "未记录",
329
+ packageDigest: issue.packageDigest,
330
+ unavailable: true,
331
+ },
332
+ };
240
333
  }
241
334
  async function network(context, work) {
242
335
  const expected = generation(context.ownerId);
@@ -269,6 +362,9 @@ export function createPanelManagement(options) {
269
362
  for (const [token, value] of reviews)
270
363
  if (value.public.expiresAt <= now())
271
364
  reviews.delete(token);
365
+ for (const [token, value] of restores)
366
+ if (value.public.expiresAt <= now())
367
+ restores.delete(token);
272
368
  }
273
369
  async function issue(context, input, current) {
274
370
  return network(context, async (guard) => {
@@ -304,12 +400,78 @@ export function createPanelManagement(options) {
304
400
  generation: generation(context.ownerId),
305
401
  public: value,
306
402
  source: pinnedSource,
403
+ origin: source,
404
+ digest,
405
+ projectState: projectState(preview.id),
406
+ catalogState: current?.catalogState,
407
+ });
408
+ return value;
409
+ });
410
+ }
411
+ async function issueProjectSource(context, input, expected) {
412
+ if (!options.allowLocalSources || !options.projectPackages || !hasProject)
413
+ throw new PanelManagementError(403, "unsupported", "这个入口不支持本地项目安装。");
414
+ return network(context, async (guard) => {
415
+ prune();
416
+ if (reviews.size >= MAX_REVIEWS)
417
+ throw new PanelManagementError(429, "busy", "待确认的面板过多,请先完成已有预览。");
418
+ if (!input || typeof input !== "object")
419
+ invalid();
420
+ let source;
421
+ let origin;
422
+ if (input.kind === "git") {
423
+ origin = await pinned(input, guard);
424
+ source = {
425
+ kind: "git",
426
+ url: origin.url,
427
+ ref: origin.commit,
428
+ ...(origin.subdir ? { subdir: origin.subdir } : {}),
429
+ };
430
+ }
431
+ else {
432
+ if ((input.kind !== "dir" && input.kind !== "zip") ||
433
+ typeof input.path !== "string" ||
434
+ !isAbsolute(input.path) ||
435
+ input.path.length > 8192 ||
436
+ input.path.includes("\0"))
437
+ invalid();
438
+ source = { kind: input.kind, path: input.path };
439
+ }
440
+ const preview = await previewLocalPanelApp(source);
441
+ await guard();
442
+ if (expected && preview.id !== expected.id)
443
+ throw new PanelManagementError(409, "conflict", "来源中的面板标识已经改变。");
444
+ const listed = (await listApps()).find((app) => app.id === preview.id);
445
+ const current = listed ? await inspectApp(listed) : undefined;
446
+ const currentRevision = current ? revision(current.app, current.digest) : null;
447
+ if (expected && currentRevision !== expected.revision)
448
+ throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
449
+ const catalog = await catalogState(preview.id);
450
+ await guard();
451
+ const { reviewToken: digest, ...publicPreview } = preview;
452
+ const value = {
453
+ reviewToken: randomBytes(32).toString("base64url"),
454
+ expiresAt: now() + REVIEW_TTL,
455
+ kind: current ? "update" : "install",
456
+ preview: publicPreview,
457
+ expectedRevision: currentRevision,
458
+ compatibility: compatibility(preview),
459
+ ...(current ? { installedVersion: current.app.version } : {}),
460
+ };
461
+ reviews.set(value.reviewToken, {
462
+ owner: context.ownerId,
463
+ generation: generation(context.ownerId),
464
+ public: value,
465
+ source,
466
+ origin,
307
467
  digest,
468
+ projectState: projectState(preview.id),
469
+ catalogState: catalog,
308
470
  });
309
471
  return value;
310
472
  });
311
473
  }
312
- async function mutate(context, work) {
474
+ async function mutate(context, change, work) {
313
475
  const expected = generation(context.ownerId);
314
476
  const guard = () => assertAuthorized(context, expected);
315
477
  const run = async () => {
@@ -318,20 +480,23 @@ export function createPanelManagement(options) {
318
480
  await guard();
319
481
  return work(guard);
320
482
  };
321
- return options.withMutation ? options.withMutation(write) : write();
483
+ return panelExecutionGate.mutate(panelPackageMutationMatches({ ...change, projectPath: options.bindingCwd ?? options.cwd }), () => (options.withMutation ? options.withMutation(write) : write()));
322
484
  };
323
485
  const next = catalogMutation.then(run, run);
324
486
  catalogMutation = next.catch(() => undefined);
325
487
  return next;
326
488
  }
327
- function setBinding(app, digest, bound, expected) {
489
+ function setBinding(app, bound, expected) {
328
490
  options.assertBinding?.();
329
491
  if (!hasProject)
330
492
  throw new PanelManagementError(400, "project_required", "请先选择项目,再绑定面板。");
331
493
  const manager = new SettingsManager(workspace, "full");
332
494
  manager.mutateSettingsForScope("project", workspace, (current) => {
333
495
  options.assertBinding?.();
334
- if (expected && revision(app, digest, current) !== expected)
496
+ if (expected &&
497
+ ("revision" in expected
498
+ ? revision(expected.app, expected.digest, current) !== expected.revision
499
+ : projectState(app.id, current) !== expected.projectState))
335
500
  throw new PanelManagementError(409, "conflict", "面板配置已改变,请刷新后重试。");
336
501
  const bindings = new Set(Array.isArray(current.panelAppBindings)
337
502
  ? current.panelAppBindings.filter((value) => typeof value === "string")
@@ -341,6 +506,17 @@ export function createPanelManagement(options) {
341
506
  else
342
507
  bindings.delete(app.id);
343
508
  current.panelAppBindings = [...bindings].sort();
509
+ if (options.projectPackages) {
510
+ const pins = parsePanelAppPackagePins(current);
511
+ if (bound) {
512
+ if (!app.packageDigest)
513
+ throw new Error("Cannot bind an unverified Panel package");
514
+ pins[app.id] = { version: app.version, packageDigest: app.packageDigest };
515
+ }
516
+ else
517
+ delete pins[app.id];
518
+ current.panelAppPins = pins;
519
+ }
344
520
  const overrides = current.panelAppOverrides &&
345
521
  typeof current.panelAppOverrides === "object" &&
346
522
  !Array.isArray(current.panelAppOverrides)
@@ -354,6 +530,121 @@ export function createPanelManagement(options) {
354
530
  }
355
531
  return {
356
532
  snapshot,
533
+ async packageHistory(context, id, expected) {
534
+ assertId(id);
535
+ assertRevision(expected);
536
+ if (!options.projectPackages || !hasProject)
537
+ throw new PanelManagementError(400, "project_required", "请先选择支持独立版本的项目。");
538
+ return network(context, async (guard) => {
539
+ const current = await restorationSelection(id);
540
+ if (current.revision !== expected)
541
+ throw new PanelManagementError(409, "conflict", "项目面板已改变,请刷新后重试。");
542
+ const inventory = await listRetainedPanelAppPackages(id);
543
+ await guard();
544
+ const latest = await restorationSelection(id);
545
+ if (latest.revision !== expected)
546
+ throw new PanelManagementError(409, "conflict", "项目面板已改变,请刷新后重试。");
547
+ return {
548
+ appId: id,
549
+ title: current.title,
550
+ expectedRevision: expected,
551
+ current: current.current,
552
+ versions: inventory.packages.map((app) => ({
553
+ version: app.version,
554
+ packageDigest: app.packageDigest,
555
+ permissions: app.permissions,
556
+ compatibility: compatibility(app),
557
+ })),
558
+ unavailablePackages: inventory.unavailableDigests.length,
559
+ };
560
+ });
561
+ },
562
+ async previewRestore(context, id, digest, expected) {
563
+ assertId(id);
564
+ assertRevision(expected);
565
+ if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest))
566
+ invalid();
567
+ if (!options.projectPackages || !hasProject)
568
+ throw new PanelManagementError(400, "project_required", "请先选择支持独立版本的项目。");
569
+ return network(context, async (guard) => {
570
+ prune();
571
+ if (restores.size >= MAX_REVIEWS)
572
+ throw new PanelManagementError(429, "busy", "待确认的版本过多,请稍后重试。");
573
+ const current = await restorationSelection(id);
574
+ if (!policy().boundApps.has(id))
575
+ throw new PanelManagementError(400, "not_bound", "请先绑定项目,再选择项目版本。");
576
+ if (current.revision !== expected)
577
+ throw new PanelManagementError(409, "conflict", "项目面板已改变,请刷新后重试。");
578
+ const state = projectState(id);
579
+ const target = await resolvePanelAppPackage(id, digest);
580
+ await guard();
581
+ const latest = await restorationSelection(id);
582
+ if (latest.revision !== expected || projectState(id) !== state)
583
+ throw new PanelManagementError(409, "conflict", "项目面板已改变,请重新审阅。");
584
+ const value = {
585
+ appId: id,
586
+ title: target.title,
587
+ version: target.version,
588
+ packageDigest: digest,
589
+ permissions: target.permissions,
590
+ addedPermissions: target.permissions.filter((permission) => !current.permissions.includes(permission)),
591
+ compatibility: compatibility(target),
592
+ current: current.current,
593
+ expectedRevision: expected,
594
+ reviewToken: randomBytes(32).toString("base64url"),
595
+ expiresAt: now() + REVIEW_TTL,
596
+ };
597
+ restores.set(value.reviewToken, {
598
+ owner: context.ownerId,
599
+ generation: generation(context.ownerId),
600
+ projectState: state,
601
+ public: structuredClone(value),
602
+ });
603
+ return value;
604
+ });
605
+ },
606
+ async cancelRestore(context, token) {
607
+ if (typeof token !== "string")
608
+ invalid();
609
+ await assertAuthorized(context);
610
+ prune();
611
+ const held = restores.get(token);
612
+ if (held && held.owner !== context.ownerId)
613
+ throw new PanelManagementError(403, "review_owner", "请在当前设备取消这个版本审阅。");
614
+ restores.delete(token);
615
+ return { cancelled: true };
616
+ },
617
+ async restore(context, token) {
618
+ if (typeof token !== "string")
619
+ invalid();
620
+ prune();
621
+ const held = restores.get(token);
622
+ if (!held)
623
+ throw new PanelManagementError(409, "review_expired", "版本审阅已失效,请重新选择。");
624
+ if (held.owner !== context.ownerId)
625
+ throw new PanelManagementError(403, "review_owner", "请在当前设备重新审阅这个版本。");
626
+ await assertAuthorized(context, held.generation);
627
+ const review = held.public;
628
+ return mutate(context, { appId: review.appId, kind: "binding" }, async (guard) => {
629
+ await assertAuthorized(context, held.generation);
630
+ if (restores.get(token) !== held || held.public.expiresAt <= now())
631
+ throw new PanelManagementError(409, "review_expired", "版本审阅已失效,请重新选择。");
632
+ const current = await restorationSelection(review.appId);
633
+ if (current.revision !== review.expectedRevision)
634
+ throw new PanelManagementError(409, "conflict", "项目面板已改变,请重新审阅。");
635
+ const target = await resolvePanelAppPackage(review.appId, review.packageDigest);
636
+ if (target.version !== review.version || !compatibility(target).supported)
637
+ throw new PanelManagementError(400, "unsupported", "这个版本无法在当前环境使用。");
638
+ await guard();
639
+ await assertAuthorized(context, held.generation);
640
+ if (restores.get(token) !== held || held.public.expiresAt <= now())
641
+ throw new PanelManagementError(409, "review_expired", "版本审阅已失效,请重新选择。");
642
+ setBinding(target, true, { projectState: held.projectState });
643
+ restores.delete(token);
644
+ await options.onChanged?.(review.appId, "binding");
645
+ return { id: review.appId, packageDigest: review.packageDigest };
646
+ });
647
+ },
357
648
  assertAuthorized,
358
649
  async discover(context, input) {
359
650
  return network(context, async (guard) => {
@@ -381,7 +672,7 @@ export function createPanelManagement(options) {
381
672
  assertId(id);
382
673
  assertRevision(expected);
383
674
  await assertAuthorized(context);
384
- const current = await catalogApp(id);
675
+ const current = await selectedApp(id);
385
676
  const currentRevision = revision(current.app, current.digest);
386
677
  if (currentRevision !== expected)
387
678
  throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
@@ -389,9 +680,37 @@ export function createPanelManagement(options) {
389
680
  if (!source)
390
681
  throw new PanelManagementError(400, "local_source", "这个面板由本地来源安装,请在桌面端更新。");
391
682
  const { commit: _commit, ...input } = source;
392
- return issue(context, input, { ...current, revision: currentRevision });
683
+ return issue(context, input, {
684
+ ...current,
685
+ revision: currentRevision,
686
+ catalogState: await catalogState(id),
687
+ });
393
688
  },
394
- async install(context, token, bind = true) {
689
+ previewProjectSource: issueProjectSource,
690
+ async previewProjectUpdate(context, id, expected) {
691
+ if (!options.allowLocalSources || !options.projectPackages || !hasProject)
692
+ throw new PanelManagementError(403, "unsupported", "这个入口不支持本地项目安装。");
693
+ assertId(id);
694
+ assertRevision(expected);
695
+ await assertAuthorized(context);
696
+ const { app, digest } = await selectedApp(id);
697
+ if (revision(app, digest) !== expected)
698
+ throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
699
+ let input;
700
+ if (typeof app.source !== "string")
701
+ input = app.source;
702
+ else {
703
+ const info = statSync(app.source);
704
+ if (info.isDirectory())
705
+ input = { kind: "dir", path: app.source };
706
+ else if (info.isFile() && extname(app.source).toLowerCase() === ".zip")
707
+ input = { kind: "zip", path: app.source };
708
+ else
709
+ throw new PanelManagementError(400, "invalid_source", "原始来源不是文件夹或 ZIP 包。");
710
+ }
711
+ return issueProjectSource(context, input, { id, revision: expected });
712
+ },
713
+ async install(context, token, bind = true, approval) {
395
714
  if (typeof token !== "string" || typeof bind !== "boolean")
396
715
  invalid();
397
716
  prune();
@@ -401,42 +720,55 @@ export function createPanelManagement(options) {
401
720
  if (held.owner !== context.ownerId)
402
721
  throw new PanelManagementError(403, "review_owner", "请在当前设备重新预览这个来源。");
403
722
  await assertAuthorized(context, held.generation);
404
- return mutate(context, async (guard) => {
723
+ if (approval?.expectedId && approval.expectedId !== held.public.preview.id)
724
+ invalid();
725
+ if (approval && held.public.kind === "update" && !approval.overwrite)
726
+ throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请确认项目更新。");
727
+ return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
405
728
  await assertAuthorized(context, held.generation);
406
729
  if (reviews.get(token) !== held || held.public.expiresAt <= now())
407
730
  throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
408
731
  const id = held.public.preview.id;
409
732
  if (held.public.kind === "update") {
410
- const current = await catalogApp(id);
411
- if (revision(current.app, current.digest) !== held.public.expectedRevision)
733
+ const current = await selectedApp(id);
734
+ if (revision(current.app, current.digest) !== held.public.expectedRevision ||
735
+ (await catalogState(id)) !== held.catalogState)
412
736
  throw new PanelManagementError(409, "conflict", "面板已改变,请重新检查更新。");
413
737
  }
414
738
  else if ((await listInstalledPanelApps()).some((item) => item.id === id))
415
739
  throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请检查更新。");
740
+ if (projectState(id) !== held.projectState)
741
+ throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
416
742
  await guard();
417
743
  const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
418
744
  overwrite: held.public.kind === "update",
419
745
  expectedId: id,
420
- recordedRef: held.public.source.ref,
746
+ recordedRef: held.origin?.ref,
421
747
  beforeCommit: async () => {
422
748
  if (held.public.kind === "update") {
423
- const current = await catalogApp(id);
424
- if (revision(current.app, current.digest) !== held.public.expectedRevision)
749
+ const current = await selectedApp(id);
750
+ if (revision(current.app, current.digest) !== held.public.expectedRevision ||
751
+ (await catalogState(id)) !== held.catalogState)
425
752
  throw new PanelManagementError(409, "conflict", "面板已改变,请重新检查更新。");
426
753
  }
754
+ if (projectState(id) !== held.projectState)
755
+ throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
427
756
  await guard();
428
757
  },
429
758
  });
430
759
  reviews.delete(token);
431
760
  origins((value) => {
432
- value[id] = { source: held.public.source, lastUpdated: installed.lastUpdated };
761
+ if (held.origin)
762
+ value[id] = { source: held.origin, lastUpdated: installed.lastUpdated };
763
+ else
764
+ delete value[id];
433
765
  });
434
766
  await guard();
435
767
  if (bind && hasProject && held.public.compatibility.supported)
436
- setBinding(installed, held.digest, true);
768
+ setBinding(installed, true, { projectState: held.projectState });
437
769
  invalidateSkillCache();
438
770
  await options.onChanged?.(id, held.public.kind);
439
- return { id };
771
+ return { id, packageDigest: installed.packageDigest };
440
772
  });
441
773
  },
442
774
  async binding(context, id, bound, expected) {
@@ -444,12 +776,19 @@ export function createPanelManagement(options) {
444
776
  assertRevision(expected);
445
777
  if (typeof bound !== "boolean")
446
778
  invalid();
447
- return mutate(context, async (guard) => {
448
- const current = await catalogApp(id);
779
+ return mutate(context, { appId: id, kind: "binding" }, async (guard) => {
780
+ const current = await selectedApp(id);
449
781
  if (bound && !compatibility(current.app).supported)
450
782
  throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
783
+ // Legacy installs need a retained copy before the project can pin them.
784
+ // An already pinned project must keep its selected version, not the catalog's.
785
+ const app = bound &&
786
+ options.projectPackages &&
787
+ !parsePanelAppPackagePins(projectSettings())[current.app.id]
788
+ ? await retainInstalledPanelApp(current.app.id, current.app.packageDigest)
789
+ : current.app;
451
790
  await guard();
452
- setBinding(current.app, current.digest, bound, expected);
791
+ setBinding(app, bound, { ...current, revision: expected });
453
792
  await options.onChanged?.(id, "binding");
454
793
  return snapshot();
455
794
  });
@@ -457,14 +796,14 @@ export function createPanelManagement(options) {
457
796
  async remove(context, id, expected) {
458
797
  assertId(id);
459
798
  assertRevision(expected);
460
- return mutate(context, async (guard) => {
461
- const current = await catalogApp(id);
799
+ return mutate(context, { appId: id, kind: "remove" }, async (guard) => {
800
+ const current = await selectedApp(id);
462
801
  if (revision(current.app, current.digest) !== expected)
463
802
  throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
464
803
  await guard();
465
804
  await uninstallPanelApp(id, {
466
805
  beforeCommit: async () => {
467
- const latest = await catalogApp(id);
806
+ const latest = await selectedApp(id);
468
807
  if (revision(latest.app, latest.digest) !== expected)
469
808
  throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
470
809
  await guard();
@@ -474,7 +813,7 @@ export function createPanelManagement(options) {
474
813
  delete value[id];
475
814
  });
476
815
  if (hasProject)
477
- setBinding(current.app, current.digest, false);
816
+ setBinding(current.app, false);
478
817
  invalidateSkillCache();
479
818
  await options.onChanged?.(id, "remove");
480
819
  return { removed: true };
@@ -485,10 +824,14 @@ export function createPanelManagement(options) {
485
824
  for (const [token, held] of reviews)
486
825
  if (held.owner === owner)
487
826
  reviews.delete(token);
827
+ for (const [token, held] of restores)
828
+ if (held.owner === owner)
829
+ restores.delete(token);
488
830
  },
489
831
  close() {
490
832
  closed = true;
491
833
  reviews.clear();
834
+ restores.clear();
492
835
  },
493
836
  };
494
837
  }
@@ -0,0 +1,8 @@
1
+ import { type PanelExecutionScope } from "./execution-gate.js";
2
+ export interface PanelPackageMutation {
3
+ appId: string;
4
+ projectPath: string;
5
+ kind: "install" | "update" | "binding" | "remove";
6
+ }
7
+ /** Pinned projects retain their bytes when a different project updates the catalog. */
8
+ export declare function panelPackageMutationMatches(change: PanelPackageMutation): (scope: PanelExecutionScope) => boolean;
@@ -0,0 +1,23 @@
1
+ import { projectPanelAppPackagePins, resolvePanelAppBindingProjectPath, } from "@cjhyy/code-shell-core";
2
+ import { panelExecutionProject } from "./execution-gate.js";
3
+ /** Pinned projects retain their bytes when a different project updates the catalog. */
4
+ export function panelPackageMutationMatches(change) {
5
+ const project = panelExecutionProject(resolvePanelAppBindingProjectPath(change.projectPath));
6
+ return (scope) => {
7
+ if (scope.appId !== change.appId)
8
+ return false;
9
+ if (change.kind === "remove")
10
+ return true;
11
+ const bindingProject = resolvePanelAppBindingProjectPath(scope.projectPath);
12
+ if (panelExecutionProject(bindingProject) === project)
13
+ return true;
14
+ if (change.kind === "binding")
15
+ return false;
16
+ try {
17
+ return !projectPanelAppPackagePins(bindingProject, change.appId)[change.appId];
18
+ }
19
+ catch {
20
+ return true;
21
+ } // An unreadable project cannot prove that it is insulated.
22
+ };
23
+ }
@@ -13,6 +13,8 @@ export interface PanelProcessOwner {
13
13
  send(event: "process.output" | "process.exit", payload: Record<string, unknown>): void;
14
14
  }
15
15
  export interface PanelAppProcessServiceOptions {
16
+ /** Host lease from before async preparation until the actual process group has exited. */
17
+ acquireExecution?(owner: PanelProcessOwner): () => void;
16
18
  env?: NodeJS.ProcessEnv;
17
19
  platform?: NodeJS.Platform;
18
20
  /** Host-owned executable directories (for example CodeShell's managed bin). */
@@ -93,6 +95,7 @@ export declare class PanelAppProcessService {
93
95
  argumentName: unknown;
94
96
  path: string;
95
97
  cleanup?: () => void;
98
+ validate?: () => Promise<void>;
96
99
  }): Promise<{
97
100
  handle: string;
98
101
  }>;