@cjhyy/code-shell-server 0.9.46 → 0.9.48

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 (38) hide show
  1. package/README.md +4 -0
  2. package/dist/hub/auth-store.d.ts +2 -0
  3. package/dist/hub/auth-store.js +26 -7
  4. package/dist/mobile-remote/codex-room-agent.d.ts +0 -6
  5. package/dist/mobile-remote/codex-room-agent.js +32 -32
  6. package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
  7. package/dist/mobile-remote/mobile-chat-turn.js +15 -2
  8. package/dist/mobile-remote/mobile-client-event-validator.js +8 -0
  9. package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
  10. package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
  11. package/dist/mobile-remote/mobile-static.d.ts +5 -6
  12. package/dist/mobile-remote/mobile-static.js +13 -14
  13. package/dist/mobile-remote/remote-host-manager.d.ts +2 -0
  14. package/dist/mobile-remote/remote-host-manager.js +27 -5
  15. package/dist/mobile-remote/resident-agent.js +27 -22
  16. package/dist/mobile-remote/room-manager.d.ts +4 -0
  17. package/dist/mobile-remote/room-manager.js +9 -2
  18. package/dist/mobile-remote/room-process-stop.d.ts +11 -0
  19. package/dist/mobile-remote/room-process-stop.js +46 -0
  20. package/dist/panels/agent-task-service.d.ts +2 -1
  21. package/dist/panels/agent-task-service.js +10 -3
  22. package/dist/panels/execution-gate.d.ts +22 -2
  23. package/dist/panels/execution-gate.js +42 -6
  24. package/dist/panels/http.d.ts +1 -0
  25. package/dist/panels/hub-automations.js +1 -0
  26. package/dist/panels/management-http.d.ts +1 -0
  27. package/dist/panels/management.d.ts +3 -0
  28. package/dist/panels/management.js +63 -15
  29. package/dist/panels/package-mutation.d.ts +1 -1
  30. package/dist/panels/package-mutation.js +3 -10
  31. package/dist/panels/resources/uploads.js +15 -6
  32. package/dist/panels/runtime.js +12 -2
  33. package/dist/panels/task-cookies.js +8 -16
  34. package/dist/panels/tool-jobs.js +7 -2
  35. package/dist/serve/headless-server.js +113 -79
  36. package/dist/static-files.d.ts +7 -0
  37. package/dist/static-files.js +114 -0
  38. package/package.json +6 -6
@@ -0,0 +1,46 @@
1
+ /** Retire an unusable room transport without leaving an unresponsive CLI alive. */
2
+ export function stopRoomProcess(child) {
3
+ if (child.exitCode !== null || child.signalCode !== null)
4
+ return;
5
+ const signal = (value) => {
6
+ if (child.pid && child.pid > 1 && process.platform !== "win32") {
7
+ try {
8
+ process.kill(-child.pid, value);
9
+ return;
10
+ }
11
+ catch {
12
+ /* A failed spawn or an exited group falls back to the exact child. */
13
+ }
14
+ }
15
+ try {
16
+ child.kill(value);
17
+ }
18
+ catch {
19
+ /* already gone */
20
+ }
21
+ };
22
+ signal("SIGTERM");
23
+ if (!child.pid)
24
+ return;
25
+ const timer = setTimeout(() => signal("SIGKILL"), 1_000);
26
+ timer.unref();
27
+ child.once("exit", () => clearTimeout(timer));
28
+ }
29
+ /**
30
+ * `close` waits for every holder of the child's stdout/stderr. A grandchild the
31
+ * CLI backgrounded with inherited stdio (a dev server, say) keeps them open
32
+ * after the CLI exits, so the room would never settle. Once the CLI has exited
33
+ * and its buffered output had a grace period to drain, release the pipes;
34
+ * the grandchild itself is left running.
35
+ */
36
+ export function releaseStdioAfterExit(child, graceMs = 1_000) {
37
+ child.once("exit", () => {
38
+ const timer = setTimeout(() => {
39
+ child.stdout?.destroy();
40
+ child.stderr?.destroy();
41
+ child.stdin?.destroy();
42
+ }, graceMs);
43
+ timer.unref();
44
+ child.once("close", () => clearTimeout(timer));
45
+ });
46
+ }
@@ -96,7 +96,8 @@ export declare class PanelAppAgentTaskService {
96
96
  get(owner: PanelAgentTaskOwner, id: unknown): PanelAgentTaskView;
97
97
  cancel(owner: PanelAgentTaskOwner, id: unknown): Promise<PanelAgentTaskView>;
98
98
  rebind(owner: PanelAgentTaskOwner): void;
99
- cancelApp(appId: string): void;
99
+ /** `projects` limits cancellation to tasks bound to those (canonical) projects. */
100
+ cancelApp(appId: string, projects?: ReadonlySet<string>): void;
100
101
  private execute;
101
102
  private scopedTask;
102
103
  private emit;
@@ -1,4 +1,4 @@
1
- import { panelExecutionGate } from "./execution-gate.js";
1
+ import { panelExecutionGate, panelExecutionProject } from "./execution-gate.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  const MAX_TASKS = 200;
4
4
  const MAX_PROMPT_CHARS = 20_000;
@@ -131,7 +131,11 @@ export class PanelAppAgentTaskService {
131
131
  cancelRequested: false,
132
132
  activity: [],
133
133
  };
134
- const release = panelExecutionGate.enter(owner);
134
+ const release = panelExecutionGate.enter({
135
+ appId: owner.appId,
136
+ projectPath: owner.projectPath,
137
+ holder: { kind: "agent-task", guestId: owner.guestId },
138
+ });
135
139
  this.tasks.set(task.id, task);
136
140
  this.trimHistory();
137
141
  this.emit(task);
@@ -174,10 +178,13 @@ export class PanelAppAgentTaskService {
174
178
  this.runtime.rebind(task.sessionId, task.owner);
175
179
  }
176
180
  }
