@cjhyy/code-shell-server 0.9.47 → 0.9.49
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/README.md +4 -0
- package/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +26 -7
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/codex-room-agent.js +3 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
- package/dist/mobile-remote/mobile-chat-turn.js +15 -2
- package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
- package/dist/mobile-remote/mobile-static.d.ts +5 -6
- package/dist/mobile-remote/mobile-static.js +13 -14
- package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
- package/dist/mobile-remote/remote-host-manager.js +132 -56
- package/dist/mobile-remote/resident-agent.js +3 -1
- package/dist/mobile-remote/room-manager.d.ts +4 -0
- package/dist/mobile-remote/room-manager.js +5 -0
- package/dist/mobile-remote/room-process-stop.d.ts +8 -0
- package/dist/mobile-remote/room-process-stop.js +18 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +10 -2
- package/dist/panels/execution-gate.js +36 -2
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/management-http.d.ts +1 -0
- package/dist/panels/management.d.ts +3 -0
- package/dist/panels/management.js +105 -21
- package/dist/panels/package-mutation.d.ts +1 -1
- package/dist/panels/package-mutation.js +3 -10
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +2 -18
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +1 -1
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/serve/headless-server.js +113 -79
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/dist/static-files.d.ts +7 -0
- package/dist/static-files.js +114 -0
- package/package.json +6 -6
|
@@ -11,7 +11,7 @@ export declare const panelBridgeLimits: Readonly<{
|
|
|
11
11
|
callTimeoutMs: 15000;
|
|
12
12
|
consentTimeoutMs: number;
|
|
13
13
|
}>;
|
|
14
|
-
export type PanelBridgeErrorCode = "PERMISSION_DENIED" | "REVOKED" | "SESSION_CHANGED" | "NOT_SUPPORTED" | "RATE_LIMITED" | "PARAMS_TOO_LARGE" | "RESULT_TOO_LARGE" | "TIMEOUT" | "INVALID_ARGUMENT" | "OPERATION_FAILED";
|
|
14
|
+
export type PanelBridgeErrorCode = "PERMISSION_DENIED" | "REVOKED" | "SESSION_CHANGED" | "NOT_SUPPORTED" | "RATE_LIMITED" | "PARAMS_TOO_LARGE" | "RESULT_TOO_LARGE" | "TIMEOUT" | "INVALID_ARGUMENT" | "OPERATION_FAILED" | "SERVICE_CAPACITY" | "SERVICE_CONFIG_CONFLICT" | "SERVICE_REVISION_CONFLICT" | "SERVICE_REQUEST_KEY_CONFLICT";
|
|
15
15
|
export declare class PanelBridgeError extends Error {
|
|
16
16
|
readonly code: PanelBridgeErrorCode;
|
|
17
17
|
readonly retryAfterMs?: number | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** What holds an execution, so a refused change can name it. Host-assigned only. */
|
|
2
2
|
export interface PanelExecutionHolder {
|
|
3
|
-
kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation";
|
|
3
|
+
kind: "panel-call" | "agent-tool" | "process" | "agent-task" | "tool-job" | "automation" | "service";
|
|
4
4
|
/** Desktop WebContents id of the Panel instance that owns this execution. */
|
|
5
5
|
guestId?: number;
|
|
6
6
|
/** Trusted Host description, such as a native tool entry name. */
|
|
@@ -32,6 +32,12 @@ export declare class PanelExecutionBusyError extends Error {
|
|
|
32
32
|
export declare class PanelExecutionGate {
|
|
33
33
|
private readonly executions;
|
|
34
34
|
private readonly sources;
|
|
35
|
+
private readonly settled;
|
|
36
|
+
private readonly serviceDrains;
|
|
37
|
+
registerServiceDrain(drain: (matches: (scope: PanelExecutionScope) => boolean) => Promise<void>): () => void;
|
|
38
|
+
/** Call only after validating the concrete authorized catalog mutation. */
|
|
39
|
+
drainServices(matches: (scope: PanelExecutionScope) => boolean): Promise<void>;
|
|
40
|
+
onMutationSettled(listener: () => void): () => void;
|
|
35
41
|
private readonly mutations;
|
|
36
42
|
enter(scope: PanelExecutionScope): () => void;
|
|
37
43
|
run<T>(scope: PanelExecutionScope, work: () => Promise<T>): Promise<T>;
|
|
@@ -39,6 +45,8 @@ export declare class PanelExecutionGate {
|
|
|
39
45
|
register(source: () => Iterable<PanelExecutionScope>): () => void;
|
|
40
46
|
/** Executions that a change matching `matches` would wait for, grouped by holder. */
|
|
41
47
|
blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
|
|
42
|
-
mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T
|
|
48
|
+
mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>,
|
|
49
|
+
/** Trusted Host preparation that validates a review before draining existing work. */
|
|
50
|
+
drain?: () => Promise<void>): Promise<T>;
|
|
43
51
|
}
|
|
44
52
|
export declare const panelExecutionGate: PanelExecutionGate;
|
|
@@ -27,6 +27,24 @@ export class PanelExecutionBusyError extends Error {
|
|
|
27
27
|
export class PanelExecutionGate {
|
|
28
28
|
executions = new Set();
|
|
29
29
|
sources = new Set();
|
|
30
|
+
settled = new Set();
|
|
31
|
+
serviceDrains = new Set();
|
|
32
|
+
registerServiceDrain(drain) {
|
|
33
|
+
this.serviceDrains.add(drain);
|
|
34
|
+
return () => {
|
|
35
|
+
this.serviceDrains.delete(drain);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Call only after validating the concrete authorized catalog mutation. */
|
|
39
|
+
async drainServices(matches) {
|
|
40
|
+
await Promise.all([...this.serviceDrains].map((drain) => drain(matches)));
|
|
41
|
+
}
|
|
42
|
+
onMutationSettled(listener) {
|
|
43
|
+
this.settled.add(listener);
|
|
44
|
+
return () => {
|
|
45
|
+
this.settled.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
30
48
|
mutations = new Set();
|
|
31
49
|
enter(scope) {
|
|
32
50
|
const held = {
|
|
@@ -77,19 +95,35 @@ export class PanelExecutionGate {
|
|
|
77
95
|
}
|
|
78
96
|
return [...grouped.values()];
|
|
79
97
|
}
|
|
80
|
-
async mutate(matches, work
|
|
98
|
+
async mutate(matches, work,
|
|
99
|
+
/** Trusted Host preparation that validates a review before draining existing work. */
|
|
100
|
+
drain) {
|
|
81
101
|
if (this.mutations.size)
|
|
82
102
|
throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
|
|
83
103
|
// No await between the snapshot and installing the admission barrier.
|
|
84
104
|
const blockers = this.blockers(matches);
|
|
85
|
-
if (blockers.length)
|
|
105
|
+
if (blockers.length && !drain)
|
|
86
106
|
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
|
|
87
107
|
this.mutations.add(matches);
|
|
88
108
|
try {
|
|
109
|
+
if (drain) {
|
|
110
|
+
await drain();
|
|
111
|
+
const remaining = this.blockers(matches);
|
|
112
|
+
if (remaining.length)
|
|
113
|
+
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", remaining);
|
|
114
|
+
}
|
|
89
115
|
return await work();
|
|
90
116
|
}
|
|
91
117
|
finally {
|
|
92
118
|
this.mutations.delete(matches);
|
|
119
|
+
for (const listener of this.settled) {
|
|
120
|
+
try {
|
|
121
|
+
listener();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* observer */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
93
127
|
}
|
|
94
128
|
}
|
|
95
129
|
}
|
package/dist/panels/http.d.ts
CHANGED
|
@@ -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;
|
|
@@ -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 ?
|
|
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,12 +496,23 @@ export function createPanelManagement(options) {
|
|
|
493
496
|
await guard();
|
|
494
497
|
return work(guard);
|
|
495
498
|
};
|
|
496
|
-
|
|
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);
|
|
500
507
|
return next;
|
|
501
508
|
}
|
|
509
|
+
async function drainSelectedServices(id, expected, guard, matches) {
|
|
510
|
+
const current = await selectedApp(id);
|
|
511
|
+
if (revision(current.app, current.digest) !== expected)
|
|
512
|
+
throw new PanelManagementError(409, "conflict", "面板已改变,请刷新后重试。");
|
|
513
|
+
await guard();
|
|
514
|
+
await panelExecutionGate.drainServices(matches);
|
|
515
|
+
}
|
|
502
516
|
function setBinding(app, bound, expected) {
|
|
503
517
|
options.assertBinding?.();
|
|
504
518
|
if (!hasProject)
|
|
@@ -524,7 +538,20 @@ export function createPanelManagement(options) {
|
|
|
524
538
|
if (bound) {
|
|
525
539
|
if (!app.packageDigest)
|
|
526
540
|
throw new Error("Cannot bind an unverified Panel package");
|
|
527
|
-
pins[app.id]
|
|
541
|
+
const existing = pins[app.id];
|
|
542
|
+
// Rebinding the package a current pin already selects changes nothing, so
|
|
543
|
+
// it must not invalidate other devices' revisions or pending reviews.
|
|
544
|
+
const unchanged = existing?.version === app.version &&
|
|
545
|
+
existing.packageDigest === app.packageDigest &&
|
|
546
|
+
!!currentPanelAppPackagePins({ panelAppPins: { [app.id]: existing } })[app.id];
|
|
547
|
+
if (!unchanged)
|
|
548
|
+
pins[app.id] = {
|
|
549
|
+
version: app.version,
|
|
550
|
+
packageDigest: app.packageDigest,
|
|
551
|
+
// Holds this project only until the next global install or update, so it
|
|
552
|
+
// must be strictly later than the install it was chosen after.
|
|
553
|
+
pinnedAt: new Date(Math.max(now(), (installedPanelAppUpdatedAt(app.id) ?? 0) + 1)).toISOString(),
|
|
554
|
+
};
|
|
528
555
|
}
|
|
529
556
|
else
|
|
530
557
|
delete pins[app.id];
|
|
@@ -638,10 +665,15 @@ export function createPanelManagement(options) {
|
|
|
638
665
|
throw new PanelManagementError(403, "review_owner", "请在当前设备重新审阅这个版本。");
|
|
639
666
|
await assertAuthorized(context, held.generation);
|
|
640
667
|
const review = held.public;
|
|
641
|
-
|
|
642
|
-
|
|
668
|
+
const assertReviewLease = () => {
|
|
669
|
+
if (closed || generation(context.ownerId) !== held.generation)
|
|
670
|
+
throw new PanelManagementError(401, "login_required", "登录已失效,请重新登录。");
|
|
643
671
|
if (restores.get(token) !== held || held.public.expiresAt <= now())
|
|
644
672
|
throw new PanelManagementError(409, "review_expired", "版本审阅已失效,请重新选择。");
|
|
673
|
+
};
|
|
674
|
+
const validateReview = async (guard) => {
|
|
675
|
+
await assertAuthorized(context, held.generation);
|
|
676
|
+
assertReviewLease();
|
|
645
677
|
const current = await restorationSelection(review.appId);
|
|
646
678
|
if (current.revision !== review.expectedRevision)
|
|
647
679
|
throw new PanelManagementError(409, "conflict", "项目面板已改变,请重新审阅。");
|
|
@@ -650,12 +682,25 @@ export function createPanelManagement(options) {
|
|
|
650
682
|
throw new PanelManagementError(400, "unsupported", "这个版本无法在当前环境使用。");
|
|
651
683
|
await guard();
|
|
652
684
|
await assertAuthorized(context, held.generation);
|
|
653
|
-
|
|
654
|
-
|
|
685
|
+
assertReviewLease();
|
|
686
|
+
if (projectState(review.appId) !== held.projectState)
|
|
687
|
+
throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新审阅。");
|
|
688
|
+
return target;
|
|
689
|
+
};
|
|
690
|
+
return mutate(context, { appId: review.appId, kind: "binding" }, async (guard) => {
|
|
691
|
+
const target = await validateReview(guard);
|
|
692
|
+
assertReviewLease();
|
|
655
693
|
setBinding(target, true, { projectState: held.projectState });
|
|
656
694
|
restores.delete(token);
|
|
657
695
|
await options.onChanged?.(review.appId, "binding");
|
|
658
696
|
return { id: review.appId, packageDigest: review.packageDigest };
|
|
697
|
+
}, async (guard, matches) => {
|
|
698
|
+
// A reviewed version selection changes the executor's package just like
|
|
699
|
+
// unbinding. Validate authority and exact retained bytes before drain,
|
|
700
|
+
// then revalidate them before writing. Other executions still block.
|
|
701
|
+
await validateReview(guard);
|
|
702
|
+
assertReviewLease();
|
|
703
|
+
await panelExecutionGate.drainServices(matches);
|
|
659
704
|
});
|
|
660
705
|
},
|
|
661
706
|
assertAuthorized,
|
|
@@ -735,12 +780,20 @@ export function createPanelManagement(options) {
|
|
|
735
780
|
await assertAuthorized(context, held.generation);
|
|
736
781
|
if (approval?.expectedId && approval.expectedId !== held.public.preview.id)
|
|
737
782
|
invalid();
|
|
783
|
+
if (approval?.releaseExecutions && held.public.kind !== "update")
|
|
784
|
+
invalid();
|
|
738
785
|
if (approval && held.public.kind === "update" && !approval.overwrite)
|
|
739
786
|
throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请确认项目更新。");
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (
|
|
787
|
+
let admitted = false;
|
|
788
|
+
const assertReviewLease = () => {
|
|
789
|
+
if (closed || generation(context.ownerId) !== held.generation)
|
|
790
|
+
throw new PanelManagementError(401, "login_required", "登录已失效,请重新登录。");
|
|
791
|
+
if (!admitted && (reviews.get(token) !== held || held.public.expiresAt <= now()))
|
|
743
792
|
throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
|
|
793
|
+
};
|
|
794
|
+
const validateReview = async (guard) => {
|
|
795
|
+
await assertAuthorized(context, held.generation);
|
|
796
|
+
assertReviewLease();
|
|
744
797
|
const id = held.public.preview.id;
|
|
745
798
|
if (held.public.kind === "update") {
|
|
746
799
|
const current = await selectedApp(id);
|
|
@@ -753,6 +806,12 @@ export function createPanelManagement(options) {
|
|
|
753
806
|
if (projectState(id) !== held.projectState)
|
|
754
807
|
throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
|
|
755
808
|
await guard();
|
|
809
|
+
assertReviewLease();
|
|
810
|
+
};
|
|
811
|
+
return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
|
|
812
|
+
await validateReview(guard);
|
|
813
|
+
assertReviewLease();
|
|
814
|
+
const id = held.public.preview.id;
|
|
756
815
|
const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
|
|
757
816
|
overwrite: held.public.kind === "update",
|
|
758
817
|
expectedId: id,
|
|
@@ -767,6 +826,7 @@ export function createPanelManagement(options) {
|
|
|
767
826
|
if (projectState(id) !== held.projectState)
|
|
768
827
|
throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
|
|
769
828
|
await guard();
|
|
829
|
+
assertReviewLease();
|
|
770
830
|
},
|
|
771
831
|
});
|
|
772
832
|
reviews.delete(token);
|
|
@@ -782,7 +842,31 @@ export function createPanelManagement(options) {
|
|
|
782
842
|
invalidateSkillCache();
|
|
783
843
|
await options.onChanged?.(id, held.public.kind);
|
|
784
844
|
return { id, packageDigest: installed.packageDigest };
|
|
785
|
-
}
|
|
845
|
+
}, held.public.kind === "update"
|
|
846
|
+
? async (guard, matches) => {
|
|
847
|
+
await validateReview(guard);
|
|
848
|
+
// A local directory/ZIP may have changed since preview. Check its
|
|
849
|
+
// reviewed bytes before cancelling work, then recheck mutable
|
|
850
|
+
// project/catalog authority after the source read.
|
|
851
|
+
const preview = await previewLocalPanelApp(held.source);
|
|
852
|
+
if (preview.id !== held.public.preview.id || preview.reviewToken !== held.digest)
|
|
853
|
+
throw new PanelManagementError(409, "conflict", "来源已改变,请重新检查更新。");
|
|
854
|
+
await validateReview(guard);
|
|
855
|
+
assertReviewLease();
|
|
856
|
+
if (approval?.releaseExecutions) {
|
|
857
|
+
// Force owns the review while its holders exit. TTL expiry during
|
|
858
|
+
// cancellation must not invalidate consent; revocation is checked.
|
|
859
|
+
admitted = true;
|
|
860
|
+
reviews.delete(token);
|
|
861
|
+
await approval.releaseExecutions(held.public.preview.id, matches);
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
// Ordinary updates drain only services. Other executions still
|
|
865
|
+
// block, and failed publication must leave this review retryable.
|
|
866
|
+
await panelExecutionGate.drainServices(matches);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
: undefined);
|
|
786
870
|
},
|
|
787
871
|
async binding(context, id, bound, expected) {
|
|
788
872
|
assertId(id);
|
|
@@ -793,18 +877,18 @@ export function createPanelManagement(options) {
|
|
|
793
877
|
const current = await selectedApp(id);
|
|
794
878
|
if (bound && !compatibility(current.app).supported)
|
|
795
879
|
throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
|
|
796
|
-
//
|
|
797
|
-
//
|
|
880
|
+
// An unpinned (or superseded) selection needs a retained copy before the
|
|
881
|
+
// project can pin it. A current pin must keep its selected version.
|
|
798
882
|
const app = bound &&
|
|
799
883
|
options.projectPackages &&
|
|
800
|
-
!
|
|
884
|
+
!currentPanelAppPackagePins(projectSettings())[current.app.id]
|
|
801
885
|
? await retainInstalledPanelApp(current.app.id, current.app.packageDigest)
|
|
802
886
|
: current.app;
|
|
803
887
|
await guard();
|
|
804
888
|
setBinding(app, bound, { ...current, revision: expected });
|
|
805
889
|
await options.onChanged?.(id, "binding");
|
|
806
890
|
return snapshot();
|
|
807
|
-
});
|
|
891
|
+
}, (guard, matches) => drainSelectedServices(id, expected, guard, matches));
|
|
808
892
|
},
|
|
809
893
|
async remove(context, id, expected) {
|
|
810
894
|
assertId(id);
|
|
@@ -830,7 +914,7 @@ export function createPanelManagement(options) {
|
|
|
830
914
|
invalidateSkillCache();
|
|
831
915
|
await options.onChanged?.(id, "remove");
|
|
832
916
|
return { removed: true };
|
|
833
|
-
});
|
|
917
|
+
}, (guard, matches) => drainSelectedServices(id, expected, guard, matches));
|
|
834
918
|
},
|
|
835
919
|
cancelOwner(owner) {
|
|
836
920
|
generations.set(owner, generation(owner) + 1);
|
|
@@ -4,5 +4,5 @@ export interface PanelPackageMutation {
|
|
|
4
4
|
projectPath: string;
|
|
5
5
|
kind: "install" | "update" | "binding" | "remove";
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
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 {
|
|
1
|
+
import { resolvePanelAppBindingProjectPath } from "@cjhyy/code-shell-core";
|
|
2
2
|
import { panelExecutionProject } from "./execution-gate.js";
|
|
3
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
|
@@ -3,6 +3,7 @@ import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { rangeBounds } from "../http-byte-range.js";
|
|
6
7
|
const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
|
7
8
|
const MAX_ENTRIES = 200;
|
|
8
9
|
const MAX_EXAMINED = 4096;
|
|
@@ -227,20 +228,44 @@ export async function handlePanelProcessDirectory(request, response, options) {
|
|
|
227
228
|
fail(404, "下载文件已经改变。");
|
|
228
229
|
await verifyDirectory();
|
|
229
230
|
await authorize();
|
|
230
|
-
|
|
231
|
+
// Directory outputs are mutable and have no stable representation validator.
|
|
232
|
+
// An If-Range condition cannot be confirmed, so return the complete file.
|
|
233
|
+
const range = request.headers["if-range"] === undefined ? request.headers.range : undefined;
|
|
234
|
+
const bounds = range === undefined
|
|
235
|
+
? { start: 0, end: metadata.size - 1 }
|
|
236
|
+
: metadata.size > 0
|
|
237
|
+
? rangeBounds(range, metadata.size)
|
|
238
|
+
: null;
|
|
239
|
+
const headers = {
|
|
231
240
|
"Content-Type": "application/octet-stream",
|
|
232
241
|
"Content-Disposition": disposition(name),
|
|
233
|
-
"
|
|
242
|
+
"Accept-Ranges": "bytes",
|
|
234
243
|
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
235
244
|
"Cache-Control": "no-store",
|
|
236
245
|
"X-Content-Type-Options": "nosniff",
|
|
237
246
|
"Referrer-Policy": "no-referrer",
|
|
247
|
+
};
|
|
248
|
+
if (!bounds) {
|
|
249
|
+
response.writeHead(416, {
|
|
250
|
+
...headers,
|
|
251
|
+
"Content-Range": `bytes */${metadata.size}`,
|
|
252
|
+
"Content-Length": 0,
|
|
253
|
+
});
|
|
254
|
+
response.end();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
response.writeHead(range === undefined ? 200 : 206, {
|
|
258
|
+
...headers,
|
|
259
|
+
"Content-Length": bounds.end - bounds.start + 1,
|
|
260
|
+
...(range === undefined
|
|
261
|
+
? {}
|
|
262
|
+
: { "Content-Range": `bytes ${bounds.start}-${bounds.end}/${metadata.size}` }),
|
|
238
263
|
});
|
|
239
264
|
if (request.method === "HEAD" || metadata.size === 0) {
|
|
240
265
|
response.end();
|
|
241
266
|
return;
|
|
242
267
|
}
|
|
243
|
-
const input = file.createReadStream({ autoClose: false,
|
|
268
|
+
const input = file.createReadStream({ autoClose: false, ...bounds });
|
|
244
269
|
let checking = false;
|
|
245
270
|
const timer = setInterval(() => {
|
|
246
271
|
if (checking)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Readable } from "node:stream";
|
|
2
2
|
import type { MediaAsset, MediaScope } from "./types.js";
|
|
3
|
+
export { rangeBounds } from "../../http-byte-range.js";
|
|
3
4
|
export declare function resourceMimeType(name: string, requested?: string): string;
|
|
4
5
|
export interface MediaLibraryOptions {
|
|
5
6
|
rootDirectory: string;
|
|
@@ -33,10 +34,6 @@ export interface MediaReadResult {
|
|
|
33
34
|
body: Readable | null;
|
|
34
35
|
}
|
|
35
36
|
export declare function resourceName(value: string): string;
|
|
36
|
-
export declare function rangeBounds(range: string, size: number): {
|
|
37
|
-
start: number;
|
|
38
|
-
end: number;
|
|
39
|
-
} | null;
|
|
40
37
|
/**
|
|
41
38
|
* Host-owned immutable, scope-local media. importFile accepts only paths already
|
|
42
39
|
* authorized by the caller (for example native file selection); guest APIs must
|
|
@@ -3,6 +3,8 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { lstat, mkdir, open, readdir, rename, rm } from "node:fs/promises";
|
|
4
4
|
import { basename, extname, join } from "node:path";
|
|
5
5
|
import { mediaDirectory, mediaScopeKey, readMediaJson, writeMediaJson } from "./storage.js";
|
|
6
|
+
import { rangeBounds } from "../../http-byte-range.js";
|
|
7
|
+
export { rangeBounds } from "../../http-byte-range.js";
|
|
6
8
|
const ASSET_ID = /^asset-[a-f0-9]{64}$/;
|
|
7
9
|
const MIME_BY_EXTENSION = {
|
|
8
10
|
".mp4": "video/mp4",
|
|
@@ -93,24 +95,6 @@ function assetRecord(raw, id) {
|
|
|
93
95
|
createdAt: value.createdAt,
|
|
94
96
|
};
|
|
95
97
|
}
|
|
96
|
-
export function rangeBounds(range, size) {
|
|
97
|
-
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
98
|
-
if (!match || (!match[1] && !match[2]))
|
|
99
|
-
return null;
|
|
100
|
-
const first = match[1] ? Number(match[1]) : undefined;
|
|
101
|
-
const last = match[2] ? Number(match[2]) : undefined;
|
|
102
|
-
if ((first !== undefined && !Number.isSafeInteger(first)) ||
|
|
103
|
-
(last !== undefined && !Number.isSafeInteger(last)))
|
|
104
|
-
return null;
|
|
105
|
-
if (first === undefined) {
|
|
106
|
-
if (!last || last <= 0)
|
|
107
|
-
return null;
|
|
108
|
-
return { start: Math.max(0, size - last), end: size - 1 };
|
|
109
|
-
}
|
|
110
|
-
if (first >= size || (last !== undefined && last < first))
|
|
111
|
-
return null;
|
|
112
|
-
return { start: first, end: Math.min(last ?? size - 1, size - 1) };
|
|
113
|
-
}
|
|
114
98
|
/**
|
|
115
99
|
* Host-owned immutable, scope-local media. importFile accepts only paths already
|
|
116
100
|
* authorized by the caller (for example native file selection); guest APIs must
|
|
@@ -25,12 +25,11 @@ function referenceId(value) {
|
|
|
25
25
|
function validIdentity(value) {
|
|
26
26
|
return value && Number.isSafeInteger(value.dev) && Number.isSafeInteger(value.ino);
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
(relocating || a.ctimeMs === b.ctimeMs));
|
|
28
|
+
// Like other editors, recognize an original by file, size and modification time.
|
|
29
|
+
// ctime is not identity: macOS changes it for tags, "last opened" and other
|
|
30
|
+
// extended attributes, which would otherwise turn unchanged originals "changed".
|
|
31
|
+
function sameSource(a, b) {
|
|
32
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
|
|
34
33
|
}
|
|
35
34
|
function view(record, state = "available") {
|
|
36
35
|
return {
|
|
@@ -221,7 +220,21 @@ export class ExternalResourceReferences {
|
|
|
221
220
|
if (error.code !== "ENOENT")
|
|
222
221
|
throw error;
|
|
223
222
|
}
|
|
224
|
-
|
|
223
|
+
const names = (await readdir(directory)).filter((name) => REFERENCE_ID.test(name.replace(/\.json$/, "")));
|
|
224
|
+
// IDs include the ctime seen at creation. Choosing the same unchanged
|
|
225
|
+
// original again must return its reference, not a duplicate asset.
|
|
226
|
+
for (const name of names) {
|
|
227
|
+
const existing = await this.record(scope, name.replace(/\.json$/, "")).catch(() => undefined);
|
|
228
|
+
if (existing?.root === root &&
|
|
229
|
+
existing.path === path &&
|
|
230
|
+
sameSource(existing.source, selected.initial) &&
|
|
231
|
+
existing.ancestors.length === selected.directory.identities.length &&
|
|
232
|
+
existing.ancestors.every((identity, index) => sameResourceIdentity(identity, selected.directory.identities[index]))) {
|
|
233
|
+
await selected.verify();
|
|
234
|
+
return view(existing);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (names.length >= MAX_REFERENCES)
|
|
225
238
|
throw new Error("External reference count limit exceeded");
|
|
226
239
|
const record = {
|
|
227
240
|
schemaVersion: 1,
|
|
@@ -302,7 +315,7 @@ export class ExternalResourceReferences {
|
|
|
302
315
|
const selected = await this.selected(scope, root, resourceRelativePath(path), options);
|
|
303
316
|
try {
|
|
304
317
|
// Metadata alone cannot establish that an unrelated replacement is the original file.
|
|
305
|
-
if (!sameSource(previous.source, selected.initial
|
|
318
|
+
if (!sameSource(previous.source, selected.initial))
|
|
306
319
|
throw new Error("This is a different file; create a new external reference instead");
|
|
307
320
|
const record = {
|
|
308
321
|
...previous,
|
package/dist/panels/runtime.js
CHANGED
|
@@ -349,7 +349,7 @@ function bridgeScript(id, origin) {
|
|
|
349
349
|
call: ask,
|
|
350
350
|
callResult: (method, params) => ask(method, params).then(value => ({ ok: true, value }), error => ({ ok: false, error: { code: error.code || "OPERATION_FAILED", message: error.message, ...(typeof error.retryAfterMs === "number" ? { retryAfterMs: error.retryAfterMs } : {}) } })),
|
|
351
351
|
on: (name, listener) => {
|
|
352
|
-
if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "tasks.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
|
|
352
|
+
if (!["context.changed", "process.output", "process.exit", "agent.task.changed", "tasks.changed", "services.changed", "media.job.changed"].includes(name) || typeof listener !== "function") {
|
|
353
353
|
throw new Error("Invalid event listener");
|
|
354
354
|
}
|
|
355
355
|
let list = listeners.get(name);
|
|
@@ -47,6 +47,8 @@ export interface ToolJobProgress {
|
|
|
47
47
|
export type ToolJobStatus = "queued" | "running" | "cancelling" | "succeeded" | "failed" | "cancelled" | "interrupted";
|
|
48
48
|
export interface ToolJob {
|
|
49
49
|
id: string;
|
|
50
|
+
/** Caller-owned idempotency identity, returned with its authorized receipt. */
|
|
51
|
+
requestKey?: string;
|
|
50
52
|
scope: ToolJobScope;
|
|
51
53
|
entry: ToolJobEntry;
|
|
52
54
|
package?: ToolJobPackage;
|
package/dist/panels/tool-jobs.js
CHANGED
|
@@ -100,7 +100,7 @@ function progressValue(progress) {
|
|
|
100
100
|
}, toolJobLimits.maxProgressBytes);
|
|
101
101
|
}
|
|
102
102
|
function publicJob(job) {
|
|
103
|
-
const { version: _version,
|
|
103
|
+
const { version: _version, requestDigest: _requestDigest, ...value } = job;
|
|
104
104
|
return toolJobJson(value, toolJobLimits.maxRecordBytes);
|
|
105
105
|
}
|
|
106
106
|
function packageValue(value) {
|