@cjhyy/code-shell-server 0.9.7 → 0.9.9

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 +85 -23
  2. package/dist/bin/code-shell-project-runtime.d.ts +2 -0
  3. package/dist/bin/code-shell-project-runtime.js +8 -0
  4. package/dist/desktop-web/http-api.d.ts +43 -0
  5. package/dist/desktop-web/http-api.js +376 -0
  6. package/dist/hub/approval-lease.d.ts +30 -0
  7. package/dist/hub/approval-lease.js +89 -0
  8. package/dist/hub/auth-http.d.ts +23 -0
  9. package/dist/hub/auth-http.js +259 -0
  10. package/dist/hub/auth-store.d.ts +52 -0
  11. package/dist/hub/auth-store.js +316 -0
  12. package/dist/hub/configuration.d.ts +64 -0
  13. package/dist/hub/configuration.js +555 -0
  14. package/dist/hub/files.d.ts +9 -0
  15. package/dist/hub/files.js +278 -0
  16. package/dist/hub/mcp-configuration.d.ts +51 -0
  17. package/dist/hub/mcp-configuration.js +558 -0
  18. package/dist/hub/mcp-probe.d.ts +24 -0
  19. package/dist/hub/mcp-probe.js +392 -0
  20. package/dist/hub/model-probe.d.ts +20 -0
  21. package/dist/hub/model-probe.js +193 -0
  22. package/dist/hub/outbound-transport.d.ts +36 -0
  23. package/dist/hub/outbound-transport.js +122 -0
  24. package/dist/hub/run-replay.d.ts +42 -0
  25. package/dist/hub/run-replay.js +89 -0
  26. package/dist/hub/session-management.d.ts +36 -0
  27. package/dist/hub/session-management.js +405 -0
  28. package/dist/hub/skills-management.d.ts +43 -0
  29. package/dist/hub/skills-management.js +487 -0
  30. package/dist/hub/uploads.d.ts +22 -0
  31. package/dist/hub/uploads.js +241 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.desktop-web.d.ts +2 -0
  34. package/dist/index.desktop-web.js +2 -0
  35. package/dist/index.js +3 -0
  36. package/dist/index.links.d.ts +6 -0
  37. package/dist/index.links.js +6 -0
  38. package/dist/index.panels.d.ts +9 -0
  39. package/dist/index.panels.js +8 -0
  40. package/dist/index.serve.d.ts +1 -0
  41. package/dist/index.serve.js +1 -0
  42. package/dist/index.storage.d.ts +2 -0
  43. package/dist/index.storage.js +2 -0
  44. package/dist/links/catalog.d.ts +10 -0
  45. package/dist/links/catalog.js +41 -0
  46. package/dist/links/device-oauth.d.ts +52 -0
  47. package/dist/links/device-oauth.js +255 -0
  48. package/dist/links/http.d.ts +34 -0
  49. package/dist/links/http.js +144 -0
  50. package/dist/links/service.d.ts +54 -0
  51. package/dist/links/service.js +578 -0
  52. package/dist/links/types.d.ts +6 -0
  53. package/dist/links/types.js +1 -0
  54. package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
  55. package/dist/mobile-remote/pending-approvals.js +2 -0
  56. package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
  57. package/dist/mobile-remote/remote-host-manager.js +90 -15
  58. package/dist/mobile-remote/room-manager.js +17 -5
  59. package/dist/panels/agent-task-host.d.ts +32 -0
  60. package/dist/panels/agent-task-host.js +457 -0
  61. package/dist/panels/agent-task-models.d.ts +27 -0
  62. package/dist/panels/agent-task-models.js +51 -0
  63. package/dist/panels/agent-task-service.d.ts +105 -0
  64. package/dist/panels/agent-task-service.js +283 -0
  65. package/dist/panels/http.d.ts +34 -0
  66. package/dist/panels/http.js +37 -0
  67. package/dist/panels/hub-binding.d.ts +12 -0
  68. package/dist/panels/hub-binding.js +174 -0
  69. package/dist/panels/management-http.d.ts +29 -0
  70. package/dist/panels/management-http.js +119 -0
  71. package/dist/panels/management.d.ts +39 -0
  72. package/dist/panels/management.js +494 -0
  73. package/dist/panels/process-files.d.ts +14 -0
  74. package/dist/panels/process-files.js +299 -0
  75. package/dist/panels/process-service.d.ts +96 -0
  76. package/dist/panels/process-service.js +501 -0
  77. package/dist/panels/runtime-services.d.ts +24 -0
  78. package/dist/panels/runtime-services.js +462 -0
  79. package/dist/panels/runtime.d.ts +51 -0
  80. package/dist/panels/runtime.js +1063 -0
  81. package/dist/panels/storage-store.d.ts +9 -0
  82. package/dist/panels/storage-store.js +154 -0
  83. package/dist/panels/types.d.ts +52 -0
  84. package/dist/panels/types.js +1 -0
  85. package/dist/project-runtime/control-server.d.ts +19 -0
  86. package/dist/project-runtime/control-server.js +300 -0
  87. package/dist/project-runtime/docker-provider.d.ts +33 -0
  88. package/dist/project-runtime/docker-provider.js +487 -0
  89. package/dist/project-runtime/lock.d.ts +6 -0
  90. package/dist/project-runtime/lock.js +43 -0
  91. package/dist/project-runtime/managed-entry.d.ts +10 -0
  92. package/dist/project-runtime/managed-entry.js +113 -0
  93. package/dist/project-runtime/manager.d.ts +40 -0
  94. package/dist/project-runtime/manager.js +249 -0
  95. package/dist/project-runtime/proxy.d.ts +22 -0
  96. package/dist/project-runtime/proxy.js +706 -0
  97. package/dist/project-runtime/registry.d.ts +54 -0
  98. package/dist/project-runtime/registry.js +286 -0
  99. package/dist/project-runtime/types.d.ts +43 -0
  100. package/dist/project-runtime/types.js +1 -0
  101. package/dist/serve/cli.d.ts +5 -0
  102. package/dist/serve/cli.js +82 -23
  103. package/dist/serve/headless-server.d.ts +11 -0
  104. package/dist/serve/headless-server.js +737 -83
  105. package/dist/session-titles-store.d.ts +17 -0
  106. package/dist/session-titles-store.js +174 -0
  107. package/dist/static-files.js +1 -0
  108. package/dist/worker-bridge-core.js +17 -6
  109. package/package.json +19 -5