177
- cancelApp(appId) {
181
+ /** `projects` limits cancellation to tasks bound to those (canonical) projects. */
182
+ cancelApp(appId, projects) {
178
183
  for (const task of this.tasks.values()) {
179
184
  if (task.owner.appId !== appId || TERMINAL_STATUSES.has(task.status))
180
185
  continue;
186
+ if (projects && !projects.has(panelExecutionProject(task.owner.projectPath)))
187
+ continue;
181
188
  task.cancelRequested = true;
182
189
  task.status = "cancelling";
183
190
  task.updatedAt = this.now();
@@ -1,13 +1,29 @@
1
+ /** What holds an execution, so a refused change can name it. Host-assigned only. */
2
+ export interface PanelExecutionHolder {
3
+ kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation";
4
+ /** Desktop WebContents id of the Panel instance that owns this execution. */
5
+ guestId?: number;
6
+ /** Trusted Host description, such as a native tool entry name. */
7
+ label?: string;
8
+ }
1
9
  export interface PanelExecutionScope {
2
10
  appId: string;
3
11
  /** Host-verified binding project, never a directory supplied by the guest. */
4
12
  projectPath: string;
13
+ holder?: PanelExecutionHolder;
14
+ }
15
+ export interface PanelExecutionBlocker extends PanelExecutionScope {
16
+ count: number;
5
17
  }
6
18
  /** Existing path aliases must not create separate admission domains. */
7
19
  export declare function panelExecutionProject(path: string): string;
8
20
  export declare class PanelExecutionBusyError extends Error {
21
+ /** Present when executions block a change; empty while another change runs. */
22
+ readonly blockers: PanelExecutionBlocker[];
9
23
  readonly status = 409;
10
- constructor(message: string);
24
+ constructor(message: string,
25
+ /** Present when executions block a change; empty while another change runs. */
26
+ blockers?: PanelExecutionBlocker[]);
11
27
  }
12
28
  /** One process owns admission for every Panel runtime sharing its installed catalog.
13
29
  * Acquire synchronously before authorization/IO; release after real execution cleanup,
@@ -21,6 +37,10 @@ export declare class PanelExecutionGate {
21
37
  run<T>(scope: PanelExecutionScope, work: () => Promise<T>): Promise<T>;
22
38
  /** Durable queues remain occupied even while paused and between process attempts. */
23
39
  register(source: () => Iterable<PanelExecutionScope>): () => void;
24
- mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>): Promise<T>;
40
+ /** Executions that a change matching `matches` would wait for, grouped by holder. */
41
+ blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
42
+ mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>,
43
+ /** Trusted Host preparation that validates a review before draining existing work. */
44
+ drain?: () => Promise<void>): Promise<T>;
25
45
  }
26
46
  export declare const panelExecutionGate: PanelExecutionGate;
