@cjhyy/code-shell-server 0.9.22 → 0.9.23
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 +45 -1
- package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
- package/dist/bin/code-shell-settings-recovery.js +4 -0
- package/dist/desktop-web/http-api.js +14 -0
- package/dist/environment-identity.d.ts +4 -0
- package/dist/environment-identity.js +67 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +7 -0
- package/dist/index.auth.d.ts +3 -0
- package/dist/index.auth.js +2 -0
- package/dist/index.links.d.ts +1 -0
- package/dist/index.links.js +1 -0
- package/dist/index.panels.d.ts +7 -0
- package/dist/index.panels.js +7 -0
- package/dist/index.remote-relay.d.ts +5 -0
- package/dist/index.remote-relay.js +6 -0
- package/dist/index.serve.d.ts +2 -0
- package/dist/index.serve.js +2 -0
- package/dist/links/http.d.ts +3 -0
- package/dist/links/http.js +11 -2
- package/dist/links/remote-configuration.d.ts +4 -0
- package/dist/links/remote-configuration.js +32 -0
- package/dist/links/service.d.ts +6 -1
- package/dist/links/service.js +241 -19
- package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
- package/dist/mobile-remote/mobile-upload-service.js +69 -11
- package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
- package/dist/mobile-remote/remote-host-manager.js +53 -5
- package/dist/panels/agent-task-service.js +9 -2
- package/dist/panels/automations.d.ts +49 -0
- package/dist/panels/automations.js +134 -0
- package/dist/panels/bridge-contract.d.ts +5 -3
- package/dist/panels/bridge-contract.js +11 -1
- package/dist/panels/directory-bookmarks.d.ts +9 -1
- package/dist/panels/directory-bookmarks.js +64 -5
- package/dist/panels/execution-gate.d.ts +26 -0
- package/dist/panels/execution-gate.js +70 -0
- package/dist/panels/http.d.ts +26 -2
- package/dist/panels/http.js +7 -1
- package/dist/panels/hub-automations.d.ts +31 -0
- package/dist/panels/hub-automations.js +352 -0
- package/dist/panels/management-http.d.ts +20 -2
- package/dist/panels/management-http.js +15 -2
- package/dist/panels/management.d.ts +26 -4
- package/dist/panels/management.js +382 -39
- package/dist/panels/package-mutation.d.ts +8 -0
- package/dist/panels/package-mutation.js +23 -0
- package/dist/panels/process-service.d.ts +3 -0
- package/dist/panels/process-service.js +77 -4
- package/dist/panels/resources/http.d.ts +11 -0
- package/dist/panels/resources/http.js +74 -0
- package/dist/panels/resources/references.d.ts +5 -0
- package/dist/panels/resources/references.js +26 -0
- package/dist/panels/runtime-services.js +16 -3
- package/dist/panels/runtime.d.ts +16 -4
- package/dist/panels/runtime.js +522 -64
- package/dist/panels/shared-tool-jobs.d.ts +40 -0
- package/dist/panels/shared-tool-jobs.js +37 -0
- package/dist/panels/storage-store.d.ts +21 -0
- package/dist/panels/storage-store.js +43 -0
- package/dist/panels/task-cookie-host.d.ts +26 -0
- package/dist/panels/task-cookie-host.js +145 -0
- package/dist/panels/task-cookies.d.ts +51 -0
- package/dist/panels/task-cookies.js +183 -0
- package/dist/panels/tool-executor.d.ts +21 -5
- package/dist/panels/tool-executor.js +171 -30
- package/dist/panels/tool-jobs-storage.d.ts +2 -0
- package/dist/panels/tool-jobs-storage.js +34 -0
- package/dist/panels/tool-jobs.d.ts +49 -3
- package/dist/panels/tool-jobs.js +270 -15
- package/dist/panels/types.d.ts +43 -0
- package/dist/project-runtime/backup.d.ts +26 -0
- package/dist/project-runtime/backup.js +500 -0
- package/dist/project-runtime/control-server.d.ts +4 -0
- package/dist/project-runtime/control-server.js +19 -1
- package/dist/project-runtime/docker-provider.d.ts +4 -0
- package/dist/project-runtime/docker-provider.js +16 -1
- package/dist/project-runtime/managed-entry.js +4 -0
- package/dist/project-runtime/registry.d.ts +2 -0
- package/dist/project-runtime/registry.js +15 -0
- package/dist/project-runtime/seccomp.d.ts +8 -0
- package/dist/project-runtime/seccomp.js +72 -0
- package/dist/project-runtime/types.d.ts +2 -0
- package/dist/remote-relay/byte-stream.d.ts +9 -0
- package/dist/remote-relay/byte-stream.js +71 -0
- package/dist/remote-relay/connector.d.ts +27 -0
- package/dist/remote-relay/connector.js +197 -0
- package/dist/remote-relay/local-stream.d.ts +10 -0
- package/dist/remote-relay/local-stream.js +120 -0
- package/dist/remote-relay/protocol.d.ts +39 -0
- package/dist/remote-relay/protocol.js +64 -0
- package/dist/serve/automation-worker.d.ts +16 -0
- package/dist/serve/automation-worker.js +126 -0
- package/dist/serve/cli.d.ts +3 -0
- package/dist/serve/cli.js +17 -0
- package/dist/serve/headless-server.d.ts +3 -0
- package/dist/serve/headless-server.js +140 -10
- package/dist/serve/settings-recovery-cli.d.ts +29 -0
- package/dist/serve/settings-recovery-cli.js +128 -0
- package/dist/session-http-responses.d.ts +7 -0
- package/dist/session-http-responses.js +40 -0
- package/dist/worker-bridge-core.d.ts +5 -0
- package/dist/worker-bridge-core.js +23 -0
- package/package.json +15 -6
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InstalledPanelApp } from "@cjhyy/code-shell-core";
|
|
2
|
+
import type { PanelTaskCookieHost } from "./task-cookie-host.js";
|
|
3
|
+
import type { TaskCookieSelection } from "./task-cookies.js";
|
|
4
|
+
import type { PanelToolJobService, ToolJob, ToolJobEvent, ToolJobRequest, ToolJobScope, ToolQueueState, ToolQueueUpdate, ToolQueueWriteResult } from "./tool-jobs.js";
|
|
5
|
+
/** Host composition only: no paths, scopes, or execution revisions come from HTTP callers. */
|
|
6
|
+
export interface SharedPanelToolBinding {
|
|
7
|
+
readonly scope: Readonly<ToolJobScope>;
|
|
8
|
+
start(request: ToolJobRequest, signal?: AbortSignal): Promise<ToolJob>;
|
|
9
|
+
list(): Promise<Array<ToolJob & {
|
|
10
|
+
readOnly: boolean;
|
|
11
|
+
}>>;
|
|
12
|
+
has(id: string): Promise<boolean>;
|
|
13
|
+
get(id: string): Promise<ToolJob & {
|
|
14
|
+
readOnly: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
cancel(id: string): Promise<ToolJob>;
|
|
17
|
+
retry(id: string): Promise<ToolJob>;
|
|
18
|
+
find(requestKey: string): Promise<ToolJob | null>;
|
|
19
|
+
getQueue(): Promise<ToolQueueState>;
|
|
20
|
+
setQueue(update: ToolQueueUpdate): Promise<ToolQueueWriteResult>;
|
|
21
|
+
subscribe(listener: (job: ToolJobEvent) => void): () => void;
|
|
22
|
+
/** Host-only port. Materialized paths must never be returned to a browser. */
|
|
23
|
+
cookies?: {
|
|
24
|
+
list(url: string): ReturnType<PanelTaskCookieHost["list"]>;
|
|
25
|
+
check(selection: TaskCookieSelection): ReturnType<PanelTaskCookieHost["check"]>;
|
|
26
|
+
materialize?(selection: TaskCookieSelection): ReturnType<PanelTaskCookieHost["materialize"]>;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface SharedPanelToolHost {
|
|
30
|
+
/** Freeze the installed execution revision once; do not silently follow package updates. */
|
|
31
|
+
bind(app: InstalledPanelApp, projectPath: string): Promise<SharedPanelToolBinding>;
|
|
32
|
+
activeCount(projectPath: string): number;
|
|
33
|
+
invalidate(projectPath: string, appId?: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** Adapts the single owning coordinator without giving a transport shutdown authority. */
|
|
36
|
+
export declare function createSharedPanelToolHost(options: {
|
|
37
|
+
service(): PanelToolJobService;
|
|
38
|
+
resolveScope(app: InstalledPanelApp, projectPath: string): Promise<ToolJobScope>;
|
|
39
|
+
cookies?: Pick<PanelTaskCookieHost, "list" | "check"> & Partial<Pick<PanelTaskCookieHost, "materialize">>;
|
|
40
|
+
}): SharedPanelToolHost;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Adapts the single owning coordinator without giving a transport shutdown authority. */
|
|
2
|
+
export function createSharedPanelToolHost(options) {
|
|
3
|
+
return {
|
|
4
|
+
async bind(app, projectPath) {
|
|
5
|
+
const scope = Object.freeze({ ...(await options.resolveScope(app, projectPath)) });
|
|
6
|
+
const service = options.service();
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
scope,
|
|
9
|
+
start: (request, signal) => service.start(scope, request, signal),
|
|
10
|
+
list: () => service.list(scope),
|
|
11
|
+
has: (id) => service.has(scope, id),
|
|
12
|
+
get: (id) => service.get(scope, id),
|
|
13
|
+
cancel: (id) => service.cancel(scope, id),
|
|
14
|
+
retry: (id) => service.retry(scope, id),
|
|
15
|
+
find: (requestKey) => service.find(scope, requestKey),
|
|
16
|
+
getQueue: () => service.getQueue(scope),
|
|
17
|
+
setQueue: (update) => service.setQueue(scope, update),
|
|
18
|
+
subscribe: (listener) => service.subscribe(scope, listener),
|
|
19
|
+
...(options.cookies && app.permissions.includes("credentials.cookies")
|
|
20
|
+
? {
|
|
21
|
+
cookies: {
|
|
22
|
+
list: (url) => options.cookies.list(scope, url),
|
|
23
|
+
check: (selection) => options.cookies.check(scope, selection),
|
|
24
|
+
...(options.cookies.materialize
|
|
25
|
+
? {
|
|
26
|
+
materialize: (selection) => options.cookies.materialize(scope, selection),
|
|
27
|
+
}
|
|
28
|
+
: {}),
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: {}),
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
activeCount: (projectPath) => options.service().activeCount(projectPath),
|
|
35
|
+
invalidate: (projectPath, appId) => options.service().cancelProject(projectPath, appId),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -4,6 +4,27 @@ export declare const DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES: number;
|
|
|
4
4
|
export type PanelAppStorage = Record<string, unknown>;
|
|
5
5
|
export declare function panelAppStorageQuotaBytes(requested?: number): number;
|
|
6
6
|
export declare function panelAppStorageKey(params: unknown): string;
|
|
7
|
+
export interface PanelAppStorageSnapshot {
|
|
8
|
+
exists: boolean;
|
|
9
|
+
value: unknown;
|
|
10
|
+
/** Content revision, not an edit counter. null means the key is absent. */
|
|
11
|
+
revision: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface PanelAppStorageChange {
|
|
14
|
+
key: string;
|
|
15
|
+
expectedRevision: string | null;
|
|
16
|
+
remove: boolean;
|
|
17
|
+
value?: unknown;
|
|
18
|
+
}
|
|
19
|
+
/** Snapshot hashes include the key; legacy JSON files require no migration. */
|
|
20
|
+
export declare function panelAppStorageSnapshot(storage: PanelAppStorage, key: string): PanelAppStorageSnapshot;
|
|
21
|
+
/** Validate and copy before any async work, so a caller cannot change a pending write. */
|
|
22
|
+
export declare function panelAppStorageChange(params: unknown): PanelAppStorageChange;
|
|
23
|
+
/** Call only under the shared per-file lock; conflict never changes the store. */
|
|
24
|
+
export declare function applyPanelAppStorageChange(storage: PanelAppStorage, change: PanelAppStorageChange): {
|
|
25
|
+
updated: boolean;
|
|
26
|
+
snapshot: PanelAppStorageSnapshot;
|
|
27
|
+
};
|
|
7
28
|
export declare function preparePanelAppStorage(file: string): Promise<void>;
|
|
8
29
|
export declare function readPanelAppStorage(file: string, quotaBytes: number): Promise<PanelAppStorage>;
|
|
9
30
|
export declare function writePanelAppStorage(file: string, storage: PanelAppStorage, quotaBytes: number, beforeCommit?: () => Promise<void>): Promise<void>;
|
|
@@ -32,6 +32,49 @@ export function panelAppStorageKey(params) {
|
|
|
32
32
|
}
|
|
33
33
|
return key;
|
|
34
34
|
}
|
|
35
|
+
/** Snapshot hashes include the key; legacy JSON files require no migration. */
|
|
36
|
+
export function panelAppStorageSnapshot(storage, key) {
|
|
37
|
+
const exists = Object.hasOwn(storage, key);
|
|
38
|
+
const value = exists ? storage[key] : null;
|
|
39
|
+
return {
|
|
40
|
+
exists,
|
|
41
|
+
value,
|
|
42
|
+
revision: exists
|
|
43
|
+
? `sha256:${createHash("sha256")
|
|
44
|
+
.update(JSON.stringify([key, value]))
|
|
45
|
+
.digest("hex")}`
|
|
46
|
+
: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Validate and copy before any async work, so a caller cannot change a pending write. */
|
|
50
|
+
export function panelAppStorageChange(params) {
|
|
51
|
+
const key = panelAppStorageKey(params);
|
|
52
|
+
const input = params;
|
|
53
|
+
const expectedRevision = input.expectedRevision;
|
|
54
|
+
if (expectedRevision !== null &&
|
|
55
|
+
(typeof expectedRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(expectedRevision)))
|
|
56
|
+
throw new Error("storage.compareAndSet requires expectedRevision (sha256 or null)");
|
|
57
|
+
if (input.remove !== undefined && typeof input.remove !== "boolean")
|
|
58
|
+
throw new Error("storage remove must be a boolean");
|
|
59
|
+
const remove = input.remove === true;
|
|
60
|
+
if (remove && Object.hasOwn(input, "value"))
|
|
61
|
+
throw new Error("storage removal cannot also include a value");
|
|
62
|
+
const encoded = remove ? undefined : JSON.stringify(input.value);
|
|
63
|
+
if (!remove && encoded === undefined)
|
|
64
|
+
throw new Error("Panel App storage only accepts JSON values");
|
|
65
|
+
return { key, expectedRevision, remove, ...(remove ? {} : { value: JSON.parse(encoded) }) };
|
|
66
|
+
}
|
|
67
|
+
/** Call only under the shared per-file lock; conflict never changes the store. */
|
|
68
|
+
export function applyPanelAppStorageChange(storage, change) {
|
|
69
|
+
const current = panelAppStorageSnapshot(storage, change.key);
|
|
70
|
+
if (current.revision !== change.expectedRevision)
|
|
71
|
+
return { updated: false, snapshot: current };
|
|
72
|
+
if (change.remove)
|
|
73
|
+
delete storage[change.key];
|
|
74
|
+
else
|
|
75
|
+
storage[change.key] = change.value;
|
|
76
|
+
return { updated: true, snapshot: panelAppStorageSnapshot(storage, change.key) };
|
|
77
|
+
}
|
|
35
78
|
function emptyStorage() {
|
|
36
79
|
// A null prototype is important here: valid app keys include names such as
|
|
37
80
|
// `__proto__`, `constructor`, and `toString`. They must remain data keys and
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type PanelTaskCookieOptions, type TaskCookieScope, type TaskCookieSelection } from "./task-cookies.js";
|
|
2
|
+
/** One Host owns this private root. Stop its native tasks before shutdown. */
|
|
3
|
+
export declare class PanelTaskCookieHost {
|
|
4
|
+
private readonly options;
|
|
5
|
+
private readonly storage;
|
|
6
|
+
private initializing?;
|
|
7
|
+
private closing?;
|
|
8
|
+
private stopped;
|
|
9
|
+
private readonly pending;
|
|
10
|
+
private readonly leases;
|
|
11
|
+
constructor(options: Omit<PanelTaskCookieOptions, "revisionKey">);
|
|
12
|
+
private ready;
|
|
13
|
+
private open;
|
|
14
|
+
private run;
|
|
15
|
+
initialize(): Promise<void>;
|
|
16
|
+
list(scope: TaskCookieScope, url: string): Promise<{
|
|
17
|
+
accounts: import("./task-cookies.js").TaskCookieAccount[];
|
|
18
|
+
}>;
|
|
19
|
+
check(scope: TaskCookieScope, selection: TaskCookieSelection): Promise<import("./task-cookies.js").TaskCookieAccount>;
|
|
20
|
+
materialize(scope: TaskCookieScope, selection: TaskCookieSelection): Promise<{
|
|
21
|
+
cleanup: () => Promise<void>;
|
|
22
|
+
path: string;
|
|
23
|
+
count: number;
|
|
24
|
+
}>;
|
|
25
|
+
shutdown(): Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, open, readdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ToolJobStorage } from "./tool-jobs-storage.js";
|
|
5
|
+
import { PanelTaskCookieService, } from "./task-cookies.js";
|
|
6
|
+
/** One Host owns this private root. Stop its native tasks before shutdown. */
|
|
7
|
+
export class PanelTaskCookieHost {
|
|
8
|
+
options;
|
|
9
|
+
storage;
|
|
10
|
+
initializing;
|
|
11
|
+
closing;
|
|
12
|
+
stopped = false;
|
|
13
|
+
pending = new Set();
|
|
14
|
+
leases = new Set();
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.storage = new ToolJobStorage(options.rootDirectory);
|
|
18
|
+
}
|
|
19
|
+
ready() {
|
|
20
|
+
return (this.initializing ??= this.open());
|
|
21
|
+
}
|
|
22
|
+
async open() {
|
|
23
|
+
await this.storage.initialize();
|
|
24
|
+
try {
|
|
25
|
+
const root = await this.storage.directory();
|
|
26
|
+
await chmod(root, 0o700);
|
|
27
|
+
const keyPath = join(root, "revision-key.json");
|
|
28
|
+
let record;
|
|
29
|
+
try {
|
|
30
|
+
record = await this.storage.read(keyPath, 1024);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== "ENOENT")
|
|
34
|
+
throw error;
|
|
35
|
+
record = { version: 1, key: randomBytes(32).toString("hex") };
|
|
36
|
+
const temporary = join(root, `${randomUUID()}.tmp`);
|
|
37
|
+
try {
|
|
38
|
+
const file = await open(temporary, "wx", 0o600);
|
|
39
|
+
try {
|
|
40
|
+
await file.writeFile(JSON.stringify(record));
|
|
41
|
+
await file.sync();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await file.close();
|
|
45
|
+
}
|
|
46
|
+
await this.storage.directory();
|
|
47
|
+
await rename(temporary, keyPath);
|
|
48
|
+
const parent = await open(root, "r");
|
|
49
|
+
try {
|
|
50
|
+
await parent.sync();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await parent.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await rm(temporary, { force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const value = record;
|
|
61
|
+
if (!value ||
|
|
62
|
+
value.version !== 1 ||
|
|
63
|
+
typeof value.key !== "string" ||
|
|
64
|
+
!/^[a-f0-9]{64}$/.test(value.key))
|
|
65
|
+
throw new Error("Invalid task Cookie Host key");
|
|
66
|
+
await chmod(keyPath, 0o600);
|
|
67
|
+
// Ownership is exclusive, so these belong to an exited Host. Never sweep by age
|
|
68
|
+
// while another Host can be running, and never touch unrelated root entries.
|
|
69
|
+
for (const name of await readdir(root)) {
|
|
70
|
+
if (/^cookies-[a-zA-Z0-9]{6}$/.test(name) || /^[a-f0-9-]{36}\.tmp$/.test(name)) {
|
|
71
|
+
await this.storage.directory();
|
|
72
|
+
await rm(join(root, name), { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return new PanelTaskCookieService({
|
|
76
|
+
...this.options,
|
|
77
|
+
rootDirectory: root,
|
|
78
|
+
revisionKey: Buffer.from(value.key, "hex"),
|
|
79
|
+
authorize: async (scope) => {
|
|
80
|
+
if (this.stopped)
|
|
81
|
+
throw new Error("Task Cookie Host is stopping");
|
|
82
|
+
await this.storage.directory();
|
|
83
|
+
await this.options.authorize(scope);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
await this.storage.close();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
run(operation) {
|
|
93
|
+
if (this.stopped)
|
|
94
|
+
return Promise.reject(new Error("Task Cookie Host is stopping"));
|
|
95
|
+
const pending = this.ready().then((service) => {
|
|
96
|
+
if (this.stopped)
|
|
97
|
+
throw new Error("Task Cookie Host is stopping");
|
|
98
|
+
return operation(service);
|
|
99
|
+
});
|
|
100
|
+
this.pending.add(pending);
|
|
101
|
+
void pending.then(() => this.pending.delete(pending), () => this.pending.delete(pending));
|
|
102
|
+
return pending;
|
|
103
|
+
}
|
|
104
|
+
initialize() {
|
|
105
|
+
return this.run(async () => { });
|
|
106
|
+
}
|
|
107
|
+
list(scope, url) {
|
|
108
|
+
return this.run((service) => service.list(scope, url));
|
|
109
|
+
}
|
|
110
|
+
check(scope, selection) {
|
|
111
|
+
return this.run((service) => service.check(scope, selection));
|
|
112
|
+
}
|
|
113
|
+
materialize(scope, selection) {
|
|
114
|
+
return this.run(async (service) => {
|
|
115
|
+
const lease = await service.materialize(scope, selection);
|
|
116
|
+
if (this.stopped) {
|
|
117
|
+
await lease.cleanup();
|
|
118
|
+
throw new Error("Task Cookie Host is stopping");
|
|
119
|
+
}
|
|
120
|
+
const cleanup = async () => {
|
|
121
|
+
await lease.cleanup();
|
|
122
|
+
this.leases.delete(cleanup);
|
|
123
|
+
};
|
|
124
|
+
this.leases.add(cleanup);
|
|
125
|
+
return { ...lease, cleanup };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
shutdown() {
|
|
129
|
+
this.stopped = true;
|
|
130
|
+
return (this.closing ??= (async () => {
|
|
131
|
+
if (!this.initializing)
|
|
132
|
+
return;
|
|
133
|
+
// open() releases ownership itself on initialization failure.
|
|
134
|
+
const opened = await this.initializing.then(() => true, () => false);
|
|
135
|
+
await Promise.allSettled([...this.pending]);
|
|
136
|
+
if (!opened)
|
|
137
|
+
return;
|
|
138
|
+
const results = await Promise.allSettled([...this.leases].map((cleanup) => cleanup()));
|
|
139
|
+
const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
140
|
+
if (errors.length)
|
|
141
|
+
throw new AggregateError(errors, "Task Cookie cleanup failed");
|
|
142
|
+
await this.storage.close();
|
|
143
|
+
})());
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Credential } from "@cjhyy/code-shell-core";
|
|
2
|
+
export interface TaskCookieScope {
|
|
3
|
+
appId: string;
|
|
4
|
+
projectPath: string;
|
|
5
|
+
revision: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TaskCookieSelection {
|
|
8
|
+
credentialId: string;
|
|
9
|
+
url: string;
|
|
10
|
+
/** Opaque Host-keyed version, bound to the app, project and reviewed package. */
|
|
11
|
+
revision: string;
|
|
12
|
+
}
|
|
13
|
+
export interface TaskCookieAccount {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
domain: string;
|
|
17
|
+
revision: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PanelTaskCookieOptions {
|
|
20
|
+
/** Private Host directory, never a workspace or task artifact directory. */
|
|
21
|
+
rootDirectory: string;
|
|
22
|
+
/** At least 32 random bytes, persisted privately by the Host across restarts. */
|
|
23
|
+
revisionKey: Uint8Array;
|
|
24
|
+
/** Host trust, installed package, project binding and credentials.cookies permission. */
|
|
25
|
+
authorize(scope: TaskCookieScope): Promise<void>;
|
|
26
|
+
/** Host-owned vault adapter. Values must never cross the UI bridge. */
|
|
27
|
+
credentials(scope: TaskCookieScope): Promise<Credential[]>;
|
|
28
|
+
now?(): number;
|
|
29
|
+
}
|
|
30
|
+
export declare function taskCookieSelection(raw: unknown): TaskCookieSelection;
|
|
31
|
+
/** Read only the selected credential reference from a native task envelope. */
|
|
32
|
+
export declare function taskCookieFromInput(raw: unknown): TaskCookieSelection | undefined;
|
|
33
|
+
/** Cookie custody only. Admission/retry consent belongs to the calling Host, not this vault. */
|
|
34
|
+
export declare class PanelTaskCookieService {
|
|
35
|
+
private readonly options;
|
|
36
|
+
private readonly key;
|
|
37
|
+
constructor(options: PanelTaskCookieOptions);
|
|
38
|
+
private version;
|
|
39
|
+
private cookies;
|
|
40
|
+
private snapshot;
|
|
41
|
+
list(scope: TaskCookieScope, url: string): Promise<{
|
|
42
|
+
accounts: TaskCookieAccount[];
|
|
43
|
+
}>;
|
|
44
|
+
private selected;
|
|
45
|
+
check(scope: TaskCookieScope, selection: TaskCookieSelection): Promise<TaskCookieAccount>;
|
|
46
|
+
materialize(scope: TaskCookieScope, selection: TaskCookieSelection): Promise<{
|
|
47
|
+
path: string;
|
|
48
|
+
count: number;
|
|
49
|
+
cleanup: () => Promise<void>;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { formatNetscapeCookies, parseCookieJar, } from "@cjhyy/code-shell-core";
|
|
5
|
+
function targetUrl(raw) {
|
|
6
|
+
if (typeof raw !== "string" || raw.length > 2048)
|
|
7
|
+
throw new Error("Cookie selection requires an HTTPS URL");
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(raw);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("Cookie selection requires an HTTPS URL");
|
|
14
|
+
}
|
|
15
|
+
if (url.protocol !== "https:" || url.username || url.password || !url.hostname)
|
|
16
|
+
throw new Error("Cookie selection requires an HTTPS URL");
|
|
17
|
+
return url;
|
|
18
|
+
}
|
|
19
|
+
function domain(raw) {
|
|
20
|
+
if (typeof raw !== "string")
|
|
21
|
+
return;
|
|
22
|
+
const value = raw.toLowerCase().replace(/^\./, "");
|
|
23
|
+
if (value.length > 253 ||
|
|
24
|
+
!value.includes(".") ||
|
|
25
|
+
!value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
|
|
26
|
+
return;
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function beneath(host, root) {
|
|
30
|
+
return host === root || host.endsWith(`.${root}`);
|
|
31
|
+
}
|
|
32
|
+
export function taskCookieSelection(raw) {
|
|
33
|
+
if (!raw ||
|
|
34
|
+
typeof raw !== "object" ||
|
|
35
|
+
Array.isArray(raw) ||
|
|
36
|
+
Object.keys(raw).some((key) => !["credentialId", "url", "revision"].includes(key)))
|
|
37
|
+
throw new Error("Invalid task Cookie selection");
|
|
38
|
+
const input = raw;
|
|
39
|
+
if (typeof input.credentialId !== "string" ||
|
|
40
|
+
!input.credentialId.trim() ||
|
|
41
|
+
input.credentialId.length > 160 ||
|
|
42
|
+
/[\x00-\x1f\x7f]/.test(input.credentialId) ||
|
|
43
|
+
typeof input.revision !== "string" ||
|
|
44
|
+
!/^[a-f0-9]{64}$/.test(input.revision))
|
|
45
|
+
throw new Error("Invalid task Cookie selection");
|
|
46
|
+
targetUrl(input.url);
|
|
47
|
+
return { credentialId: input.credentialId, url: input.url, revision: input.revision };
|
|
48
|
+
}
|
|
49
|
+
/** Read only the selected credential reference from a native task envelope. */
|
|
50
|
+
export function taskCookieFromInput(raw) {
|
|
51
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
52
|
+
return;
|
|
53
|
+
const argument = raw.cookieArgument;
|
|
54
|
+
if (argument === undefined)
|
|
55
|
+
return;
|
|
56
|
+
if (!argument || typeof argument !== "object" || Array.isArray(argument))
|
|
57
|
+
throw new Error("Invalid task Cookie argument");
|
|
58
|
+
const { argumentName, ...selection } = argument;
|
|
59
|
+
if (typeof argumentName !== "string" || !/^--[a-z][a-z0-9-]{0,63}$/.test(argumentName))
|
|
60
|
+
throw new Error("Invalid task Cookie argument");
|
|
61
|
+
return taskCookieSelection(selection);
|
|
62
|
+
}
|
|
63
|
+
/** Cookie custody only. Admission/retry consent belongs to the calling Host, not this vault. */
|
|
64
|
+
export class PanelTaskCookieService {
|
|
65
|
+
options;
|
|
66
|
+
key;
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.options = options;
|
|
69
|
+
if (!(options.revisionKey instanceof Uint8Array) || options.revisionKey.byteLength < 32)
|
|
70
|
+
throw new Error("Task Cookie revisions require a private Host key");
|
|
71
|
+
if (!isAbsolute(options.rootDirectory))
|
|
72
|
+
throw new Error("Task Cookie root must be absolute");
|
|
73
|
+
this.key = Buffer.from(options.revisionKey);
|
|
74
|
+
}
|
|
75
|
+
version(scope, credential, root) {
|
|
76
|
+
return createHmac("sha256", this.key)
|
|
77
|
+
.update(JSON.stringify([
|
|
78
|
+
"codeshell-task-cookie-v1",
|
|
79
|
+
scope.appId,
|
|
80
|
+
scope.projectPath,
|
|
81
|
+
scope.revision,
|
|
82
|
+
credential.id,
|
|
83
|
+
credential.label,
|
|
84
|
+
root,
|
|
85
|
+
credential.secret,
|
|
86
|
+
]))
|
|
87
|
+
.digest("hex");
|
|
88
|
+
}
|
|
89
|
+
cookies(credential, root) {
|
|
90
|
+
const now = (this.options.now?.() ?? Date.now()) / 1000;
|
|
91
|
+
if (typeof credential.secret !== "string" ||
|
|
92
|
+
Buffer.byteLength(credential.secret) > 4 * 1024 * 1024)
|
|
93
|
+
return [];
|
|
94
|
+
const jar = parseCookieJar(credential.secret);
|
|
95
|
+
if (jar.length > 10000)
|
|
96
|
+
return [];
|
|
97
|
+
return jar.filter((cookie) => {
|
|
98
|
+
if (!cookie ||
|
|
99
|
+
typeof cookie !== "object" ||
|
|
100
|
+
typeof cookie.name !== "string" ||
|
|
101
|
+
typeof cookie.value !== "string" ||
|
|
102
|
+
!cookie.name ||
|
|
103
|
+
/[\x00-\x1f\x7f]/.test(cookie.name + cookie.value) ||
|
|
104
|
+
(cookie.path !== undefined &&
|
|
105
|
+
(typeof cookie.path !== "string" ||
|
|
106
|
+
!cookie.path.startsWith("/") ||
|
|
107
|
+
/[\x00-\x1f\x7f]/.test(cookie.path))))
|
|
108
|
+
return false;
|
|
109
|
+
const cookieDomain = domain(cookie.domain);
|
|
110
|
+
if (!cookieDomain || !beneath(cookieDomain, root))
|
|
111
|
+
return false;
|
|
112
|
+
if (cookie.expirationDate !== undefined &&
|
|
113
|
+
(typeof cookie.expirationDate !== "number" ||
|
|
114
|
+
!Number.isFinite(cookie.expirationDate) ||
|
|
115
|
+
(cookie.expirationDate > 0 && cookie.expirationDate <= now)))
|
|
116
|
+
return false;
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async snapshot(scope, url) {
|
|
121
|
+
await this.options.authorize(scope);
|
|
122
|
+
const result = (await this.options.credentials(scope)).flatMap((credential) => {
|
|
123
|
+
const root = domain(credential.meta?.domain);
|
|
124
|
+
if (credential.type !== "cookie" || !root || !beneath(url.hostname.toLowerCase(), root))
|
|
125
|
+
return [];
|
|
126
|
+
const cookies = this.cookies(credential, root);
|
|
127
|
+
if (!cookies.length)
|
|
128
|
+
return [];
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
account: {
|
|
132
|
+
id: credential.id,
|
|
133
|
+
label: credential.label,
|
|
134
|
+
domain: root,
|
|
135
|
+
revision: this.version(scope, credential, root),
|
|
136
|
+
},
|
|
137
|
+
cookies,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
});
|
|
141
|
+
await this.options.authorize(scope);
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
async list(scope, url) {
|
|
145
|
+
return { accounts: (await this.snapshot(scope, targetUrl(url))).map((item) => item.account) };
|
|
146
|
+
}
|
|
147
|
+
async selected(scope, raw) {
|
|
148
|
+
const input = taskCookieSelection(raw);
|
|
149
|
+
const matches = (await this.snapshot(scope, targetUrl(input.url))).filter((item) => item.account.id === input.credentialId);
|
|
150
|
+
const item = matches.length === 1 ? matches[0] : undefined;
|
|
151
|
+
if (!item ||
|
|
152
|
+
!timingSafeEqual(Buffer.from(item.account.revision, "hex"), Buffer.from(input.revision, "hex")))
|
|
153
|
+
throw new Error("Selected Cookie login changed or is unavailable; select and authorize it again");
|
|
154
|
+
return item;
|
|
155
|
+
}
|
|
156
|
+
async check(scope, selection) {
|
|
157
|
+
return (await this.selected(scope, selection)).account;
|
|
158
|
+
}
|
|
159
|
+
async materialize(scope, selection) {
|
|
160
|
+
const { cookies } = await this.selected(scope, selection);
|
|
161
|
+
const root = this.options.rootDirectory;
|
|
162
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
163
|
+
const info = await lstat(root);
|
|
164
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
165
|
+
throw new Error("Invalid task Cookie root");
|
|
166
|
+
await chmod(root, 0o700);
|
|
167
|
+
const directory = await mkdtemp(join(await realpath(root), "cookies-"));
|
|
168
|
+
await chmod(directory, 0o700);
|
|
169
|
+
const path = join(directory, "cookies.txt");
|
|
170
|
+
let cleaning;
|
|
171
|
+
const cleanup = () => (cleaning ??= rm(directory, { recursive: true, force: true }));
|
|
172
|
+
try {
|
|
173
|
+
await writeFile(path, formatNetscapeCookies(cookies), { mode: 0o600, flag: "wx" });
|
|
174
|
+
// Recheck after asynchronous filesystem work, before releasing a secret to the program.
|
|
175
|
+
await this.check(scope, selection);
|
|
176
|
+
return { path, count: cookies.length, cleanup };
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
await cleanup();
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PanelAppProcessService, type PanelProcessOwner } from "./process-service.js";
|
|
2
2
|
import { PanelResourceService } from "./resources/service.js";
|
|
3
|
+
import { type PanelTaskCookieService, type TaskCookieSelection } from "./task-cookies.js";
|
|
3
4
|
interface Scope {
|
|
4
5
|
appId: string;
|
|
5
6
|
projectPath: string;
|
|
@@ -23,19 +24,28 @@ interface ExecutionContext {
|
|
|
23
24
|
message?: string;
|
|
24
25
|
}): Promise<void>;
|
|
25
26
|
}
|
|
27
|
+
interface ToolResource {
|
|
28
|
+
assetId: string;
|
|
29
|
+
path: string;
|
|
30
|
+
/** "read": no copy; the Host lists the verified original under `path` in the sealed manifest. */
|
|
31
|
+
access?: "copy" | "read";
|
|
32
|
+
}
|
|
26
33
|
interface ToolInput {
|
|
27
34
|
request: Record<string, unknown>;
|
|
28
|
-
resources?:
|
|
29
|
-
assetId: string;
|
|
30
|
-
path: string;
|
|
31
|
-
}>;
|
|
35
|
+
resources?: ToolResource[];
|
|
32
36
|
directoryArguments?: Array<{
|
|
33
37
|
argumentName: string;
|
|
34
|
-
directory: "job" | "app-data";
|
|
38
|
+
directory: "job" | "app-data" | "bookmark";
|
|
39
|
+
bookmark?: string;
|
|
35
40
|
path?: string;
|
|
36
41
|
}>;
|
|
37
42
|
connectionIds?: string[];
|
|
38
43
|
connectionArgument?: string;
|
|
44
|
+
/** Required with `access: "read"` resources: the sealed manifest's launch argument. */
|
|
45
|
+
originalsArgument?: string;
|
|
46
|
+
cookieArgument?: TaskCookieSelection & {
|
|
47
|
+
argumentName: string;
|
|
48
|
+
};
|
|
39
49
|
}
|
|
40
50
|
export interface PanelToolExecutorOptions {
|
|
41
51
|
processes: PanelAppProcessService;
|
|
@@ -44,9 +54,15 @@ export interface PanelToolExecutorOptions {
|
|
|
44
54
|
owner(job: Job, send: PanelProcessOwner["send"]): PanelProcessOwner;
|
|
45
55
|
releaseOwner(owner: PanelProcessOwner): void;
|
|
46
56
|
appDataDirectory(scope: Scope): Promise<string>;
|
|
57
|
+
/** Resolve a saved Host grant in this exact app/project; never accept a caller path. */
|
|
58
|
+
resolveDirectoryBookmark?(scope: Scope, bookmark: string): Promise<string>;
|
|
47
59
|
authorize(scope: Scope): Promise<void>;
|
|
48
60
|
authorizeConnections(scope: Scope): Promise<void>;
|
|
61
|
+
/** Grants `access: "read"` resources; hosts without it refuse direct reads. */
|
|
62
|
+
authorizeDirectRead?(scope: Scope): Promise<void>;
|
|
49
63
|
sealedRoot: string;
|
|
64
|
+
/** Configure only when start/retry admission obtains explicit selected-account consent. */
|
|
65
|
+
cookies?: Pick<PanelTaskCookieService, "check" | "materialize">;
|
|
50
66
|
}
|
|
51
67
|
/** The only background processor: launch a reviewed tool and transport bounded JSON/files. */
|
|
52
68
|
export declare function createPanelToolExecutor(options: PanelToolExecutorOptions): {
|