@@ -0,0 +1,119 @@
1
+ import { createPanelManagement, PanelManagementError, publicPanelError, } from "./management.js";
2
+ const ROOT = "/api/v1/panels";
3
+ const MAX_BODY = 16 * 1024;
4
+ function json(response, status, value) {
5
+ response.writeHead(status, {
6
+ "Content-Type": "application/json; charset=utf-8",
7
+ "Cache-Control": "no-store",
8
+ });
9
+ response.end(JSON.stringify(value));
10
+ }
11
+ async function body(request, allowed) {
12
+ if ((request.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase() !==
13
+ "application/json")
14
+ throw new PanelManagementError(400, "invalid_request", "请发送 JSON 请求。");
15
+ const length = Number(request.headers["content-length"] ?? 0);
16
+ if (!Number.isFinite(length) || length < 0 || length > MAX_BODY) {
17
+ request.resume();
18
+ throw new PanelManagementError(413, "invalid_request", "请求内容过大。");
19
+ }
20
+ let size = 0;
21
+ const chunks = [];
22
+ for await (const part of request.iterator({ destroyOnReturn: false })) {
23
+ const chunk = Buffer.from(part);
24
+ size += chunk.length;
25
+ if (size > MAX_BODY) {
26
+ request.resume();
27
+ throw new PanelManagementError(413, "invalid_request", "请求内容过大。");
28
+ }
29
+ chunks.push(chunk);
30
+ }
31
+ let value;
32
+ try {
33
+ value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
34
+ }
35
+ catch {
36
+ throw new PanelManagementError(400, "invalid_request", "JSON 请求格式无效。");
37
+ }
38
+ if (!value ||
39
+ typeof value !== "object" ||
40
+ Array.isArray(value) ||
41
+ Object.keys(value).some((key) => !allowed.includes(key)))
42
+ throw new PanelManagementError(400, "invalid_request", "请求参数无效。");
43
+ return value;
44
+ }
45
+ /** Call only after the host's same-origin authentication boundary. */
46
+ export function createPanelManagementHttp(options) {
47
+ const service = createPanelManagement(options);
48
+ return {
49
+ service,
50
+ close: service.close,
51
+ cancelOwner: service.cancelOwner,
52
+ async handle(request, response) {
53
+ const url = new URL(request.url ?? "/", "http://localhost");
54
+ if (url.pathname !== ROOT && !url.pathname.startsWith(ROOT + "/"))
55
+ return false;
56
+ let abandoned = false;
57
+ const onClose = () => {
58
+ if (!response.writableEnded)
59
+ abandoned = true;
60
+ };
61
+ response.once("close", onClose);
62
+ try {
63
+ const owner = await options.ownerId(request);
64
+ if (!owner)
65
+ throw new PanelManagementError(401, "login_required", "请先登录。");
66
+ const context = {
67
+ ownerId: owner,
68
+ authorize: async () => !abandoned &&
69
+ !request.aborted &&
70
+ (await options.isAuthorized(request)) &&
71
+ (await options.ownerId(request)) === owner,
72
+ };
73
+ await service.assertAuthorized(context);
74
+ const method = request.method;
75
+ let result;
76
+ if (url.pathname === ROOT && method === "GET")
77
+ result = await service.snapshot();
78
+ else if (url.pathname === ROOT + "/github/discover" && method === "POST")
79
+ result = await service.discover(context, await body(request, ["url", "ref", "subdir"]));
80
+ else if (url.pathname === ROOT + "/preview" && method === "POST")
81
+ result = await service.preview(context, (await body(request, ["source"])).source);
82
+ else if (url.pathname === ROOT + "/install" && method === "POST") {
83
+ const input = await body(request, ["reviewToken", "bind"]);
84
+ result = await service.install(context, input.reviewToken, input.bind);
85
+ }
86
+ else {
87
+ const match = /^\/api\/v1\/panels\/([^/]+)(?:\/(binding|update-preview))?$/.exec(url.pathname);
88
+ if (!match)
89
+ throw new PanelManagementError(404, "not_found", "找不到这个面板操作。");
90
+ const id = decodeURIComponent(match[1]);
91
+ if (match[2] === "binding" && method === "PATCH") {
92
+ const input = await body(request, ["bound", "expectedRevision"]);
93
+ result = await service.binding(context, id, input.bound, input.expectedRevision);
94
+ }
95
+ else if (match[2] === "update-preview" && method === "POST")
96
+ result = await service.previewUpdate(context, id, (await body(request, ["expectedRevision"])).expectedRevision);
97
+ else if (!match[2] && method === "DELETE")
98
+ result = await service.remove(context, id, (await body(request, ["expectedRevision"])).expectedRevision);
99
+ else
100
+ throw new PanelManagementError(404, "not_found", "找不到这个面板操作。");
101
+ }
102
+ await service.assertAuthorized(context);
103
+ if (!response.destroyed)
104
+ json(response, 200, result);
105
+ }
106
+ catch (error) {
107
+ const failure = error instanceof URIError
108
+ ? new PanelManagementError(400, "invalid_request", "面板标识格式无效。")
109
+ : publicPanelError(error);
110
+ if (!response.destroyed)
111
+ json(response, failure.status, { code: failure.code, error: failure.message });
112
+ }
113
+ finally {
114
+ response.off("close", onClose);
115
+ }
116
+ return true;
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,39 @@
1
+ import { type PanelAppPreview } from "@cjhyy/code-shell-core";
2
+ import { getRefCommit } from "@cjhyy/code-shell-core/internal/skills";
3
+ import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelSnapshot } from "./types.js";
4
+ export declare class PanelManagementError extends Error {
5
+ readonly status: number;
6
+ readonly code: string;
7
+ constructor(status: number, code: string, message: string);
8
+ }
9
+ export declare function publicPanelError(error: unknown): PanelManagementError;
10
+ export interface PanelManagementOptions {
11
+ /** Already authorized concrete workspace; never selected by request bodies. */
12
+ cwd: string;
13
+ /** Host-authorized main project for a worktree. Defaults to the exact cwd. */
14
+ bindingCwd?: string;
15
+ /** Recheck a host-verified, frozen project/worktree association before use. */
16
+ assertBinding?: () => void;
17
+ withMutation?: <T>(write: () => Promise<T>) => Promise<T>;
18
+ onChanged?: (id: string, kind: "install" | "update" | "binding" | "remove") => void | Promise<void>;
19
+ compatibility?: (app: Pick<PanelAppPreview, "permissions" | "agent">) => PanelCompatibility;
20
+ now?: () => number;
21
+ /** Network seam only; production still uses the reviewed Core installer. */
22
+ resolveCommit?: typeof getRefCommit;
23
+ }
24
+ export declare function createPanelManagement(options: PanelManagementOptions): {
25
+ snapshot: () => Promise<PanelSnapshot>;
26
+ assertAuthorized: (context: PanelOperationContext, expected?: number) => Promise<void>;
27
+ discover(context: PanelOperationContext, input: unknown): Promise<PanelDiscovery>;
28
+ preview: (context: PanelOperationContext, input: unknown) => Promise<PanelReview>;
29
+ previewUpdate(context: PanelOperationContext, id: unknown, expected: unknown): Promise<PanelReview>;
30
+ install(context: PanelOperationContext, token: unknown, bind?: boolean): Promise<{
31
+ id: string;
32
+ }>;
33
+ binding(context: PanelOperationContext, id: unknown, bound: unknown, expected: unknown): Promise<PanelSnapshot>;
34
+ remove(context: PanelOperationContext, id: unknown, expected: unknown): Promise<{
35
+ removed: true;
36
+ }>;
37
+ cancelOwner(owner: string): void;
38
+ close(): void;
39
+ };
@@ -0,0 +1,494 @@
1
+ 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";
5
+ import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
6
+ import { getRefCommit, parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
7
+ const REVIEW_TTL = 8 * 60_000;
8
+ const MAX_REVIEWS = 8;
9
+ const MAX_NETWORK = 2;
10
+ const SHA = /^[a-f0-9]{40}$/i;
11
+ // All workspace services in one host share the same global installed catalog.
12
+ let catalogMutation = Promise.resolve();
13
+ export class PanelManagementError extends Error {
14
+ status;
15
+ code;
16
+ constructor(status, code, message) {
17
+ super(message);
18
+ this.status = status;
19
+ this.code = code;
20
+ this.name = "PanelManagementError";
21
+ }
22
+ }
23
+ export function publicPanelError(error) {
24
+ if (error instanceof PanelManagementError)
25
+ return error;
26
+ if (error instanceof PanelAppReviewChangedError || error instanceof PanelAppAlreadyInstalledError)
27
+ return new PanelManagementError(409, "conflict", "面板或来源已经变化,请重新预览后操作。");
28
+ // Core's shared GitHub reader throws ordinary Errors. Preserve actionable
29
+ // upstream failures without exposing arbitrary filesystem or transport text.
30
+ const cause = error;
31
+ const message = typeof cause?.message === "string" ? cause.message : "";
32
+ const code = String(cause?.code ?? cause?.cause?.code ?? "");
33
+ if (/GitHub.*(?:速率限制|rate.?limit|(?:API|HTTP)\s+429)/i.test(message))
34
+ return new PanelManagementError(429, "github_rate_limit", "GitHub 请求次数已达到限制,请稍后重试。已安装的面板仍可使用。");
35
+ if (cause?.name === "AbortError" ||
36
+ cause?.name === "TimeoutError" ||
37
+ /(?:ETIMEDOUT|UND_ERR_(?:CONNECT|HEADERS|BODY)_TIMEOUT)/.test(code) ||
38
+ /GitHub.*(?:timed?\s*out|timeout|aborted)/i.test(message))
39
+ return new PanelManagementError(504, "github_timeout", "连接 GitHub 超时,请检查服务端网络后重试。");
40
+ if (/找不到仓库(404)|GitHub.*(?:repository or ref was not found|(?:API|HTTP)\s+404)/i.test(message))
41
+ return new PanelManagementError(404, "github_not_found", "找不到这个公开 GitHub 仓库、分支或标签,请检查来源地址。");
42
+ if (/GitHub.*(?:拒绝访问.*403|(?:API|HTTP)\s+403)/i.test(message))
43
+ return new PanelManagementError(403, "github_forbidden", "GitHub 拒绝访问这个来源,请检查仓库是否公开及服务端网络。");
44
+ if (/GitHub.*(?:API|HTTP)\s+5\d\d/i.test(message))
45
+ return new PanelManagementError(503, "github_unavailable", "GitHub 暂时不可用,请稍后重试。");
46
+ if (/(?:ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|UND_ERR_SOCKET)/.test(code) ||
47
+ /^(?:fetch failed|Failed to fetch)$/i.test(message) ||
48
+ /GitHub source download failed/i.test(message))
49
+ return new PanelManagementError(502, "github_network", "服务端无法连接 GitHub,请检查网络、DNS 或代理设置后重试。");
50
+ if (error instanceof PanelAppInstallError)
51
+ return new PanelManagementError(400, "invalid_source", error.message);
52
+ if (error?.status === 409)
53
+ return new PanelManagementError(409, "busy", "任务正在运行,请结束后修改面板。");
54
+ return new PanelManagementError(503, "unavailable", "面板操作未完成,请刷新后重试。");
55
+ }
56
+ function invalid() {
57
+ throw new PanelManagementError(400, "invalid_request", "面板请求参数无效。");
58
+ }
59
+ function originFile() {
60
+ const root = panelAppsRoot();
61
+ mkdirSync(root, { recursive: true, mode: 0o700 });
62
+ const info = lstatSync(root);
63
+ if (info.isSymbolicLink() || !info.isDirectory())
64
+ throw new PanelManagementError(403, "unsafe_path", "面板安装目录必须是普通目录。");
65
+ return join(root, ".web-sources.json");
66
+ }
67
+ function origins(change) {
68
+ return mutateJsonFile(originFile(), {
69
+ maxBytes: 1024 * 1024,
70
+ mode: 0o600,
71
+ parse(raw) {
72
+ if (!raw)
73
+ return {};
74
+ const parsed = JSON.parse(raw);
75
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
76
+ throw new Error("Invalid panel origins");
77
+ return parsed;
78
+ },
79
+ serialize: (value) => JSON.stringify(value),
80
+ mutation(current) {
81
+ change?.(current);
82
+ return { ...(change ? { value: current } : {}), result: current };
83
+ },
84
+ });
85
+ }
86
+ function inputSource(input) {
87
+ if (!input || typeof input !== "object" || Array.isArray(input))
88
+ invalid();
89
+ const value = input;
90
+ if (Object.keys(value).some((key) => !["kind", "url", "ref", "subdir"].includes(key)) ||
91
+ (value.kind !== undefined && value.kind !== "git") ||
92
+ typeof value.url !== "string")
93
+ invalid();
94
+ const raw = value.url.trim();
95
+ const url = /^[\w.-]+\/[\w.-]+\/?$/.test(raw) ? `https://github.com/${raw}` : raw;
96
+ let parsed;
97
+ try {
98
+ const checked = new URL(url);
99
+ if (checked.search || checked.hash || /\/blob\//.test(checked.pathname))
100
+ invalid();
101
+ parsed = parseGithubUrl(url);
102
+ }
103
+ catch {
104
+ invalid();
105
+ }
106
+ if ((value.ref !== undefined && typeof value.ref !== "string") ||
107
+ (value.subdir !== undefined && typeof value.subdir !== "string") ||
108
+ (value.ref && parsed.ref) ||
109
+ (value.subdir && parsed.subpath))
110
+ invalid();
111
+ const ref = value.ref?.trim() || parsed.ref || "HEAD";
112
+ const subdir = value.subdir?.trim() || parsed.subpath;
113
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(ref) ||
114
+ ref.includes("..") ||
115
+ ref.includes("//") ||
116
+ ref.endsWith("/") ||
117
+ ref.endsWith(".lock"))
118
+ invalid();
119
+ if (subdir &&
120
+ (subdir.length > 1024 ||
121
+ subdir.includes("\\") ||
122
+ subdir.includes("\0") ||
123
+ subdir
124
+ .split("/")
125
+ .some((part) => !part || part === "." || part === ".." || part.startsWith("."))))
126
+ invalid();
127
+ return {
128
+ source: {
129
+ kind: "git",
130
+ url: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
131
+ ref,
132
+ ...(subdir ? { subdir } : {}),
133
+ },
134
+ owner: parsed.owner,
135
+ repo: parsed.repo,
136
+ };
137
+ }
138
+ function assertId(id) {
139
+ if (typeof id !== "string")
140
+ invalid();
141
+ try {
142
+ assertSafePanelAppId(id);
143
+ }
144
+ catch {
145
+ invalid();
146
+ }
147
+ }
148
+ function assertRevision(revision) {
149
+ if (typeof revision !== "string" || !/^[a-f0-9]{64}$/.test(revision))
150
+ invalid();
151
+ }
152
+ export function createPanelManagement(options) {
153
+ const now = options.now ?? Date.now;
154
+ const workspace = resolve(options.bindingCwd ?? options.cwd);
155
+ const hasProject = workspace !== resolve(join(userHome(), ".code-shell", "no-repo"));
156
+ const reviews = new Map();
157
+ const generations = new Map();
158
+ let closed = false;
159
+ let networkBusy = 0;
160
+ const compatibility = options.compatibility ?? (() => ({ supported: true, reasons: [] }));
161
+ const generation = (owner) => generations.get(owner) ?? 0;
162
+ async function assertAuthorized(context, expected = generation(context.ownerId)) {
163
+ if (closed || generation(context.ownerId) !== expected || !(await context.authorize()))
164
+ throw new PanelManagementError(401, "login_required", "登录已失效,请重新登录。");
165
+ options.assertBinding?.();
166
+ }
167
+ function policy(project) {
168
+ const manager = new SettingsManager(workspace, "full");
169
+ return resolvePanelAppBindingPolicy(manager.getForScope("user"), project ?? manager.getForScope("project", workspace), hasProject);
170
+ }
171
+ function revision(app, digest, project) {
172
+ const current = policy(project);
173
+ return createHash("sha256")
174
+ .update(JSON.stringify({
175
+ app,
176
+ digest,
177
+ bound: current.boundApps.has(app.id),
178
+ disabled: current.globalDisabledApps.has(app.id),
179
+ }))
180
+ .digest("hex");
181
+ }
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
+ if (info.isSymbolicLink() || !info.isDirectory())
189
+ throw new PanelManagementError(403, "unsafe_path", "面板安装目录不支持链接。");
190
+ const preview = await previewLocalPanelApp({ kind: "dir", path: target });
191
+ return { app, digest: preview.reviewToken };
192
+ }
193
+ function savedSource(app, saved) {
194
+ if (typeof app.source === "string")
195
+ return;
196
+ const record = saved[app.id];
197
+ if (record &&
198
+ record.lastUpdated === app.lastUpdated &&
199
+ record.source?.url === app.source.url &&
200
+ record.source?.ref === app.source.ref &&
201
+ SHA.test(record.source.commit))
202
+ return record.source;
203
+ const parsed = inputSource(app.source);
204
+ return {
205
+ ...parsed.source,
206
+ ref: parsed.source.ref ?? "HEAD",
207
+ commit: SHA.test(parsed.source.ref ?? "") ? parsed.source.ref : "",
208
+ };
209
+ }
210
+ async function snapshot() {
211
+ options.assertBinding?.();
212
+ const current = policy();
213
+ const saved = origins();
214
+ const panels = [];
215
+ for (const listed of await listInstalledPanelApps()) {
216
+ const { app, digest } = await catalogApp(listed.id);
217
+ const source = savedSource(app, saved);
218
+ const { installPath: _path, source: _source, installedAt: _installedAt, lastUpdated: _lastUpdated, ...publicApp } = app;
219
+ const support = compatibility(app);
220
+ panels.push({
221
+ ...publicApp,
222
+ revision: revision(app, digest),
223
+ bound: current.boundApps.has(app.id),
224
+ globalDisabled: current.globalDisabledApps.has(app.id),
225
+ enabled: current.hasProject &&
226
+ current.boundApps.has(app.id) &&
227
+ !current.globalDisabledApps.has(app.id) &&
228
+ support.supported,
229
+ updatable: Boolean(source),
230
+ source: source
231
+ ? {
232
+ ...source,
233
+ label: `${new URL(source.url).pathname.replace(/^\/|\.git$/g, "")}${source.subdir ? `/${source.subdir}` : ""}`,
234
+ }
235
+ : { kind: "local", label: basename(app.source) },
236
+ compatibility: support,
237
+ });
238
+ }
239
+ return { panels, workspace, hasProject };
240
+ }
241
+ async function network(context, work) {
242
+ const expected = generation(context.ownerId);
243
+ const guard = () => assertAuthorized(context, expected);
244
+ await guard();
245
+ if (networkBusy >= MAX_NETWORK)
246
+ throw new PanelManagementError(429, "busy", "已有来源检查正在进行,请稍后重试。");
247
+ networkBusy++;
248
+ try {
249
+ const result = await work(guard);
250
+ await guard();
251
+ return result;
252
+ }
253
+ finally {
254
+ networkBusy--;
255
+ }
256
+ }
257
+ async function pinned(input, guard) {
258
+ const parsed = inputSource(input);
259
+ const ref = parsed.source.ref ?? "HEAD";
260
+ const commit = SHA.test(ref)
261
+ ? ref.toLowerCase()
262
+ : await (options.resolveCommit ?? getRefCommit)({ owner: parsed.owner, repo: parsed.repo }, ref);
263
+ await guard();
264
+ if (!SHA.test(commit))
265
+ throw new PanelManagementError(502, "invalid_source", "GitHub 没有返回有效的提交版本。");
266
+ return { ...parsed.source, ref, commit: commit.toLowerCase() };
267
+ }
268
+ function prune() {
269
+ for (const [token, value] of reviews)
270
+ if (value.public.expiresAt <= now())
271
+ reviews.delete(token);
272
+ }
273
+ async function issue(context, input, current) {
274
+ return network(context, async (guard) => {
275
+ prune();
276
+ if (reviews.size >= MAX_REVIEWS)
277
+ throw new PanelManagementError(429, "busy", "待确认的面板过多,请先完成已有预览。");
278
+ const source = await pinned(input, guard);
279
+ const pinnedSource = {
280
+ kind: "git",
281
+ url: source.url,
282
+ ref: source.commit,
283
+ ...(source.subdir ? { subdir: source.subdir } : {}),
284
+ };
285
+ const preview = await previewLocalPanelApp(pinnedSource);
286
+ await guard();
287
+ if (current && preview.id !== current.app.id)
288
+ throw new PanelManagementError(409, "conflict", "来源中的面板标识已经改变。");
289
+ if (!current && preview.alreadyInstalled)
290
+ throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请从列表检查更新。");
291
+ const { reviewToken: digest, ...publicPreview } = preview;
292
+ const reviewToken = randomBytes(32).toString("base64url");
293
+ const value = {
294
+ reviewToken,
295
+ expiresAt: now() + REVIEW_TTL,
296
+ kind: current ? "update" : "install",
297
+ preview: publicPreview,
298
+ source,
299
+ expectedRevision: current?.revision ?? null,
300
+ compatibility: compatibility(preview),
301
+ };
302
+ reviews.set(reviewToken, {
303
+ owner: context.ownerId,
304
+ generation: generation(context.ownerId),
305
+ public: value,
306
+ source: pinnedSource,
307
+ digest,
308
+ });
309
+ return value;
310
+ });
311
+ }
312
+ async function mutate(context, work) {
313
+ const expected = generation(context.ownerId);
314
+ const guard = () => assertAuthorized(context, expected);
315
+ const run = async () => {
316
+ await guard();
317
+ const write = async () => {
318
+ await guard();
319
+ return work(guard);
320
+ };
321
+ return options.withMutation ? options.withMutation(write) : write();
322
+ };
323
+ const next = catalogMutation.then(run, run);
324
+ catalogMutation = next.catch(() => undefined);
325
+ return next;
326
+ }
327
+ function setBinding(app, digest, bound, expected) {
328
+ options.assertBinding?.();
329
+ if (!hasProject)
330
+ throw new PanelManagementError(400, "project_required", "请先选择项目,再绑定面板。");
331
+ const manager = new SettingsManager(workspace, "full");
332
+ manager.mutateSettingsForScope("project", workspace, (current) => {
333
+ options.assertBinding?.();
334
+ if (expected && revision(app, digest, current) !== expected)
335
+ throw new PanelManagementError(409, "conflict", "面板配置已改变,请刷新后重试。");
336
+ const bindings = new Set(Array.isArray(current.panelAppBindings)
337
+ ? current.panelAppBindings.filter((value) => typeof value === "string")
338
+ : []);
339
+ if (bound)
340
+ bindings.add(app.id);
341
+ else
342
+ bindings.delete(app.id);
343
+ current.panelAppBindings = [...bindings].sort();
344
+ const overrides = current.panelAppOverrides &&
345
+ typeof current.panelAppOverrides === "object" &&
346
+ !Array.isArray(current.panelAppOverrides)
347
+ ? { ...current.panelAppOverrides }
348
+ : {};
349
+ // Clear legacy overrides only for this app; a stale `on` must not defeat unbind.
350
+ delete overrides[app.id];
351
+ current.panelAppOverrides = overrides;
352
+ });
353
+ invalidateSkillCache();
354
+ }
355
+ return {
356
+ snapshot,
357
+ assertAuthorized,
358
+ async discover(context, input) {
359
+ return network(context, async (guard) => {
360
+ const source = await pinned(input, guard);
361
+ const result = await discoverGitPanelApps({
362
+ kind: "git",
363
+ url: source.url,
364
+ ref: source.commit,
365
+ ...(source.subdir ? { subdir: source.subdir } : {}),
366
+ });
367
+ await guard();
368
+ // Keep the selected branch in candidates; preview resolves and pins it again.
369
+ return {
370
+ ...result,
371
+ source,
372
+ panels: result.panels.map((item) => ({
373
+ ...item,
374
+ source: { ...item.source, ref: source.ref },
375
+ })),
376
+ };
377
+ });
378
+ },
379
+ preview: (context, input) => issue(context, input),
380
+ async previewUpdate(context, id, expected) {
381
+ assertId(id);
382
+ assertRevision(expected);
383
+ await assertAuthorized(context);
384
+ const current = await catalogApp(id);
385
+ const currentRevision = revision(current.app, current.digest);
386
+ if (currentRevision !== expected)
387
+ throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
388
+ const source = savedSource(current.app, origins());
389
+ if (!source)
390
+ throw new PanelManagementError(400, "local_source", "这个面板由本地来源安装,请在桌面端更新。");
391
+ const { commit: _commit, ...input } = source;
392
+ return issue(context, input, { ...current, revision: currentRevision });
393
+ },
394
+ async install(context, token, bind = true) {
395
+ if (typeof token !== "string" || typeof bind !== "boolean")
396
+ invalid();
397
+ prune();
398
+ const held = reviews.get(token);
399
+ if (!held)
400
+ throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
401
+ if (held.owner !== context.ownerId)
402
+ throw new PanelManagementError(403, "review_owner", "请在当前设备重新预览这个来源。");
403
+ await assertAuthorized(context, held.generation);
404
+ return mutate(context, async (guard) => {
405
+ await assertAuthorized(context, held.generation);
406
+ if (reviews.get(token) !== held || held.public.expiresAt <= now())
407
+ throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
408
+ const id = held.public.preview.id;
409
+ if (held.public.kind === "update") {
410
+ const current = await catalogApp(id);
411
+ if (revision(current.app, current.digest) !== held.public.expectedRevision)
412
+ throw new PanelManagementError(409, "conflict", "面板已改变,请重新检查更新。");
413
+ }
414
+ else if ((await listInstalledPanelApps()).some((item) => item.id === id))
415
+ throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请检查更新。");
416
+ await guard();
417
+ const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
418
+ overwrite: held.public.kind === "update",
419
+ expectedId: id,
420
+ recordedRef: held.public.source.ref,
421
+ beforeCommit: async () => {
422
+ if (held.public.kind === "update") {
423
+ const current = await catalogApp(id);
424
+ if (revision(current.app, current.digest) !== held.public.expectedRevision)
425
+ throw new PanelManagementError(409, "conflict", "面板已改变,请重新检查更新。");
426
+ }
427
+ await guard();
428
+ },
429
+ });
430
+ reviews.delete(token);
431
+ origins((value) => {
432
+ value[id] = { source: held.public.source, lastUpdated: installed.lastUpdated };
433
+ });
434
+ await guard();
435
+ if (bind && hasProject && held.public.compatibility.supported)
436
+ setBinding(installed, held.digest, true);
437
+ invalidateSkillCache();
438
+ await options.onChanged?.(id, held.public.kind);
439
+ return { id };
440
+ });
441
+ },
442
+ async binding(context, id, bound, expected) {
443
+ assertId(id);
444
+ assertRevision(expected);
445
+ if (typeof bound !== "boolean")
446
+ invalid();
447
+ return mutate(context, async (guard) => {
448
+ const current = await catalogApp(id);
449
+ if (bound && !compatibility(current.app).supported)
450
+ throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
451
+ await guard();
452
+ setBinding(current.app, current.digest, bound, expected);
453
+ await options.onChanged?.(id, "binding");
454
+ return snapshot();
455
+ });
456
+ },
457
+ async remove(context, id, expected) {
458
+ assertId(id);
459
+ assertRevision(expected);
460
+ return mutate(context, async (guard) => {
461
+ const current = await catalogApp(id);
462
+ if (revision(current.app, current.digest) !== expected)
463
+ throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
464
+ await guard();
465
+ await uninstallPanelApp(id, {
466
+ beforeCommit: async () => {
467
+ const latest = await catalogApp(id);
468
+ if (revision(latest.app, latest.digest) !== expected)
469
+ throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
470
+ await guard();
471
+ },
472
+ });
473
+ origins((value) => {
474
+ delete value[id];
475
+ });
476
+ if (hasProject)
477
+ setBinding(current.app, current.digest, false);
478
+ invalidateSkillCache();
479
+ await options.onChanged?.(id, "remove");
480
+ return { removed: true };
481
+ });
482
+ },
483
+ cancelOwner(owner) {
484
+ generations.set(owner, generation(owner) + 1);
485
+ for (const [token, held] of reviews)
486
+ if (held.owner === owner)
487
+ reviews.delete(token);
488
+ },
489
+ close() {
490
+ closed = true;
491
+ reviews.clear();
492
+ },
493
+ };
494
+ }
@@ -0,0 +1,14 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export interface PanelProcessDirectoryOptions {
3
+ /** Host-resolved directory from an opaque process directory grant, never a URL path. */
4
+ root: string;
5
+ /** The exact authenticated route for that opaque grant. */
6
+ baseUrl: string;
7
+ /** Trusted gateway prefix, used in rendered links only. */
8
+ linkPrefix?: string;
9
+ /** Trusted host workspace retained in browser links for Desktop Web routing. */
10
+ workspace?: string;
11
+ isAuthorized(): Promise<boolean>;
12
+ }
13
+ /** Authenticated read-only exports from one granted directory; no recursive paths. */
14
+ export declare function handlePanelProcessDirectory(request: IncomingMessage, response: ServerResponse, options: PanelProcessDirectoryOptions): Promise<void>;