@@ -10,9 +10,13 @@ export function panelExecutionProject(path) {
10
10
  }
11
11
  }
12
12
  export class PanelExecutionBusyError extends Error {
13
+ blockers;
13
14
  status = 409;
14
- constructor(message) {
15
+ constructor(message,
16
+ /** Present when executions block a change; empty while another change runs. */
17
+ blockers = []) {
15
18
  super(message);
19
+ this.blockers = blockers;
16
20
  this.name = "PanelExecutionBusyError";
17
21
  }
18
22
  }
@@ -25,7 +29,11 @@ export class PanelExecutionGate {
25
29
  sources = new Set();
26
30
  mutations = new Set();
27
31
  enter(scope) {
28
- const held = { appId: scope.appId, projectPath: panelExecutionProject(scope.projectPath) };
32
+ const held = {
33
+ appId: scope.appId,
34
+ projectPath: panelExecutionProject(scope.projectPath),
35
+ ...(scope.holder ? { holder: { ...scope.holder } } : {}),
36
+ };
29
37
  if ([...this.mutations].some((matches) => matches(held)))
30
38
  throw new PanelExecutionBusyError("面板正在更新,请完成后重试。");
31
39
  this.executions.add(held);
@@ -49,15 +57,43 @@ export class PanelExecutionGate {
49
57
  this.sources.delete(source);
50
58
  };
51
59
  }
52
- async mutate(matches, work) {
60
+ /** Executions that a change matching `matches` would wait for, grouped by holder. */
61
+ blockers(matches) {
62
+ const grouped = new Map();
63
+ const scopes = [...this.executions, ...[...this.sources].flatMap((source) => [...source()])];
64
+ for (const scope of scopes) {
65
+ if (!matches(scope))
66
+ continue;
67
+ const blocker = {
68
+ appId: scope.appId,
69
+ projectPath: scope.projectPath,
70
+ ...(scope.holder ? { holder: { ...scope.holder } } : {}),
71
+ };
72
+ const key = JSON.stringify(blocker), existing = grouped.get(key);
73
+ if (existing)
74
+ existing.count++;
75
+ else
76
+ grouped.set(key, { ...blocker, count: 1 });
77
+ }
78
+ return [...grouped.values()];
79
+ }
80
+ async mutate(matches, work,
81
+ /** Trusted Host preparation that validates a review before draining existing work. */
82
+ drain) {
53
83
  if (this.mutations.size)
54
84
  throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
55
85
  // No await between the snapshot and installing the admission barrier.
56
- if ([...this.executions].some(matches) ||
57
- [...this.sources].some((source) => [...source()].some(matches)))
58
- throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。");
86
+ const blockers = this.blockers(matches);
87
+ if (blockers.length && !drain)
88
+ throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
59
89
  this.mutations.add(matches);
60
90
  try {
91
+ if (drain) {
92
+ await drain();
93
+ const remaining = this.blockers(matches);
94
+ if (remaining.length)
95
+ throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", remaining);
96
+ }
61
97
  return await work();
62
98
  }
63
99
  finally {
@@ -39,6 +39,7 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
39
39
  install(context: import("./types.js").PanelOperationContext, token: unknown, bind?: boolean, approval?: {
40
40
  overwrite?: boolean;
41
41
  expectedId?: string;
42
+ releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
42
43
  }): Promise<{
43
44
  id: string;
44
45
  packageDigest: string | undefined;
@@ -98,6 +98,7 @@ export function createHubPanelAutomationHost(options) {
98
98
  releaseExecution = panelExecutionGate.enter({
99
99
  appId: job.panelSource.appId,
100
100
  projectPath: options.bindingCwd ?? cwd,
101
+ holder: { kind: "automation" },
101
102
  });
102
103
  }
103
104
  try {
@@ -30,6 +30,7 @@ export declare function createPanelManagementHttp(options: PanelManagementHttpOp
30
30
  install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
31
31
  overwrite?: boolean;
32
32
  expectedId?: string;
33
+ releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
33
34
  }): Promise<{
34
35
  id: string;
35
36
  packageDigest: string | undefined;
@@ -1,3 +1,4 @@
1
+ import { type PanelExecutionScope } from "./execution-gate.js";
1
2
  import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
2
3
  import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
3
4
  export declare class PanelManagementError extends Error {
@@ -50,6 +51,8 @@ export declare function createPanelManagement(options: PanelManagementOptions):
50
51
  install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
51
52
  overwrite?: boolean;
52
53
  expectedId?: string;
54
+ /** Trusted Host callback, never supplied by an HTTP request body. */
55
+ releaseExecutions?: (appId: string, matches: (scope: PanelExecutionScope) => boolean) => Promise<void>;
53
56
  }): Promise<{
54
57
  id: string;
55
58
  packageDigest: string | undefined;
@@ -3,7 +3,7 @@ import { panelPackageMutationMatches } from "./package-mutation.js";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { lstatSync, mkdirSync, statSync } from "node:fs";
5
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, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
6
+ import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, currentPanelAppPackagePins, installedPanelAppUpdatedAt, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
7
7
  import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
8
8
  import { parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
9
9
  const REVIEW_TTL = 8 * 60_000;
@@ -177,14 +177,17 @@ export function createPanelManagement(options) {
177
177
  .update(JSON.stringify({
178
178
  app,
179
179
  digest,
180
- ...(options.projectPackages
181
- ? { pin: parsePanelAppPackagePins(project ?? projectSettings())[app.id] ?? null }
182
- : {}),
180
+ ...(options.projectPackages ? { pin: selectedPin(app.id, project) } : {}),
183
181
  bound: current.boundApps.has(app.id),
184
182
  disabled: current.globalDisabledApps.has(app.id),
185
183
  }))
186
184
  .digest("hex");
187
185
  }
186
+ /** The pinned package identity; when it was pinned does not change the selection. */
187
+ function selectedPin(id, project) {
188
+ const pin = parsePanelAppPackagePins(project ?? projectSettings())[id];
189
+ return pin ? { version: pin.version, packageDigest: pin.packageDigest } : null;
190
+ }
188
191
  function projectSettings() {
189
192
  return new SettingsManager(workspace, "full").getRawForScope("project", workspace, {
190
193
  strict: true,
@@ -291,7 +294,7 @@ export function createPanelManagement(options) {
291
294
  // Never attach a new revision to stale displayed binding state. File and
292
295
  // package inspection above can yield to another device's mutation.
293
296
  for (const app of [...panels, ...issues]) {
294
- const pin = options.projectPackages ? parsePanelAppPackagePins(project)[app.id] : undefined;
297
+ const pin = options.projectPackages ? currentPanelAppPackagePins(project)[app.id] : undefined;
295
298
  if (projectState(app.id, project) !== projectState(app.id) ||
296
299
  current.globalDisabledApps.has(app.id) !== policy().globalDisabledApps.has(app.id) ||
297
300
  (pin && (pin.packageDigest !== app.packageDigest || pin.version !== app.version)))
@@ -484,7 +487,7 @@ export function createPanelManagement(options) {
484
487
  return value;
485
488
  });
486
489
  }
487
- async function mutate(context, change, work) {
490
+ async function mutate(context, change, work, prepare) {
488
491
  const expected = generation(context.ownerId);
489
492
  const guard = () => assertAuthorized(context, expected);
490
493
  const run = async () => {
@@ -493,7 +496,11 @@ export function createPanelManagement(options) {
493
496
  await guard();
494
497
  return work(guard);
495
498
  };
496
- return panelExecutionGate.mutate(panelPackageMutationMatches({ ...change, projectPath: options.bindingCwd ?? options.cwd }), () => (options.withMutation ? options.withMutation(write) : write()));
499
+ const matches = panelPackageMutationMatches({
500
+ ...change,
501
+ projectPath: options.bindingCwd ?? options.cwd,
502
+ });
503
+ return panelExecutionGate.mutate(matches, () => (options.withMutation ? options.withMutation(write) : write()), prepare ? () => prepare(guard, matches) : undefined);
497
504
  };
498
505
  const next = catalogMutation.then(run, run);
499
506
  catalogMutation = next.catch(() => undefined);
@@ -524,7 +531,20 @@ export function createPanelManagement(options) {
524
531
  if (bound) {
525
532
  if (!app.packageDigest)
526
533
  throw new Error("Cannot bind an unverified Panel package");
527
- pins[app.id] = { version: app.version, packageDigest: app.packageDigest };
534
+ const existing = pins[app.id];
535
+ // Rebinding the package a current pin already selects changes nothing, so
536
+ // it must not invalidate other devices' revisions or pending reviews.
537
+ const unchanged = existing?.version === app.version &&
538
+ existing.packageDigest === app.packageDigest &&
539
+ !!currentPanelAppPackagePins({ panelAppPins: { [app.id]: existing } })[app.id];
540
+ if (!unchanged)
541
+ pins[app.id] = {
542
+ version: app.version,
543
+ packageDigest: app.packageDigest,
544
+ // Holds this project only until the next global install or update, so it
545
+ // must be strictly later than the install it was chosen after.
546
+ pinnedAt: new Date(Math.max(now(), (installedPanelAppUpdatedAt(app.id) ?? 0) + 1)).toISOString(),
547
+ };
528
548
  }
529
549
  else
530
550
  delete pins[app.id];
@@ -735,12 +755,18 @@ export function createPanelManagement(options) {
735
755
  await assertAuthorized(context, held.generation);
736
756
  if (approval?.expectedId && approval.expectedId !== held.public.preview.id)
737
757
  invalid();
758
+ if (approval?.releaseExecutions && held.public.kind !== "update")
759
+ invalid();
738
760
  if (approval && held.public.kind === "update" && !approval.overwrite)
739
761
  throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请确认项目更新。");
740
- return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
741
- await assertAuthorized(context, held.generation);
742
- if (reviews.get(token) !== held || held.public.expiresAt <= now())
762
+ let admitted = false;
763
+ const assertReviewLease = () => {
764
+ if (!admitted && (reviews.get(token) !== held || held.public.expiresAt <= now()))
743
765
  throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
766
+ };
767
+ const validateReview = async (guard) => {
768
+ await assertAuthorized(context, held.generation);
769
+ assertReviewLease();
744
770
  const id = held.public.preview.id;
745
771
  if (held.public.kind === "update") {
746
772
  const current = await selectedApp(id);
@@ -753,6 +779,11 @@ export function createPanelManagement(options) {
753
779
  if (projectState(id) !== held.projectState)
754
780
  throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
755
781
  await guard();
782
+ assertReviewLease();
783
+ };
784
+ return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
785
+ await validateReview(guard);
786
+ const id = held.public.preview.id;
756
787
  const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
757
788
  overwrite: held.public.kind === "update",
758
789
  expectedId: id,
@@ -782,7 +813,24 @@ export function createPanelManagement(options) {
782
813
  invalidateSkillCache();
783
814
  await options.onChanged?.(id, held.public.kind);
784
815
  return { id, packageDigest: installed.packageDigest };
785
- });
816
+ }, approval?.releaseExecutions
817
+ ? async (guard, matches) => {
818
+ await validateReview(guard);
819
+ // A local directory/ZIP may have changed since preview. Check its
820
+ // reviewed bytes before cancelling work, then recheck mutable
821
+ // project/catalog authority after the source read.
822
+ const preview = await previewLocalPanelApp(held.source);
823
+ if (preview.id !== held.public.preview.id || preview.reviewToken !== held.digest)
824
+ throw new PanelManagementError(409, "conflict", "来源已改变,请重新检查更新。");
825
+ await validateReview(guard);
826
+ // This admitted operation owns the review while its holders exit.
827
+ // TTL expiry during cancellation must not invalidate that consent;
828
+ // owner revocation and package/project changes are still checked.
829
+ admitted = true;
830
+ reviews.delete(token);
831
+ await approval.releaseExecutions(held.public.preview.id, matches);
832
+ }
833
+ : undefined);
786
834
  },
787
835
  async binding(context, id, bound, expected) {
788
836
  assertId(id);
@@ -793,11 +841,11 @@ export function createPanelManagement(options) {
793
841
  const current = await selectedApp(id);
794
842
  if (bound && !compatibility(current.app).supported)
795
843
  throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
796
- // Legacy installs need a retained copy before the project can pin them.
797
- // An already pinned project must keep its selected version, not the catalog's.
844
+ // An unpinned (or superseded) selection needs a retained copy before the
845
+ // project can pin it. A current pin must keep its selected version.
798
846
  const app = bound &&
799
847
  options.projectPackages &&
800
- !parsePanelAppPackagePins(projectSettings())[current.app.id]
848
+ !currentPanelAppPackagePins(projectSettings())[current.app.id]
801
849
  ? await retainInstalledPanelApp(current.app.id, current.app.packageDigest)
802
850
  : current.app;
803
851
  await guard();
@@ -4,5 +4,5 @@ export interface PanelPackageMutation {
4
4
  projectPath: string;
5
5
  kind: "install" | "update" | "binding" | "remove";
6
6
  }
7
- /** Pinned projects retain their bytes when a different project updates the catalog. */
7
+ /** A catalog install or update supersedes every project's pin, so it reaches every project. */
8
8
  export declare function panelPackageMutationMatches(change: PanelPackageMutation): (scope: PanelExecutionScope) => boolean;
@@ -1,6 +1,6 @@
1
- import { projectPanelAppPackagePins, resolvePanelAppBindingProjectPath, } from "@cjhyy/code-shell-core";
1
+ import { resolvePanelAppBindingProjectPath } from "@cjhyy/code-shell-core";
2
2
  import { panelExecutionProject } from "./execution-gate.js";
3
- /** Pinned projects retain their bytes when a different project updates the catalog. */
3
+ /** A catalog install or update supersedes every project's pin, so it reaches every project. */
4
4
  export function panelPackageMutationMatches(change) {
5
5
  const project = panelExecutionProject(resolvePanelAppBindingProjectPath(change.projectPath));
6
6
  return (scope) => {
@@ -11,13 +11,6 @@ export function panelPackageMutationMatches(change) {
11
11
  const bindingProject = resolvePanelAppBindingProjectPath(scope.projectPath);
12
12
  if (panelExecutionProject(bindingProject) === project)
13
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.
14
+ return change.kind !== "binding";
22
15
  };
23
16
  }
@@ -214,12 +214,21 @@ export class ResourceUploadIngest {
214
214
  }
215
215
  async cleanupExpired() {
216
216
  await this.initialize();
217
- for (const record of [...this.records.values()])
218
- if (record.expiresAt <= this.options.now()) {
219
- const key = this.key(record.scope, record.sessionId);
220
- this.finishes.get(key)?.abort();
221
- await this.locked(key, () => this.remove(record));
222
- }
217
+ for (const key of [...this.records.keys()]) {
218
+ // Another upload may have renewed while cleanup waited on an earlier
219
+ // entry. Only the current expired record can cancel a running finish.
220
+ const record = this.records.get(key);
221
+ if (!record || record.expiresAt > this.options.now())
222
+ continue;
223
+ this.finishes.get(key)?.abort();
224
+ await this.locked(key, async () => {
225
+ // A write already holding this lock may acknowledge a chunk and renew
226
+ // the lease before cleanup enters. Never delete that renewed upload.
227
+ const current = this.records.get(key);
228
+ if (current && current.expiresAt <= this.options.now())
229
+ await this.remove(current);
230
+ });
231
+ }
223
232
  }
224
233
  async begin(scope, raw) {
225
234
  await this.authorize(scope);
@@ -553,6 +553,7 @@ export function createPanelRuntime(options) {
553
553
  acquireExecution: (owner) => panelExecutionGate.enter({
554
554
  appId: owner.appId,
555
555
  projectPath: options.bindingCwd ?? options.cwd,
556
+ holder: { kind: "process" },
556
557
  }),
557
558
  approvalScope: "guest",
558
559
  resolvePackageEntry: async (owner, name) => {
@@ -941,7 +942,11 @@ export function createPanelRuntime(options) {
941
942
  };
942
943
  }
943
944
  async function onPanelAction(scope, input) {
944
- return panelExecutionGate.run({ appId: scope.appId, projectPath: options.bindingCwd ?? options.cwd }, () => onPanelActionAdmitted(scope, input));
945
+ return panelExecutionGate.run({
946
+ appId: scope.appId,
947
+ projectPath: options.bindingCwd ?? options.cwd,
948
+ holder: { kind: "panel-call" },
949
+ }, () => onPanelActionAdmitted(scope, input));
945
950
  }
946
951
  async function onPanelActionAdmitted(scope, input) {
947
952
  const grant = grants.get(scope.instanceId);
@@ -980,6 +985,7 @@ export function createPanelRuntime(options) {
980
985
  const releaseExecution = panelExecutionGate.enter({
981
986
  appId: grant.app.id,
982
987
  projectPath: options.bindingCwd ?? options.cwd,
988
+ holder: { kind: "agent-tool" },
983
989
  });
984
990
  const requestId = randomBytes(18).toString("base64url");
985
991
  const value = await new Promise((done) => {
@@ -1309,7 +1315,11 @@ export function createPanelRuntime(options) {
1309
1315
  }
1310
1316
  }
1311
1317
  async function call(grant, input) {
1312
- return panelExecutionGate.run({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, () => callAdmitted(grant, input));
1318
+ return panelExecutionGate.run({
1319
+ appId: grant.app.id,
1320
+ projectPath: options.bindingCwd ?? options.cwd,
1321
+ holder: { kind: "panel-call" },
1322
+ }, () => callAdmitted(grant, input));
1313
1323
  }
1314
1324
  async function callAdmitted(grant, input) {
1315
1325
  if (!(await authorized(grant)))
@@ -1,7 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { chmod, lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
3
3
  import { isAbsolute, join } from "node:path";
4
- import { formatNetscapeCookies, parseCookieJar, } from "@cjhyy/code-shell-core";
4
+ import { formatNetscapeCookies, normalizeCookieDomain, cookieAppliesToHost, parseCookieJar, } from "@cjhyy/code-shell-core";
5
5
  function targetUrl(raw) {
6
6
  if (typeof raw !== "string" || raw.length > 2048)
7
7
  throw new Error("Cookie selection requires an HTTPS URL");
@@ -16,16 +16,6 @@ function targetUrl(raw) {
16
16
  throw new Error("Cookie selection requires an HTTPS URL");
17
17
  return url;
18
18
  }
19
- function domain(raw) {
20
- if (typeof raw !== "string")
21
- return;
22
- const value = raw.toLowerCase().replace(/^\./, "");
23
- if (value.length > 253 ||
24
- !value.includes(".") ||
25
- !value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
26
- return;
27
- return value;
28
- }
29
19
  function beneath(host, root) {
30
20
  return host === root || host.endsWith(`.${root}`);
31
21
  }
@@ -86,7 +76,7 @@ export class PanelTaskCookieService {
86
76
  ]))
87
77
  .digest("hex");
88
78
  }
89
- cookies(credential, root) {
79
+ cookies(credential, host) {
90
80
  const now = (this.options.now?.() ?? Date.now()) / 1000;
91
81
  if (typeof credential.secret !== "string" ||
92
82
  Buffer.byteLength(credential.secret) > 4 * 1024 * 1024)
@@ -106,8 +96,10 @@ export class PanelTaskCookieService {
106
96
  !cookie.path.startsWith("/") ||
107
97
  /[\x00-\x1f\x7f]/.test(cookie.path))))
108
98
  return false;
109
- const cookieDomain = domain(cookie.domain);
110
- if (!cookieDomain || !beneath(cookieDomain, root))
99
+ // Account eligibility and actual Cookie scope are different fences:
100
+ // a login captured on www.example.com can carry .example.com cookies,
101
+ // but must not export host-only cookies from a sibling login host.
102
+ if (!cookieAppliesToHost(cookie, host))
111
103
  return false;
112
104
  if (cookie.expirationDate !== undefined &&
113
105
  (typeof cookie.expirationDate !== "number" ||
@@ -120,10 +112,10 @@ export class PanelTaskCookieService {
120
112
  async snapshot(scope, url) {
121
113
  await this.options.authorize(scope);
122
114
  const result = (await this.options.credentials(scope)).flatMap((credential) => {
123
- const root = domain(credential.meta?.domain);
115
+ const root = normalizeCookieDomain(credential.meta?.domain);
124
116
  if (credential.type !== "cookie" || !root || !beneath(url.hostname.toLowerCase(), root))
125
117
  return [];
126
- const cookies = this.cookies(credential, root);
118
+ const cookies = this.cookies(credential, url.hostname);
127
119
  if (!cookies.length)
128
120
  return [];
129
121
  return [
@@ -179,11 +179,16 @@ export class PanelToolJobService {
179
179
  };
180
180
  }
181
181
  executionScopes() {
182
+ const held = (scope, label) => ({
183
+ appId: scope.appId,
184
+ projectPath: scope.projectPath,
185
+ holder: { kind: "tool-job", ...(label ? { label } : {}) },
186
+ });
182
187
  return [
183
- ...[...this.preparing.values()].map((item) => item.scope),
188
+ ...[...this.preparing.values()].map((item) => held(item.scope)),
184
189
  ...[...this.jobs.values()]
185
190
  .filter((job) => !TERMINAL.has(job.status) || this.active.has(job.id))
186
- .map((job) => job.scope),
191
+ .map((job) => held(job.scope, job.entry.name)),
187
192
  ];
188
193
  }
189
194
  activeCount(projectPath) {