@cjhyy/code-shell-server 0.9.50 → 0.9.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hub/configuration.d.ts +11 -0
- package/dist/hub/configuration.js +34 -1
- package/dist/hub/model-parameter-metadata.d.ts +26 -0
- package/dist/hub/model-parameter-metadata.js +92 -0
- package/dist/index.panels.d.ts +1 -1
- package/dist/index.panels.js +1 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +4 -0
- package/dist/mobile-remote/mobile-chat-turn.js +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +7 -1
- package/dist/mobile-remote/remote-host-manager.d.ts +2 -0
- package/dist/mobile-remote/remote-host-manager.js +20 -10
- package/dist/panels/directory-picker.d.ts +48 -0
- package/dist/panels/directory-picker.js +247 -0
- package/dist/panels/http.d.ts +1 -1
- package/dist/panels/management-http.d.ts +1 -1
- package/dist/panels/management.d.ts +11 -2
- package/dist/panels/management.js +15 -7
- package/dist/panels/runtime.js +123 -16
- package/package.json +6 -6
|
@@ -31,6 +31,10 @@ export declare function createHubConfiguration(options: HubConfigurationOptions)
|
|
|
31
31
|
text?: string | undefined;
|
|
32
32
|
};
|
|
33
33
|
connections: {
|
|
34
|
+
paramValues?: {
|
|
35
|
+
[k: string]: string | number | boolean;
|
|
36
|
+
} | undefined;
|
|
37
|
+
unavailableParameters?: string[] | undefined;
|
|
34
38
|
id: string;
|
|
35
39
|
revision: string;
|
|
36
40
|
catalogId: string;
|
|
@@ -46,6 +50,13 @@ export declare function createHubConfiguration(options: HubConfigurationOptions)
|
|
|
46
50
|
adapterKind: string;
|
|
47
51
|
defaultBaseUrl: string;
|
|
48
52
|
needsKey: boolean;
|
|
53
|
+
modelPresets: {
|
|
54
|
+
parameters: import("./model-parameter-metadata.js").PublicModelParameter[];
|
|
55
|
+
supportsVision?: boolean | undefined;
|
|
56
|
+
maxContextTokens?: number | undefined;
|
|
57
|
+
value: string;
|
|
58
|
+
label: string;
|
|
59
|
+
}[];
|
|
49
60
|
}[];
|
|
50
61
|
skills: {
|
|
51
62
|
disabledReason?: string | undefined;
|
|
@@ -4,6 +4,7 @@ import { SettingsManager, invalidateSkillCache, mergePluginMcpServers, scanSkill
|
|
|
4
4
|
import { computeEffectiveDisabledLists, getMergedCatalog } from "@cjhyy/code-shell-core/internal";
|
|
5
5
|
import { withModelTraceContext } from "@cjhyy/code-shell-core/internal";
|
|
6
6
|
import { probeConfiguredModel } from "@cjhyy/code-shell-host";
|
|
7
|
+
import { publicModelPresets, publicParameterValues, validateParameterPatch, unavailableParameters, } from "./model-parameter-metadata.js";
|
|
7
8
|
const ROOT = "/api/v1/configuration";
|
|
8
9
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
10
|
const MAX_SKILL_BYTES = 2 * 1024 * 1024;
|
|
@@ -69,6 +70,7 @@ export function createHubConfiguration(options) {
|
|
|
69
70
|
protocol: template?.protocol,
|
|
70
71
|
adapterKind: template?.adapterKind,
|
|
71
72
|
defaultBaseUrl: template?.defaultBaseUrl,
|
|
73
|
+
modelPresets: template?.modelPresets,
|
|
72
74
|
},
|
|
73
75
|
}))
|
|
74
76
|
.digest("hex");
|
|
@@ -131,6 +133,12 @@ export function createHubConfiguration(options) {
|
|
|
131
133
|
baseUrl: displayUrl(connection.baseUrl ?? credential?.baseUrl ?? template?.defaultBaseUrl),
|
|
132
134
|
hasApiKey: Boolean(credential?.apiKey),
|
|
133
135
|
needsKey: template?.needsKey !== false,
|
|
136
|
+
...(connection.tag === "text"
|
|
137
|
+
? {
|
|
138
|
+
paramValues: publicParameterValues(template?.modelPresets?.find((preset) => preset.value === connection.model), connection.paramValues),
|
|
139
|
+
unavailableParameters: unavailableParameters(template?.modelPresets?.find((preset) => preset.value === connection.model), connection.paramValues),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
134
142
|
};
|
|
135
143
|
}),
|
|
136
144
|
catalog: catalog
|
|
@@ -141,6 +149,7 @@ export function createHubConfiguration(options) {
|
|
|
141
149
|
adapterKind: entry.adapterKind,
|
|
142
150
|
defaultBaseUrl: displayUrl(entry.defaultBaseUrl),
|
|
143
151
|
needsKey: entry.needsKey !== false,
|
|
152
|
+
modelPresets: publicModelPresets(entry),
|
|
144
153
|
})),
|
|
145
154
|
skills: allSkills.map((skill) => ({
|
|
146
155
|
name: skill.name,
|
|
@@ -200,7 +209,15 @@ export function createHubConfiguration(options) {
|
|
|
200
209
|
});
|
|
201
210
|
}
|
|
202
211
|
function updateConnection(input) {
|
|
203
|
-
knownFields(input, [
|
|
212
|
+
knownFields(input, [
|
|
213
|
+
"id",
|
|
214
|
+
"catalogId",
|
|
215
|
+
"model",
|
|
216
|
+
"baseUrl",
|
|
217
|
+
"apiKey",
|
|
218
|
+
"expectedRevision",
|
|
219
|
+
"paramValues",
|
|
220
|
+
]);
|
|
204
221
|
const id = boundedString(input.id, "id", 128);
|
|
205
222
|
const catalogId = boundedString(input.catalogId, "catalogId", 128);
|
|
206
223
|
const model = boundedString(input.model, "model", 256);
|
|
@@ -213,6 +230,21 @@ export function createHubConfiguration(options) {
|
|
|
213
230
|
const effective = settingsManager().get();
|
|
214
231
|
checkConnectionRevision(input, id, effective);
|
|
215
232
|
const existing = effective.modelConnections.find((entry) => entry.id === id);
|
|
233
|
+
if (input.paramValues !== undefined &&
|
|
234
|
+
(existing ? typeof input.expectedRevision !== "string" : input.expectedRevision !== null))
|
|
235
|
+
throw new HubConfigurationError(409, "请载入最新连接后再保存模型参数。");
|
|
236
|
+
const parameters = input.paramValues === undefined
|
|
237
|
+
? undefined
|
|
238
|
+
: validateParameterPatch(template.modelPresets?.find((preset) => preset.value === model), input.paramValues);
|
|
239
|
+
if (input.paramValues !== undefined && parameters === undefined)
|
|
240
|
+
throw new HubConfigurationError(400, "模型参数不受支持或超出允许范围,请刷新后检查。");
|
|
241
|
+
const mergedParameters = { ...existing?.paramValues };
|
|
242
|
+
for (const [key, value] of Object.entries(parameters ?? {})) {
|
|
243
|
+
if (value === null)
|
|
244
|
+
delete mergedParameters[key];
|
|
245
|
+
else
|
|
246
|
+
mergedParameters[key] = value;
|
|
247
|
+
}
|
|
216
248
|
if (existing && (existing.tag !== "text" || existing.catalogId !== catalogId)) {
|
|
217
249
|
throw new HubConfigurationError(400, "existing connection provider cannot be changed");
|
|
218
250
|
}
|
|
@@ -246,6 +278,7 @@ export function createHubConfiguration(options) {
|
|
|
246
278
|
model,
|
|
247
279
|
...(credentialId ? { credentialId } : {}),
|
|
248
280
|
...(baseUrl !== undefined ? { baseUrl: baseUrl || template.defaultBaseUrl } : {}),
|
|
281
|
+
...(parameters !== undefined ? { paramValues: mergedParameters } : {}),
|
|
249
282
|
};
|
|
250
283
|
current.modelConnections = [
|
|
251
284
|
...effective.modelConnections.filter((entry) => entry.id !== id),
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { getMergedCatalog } from "@cjhyy/code-shell-core/internal";
|
|
2
|
+
type Catalog = ReturnType<typeof getMergedCatalog>[number];
|
|
3
|
+
type Preset = NonNullable<Catalog["modelPresets"]>[number];
|
|
4
|
+
export interface PublicModelParameter {
|
|
5
|
+
name: string;
|
|
6
|
+
label: string;
|
|
7
|
+
control: "enum" | "number" | "toggle";
|
|
8
|
+
options?: string[];
|
|
9
|
+
min?: number;
|
|
10
|
+
max?: number;
|
|
11
|
+
default?: string | number | boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function modelParameters(preset?: Preset): PublicModelParameter[];
|
|
14
|
+
export declare function publicParameterValues(preset: Preset | undefined, values: unknown): {
|
|
15
|
+
[k: string]: string | number | boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function unavailableParameters(preset: Preset | undefined, values: unknown): string[];
|
|
18
|
+
export declare function validateParameterPatch(preset: Preset | undefined, input: unknown): Record<string, string | number | boolean | null> | undefined;
|
|
19
|
+
export declare function publicModelPresets(entry: Catalog): {
|
|
20
|
+
parameters: PublicModelParameter[];
|
|
21
|
+
supportsVision?: boolean | undefined;
|
|
22
|
+
maxContextTokens?: number | undefined;
|
|
23
|
+
value: string;
|
|
24
|
+
label: string;
|
|
25
|
+
}[];
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Deliberately limited to text-generation controls; credential, endpoint,
|
|
2
|
+
* header, arbitrary text and wire-path declarations never reach the browser. */
|
|
3
|
+
const NAMES = new Set([
|
|
4
|
+
"reasoning",
|
|
5
|
+
"reasoning_effort",
|
|
6
|
+
"thinking_type",
|
|
7
|
+
"temperature",
|
|
8
|
+
"top_p",
|
|
9
|
+
"max_tokens",
|
|
10
|
+
]);
|
|
11
|
+
const FIELDS = {
|
|
12
|
+
reasoning: [
|
|
13
|
+
"reasoning",
|
|
14
|
+
"reasoning_effort",
|
|
15
|
+
"reasoning.effort",
|
|
16
|
+
"thinking.budget_tokens",
|
|
17
|
+
"thinking.type",
|
|
18
|
+
],
|
|
19
|
+
reasoning_effort: ["reasoning_effort"],
|
|
20
|
+
thinking_type: ["thinking_type", "thinking.type"],
|
|
21
|
+
temperature: ["temperature"],
|
|
22
|
+
top_p: ["top_p"],
|
|
23
|
+
max_tokens: ["max_tokens"],
|
|
24
|
+
};
|
|
25
|
+
export function modelParameters(preset) {
|
|
26
|
+
return (preset?.params ?? []).flatMap((spec) => {
|
|
27
|
+
if (!NAMES.has(spec.name) ||
|
|
28
|
+
preset.params.filter((candidate) => candidate.name === spec.name).length !== 1 ||
|
|
29
|
+
(spec.control !== "enum" && spec.control !== "number" && spec.control !== "toggle") ||
|
|
30
|
+
!FIELDS[spec.name]?.includes(spec.wire?.field ?? spec.name))
|
|
31
|
+
return [];
|
|
32
|
+
const result = {
|
|
33
|
+
name: spec.name,
|
|
34
|
+
label: spec.label ?? spec.name,
|
|
35
|
+
control: spec.control,
|
|
36
|
+
...(spec.control === "enum" ? { options: [...(spec.options ?? [])] } : {}),
|
|
37
|
+
...(spec.control === "number" ? { min: spec.min ?? 0, max: spec.max ?? 1_000_000_000 } : {}),
|
|
38
|
+
};
|
|
39
|
+
if (validParameter(result, spec.default))
|
|
40
|
+
result.default = spec.default;
|
|
41
|
+
return [result];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function validParameter(spec, value) {
|
|
45
|
+
if (spec.control === "enum")
|
|
46
|
+
return typeof value === "string" && value.length <= 256 && !!spec.options?.includes(value);
|
|
47
|
+
if (spec.control === "toggle")
|
|
48
|
+
return typeof value === "boolean";
|
|
49
|
+
if (spec.control !== "number")
|
|
50
|
+
return false;
|
|
51
|
+
return (typeof value === "number" &&
|
|
52
|
+
Number.isFinite(value) &&
|
|
53
|
+
value >= (spec.min ?? 0) &&
|
|
54
|
+
value <= (spec.max ?? 1_000_000_000));
|
|
55
|
+
}
|
|
56
|
+
export function publicParameterValues(preset, values) {
|
|
57
|
+
const source = values && typeof values === "object" && !Array.isArray(values) ? values : {};
|
|
58
|
+
return Object.fromEntries(modelParameters(preset).flatMap((spec) => {
|
|
59
|
+
const value = source[spec.name];
|
|
60
|
+
return validParameter(spec, value) ? [[spec.name, value]] : [];
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
export function unavailableParameters(preset, values) {
|
|
64
|
+
const source = values && typeof values === "object" && !Array.isArray(values) ? values : {};
|
|
65
|
+
return modelParameters(preset)
|
|
66
|
+
.filter((spec) => Object.hasOwn(source, spec.name) &&
|
|
67
|
+
!validParameter(spec, source[spec.name]))
|
|
68
|
+
.map((spec) => spec.name);
|
|
69
|
+
}
|
|
70
|
+
export function validateParameterPatch(preset, input) {
|
|
71
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
72
|
+
return undefined;
|
|
73
|
+
const specs = modelParameters(preset);
|
|
74
|
+
const patch = Object.entries(input);
|
|
75
|
+
if (patch.length > NAMES.size)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (patch.some(([key, value]) => {
|
|
78
|
+
const spec = specs.find((candidate) => candidate.name === key);
|
|
79
|
+
return !spec || (value !== null && !validParameter(spec, value));
|
|
80
|
+
}))
|
|
81
|
+
return undefined;
|
|
82
|
+
return input;
|
|
83
|
+
}
|
|
84
|
+
export function publicModelPresets(entry) {
|
|
85
|
+
return (entry.modelPresets ?? []).map((preset) => ({
|
|
86
|
+
value: preset.value,
|
|
87
|
+
label: preset.label ?? preset.value,
|
|
88
|
+
...(preset.maxContextTokens !== undefined ? { maxContextTokens: preset.maxContextTokens } : {}),
|
|
89
|
+
...(preset.supportsVision !== undefined ? { supportsVision: preset.supportsVision } : {}),
|
|
90
|
+
parameters: modelParameters(preset),
|
|
91
|
+
}));
|
|
92
|
+
}
|
package/dist/index.panels.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createPanelHttp } from "./panels/http.js";
|
|
2
2
|
export * from "./panels/automations.js";
|
|
3
3
|
export * from "./panels/hub-automations.js";
|
|
4
|
-
export { createPanelManagement, PanelManagementError } from "./panels/management.js";
|
|
4
|
+
export { createPanelManagement, createPanelSnapshotReads, PanelManagementError, type PanelSnapshotReads, } from "./panels/management.js";
|
|
5
5
|
export { createPanelRuntime, panelWebCompatibility } from "./panels/runtime.js";
|
|
6
6
|
export { createHubPanelBinding } from "./panels/hub-binding.js";
|
|
7
7
|
export * from "./panels/project-scope.js";
|
package/dist/index.panels.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createPanelHttp } from "./panels/http.js";
|
|
2
2
|
export * from "./panels/automations.js";
|
|
3
3
|
export * from "./panels/hub-automations.js";
|
|
4
|
-
export { createPanelManagement, PanelManagementError } from "./panels/management.js";
|
|
4
|
+
export { createPanelManagement, createPanelSnapshotReads, PanelManagementError, } from "./panels/management.js";
|
|
5
5
|
export { createPanelRuntime, panelWebCompatibility } from "./panels/runtime.js";
|
|
6
6
|
export { createHubPanelBinding } from "./panels/hub-binding.js";
|
|
7
7
|
export * from "./panels/project-scope.js";
|
|
@@ -14,6 +14,10 @@ export interface DispatchMobileChatTurnInput {
|
|
|
14
14
|
attachments?: MobileImageAttachment[];
|
|
15
15
|
clientMessageId?: string;
|
|
16
16
|
modelTraceSource?: string;
|
|
17
|
+
/** Host-resolved per-run model pool key; never a global model setting. */
|
|
18
|
+
model?: string;
|
|
19
|
+
configuredModel?: import("@cjhyy/code-shell-core/internal").ConfiguredTextModelIntent;
|
|
20
|
+
workspaceProfile?: string;
|
|
17
21
|
permissionMode?: PermissionMode;
|
|
18
22
|
runId: string;
|
|
19
23
|
bridge: MobileRunBridge;
|
|
@@ -44,6 +44,9 @@ export async function dispatchMobileChatTurn(input) {
|
|
|
44
44
|
id: input.runId,
|
|
45
45
|
params: {
|
|
46
46
|
task: text,
|
|
47
|
+
...(input.model ? { model: input.model } : {}),
|
|
48
|
+
...(input.configuredModel ? { configuredModel: { ...input.configuredModel } } : {}),
|
|
49
|
+
...(input.workspaceProfile ? { workspaceProfile: input.workspaceProfile } : {}),
|
|
47
50
|
...(input.modelTraceSource && /^panel:[a-z][a-z0-9-]{0,63}$/.test(input.modelTraceSource)
|
|
48
51
|
? { modelTraceSource: input.modelTraceSource }
|
|
49
52
|
: {}),
|
|
@@ -28,8 +28,14 @@ export function parseMobileClientEvent(value) {
|
|
|
28
28
|
break;
|
|
29
29
|
case "chat.send":
|
|
30
30
|
valid =
|
|
31
|
-
|
|
31
|
+
event.configuredModel === undefined &&
|
|
32
|
+
event.model === undefined &&
|
|
33
|
+
optionalMember(event.permissionMode, PERMISSION_MODES) &&
|
|
32
34
|
boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
|
|
35
|
+
(event.nativeConnectionId === undefined ||
|
|
36
|
+
(boundedTrimmedString(event.nativeConnectionId, MAX_ID_LENGTH) &&
|
|
37
|
+
!/[\r\n\0]/.test(event.nativeConnectionId))) &&
|
|
38
|
+
!(event.modelKey !== undefined && event.nativeConnectionId !== undefined) &&
|
|
33
39
|
(event.modelKey === undefined ||
|
|
34
40
|
(boundedString(event.modelKey, MAX_ID_LENGTH) &&
|
|
35
41
|
/^(codex|claude-code)\/[^\r\n\0]*$/.test(event.modelKey))) &&
|
|
@@ -44,6 +44,8 @@ export interface RemoteHostManagerOptions {
|
|
|
44
44
|
outputJournalPageFrames?: 2048;
|
|
45
45
|
/** Selected-session Host implements verified display views and bounded detail parts. */
|
|
46
46
|
historyView?: boolean;
|
|
47
|
+
/** The attached command Host resolves native text connections per authorized Session. */
|
|
48
|
+
nativeModelSelection?: boolean;
|
|
47
49
|
onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
|
|
48
50
|
/**
|
|
49
51
|
* Absolute path to the built mobile app (out/mobile). Defaults to the
|
|
@@ -474,13 +474,18 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
474
474
|
return {
|
|
475
475
|
type: "pair.ok",
|
|
476
476
|
device,
|
|
477
|
-
...(this.opts.outputJournal
|
|
477
|
+
...(this.opts.outputJournal || this.opts.nativeModelSelection
|
|
478
478
|
? {
|
|
479
479
|
capabilities: {
|
|
480
|
-
outputJournal: 1,
|
|
481
|
-
...(this.opts.
|
|
482
|
-
...(this.opts.
|
|
483
|
-
|
|
480
|
+
...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
|
|
481
|
+
...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
|
|
482
|
+
...(this.opts.outputJournal && this.opts.historyView
|
|
483
|
+
? { historyView: 1 }
|
|
484
|
+
: {}),
|
|
485
|
+
...(this.opts.outputJournal && this.opts.legacyBasePages
|
|
486
|
+
? { legacyBasePages: 1 }
|
|
487
|
+
: {}),
|
|
488
|
+
...(this.opts.outputJournal && this.opts.outputJournalPageFrames === 2048
|
|
484
489
|
? { outputJournalPageFrames: 2048 }
|
|
485
490
|
: {}),
|
|
486
491
|
},
|
|
@@ -501,13 +506,18 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
501
506
|
return {
|
|
502
507
|
type: "auth.ok",
|
|
503
508
|
device,
|
|
504
|
-
...(this.opts.outputJournal
|
|
509
|
+
...(this.opts.outputJournal || this.opts.nativeModelSelection
|
|
505
510
|
? {
|
|
506
511
|
capabilities: {
|
|
507
|
-
outputJournal: 1,
|
|
508
|
-
...(this.opts.
|
|
509
|
-
...(this.opts.
|
|
510
|
-
|
|
512
|
+
...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
|
|
513
|
+
...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
|
|
514
|
+
...(this.opts.outputJournal && this.opts.historyView
|
|
515
|
+
? { historyView: 1 }
|
|
516
|
+
: {}),
|
|
517
|
+
...(this.opts.outputJournal && this.opts.legacyBasePages
|
|
518
|
+
? { legacyBasePages: 1 }
|
|
519
|
+
: {}),
|
|
520
|
+
...(this.opts.outputJournal && this.opts.outputJournalPageFrames === 2048
|
|
511
521
|
? { outputJournalPageFrames: 2048 }
|
|
512
522
|
: {}),
|
|
513
523
|
},
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare const directoryPickerLifetime: number;
|
|
2
|
+
export interface PanelDirectorySelection {
|
|
3
|
+
path: string;
|
|
4
|
+
assertCurrent(): void;
|
|
5
|
+
}
|
|
6
|
+
/** Used only after a Host bookmark proves a previous choice; a client path is never sufficient. */
|
|
7
|
+
export declare function capturePanelWorkspaceDirectory(cwd: string, path: string): PanelDirectorySelection;
|
|
8
|
+
/** A pending parent-UI choice of an existing project directory, not filesystem authority. */
|
|
9
|
+
export declare class PanelDirectoryPicker {
|
|
10
|
+
private readonly cwd;
|
|
11
|
+
private readonly now;
|
|
12
|
+
readonly rootPath: string;
|
|
13
|
+
readonly rootToken: string;
|
|
14
|
+
readonly initialToken: string;
|
|
15
|
+
readonly expiresAt: number;
|
|
16
|
+
private readonly root;
|
|
17
|
+
private readonly choices;
|
|
18
|
+
private closed;
|
|
19
|
+
private reading;
|
|
20
|
+
private tokenBytes;
|
|
21
|
+
constructor(cwd: string, now: () => number);
|
|
22
|
+
close(): void;
|
|
23
|
+
private assertRoot;
|
|
24
|
+
assertCurrent(): void;
|
|
25
|
+
private check;
|
|
26
|
+
private get;
|
|
27
|
+
private add;
|
|
28
|
+
list(token: unknown, authorize: () => Promise<void>): Promise<{
|
|
29
|
+
rootToken: string;
|
|
30
|
+
directories: {
|
|
31
|
+
name: string;
|
|
32
|
+
token: string;
|
|
33
|
+
}[];
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
parentToken?: string | undefined;
|
|
36
|
+
directoryToken: string;
|
|
37
|
+
relativePath: string;
|
|
38
|
+
name: string;
|
|
39
|
+
}>;
|
|
40
|
+
assertListing(listing: {
|
|
41
|
+
directoryToken: string;
|
|
42
|
+
directories: Array<{
|
|
43
|
+
token: string;
|
|
44
|
+
}>;
|
|
45
|
+
}): void;
|
|
46
|
+
/** Called synchronously after final authorization, while this exact confirmation is pending. */
|
|
47
|
+
select(token: unknown): PanelDirectorySelection;
|
|
48
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { opendir } from "node:fs/promises";
|
|
4
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { PanelManagementError } from "./management.js";
|
|
6
|
+
import { PanelBridgeError } from "./bridge-contract.js";
|
|
7
|
+
export const directoryPickerLifetime = 5 * 60_000;
|
|
8
|
+
const TOKEN = /^[A-Za-z0-9_-]{24}$/;
|
|
9
|
+
const MAX_TOKENS = 1024;
|
|
10
|
+
const MAX_ENTRIES = 200;
|
|
11
|
+
const MAX_SCAN = 4096;
|
|
12
|
+
const MAX_TOKEN_BYTES = 1024 * 1024;
|
|
13
|
+
function unavailable() {
|
|
14
|
+
throw new PanelManagementError(409, "panel_directory_picker", "目录选择已失效,请重新发起。");
|
|
15
|
+
}
|
|
16
|
+
function identity(path) {
|
|
17
|
+
try {
|
|
18
|
+
const info = lstatSync(path, { bigint: true });
|
|
19
|
+
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path)
|
|
20
|
+
unavailable();
|
|
21
|
+
return { path, dev: info.dev, ino: info.ino };
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return unavailable();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function assertIdentity(expected) {
|
|
28
|
+
const actual = identity(expected.path);
|
|
29
|
+
if (actual.dev !== expected.dev || actual.ino !== expected.ino)
|
|
30
|
+
unavailable();
|
|
31
|
+
}
|
|
32
|
+
function visibleName(name) {
|
|
33
|
+
return (name.length > 0 &&
|
|
34
|
+
name.length <= 255 &&
|
|
35
|
+
!name.startsWith(".") &&
|
|
36
|
+
!/[\\/\u0000-\u001f\u007f]/u.test(name));
|
|
37
|
+
}
|
|
38
|
+
/** Used only after a Host bookmark proves a previous choice; a client path is never sufficient. */
|
|
39
|
+
export function capturePanelWorkspaceDirectory(cwd, path) {
|
|
40
|
+
let root;
|
|
41
|
+
try {
|
|
42
|
+
root = realpathSync(cwd);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return unavailable();
|
|
46
|
+
}
|
|
47
|
+
const rel = relative(root, path);
|
|
48
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`))
|
|
49
|
+
unavailable();
|
|
50
|
+
const parts = rel.split(sep).filter(Boolean);
|
|
51
|
+
if (parts.length > 64)
|
|
52
|
+
unavailable();
|
|
53
|
+
const chain = [identity(root)];
|
|
54
|
+
for (let i = 0; i < parts.length; i++)
|
|
55
|
+
chain.push(identity(join(root, ...parts.slice(0, i + 1))));
|
|
56
|
+
const assertCurrent = () => {
|
|
57
|
+
try {
|
|
58
|
+
if (realpathSync(cwd) !== root)
|
|
59
|
+
unavailable();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
unavailable();
|
|
63
|
+
}
|
|
64
|
+
for (const entry of chain)
|
|
65
|
+
assertIdentity(entry);
|
|
66
|
+
};
|
|
67
|
+
assertCurrent();
|
|
68
|
+
return { path, assertCurrent };
|
|
69
|
+
}
|
|
70
|
+
/** A pending parent-UI choice of an existing project directory, not filesystem authority. */
|
|
71
|
+
export class PanelDirectoryPicker {
|
|
72
|
+
cwd;
|
|
73
|
+
now;
|
|
74
|
+
rootPath;
|
|
75
|
+
rootToken;
|
|
76
|
+
initialToken;
|
|
77
|
+
expiresAt;
|
|
78
|
+
root;
|
|
79
|
+
choices = new Map();
|
|
80
|
+
closed = false;
|
|
81
|
+
reading = false;
|
|
82
|
+
tokenBytes = 0;
|
|
83
|
+
constructor(cwd, now) {
|
|
84
|
+
this.cwd = cwd;
|
|
85
|
+
this.now = now;
|
|
86
|
+
this.rootPath = realpathSync(cwd);
|
|
87
|
+
this.root = identity(this.rootPath);
|
|
88
|
+
this.expiresAt = now() + directoryPickerLifetime;
|
|
89
|
+
this.rootToken = this.add([], [this.root]).token;
|
|
90
|
+
const path = join(this.rootPath, "downloads");
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
child = identity(path);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!(error instanceof PanelManagementError) &&
|
|
97
|
+
error.code !== "ENOENT")
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
this.initialToken = child
|
|
101
|
+
? this.add(["downloads"], [this.root, child], this.rootToken).token
|
|
102
|
+
: this.rootToken;
|
|
103
|
+
this.assertCurrent();
|
|
104
|
+
}
|
|
105
|
+
close() {
|
|
106
|
+
this.closed = true;
|
|
107
|
+
this.choices.clear();
|
|
108
|
+
this.tokenBytes = 0;
|
|
109
|
+
}
|
|
110
|
+
assertRoot() {
|
|
111
|
+
try {
|
|
112
|
+
if (realpathSync(this.cwd) !== this.rootPath)
|
|
113
|
+
unavailable();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
unavailable();
|
|
117
|
+
}
|
|
118
|
+
assertIdentity(this.root);
|
|
119
|
+
}
|
|
120
|
+
assertCurrent() {
|
|
121
|
+
if (this.closed || this.now() >= this.expiresAt)
|
|
122
|
+
unavailable();
|
|
123
|
+
this.assertRoot();
|
|
124
|
+
}
|
|
125
|
+
check(choice) {
|
|
126
|
+
this.assertCurrent();
|
|
127
|
+
for (const entry of choice.chain)
|
|
128
|
+
assertIdentity(entry);
|
|
129
|
+
}
|
|
130
|
+
get(token) {
|
|
131
|
+
const choice = typeof token === "string" && TOKEN.test(token) && this.choices.get(token);
|
|
132
|
+
if (!choice)
|
|
133
|
+
unavailable();
|
|
134
|
+
this.check(choice);
|
|
135
|
+
return choice;
|
|
136
|
+
}
|
|
137
|
+
add(parts, chain, parentToken) {
|
|
138
|
+
const existing = [...this.choices.values()].find((item) => item.parts.join("/") === parts.join("/"));
|
|
139
|
+
if (existing) {
|
|
140
|
+
this.check(existing);
|
|
141
|
+
return existing;
|
|
142
|
+
}
|
|
143
|
+
const bytes = Buffer.byteLength(join(this.rootPath, ...parts)) +
|
|
144
|
+
Buffer.byteLength(parts.join("/")) +
|
|
145
|
+
chain.length * 32 +
|
|
146
|
+
256;
|
|
147
|
+
if (this.choices.size >= MAX_TOKENS ||
|
|
148
|
+
parts.length > 64 ||
|
|
149
|
+
this.tokenBytes + bytes > MAX_TOKEN_BYTES)
|
|
150
|
+
unavailable();
|
|
151
|
+
const choice = {
|
|
152
|
+
token: randomBytes(18).toString("base64url"),
|
|
153
|
+
parts,
|
|
154
|
+
chain,
|
|
155
|
+
...(parentToken ? { parentToken } : {}),
|
|
156
|
+
};
|
|
157
|
+
this.choices.set(choice.token, choice);
|
|
158
|
+
this.tokenBytes += bytes;
|
|
159
|
+
return choice;
|
|
160
|
+
}
|
|
161
|
+
async list(token, authorize) {
|
|
162
|
+
if (this.reading)
|
|
163
|
+
throw new PanelBridgeError("RATE_LIMITED", "目录正在读取,请稍后重试。", 50);
|
|
164
|
+
this.reading = true;
|
|
165
|
+
try {
|
|
166
|
+
const choice = this.get(token);
|
|
167
|
+
await authorize();
|
|
168
|
+
this.check(choice);
|
|
169
|
+
const directories = [];
|
|
170
|
+
let truncated = false;
|
|
171
|
+
{
|
|
172
|
+
const directory = await opendir(join(this.rootPath, ...choice.parts));
|
|
173
|
+
let scanned = 0;
|
|
174
|
+
try {
|
|
175
|
+
this.check(choice);
|
|
176
|
+
for await (const entry of directory) {
|
|
177
|
+
this.check(choice);
|
|
178
|
+
if (++scanned > MAX_SCAN) {
|
|
179
|
+
truncated = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !visibleName(entry.name))
|
|
183
|
+
continue;
|
|
184
|
+
if (directories.length >= MAX_ENTRIES) {
|
|
185
|
+
truncated = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
const parts = [...choice.parts, entry.name];
|
|
189
|
+
const path = join(this.rootPath, ...parts);
|
|
190
|
+
let child;
|
|
191
|
+
try {
|
|
192
|
+
child = identity(path);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const item = this.add(parts, [...choice.chain, child], choice.token);
|
|
198
|
+
directories.push({ name: entry.name, token: item.token });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
// for-await closes its directory; the explicit close covers failure before iteration.
|
|
203
|
+
try {
|
|
204
|
+
await directory.close();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* Already closed by the iterator. */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
await authorize();
|
|
212
|
+
this.check(choice);
|
|
213
|
+
for (const item of directories)
|
|
214
|
+
this.get(item.token);
|
|
215
|
+
return {
|
|
216
|
+
directoryToken: choice.token,
|
|
217
|
+
relativePath: choice.parts.join("/"),
|
|
218
|
+
name: choice.parts.at(-1) ?? (basename(this.rootPath) || this.rootPath),
|
|
219
|
+
...(choice.parentToken ? { parentToken: choice.parentToken } : {}),
|
|
220
|
+
rootToken: this.rootToken,
|
|
221
|
+
directories: directories.sort((a, b) => a.name.localeCompare(b.name)),
|
|
222
|
+
truncated,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
this.reading = false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
assertListing(listing) {
|
|
230
|
+
this.get(listing.directoryToken);
|
|
231
|
+
for (const item of listing.directories)
|
|
232
|
+
this.get(item.token);
|
|
233
|
+
}
|
|
234
|
+
/** Called synchronously after final authorization, while this exact confirmation is pending. */
|
|
235
|
+
select(token) {
|
|
236
|
+
const choice = this.get(token);
|
|
237
|
+
const path = join(this.rootPath, ...choice.parts);
|
|
238
|
+
const chain = [...choice.chain];
|
|
239
|
+
const assertCurrent = () => {
|
|
240
|
+
this.assertRoot();
|
|
241
|
+
for (const entry of chain)
|
|
242
|
+
assertIdentity(entry);
|
|
243
|
+
};
|
|
244
|
+
assertCurrent();
|
|
245
|
+
return { path, assertCurrent };
|
|
246
|
+
}
|
|
247
|
+
}
|
package/dist/panels/http.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
|
|
|
17
17
|
authorizePanelDirectory?: PanelDirectoryAuthorizer;
|
|
18
18
|
}): {
|
|
19
19
|
service: {
|
|
20
|
-
snapshot: (appId?: string) => Promise<import("./types.js").PanelSnapshot>;
|
|
20
|
+
snapshot: (appId?: string, reads?: import("./management.js").PanelSnapshotReads) => Promise<import("./types.js").PanelSnapshot>;
|
|
21
21
|
packageHistory(context: import("./types.js").PanelOperationContext, id: unknown, expected: unknown): Promise<import("./types.js").PanelPackageHistory>;
|
|
22
22
|
previewRestore(context: import("./types.js").PanelOperationContext, id: unknown, digest: unknown, expected: unknown): Promise<import("./types.js").PanelPackageRestoreReview>;
|
|
23
23
|
cancelRestore(context: import("./types.js").PanelOperationContext, token: unknown): Promise<{
|
|
@@ -8,7 +8,7 @@ export interface PanelManagementHttpOptions extends PanelManagementOptions {
|
|
|
8
8
|
/** Call only after the host's same-origin authentication boundary. */
|
|
9
9
|
export declare function createPanelManagementHttp(options: PanelManagementHttpOptions): {
|
|
10
10
|
service: {
|
|
11
|
-
snapshot: (appId?: string) => Promise<import("./types.js").PanelSnapshot>;
|
|
11
|
+
snapshot: (appId?: string, reads?: import("./management.js").PanelSnapshotReads) => Promise<import("./types.js").PanelSnapshot>;
|
|
12
12
|
packageHistory(context: PanelOperationContext, id: unknown, expected: unknown): Promise<import("./types.js").PanelPackageHistory>;
|
|
13
13
|
previewRestore(context: PanelOperationContext, id: unknown, digest: unknown, expected: unknown): Promise<import("./types.js").PanelPackageRestoreReview>;
|
|
14
14
|
cancelRestore(context: PanelOperationContext, token: unknown): Promise<{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type PanelExecutionScope } from "./execution-gate.js";
|
|
2
|
-
import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
|
|
2
|
+
import { type PanelAppInspectionReads, type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
|
|
3
3
|
import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
|
|
4
4
|
export declare class PanelManagementError extends Error {
|
|
5
5
|
readonly status: number;
|
|
@@ -28,8 +28,17 @@ export interface PanelManagementOptions {
|
|
|
28
28
|
repo: string;
|
|
29
29
|
}, ref: string) => Promise<string>;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Verified reads shared by the snapshots of one multi-project request, so the
|
|
33
|
+
* same package is not re-verified once per project. Create one per request.
|
|
34
|
+
*/
|
|
35
|
+
export interface PanelSnapshotReads {
|
|
36
|
+
inspection: PanelAppInspectionReads;
|
|
37
|
+
digests: Map<string, Promise<string>>;
|
|
38
|
+
}
|
|
39
|
+
export declare function createPanelSnapshotReads(): PanelSnapshotReads;
|
|
31
40
|
export declare function createPanelManagement(options: PanelManagementOptions): {
|
|
32
|
-
snapshot: (appId?: string) => Promise<PanelSnapshot>;
|
|
41
|
+
snapshot: (appId?: string, reads?: PanelSnapshotReads) => Promise<PanelSnapshot>;
|
|
33
42
|
packageHistory(context: PanelOperationContext, id: unknown, expected: unknown): Promise<PanelPackageHistory>;
|
|
34
43
|
previewRestore(context: PanelOperationContext, id: unknown, digest: unknown, expected: unknown): Promise<PanelPackageRestoreReview>;
|
|
35
44
|
cancelRestore(context: PanelOperationContext, token: unknown): Promise<{
|
|
@@ -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, currentPanelAppPackagePins, installedPanelAppUpdatedAt, 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, inspectInstalledPanelAppDigest, 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;
|
|
@@ -151,6 +151,9 @@ function assertRevision(revision) {
|
|
|
151
151
|
if (typeof revision !== "string" || !/^[a-f0-9]{64}$/.test(revision))
|
|
152
152
|
invalid();
|
|
153
153
|
}
|
|
154
|
+
export function createPanelSnapshotReads() {
|
|
155
|
+
return { inspection: { packages: new Map() }, digests: new Map() };
|
|
156
|
+
}
|
|
154
157
|
export function createPanelManagement(options) {
|
|
155
158
|
const now = options.now ?? Date.now;
|
|
156
159
|
const workspace = resolve(options.bindingCwd ?? options.cwd);
|
|
@@ -206,12 +209,17 @@ export function createPanelManagement(options) {
|
|
|
206
209
|
const listApps = () => options.projectPackages
|
|
207
210
|
? inspectProjectPanelApps(workspace).then((result) => result.apps)
|
|
208
211
|
: listInstalledPanelApps();
|
|
209
|
-
async function inspectApp(app) {
|
|
212
|
+
async function inspectApp(app, reads) {
|
|
210
213
|
const info = lstatSync(app.installPath);
|
|
211
214
|
if (info.isSymbolicLink() || !info.isDirectory())
|
|
212
215
|
throw new PanelManagementError(403, "unsafe_path", "面板安装目录不支持链接。");
|
|
213
|
-
|
|
214
|
-
|
|
216
|
+
// Same digest a dir preview reports, without re-hashing unchanged installed bytes.
|
|
217
|
+
let digest = reads?.digests.get(app.installPath);
|
|
218
|
+
if (!digest) {
|
|
219
|
+
digest = inspectInstalledPanelAppDigest(app.installPath);
|
|
220
|
+
reads?.digests.set(app.installPath, digest);
|
|
221
|
+
}
|
|
222
|
+
return { app, digest: await digest };
|
|
215
223
|
}
|
|
216
224
|
async function selectedApp(id) {
|
|
217
225
|
const app = (await listApps()).find((item) => item.id === id);
|
|
@@ -240,12 +248,12 @@ export function createPanelManagement(options) {
|
|
|
240
248
|
commit: SHA.test(parsed.source.ref ?? "") ? parsed.source.ref : "",
|
|
241
249
|
};
|
|
242
250
|
}
|
|
243
|
-
async function snapshot(appId) {
|
|
251
|
+
async function snapshot(appId, reads) {
|
|
244
252
|
if (appId !== undefined)
|
|
245
253
|
assertSafePanelAppId(appId);
|
|
246
254
|
options.assertBinding?.();
|
|
247
255
|
const inspected = options.projectPackages
|
|
248
|
-
? await inspectProjectPanelApps(workspace, appId)
|
|
256
|
+
? await inspectProjectPanelApps(workspace, appId, reads?.inspection)
|
|
249
257
|
: undefined;
|
|
250
258
|
const apps = inspected?.apps ?? (await listInstalledPanelApps(appId));
|
|
251
259
|
options.assertBinding?.();
|
|
@@ -268,7 +276,7 @@ export function createPanelManagement(options) {
|
|
|
268
276
|
.digest("hex"),
|
|
269
277
|
}));
|
|
270
278
|
for (const listed of apps) {
|
|
271
|
-
const { app, digest } = await inspectApp(listed);
|
|
279
|
+
const { app, digest } = await inspectApp(listed, reads);
|
|
272
280
|
const source = savedSource(app, saved);
|
|
273
281
|
const { installPath: _path, source: _source, installedAt: _installedAt, lastUpdated: _lastUpdated, ...publicApp } = app;
|
|
274
282
|
const support = compatibility(app);
|
package/dist/panels/runtime.js
CHANGED
|
@@ -18,6 +18,7 @@ import { PanelTaskCookieHost } from "./task-cookie-host.js";
|
|
|
18
18
|
import { taskCookieFromInput, taskCookieSelection, } from "./task-cookies.js";
|
|
19
19
|
import { desktopPanelDirectoryBookmarks, hubPanelDirectoryBookmarks, } from "./directory-bookmarks.js";
|
|
20
20
|
import { handlePanelProcessDirectory } from "./process-files.js";
|
|
21
|
+
import { PanelDirectoryPicker, capturePanelWorkspaceDirectory, directoryPickerLifetime, } from "./directory-picker.js";
|
|
21
22
|
import { PanelAppProcessService, panelExecutableDirectories, panelProcessInfo, } from "./process-service.js";
|
|
22
23
|
import { PanelManagementError } from "./management.js";
|
|
23
24
|
import { panelAutomationMethods } from "./automations.js";
|
|
@@ -305,7 +306,7 @@ function bridgeScript(id, origin) {
|
|
|
305
306
|
const requestId = String(++next);
|
|
306
307
|
const timer = setTimeout(() => {
|
|
307
308
|
pending.delete(requestId); reject(new Error("Panel request timed out"));
|
|
308
|
-
}, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio", "resources.recordVideo"].includes(method) ? 30 * 60 * 1000 : 60000);
|
|
309
|
+
}, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio", "resources.recordVideo"].includes(method) ? 30 * 60 * 1000 : method === "filesystem.pickDirectory" ? 6 * 60 * 1000 : 60000);
|
|
309
310
|
pending.set(requestId, { resolve, reject, timer });
|
|
310
311
|
try {
|
|
311
312
|
parent.postMessage({ type: "codeshell-panel:call", instanceId: id, requestId, method, params }, origin);
|
|
@@ -911,6 +912,52 @@ export function createPanelRuntime(options) {
|
|
|
911
912
|
});
|
|
912
913
|
return result && (await authorized(grant));
|
|
913
914
|
}
|
|
915
|
+
async function pickDirectory(grant) {
|
|
916
|
+
if (!(await authorized(grant)))
|
|
917
|
+
error(410, "面板授权已失效。");
|
|
918
|
+
if ([...grant.confirmations.values()].some((item) => item.picker && item.result === undefined))
|
|
919
|
+
error(409, "请先完成当前目录选择。");
|
|
920
|
+
for (const [id, item] of grant.confirmations) {
|
|
921
|
+
if (item.result !== undefined)
|
|
922
|
+
grant.confirmations.delete(id);
|
|
923
|
+
}
|
|
924
|
+
if (grant.confirmations.size >= 64)
|
|
925
|
+
error(429, "待确认操作过多。");
|
|
926
|
+
const picker = new PanelDirectoryPicker(options.cwd, now);
|
|
927
|
+
const requestId = randomBytes(18).toString("base64url");
|
|
928
|
+
const pending = { picker };
|
|
929
|
+
const allowed = await new Promise((done) => {
|
|
930
|
+
const timer = setTimeout(() => pending.finish(false), directoryPickerLifetime);
|
|
931
|
+
timer.unref();
|
|
932
|
+
pending.finish = (value) => {
|
|
933
|
+
if (pending.result !== undefined)
|
|
934
|
+
return;
|
|
935
|
+
pending.result = value;
|
|
936
|
+
clearTimeout(timer);
|
|
937
|
+
picker.close();
|
|
938
|
+
done(value);
|
|
939
|
+
};
|
|
940
|
+
grant.confirmations.set(requestId, pending);
|
|
941
|
+
emit(grant, "host.confirm", {
|
|
942
|
+
requestId,
|
|
943
|
+
title: options.host === "desktop" ? "选择电脑上的目录" : "选择服务器上的目录",
|
|
944
|
+
body: "仅可选择当前项目内的目录,确认后授予此面板使用。",
|
|
945
|
+
expiresAt: picker.expiresAt,
|
|
946
|
+
directoryPicker: {
|
|
947
|
+
kind: "workspace-directory",
|
|
948
|
+
rootPath: picker.rootPath,
|
|
949
|
+
rootToken: picker.rootToken,
|
|
950
|
+
initialToken: picker.initialToken,
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
if (!(await authorized(grant)))
|
|
955
|
+
error(410, "面板授权已失效。");
|
|
956
|
+
if (!allowed)
|
|
957
|
+
return undefined;
|
|
958
|
+
pending.selection.assertCurrent();
|
|
959
|
+
return pending.selection;
|
|
960
|
+
}
|
|
914
961
|
function taskScope(grant) {
|
|
915
962
|
return {
|
|
916
963
|
instanceId: grant.id,
|
|
@@ -1166,12 +1213,19 @@ export function createPanelRuntime(options) {
|
|
|
1166
1213
|
consentTimeoutMs: 50000,
|
|
1167
1214
|
},
|
|
1168
1215
|
}),
|
|
1169
|
-
...(app.permissions.includes("resources")
|
|
1216
|
+
...(app.permissions.includes("resources") || app.permissions.includes("process")
|
|
1170
1217
|
? {
|
|
1171
1218
|
methodLimits: {
|
|
1172
|
-
"resources
|
|
1173
|
-
|
|
1219
|
+
...(app.permissions.includes("resources")
|
|
1220
|
+
? {
|
|
1221
|
+
"resources.recordAudio": { timeoutMs: 30 * 60 * 1000 },
|
|
1222
|
+
"resources.recordVideo": { timeoutMs: 30 * 60 * 1000 },
|
|
1223
|
+
}
|
|
1224
|
+
: {}),
|
|
1174
1225
|
...(app.permissions.includes("process")
|
|
1226
|
+
? { "filesystem.pickDirectory": { timeoutMs: 6 * 60_000 } }
|
|
1227
|
+
: {}),
|
|
1228
|
+
...(app.permissions.includes("process") && app.permissions.includes("resources")
|
|
1175
1229
|
? {
|
|
1176
1230
|
"tasks.start": {
|
|
1177
1231
|
maxParamsBytes: toolJobLimits.maxInputBytes,
|
|
@@ -1583,16 +1637,30 @@ export function createPanelRuntime(options) {
|
|
|
1583
1637
|
if (method === "filesystem.restoreDirectory") {
|
|
1584
1638
|
await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
|
|
1585
1639
|
const saved = directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
const restored = await processes.grantDirectory(owner, saved);
|
|
1640
|
+
const workspaceSelection = !options.authorizePanelDirectory
|
|
1641
|
+
? capturePanelWorkspaceDirectory(options.cwd, saved)
|
|
1642
|
+
: undefined;
|
|
1643
|
+
const restored = await processes.grantDirectory(owner, saved, workspaceSelection);
|
|
1591
1644
|
await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
|
|
1592
1645
|
directoryBookmarks.restore(grant.app.id, options.bindingCwd ?? options.cwd, params?.bookmark);
|
|
1646
|
+
workspaceSelection?.assertCurrent();
|
|
1593
1647
|
return { ...restored, bookmark: params.bookmark };
|
|
1594
1648
|
}
|
|
1595
|
-
|
|
1649
|
+
if (method === "filesystem.pickDirectory") {
|
|
1650
|
+
const selection = await pickDirectory(grant);
|
|
1651
|
+
if (!selection)
|
|
1652
|
+
return { cancelled: true };
|
|
1653
|
+
selection.assertCurrent();
|
|
1654
|
+
const selected = await processes.grantDirectory(owner, selection.path, selection);
|
|
1655
|
+
selection.assertCurrent();
|
|
1656
|
+
if (!(await authorized(grant)))
|
|
1657
|
+
error(410, "面板授权已失效。");
|
|
1658
|
+
selection.assertCurrent();
|
|
1659
|
+
const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
|
|
1660
|
+
processes.directoryPath(owner, selected.handle);
|
|
1661
|
+
return { ...selected, bookmark };
|
|
1662
|
+
}
|
|
1663
|
+
const name = params?.name;
|
|
1596
1664
|
const directory = name === "project"
|
|
1597
1665
|
? options.cwd
|
|
1598
1666
|
: name === "downloads"
|
|
@@ -1604,16 +1672,13 @@ export function createPanelRuntime(options) {
|
|
|
1604
1672
|
: undefined;
|
|
1605
1673
|
if (!directory)
|
|
1606
1674
|
error(400, "不支持这个服务端目录。");
|
|
1607
|
-
if (method === "filesystem.pickDirectory" &&
|
|
1608
|
-
!(await confirm(grant, "使用服务端下载目录?", directory)))
|
|
1609
|
-
return { cancelled: true };
|
|
1610
1675
|
await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
|
|
1611
1676
|
if (name !== "project")
|
|
1612
1677
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
1613
1678
|
if (!(await authorized(grant)))
|
|
1614
1679
|
error(410, "面板授权已失效。");
|
|
1615
1680
|
const selected = await processes.grantDirectory(owner, directory);
|
|
1616
|
-
if (
|
|
1681
|
+
if (!["downloads", "project"].includes(String(name)))
|
|
1617
1682
|
return selected;
|
|
1618
1683
|
const bookmark = directoryBookmarks.remember(grant.app.id, options.bindingCwd ?? options.cwd, selected.path);
|
|
1619
1684
|
if (!(await authorized(grant)))
|
|
@@ -1780,7 +1845,7 @@ export function createPanelRuntime(options) {
|
|
|
1780
1845
|
}
|
|
1781
1846
|
const directoryMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/directory\/([a-f0-9-]{36})$/.exec(url.pathname);
|
|
1782
1847
|
const resourceMatch = /^\/api\/v1\/panels\/runtime\/([\w-]+)\/resources\/((?:asset|external)-[a-f0-9]{64})$/.exec(url.pathname);
|
|
1783
|
-
const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|tool-results))?$/.exec(url.pathname) ??
|
|
1848
|
+
const match = /^\/api\/v1\/panels\/runtime\/([\w-]+)(?:\/(call|renew|events|confirm|directory-picker|tool-results))?$/.exec(url.pathname) ??
|
|
1784
1849
|
directoryMatch ??
|
|
1785
1850
|
resourceMatch;
|
|
1786
1851
|
const grant = match && grants.get(match[1]);
|
|
@@ -1850,15 +1915,57 @@ export function createPanelRuntime(options) {
|
|
|
1850
1915
|
const batch = grant.events.slice(0, 128);
|
|
1851
1916
|
json(response, 200, { events: batch, cursor: batch.at(-1)?.id ?? after });
|
|
1852
1917
|
}
|
|
1918
|
+
else if (request.method === "POST" && match[2] === "directory-picker") {
|
|
1919
|
+
if (request.headers.origin !== grant.origin)
|
|
1920
|
+
error(403, "目录选择仅供当前宿主页面使用。");
|
|
1921
|
+
const input = await body(request);
|
|
1922
|
+
if (Object.keys(input).some((key) => !["requestId", "directoryToken"].includes(key)))
|
|
1923
|
+
error(400, "目录选择参数无效。");
|
|
1924
|
+
const pending = typeof input.requestId === "string" && grant.confirmations.get(input.requestId);
|
|
1925
|
+
if (!pending || !pending.picker || pending.result !== undefined)
|
|
1926
|
+
error(409, "目录选择已失效,请重新发起。");
|
|
1927
|
+
const assertPending = async () => {
|
|
1928
|
+
if (!(await authorized(grant)))
|
|
1929
|
+
error(410, "面板授权已失效。");
|
|
1930
|
+
if (pending.result !== undefined || now() >= pending.picker.expiresAt) {
|
|
1931
|
+
pending.finish(false);
|
|
1932
|
+
error(409, "目录选择已失效,请重新发起。");
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
await assertPending();
|
|
1936
|
+
const listing = await pending.picker.list(input.directoryToken, assertPending);
|
|
1937
|
+
await assertPending();
|
|
1938
|
+
pending.picker.assertListing(listing);
|
|
1939
|
+
json(response, 200, { requestId: input.requestId, ...listing });
|
|
1940
|
+
}
|
|
1853
1941
|
else if (request.method === "POST" && match[2] === "confirm") {
|
|
1854
1942
|
const input = await body(request);
|
|
1855
1943
|
const pending = typeof input.requestId === "string" && grant.confirmations.get(input.requestId);
|
|
1856
1944
|
if (!pending ||
|
|
1857
1945
|
typeof input.allowed !== "boolean" ||
|
|
1858
|
-
Object.keys(input).some((key) => !["requestId", "allowed"].includes(key))
|
|
1946
|
+
Object.keys(input).some((key) => !["requestId", "allowed", ...(pending?.picker ? ["directoryToken"] : [])].includes(key)) ||
|
|
1947
|
+
(input.allowed === false && input.directoryToken !== undefined))
|
|
1859
1948
|
error(400, "找不到这个待确认操作。");
|
|
1860
1949
|
if (!(await authorized(grant)))
|
|
1861
1950
|
error(410, "面板授权已失效。");
|
|
1951
|
+
if (pending.picker) {
|
|
1952
|
+
if (request.headers.origin !== grant.origin)
|
|
1953
|
+
error(403, "目录选择仅供当前宿主页面使用。");
|
|
1954
|
+
if (input.allowed) {
|
|
1955
|
+
if (pending.result === undefined) {
|
|
1956
|
+
if (now() >= pending.picker.expiresAt) {
|
|
1957
|
+
pending.finish(false);
|
|
1958
|
+
error(409, "目录选择已失效,请重新发起。");
|
|
1959
|
+
}
|
|
1960
|
+
pending.selection = pending.picker.select(input.directoryToken);
|
|
1961
|
+
pending.directoryToken = input.directoryToken;
|
|
1962
|
+
}
|
|
1963
|
+
else if (pending.result !== true ||
|
|
1964
|
+
input.directoryToken !== pending.directoryToken) {
|
|
1965
|
+
error(409, "目录选择已失效,请重新发起。");
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1862
1969
|
pending.finish(input.allowed);
|
|
1863
1970
|
json(response, 200, { accepted: true, allowed: pending.result });
|
|
1864
1971
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cjhyy/code-shell-server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.52",
|
|
4
4
|
"description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@cjhyy/code-shell-capability-coding": "0.9.
|
|
66
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
67
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
65
|
+
"@cjhyy/code-shell-capability-coding": "0.9.52",
|
|
66
|
+
"@cjhyy/code-shell-core": "0.9.52",
|
|
67
|
+
"@cjhyy/code-shell-web": "0.9.52",
|
|
68
68
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
69
69
|
"@types/ws": "^8.18.1",
|
|
70
70
|
"ws": "^8.21.0",
|
|
71
|
-
"@cjhyy/code-shell-link": "0.9.
|
|
72
|
-
"@cjhyy/code-shell-host": "0.9.
|
|
71
|
+
"@cjhyy/code-shell-link": "0.9.52",
|
|
72
|
+
"@cjhyy/code-shell-host": "0.9.52"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
75
|
"node": ">=20.10"
|