@cjhyy/code-shell-server 0.9.52 → 0.10.1
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/account/cli.d.ts +23 -0
- package/dist/account/cli.js +249 -0
- package/dist/account/cloud-contract.d.ts +39 -0
- package/dist/account/cloud-contract.js +134 -0
- package/dist/account/cloud-types.d.ts +47 -0
- package/dist/account/cloud-types.js +1 -0
- package/dist/account/feature-service.d.ts +47 -0
- package/dist/account/feature-service.js +184 -0
- package/dist/account/http.d.ts +17 -0
- package/dist/account/http.js +158 -0
- package/dist/account/links.d.ts +13 -0
- package/dist/account/links.js +137 -0
- package/dist/account/manager.d.ts +96 -0
- package/dist/account/manager.js +976 -0
- package/dist/account/native-lock.d.ts +6 -0
- package/dist/account/native-lock.js +26 -0
- package/dist/account/native-store.d.ts +32 -0
- package/dist/account/native-store.js +162 -0
- package/dist/account/profile.d.ts +4 -0
- package/dist/account/profile.js +46 -0
- package/dist/account/store.d.ts +34 -0
- package/dist/account/store.js +118 -0
- package/dist/account/types.d.ts +138 -0
- package/dist/account/types.js +2 -0
- package/dist/desktop-web/http-api.d.ts +4 -0
- package/dist/desktop-web/http-api.js +12 -4
- package/dist/environment-identity.d.ts +1 -1
- package/dist/environment-identity.js +2 -1
- package/dist/headless-worker-env.d.ts +2 -0
- package/dist/headless-worker-env.js +15 -0
- package/dist/hub/approval-lease.js +6 -1
- package/dist/hub/run-replay.js +1 -1
- package/dist/hub/session-management.js +1 -1
- package/dist/index.account.d.ts +12 -0
- package/dist/index.account.js +12 -0
- package/dist/index.mobile-remote.d.ts +2 -0
- package/dist/index.mobile-remote.js +1 -0
- package/dist/index.worker.d.ts +1 -0
- package/dist/index.worker.js +1 -0
- package/dist/mobile-remote/account-relay-access.d.ts +59 -0
- package/dist/mobile-remote/account-relay-access.js +174 -0
- package/dist/mobile-remote/approval-clock.d.ts +3 -0
- package/dist/mobile-remote/approval-clock.js +15 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
- package/dist/mobile-remote/codex-room-agent.js +17 -1
- package/dist/mobile-remote/mobile-client-event-validator.js +40 -1
- package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
- package/dist/mobile-remote/mobile-upload-service.js +25 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +19 -4
- package/dist/mobile-remote/remote-host-manager.js +124 -17
- package/dist/mobile-remote/resident-agent.d.ts +14 -0
- package/dist/mobile-remote/resident-agent.js +38 -11
- package/dist/mobile-remote/room-history.d.ts +7 -0
- package/dist/mobile-remote/room-history.js +56 -1
- package/dist/mobile-remote/room-manager.d.ts +216 -9
- package/dist/mobile-remote/room-manager.js +700 -65
- package/dist/mobile-remote/room-process-stop.d.ts +15 -0
- package/dist/mobile-remote/room-process-stop.js +41 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
- package/dist/mobile-remote/trusted-device-store.js +54 -1
- package/dist/mobile-remote/turn-queue.d.ts +183 -0
- package/dist/mobile-remote/turn-queue.js +422 -0
- package/dist/mobile-remote/types.d.ts +1 -1
- package/dist/panels/agent-task-host.js +6 -12
- package/dist/panels/agent-task-service.d.ts +2 -0
- package/dist/panels/agent-task-service.js +7 -0
- package/dist/panels/browser-capture.d.ts +1 -15
- package/dist/panels/browser-capture.js +1 -35
- package/dist/panels/http.d.ts +15 -15
- package/dist/panels/management-http.d.ts +9 -9
- package/dist/panels/process-service.d.ts +5 -4
- package/dist/panels/process-service.js +54 -16
- package/dist/panels/runtime.js +25 -14
- package/dist/panels/tool-jobs-storage.d.ts +4 -0
- package/dist/panels/tool-jobs-storage.js +111 -34
- package/dist/panels/types.d.ts +1 -97
- package/dist/remote-relay/connector.d.ts +8 -0
- package/dist/remote-relay/connector.js +29 -2
- package/dist/remote-relay/protocol.d.ts +21 -0
- package/dist/remote-relay/protocol.js +31 -0
- package/dist/serve/cli.js +6 -0
- package/dist/serve/headless-server.js +24 -19
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +14 -1
- package/package.json +10 -6
|
@@ -26,9 +26,9 @@ export interface PanelAppProcessServiceOptions {
|
|
|
26
26
|
/** Rechecked after asynchronous work and immediately before admitting a process. */
|
|
27
27
|
isOwnerAuthorized?(owner: PanelProcessOwner): boolean | Promise<boolean>;
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
* to
|
|
31
|
-
* app-wide durable hooks.
|
|
29
|
+
* When executable confirmation is required, app scope can delegate durable
|
|
30
|
+
* approval to Host hooks. Guest scope isolates authenticated instances and
|
|
31
|
+
* never uses the app-wide durable hooks.
|
|
32
32
|
*/
|
|
33
33
|
approvalScope?: "app" | "guest";
|
|
34
34
|
confirmExecution(input: {
|
|
@@ -40,7 +40,7 @@ export interface PanelAppProcessServiceOptions {
|
|
|
40
40
|
}): Promise<boolean>;
|
|
41
41
|
isExecutionApproved?(scope: PanelProcessApprovalScope): Promise<boolean>;
|
|
42
42
|
rememberExecutionApproval?(scope: PanelProcessApprovalScope): Promise<void>;
|
|
43
|
-
/**
|
|
43
|
+
/** Hosts can use installed-app process permission and authorized-project binding as consent. */
|
|
44
44
|
allowAuthorizedProcessWithoutPrompt?: boolean;
|
|
45
45
|
/** Resolve a declared entry in this app revision; return a canonical, reviewed file. */
|
|
46
46
|
resolvePackageEntry?(owner: PanelProcessOwner, name: string): Promise<{
|
|
@@ -75,6 +75,7 @@ export declare class PanelAppProcessService {
|
|
|
75
75
|
private readonly retiring;
|
|
76
76
|
private readonly receipts;
|
|
77
77
|
private readonly approvedExecutables;
|
|
78
|
+
private readonly pendingExecutionApprovals;
|
|
78
79
|
private readonly guestEpochs;
|
|
79
80
|
private readonly reservations;
|
|
80
81
|
private closed;
|
|
@@ -169,6 +169,7 @@ export class PanelAppProcessService {
|
|
|
169
169
|
retiring = new Map();
|
|
170
170
|
receipts;
|
|
171
171
|
approvedExecutables = new Set();
|
|
172
|
+
pendingExecutionApprovals = new Map();
|
|
172
173
|
guestEpochs = new Map();
|
|
173
174
|
reservations = new Map();
|
|
174
175
|
closed = false;
|
|
@@ -447,6 +448,7 @@ export class PanelAppProcessService {
|
|
|
447
448
|
// Admission is reserved before the first await, including a user's approval dialog.
|
|
448
449
|
const releaseExecution = this.options.acquireExecution?.(owner);
|
|
449
450
|
let processOwnsExecution = false;
|
|
451
|
+
let pendingApproval;
|
|
450
452
|
this.reservations.set(owner.guestId, reserved + 1);
|
|
451
453
|
try {
|
|
452
454
|
await this.authorize(owner, epoch);
|
|
@@ -462,29 +464,45 @@ export class PanelAppProcessService {
|
|
|
462
464
|
const guestScoped = this.options.approvalScope === "guest";
|
|
463
465
|
const approvalKey = (guestScoped ? `${owner.guestId}\0${epoch}\0` : "") +
|
|
464
466
|
`${scope.appId}\0${scope.revision}\0${scope.executablePath}\0${scope.executableFingerprint}`;
|
|
465
|
-
//
|
|
466
|
-
// bound project
|
|
467
|
+
// Hosts may use installed-app process permission and their authorized,
|
|
468
|
+
// bound project as consent; authorize() still runs before every spawn.
|
|
467
469
|
let approved = this.options.allowAuthorizedProcessWithoutPrompt === true ||
|
|
468
470
|
this.approvedExecutables.has(approvalKey);
|
|
469
471
|
if (!approved && !guestScoped && this.options.isExecutionApproved) {
|
|
470
472
|
approved = await this.options.isExecutionApproved(scope).catch(() => false);
|
|
471
473
|
await this.authorize(owner, epoch);
|
|
472
474
|
}
|
|
475
|
+
approved ||= this.approvedExecutables.has(approvalKey);
|
|
473
476
|
if (!approved) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
477
|
+
let approval = this.pendingExecutionApprovals.get(approvalKey);
|
|
478
|
+
if (!approval) {
|
|
479
|
+
approval = {
|
|
480
|
+
guestId: owner.guestId,
|
|
481
|
+
waiters: 0,
|
|
482
|
+
result: (async () => {
|
|
483
|
+
const allowed = await this.options.confirmExecution({
|
|
484
|
+
guestId: owner.guestId,
|
|
485
|
+
appId: owner.appId,
|
|
486
|
+
appTitle: owner.appTitle,
|
|
487
|
+
executable: executable.name,
|
|
488
|
+
executablePath: executable.path,
|
|
489
|
+
});
|
|
490
|
+
await this.authorize(owner, epoch);
|
|
491
|
+
if (!allowed)
|
|
492
|
+
throw new Error(`User denied running ${executable.name}`);
|
|
493
|
+
if (!guestScoped) {
|
|
494
|
+
await this.options.rememberExecutionApproval?.(scope).catch(() => undefined);
|
|
495
|
+
await this.authorize(owner, epoch);
|
|
496
|
+
}
|
|
497
|
+
})(),
|
|
498
|
+
};
|
|
499
|
+
this.pendingExecutionApprovals.set(approvalKey, approval);
|
|
487
500
|
}
|
|
501
|
+
approval.waiters++;
|
|
502
|
+
pendingApproval = { key: approvalKey, approval };
|
|
503
|
+
await approval.result;
|
|
504
|
+
// Sharing consent does not share a caller's authority or process handles.
|
|
505
|
+
await this.authorize(owner, epoch);
|
|
488
506
|
}
|
|
489
507
|
// A binary replaced while a dialog was open must earn a new approval.
|
|
490
508
|
if ((await executableFingerprint(executable.path)) !== scope.executableFingerprint) {
|
|
@@ -618,6 +636,13 @@ export class PanelAppProcessService {
|
|
|
618
636
|
}),
|
|
619
637
|
};
|
|
620
638
|
this.processes.set(processId, running);
|
|
639
|
+
// A successful launcher may leave children behind (including children
|
|
640
|
+
// retaining stdout). Reap its group on leader exit, before close/lease release.
|
|
641
|
+
child.once("exit", () => {
|
|
642
|
+
const pid = child.pid;
|
|
643
|
+
if (!running.termination && typeof pid === "number" && pid > 1)
|
|
644
|
+
running.termination = killProcessGroup(pid).catch(() => { });
|
|
645
|
+
});
|
|
621
646
|
if (modelTraceOwner && this.options.modelTraces) {
|
|
622
647
|
const channel = new PanelModelTraceChannel({
|
|
623
648
|
service: this.options.modelTraces,
|
|
@@ -668,7 +693,7 @@ export class PanelAppProcessService {
|
|
|
668
693
|
clearTimeout(lifetime);
|
|
669
694
|
if (inputChecks)
|
|
670
695
|
clearInterval(inputChecks);
|
|
671
|
-
//
|
|
696
|
+
// Natural exit and cancellation both wait for the entire process group.
|
|
672
697
|
await running.termination;
|
|
673
698
|
let finalCode = code;
|
|
674
699
|
if (inputChecks && this.processes.get(processId) === running) {
|
|
@@ -704,6 +729,14 @@ export class PanelAppProcessService {
|
|
|
704
729
|
return { processId, executable: executable.name };
|
|
705
730
|
}
|
|
706
731
|
finally {
|
|
732
|
+
if (pendingApproval) {
|
|
733
|
+
const { key, approval } = pendingApproval;
|
|
734
|
+
approval.waiters--;
|
|
735
|
+
// Keep successful consent in flight until a caller has cached it after
|
|
736
|
+
// all spawn checks. Rejections disappear once every waiter has observed them.
|
|
737
|
+
if (!approval.waiters && this.pendingExecutionApprovals.get(key) === approval)
|
|
738
|
+
this.pendingExecutionApprovals.delete(key);
|
|
739
|
+
}
|
|
707
740
|
if (!processOwnsExecution)
|
|
708
741
|
releaseExecution?.();
|
|
709
742
|
const remaining = (this.reservations.get(owner.guestId) ?? 1) - 1;
|
|
@@ -874,6 +907,10 @@ export class PanelAppProcessService {
|
|
|
874
907
|
}
|
|
875
908
|
revokeGuest(guestId) {
|
|
876
909
|
this.guestEpochs.set(guestId, (this.guestEpochs.get(guestId) ?? 0) + 1);
|
|
910
|
+
for (const [key, approval] of this.pendingExecutionApprovals) {
|
|
911
|
+
if (approval.guestId === guestId)
|
|
912
|
+
this.pendingExecutionApprovals.delete(key);
|
|
913
|
+
}
|
|
877
914
|
if (this.options.approvalScope === "guest") {
|
|
878
915
|
for (const key of this.approvedExecutables) {
|
|
879
916
|
if (key.startsWith(`${guestId}\0`))
|
|
@@ -938,6 +975,7 @@ export class PanelAppProcessService {
|
|
|
938
975
|
for (const guestId of guests)
|
|
939
976
|
this.revokeGuest(guestId);
|
|
940
977
|
this.approvedExecutables.clear();
|
|
978
|
+
this.pendingExecutionApprovals.clear();
|
|
941
979
|
this.receipts.close();
|
|
942
980
|
}
|
|
943
981
|
terminate(running) {
|
package/dist/panels/runtime.js
CHANGED
|
@@ -412,6 +412,14 @@ function injectBridge(html, source) {
|
|
|
412
412
|
const offset = doctype?.[0].length ?? 0;
|
|
413
413
|
return html.slice(0, offset) + script + html.slice(offset);
|
|
414
414
|
}
|
|
415
|
+
// CSP3 and Chromium resolve 'self' in a sandboxed document to its URL's origin,
|
|
416
|
+
// but WebKit (every iOS browser) matches nothing, blocking all of a panel's own
|
|
417
|
+
// scripts and styles. Spell that origin out so every engine enforces one policy.
|
|
418
|
+
function resolveMetaPolicySelf(html, origin) {
|
|
419
|
+
return html.replace(/<meta\b[^>]*>/gi, (tag) => /\shttp-equiv\s*=\s*(["']?)content-security-policy\1/i.test(tag)
|
|
420
|
+
? tag.replaceAll("'self'", origin)
|
|
421
|
+
: tag);
|
|
422
|
+
}
|
|
415
423
|
/** Owner-bound capability grants serve reviewed static bytes to an opaque-origin iframe. */
|
|
416
424
|
export function createPanelRuntime(options) {
|
|
417
425
|
const publicPathPrefix = options.publicPathPrefix ?? "";
|
|
@@ -557,6 +565,9 @@ export function createPanelRuntime(options) {
|
|
|
557
565
|
holder: { kind: "process" },
|
|
558
566
|
}),
|
|
559
567
|
approvalScope: "guest",
|
|
568
|
+
// Match Desktop: installed process permission and the authorized project binding
|
|
569
|
+
// are consent. Every spawn still rechecks owner/revision, handles and fingerprints.
|
|
570
|
+
allowAuthorizedProcessWithoutPrompt: true,
|
|
560
571
|
resolvePackageEntry: async (owner, name) => {
|
|
561
572
|
const taskScope = toolOwners.get(owner.guestId);
|
|
562
573
|
if (taskScope) {
|
|
@@ -583,13 +594,7 @@ export function createPanelRuntime(options) {
|
|
|
583
594
|
const grant = byGuest.get(owner.guestId);
|
|
584
595
|
return !!grant && (await authorized(grant));
|
|
585
596
|
},
|
|
586
|
-
confirmExecution: async (
|
|
587
|
-
if (toolOwners.has(input.guestId))
|
|
588
|
-
return true;
|
|
589
|
-
const grant = byGuest.get(input.guestId);
|
|
590
|
-
return (!!grant &&
|
|
591
|
-
(await confirm(grant, `允许 ${input.appTitle} 运行 ${input.executable}?`, `程序将在服务端运行,可使用当前运行用户的文件与网络权限。\n程序:${input.executablePath}`)));
|
|
592
|
-
},
|
|
597
|
+
confirmExecution: async () => true,
|
|
593
598
|
});
|
|
594
599
|
const resources = new PanelResourceService({
|
|
595
600
|
rootDirectory: join(options.dataDir, "panel-app-media"),
|
|
@@ -1360,6 +1365,7 @@ export function createPanelRuntime(options) {
|
|
|
1360
1365
|
instanceId: id,
|
|
1361
1366
|
src: ASSETS + asset + "/" + app.entry.split("/").map(encodeURIComponent).join("/"),
|
|
1362
1367
|
expiresAt: grant.expiresAt,
|
|
1368
|
+
serverTime: now(),
|
|
1363
1369
|
context,
|
|
1364
1370
|
limitations: panel.compatibility.reasons,
|
|
1365
1371
|
};
|
|
@@ -1595,9 +1601,8 @@ export function createPanelRuntime(options) {
|
|
|
1595
1601
|
if (method.startsWith("agent.task.")) {
|
|
1596
1602
|
if (!agentTasks)
|
|
1597
1603
|
error(501, "当前宿主未提供独立面板任务。");
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
error(403, "你取消了面板 AI 任务。");
|
|
1604
|
+
// Match Desktop: the installed app's agent.task permission and live project
|
|
1605
|
+
// binding authorize task creation. Worker tool approvals stay independent.
|
|
1601
1606
|
return agentTasks.call(taskScope(grant), method, params);
|
|
1602
1607
|
}
|
|
1603
1608
|
if (method.startsWith("process.") || method.startsWith("filesystem.")) {
|
|
@@ -1904,7 +1909,7 @@ export function createPanelRuntime(options) {
|
|
|
1904
1909
|
if (!(await authorized(grant)))
|
|
1905
1910
|
error(410, "面板授权已失效。");
|
|
1906
1911
|
grant.expiresAt = now() + GRANT_TTL;
|
|
1907
|
-
json(response, 200, { expiresAt: grant.expiresAt });
|
|
1912
|
+
json(response, 200, { expiresAt: grant.expiresAt, serverTime: now() });
|
|
1908
1913
|
}
|
|
1909
1914
|
else if (request.method === "GET" && match[2] === "events") {
|
|
1910
1915
|
const after = Number(url.searchParams.get("after") ?? 0);
|
|
@@ -1913,7 +1918,11 @@ export function createPanelRuntime(options) {
|
|
|
1913
1918
|
grant.events = grant.events.filter((entry) => entry.id > after);
|
|
1914
1919
|
grant.eventBytes = grant.events.reduce((total, entry) => total + Buffer.byteLength(JSON.stringify(entry.payload) ?? "null"), 0);
|
|
1915
1920
|
const batch = grant.events.slice(0, 128);
|
|
1916
|
-
json(response, 200, {
|
|
1921
|
+
json(response, 200, {
|
|
1922
|
+
events: batch,
|
|
1923
|
+
cursor: batch.at(-1)?.id ?? after,
|
|
1924
|
+
serverTime: now(),
|
|
1925
|
+
});
|
|
1917
1926
|
}
|
|
1918
1927
|
else if (request.method === "POST" && match[2] === "directory-picker") {
|
|
1919
1928
|
if (request.headers.origin !== grant.origin)
|
|
@@ -2091,12 +2100,14 @@ export function createPanelRuntime(options) {
|
|
|
2091
2100
|
// including when the public site is behind HTTPS termination.
|
|
2092
2101
|
const source = publicPathPrefix + ASSETS + grant.asset + "/";
|
|
2093
2102
|
if (extname(path).toLowerCase() === ".html") {
|
|
2094
|
-
bytes = Buffer.from(injectBridge(bytes.toString("utf8"), source));
|
|
2095
2103
|
const resourceOrigin = grant.origin;
|
|
2104
|
+
bytes = Buffer.from(resolveMetaPolicySelf(injectBridge(bytes.toString("utf8"), source), resourceOrigin));
|
|
2096
2105
|
const assetSource = resourceOrigin + source;
|
|
2097
2106
|
// Panels export their own generated files (for example JSON backups).
|
|
2098
2107
|
// Download permission does not grant origin access, network fetches or popups.
|
|
2099
2108
|
// The embedding iframe must enable the same permission.
|
|
2109
|
+
// Forms need submission events and method=dialog for local UI. The
|
|
2110
|
+
// enforcing form-action policy still blocks every form navigation.
|
|
2100
2111
|
response.setHeader("Content-Security-Policy", "default-src 'none'; script-src " +
|
|
2101
2112
|
assetSource +
|
|
2102
2113
|
" 'wasm-unsafe-eval'; style-src " +
|
|
@@ -2109,7 +2120,7 @@ export function createPanelRuntime(options) {
|
|
|
2109
2120
|
assetSource +
|
|
2110
2121
|
" data:; connect-src " +
|
|
2111
2122
|
assetSource +
|
|
2112
|
-
"; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads; frame-ancestors " +
|
|
2123
|
+
"; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-downloads allow-forms; frame-ancestors " +
|
|
2113
2124
|
resourceOrigin);
|
|
2114
2125
|
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), display-capture=()");
|
|
2115
2126
|
}
|
|
@@ -5,9 +5,13 @@ export declare class ToolJobStorage {
|
|
|
5
5
|
private root;
|
|
6
6
|
private identity;
|
|
7
7
|
private nonce;
|
|
8
|
+
private ownerClaimFile;
|
|
8
9
|
private readonly directories;
|
|
9
10
|
constructor(configuredRoot: string);
|
|
10
11
|
initialize(): Promise<void>;
|
|
12
|
+
private assertDeadOwner;
|
|
13
|
+
private acquireOwnerClaim;
|
|
14
|
+
private releaseOwnerClaim;
|
|
11
15
|
directory(id?: string, work?: boolean, create?: boolean): Promise<string>;
|
|
12
16
|
read(path: string, limit: number): Promise<unknown>;
|
|
13
17
|
write(id: string, value: unknown, limit: number): Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { lstat, mkdir, open, realpath, readdir, rename, rm, rmdir, unlink } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
export function toolJobJson(value, limit) {
|
|
6
6
|
let text;
|
|
@@ -20,6 +20,7 @@ export class ToolJobStorage {
|
|
|
20
20
|
root = "";
|
|
21
21
|
identity = "";
|
|
22
22
|
nonce = "";
|
|
23
|
+
ownerClaimFile = "";
|
|
23
24
|
directories = new Map();
|
|
24
25
|
constructor(configuredRoot) {
|
|
25
26
|
this.configuredRoot = configuredRoot;
|
|
@@ -33,43 +34,114 @@ export class ToolJobStorage {
|
|
|
33
34
|
throw new Error("invalid tool job root");
|
|
34
35
|
this.root = await realpath(this.configuredRoot);
|
|
35
36
|
this.identity = `${initial.dev}:${initial.ino}`;
|
|
36
|
-
|
|
37
|
-
const create = () => open(lockPath, "wx", 0o600);
|
|
38
|
-
let lock;
|
|
37
|
+
this.nonce = randomUUID();
|
|
39
38
|
try {
|
|
40
|
-
|
|
39
|
+
await this.acquireOwnerClaim();
|
|
40
|
+
// Keep the previous file format as an interoperability barrier for older
|
|
41
|
+
// Hosts. All current Hosts hold the atomic directory claim before touching it.
|
|
42
|
+
const lockPath = join(this.root, "owner.lock");
|
|
43
|
+
try {
|
|
44
|
+
const current = (await this.read(lockPath, 1024));
|
|
45
|
+
this.assertDeadOwner(current.pid);
|
|
46
|
+
await rm(lockPath);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== "ENOENT")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const lock = await open(lockPath, "wx", 0o600);
|
|
53
|
+
try {
|
|
54
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, nonce: this.nonce }));
|
|
55
|
+
await lock.sync();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await lock.close();
|
|
59
|
+
}
|
|
41
60
|
}
|
|
42
61
|
catch (error) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
await this.releaseOwnerClaim();
|
|
63
|
+
this.nonce = "";
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
assertDeadOwner(pid) {
|
|
68
|
+
if (!Number.isSafeInteger(pid) || Number(pid) <= 1) {
|
|
69
|
+
throw new Error("invalid tool job ownership lock");
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
process.kill(Number(pid), 0);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code === "ESRCH")
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error("tool job storage is already owned by a running Host");
|
|
79
|
+
}
|
|
80
|
+
async acquireOwnerClaim() {
|
|
81
|
+
const destination = join(this.root, "owner.claim");
|
|
82
|
+
const temporary = join(this.root, `.owner-${this.nonce}`);
|
|
83
|
+
const marker = `${this.nonce}.json`;
|
|
84
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
85
|
+
try {
|
|
86
|
+
const file = await open(join(temporary, marker), "wx", 0o600);
|
|
87
|
+
try {
|
|
88
|
+
await file.writeFile(JSON.stringify({ pid: process.pid, nonce: this.nonce }));
|
|
89
|
+
await file.sync();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await file.close();
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await rename(temporary, destination);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code ?? ""))
|
|
99
|
+
throw error;
|
|
100
|
+
const directory = await lstat(destination);
|
|
101
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
102
|
+
throw new Error("invalid tool job ownership claim", { cause: error });
|
|
103
|
+
const entries = await readdir(destination);
|
|
104
|
+
if (entries.length > 1 || entries.some((entry) => !/^[a-f0-9-]{36}\.json$/.test(entry))) {
|
|
105
|
+
throw new Error("invalid tool job ownership claim", { cause: error });
|
|
51
106
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
107
|
+
if (entries[0]) {
|
|
108
|
+
const stalePath = join(destination, entries[0]);
|
|
109
|
+
try {
|
|
110
|
+
const current = (await this.read(stalePath, 1024));
|
|
111
|
+
this.assertDeadOwner(current.pid);
|
|
112
|
+
// Delete only the observed owner's unique marker. A delayed
|
|
113
|
+
// reclaimer cannot unlink a new owner's different marker.
|
|
114
|
+
await unlink(stalePath);
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
if (cause.code !== "ENOENT")
|
|
118
|
+
throw cause;
|
|
119
|
+
}
|
|
55
120
|
}
|
|
121
|
+
// Never move the existing claim: a replacement may already own it.
|
|
122
|
+
// Some platforms cannot replace even an empty destination directory.
|
|
123
|
+
// rmdir is safe here: it cannot remove a newly published nonempty claim.
|
|
124
|
+
await rmdir(destination).catch((cause) => {
|
|
125
|
+
if (cause.code !== "ENOENT")
|
|
126
|
+
throw cause;
|
|
127
|
+
});
|
|
128
|
+
await rename(temporary, destination);
|
|
56
129
|
}
|
|
57
|
-
|
|
58
|
-
throw new Error("invalid tool job ownership lock", { cause: error });
|
|
59
|
-
if (alive)
|
|
60
|
-
throw new Error("tool job storage is already owned by a running Host", { cause: error });
|
|
61
|
-
await rm(lockPath);
|
|
62
|
-
lock = await create();
|
|
63
|
-
}
|
|
64
|
-
this.nonce = randomUUID();
|
|
65
|
-
try {
|
|
66
|
-
await lock.writeFile(JSON.stringify({ pid: process.pid, nonce: this.nonce }));
|
|
67
|
-
await lock.sync();
|
|
130
|
+
this.ownerClaimFile = join(destination, marker);
|
|
68
131
|
}
|
|
69
132
|
finally {
|
|
70
|
-
await
|
|
133
|
+
await rm(temporary, { recursive: true, force: true });
|
|
71
134
|
}
|
|
72
135
|
}
|
|
136
|
+
async releaseOwnerClaim() {
|
|
137
|
+
if (!this.ownerClaimFile)
|
|
138
|
+
return;
|
|
139
|
+
await unlink(this.ownerClaimFile).catch((error) => {
|
|
140
|
+
if (error.code !== "ENOENT")
|
|
141
|
+
throw error;
|
|
142
|
+
});
|
|
143
|
+
this.ownerClaimFile = "";
|
|
144
|
+
}
|
|
73
145
|
async directory(id, work = false, create = false) {
|
|
74
146
|
const info = await lstat(this.root);
|
|
75
147
|
if (!info.isDirectory() ||
|
|
@@ -186,12 +258,17 @@ export class ToolJobStorage {
|
|
|
186
258
|
async close() {
|
|
187
259
|
if (!this.nonce)
|
|
188
260
|
return;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
261
|
+
try {
|
|
262
|
+
const path = join(await this.directory(), "owner.lock");
|
|
263
|
+
const lock = (await this.read(path, 1024));
|
|
264
|
+
if (lock.nonce === this.nonce)
|
|
265
|
+
await rm(path);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
await this.releaseOwnerClaim();
|
|
269
|
+
this.nonce = "";
|
|
270
|
+
this.directories.clear();
|
|
271
|
+
}
|
|
195
272
|
}
|
|
196
273
|
async remove(id) {
|
|
197
274
|
const path = await this.directory(id);
|
package/dist/panels/types.d.ts
CHANGED
|
@@ -1,97 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export interface PanelCompatibility {
|
|
3
|
-
supported: boolean;
|
|
4
|
-
reasons: string[];
|
|
5
|
-
}
|
|
6
|
-
export interface PanelGitSource {
|
|
7
|
-
kind: "git";
|
|
8
|
-
url: string;
|
|
9
|
-
/** The original branch/tag, retained separately from the pinned archive commit. */
|
|
10
|
-
ref: string;
|
|
11
|
-
subdir?: string;
|
|
12
|
-
commit: string;
|
|
13
|
-
}
|
|
14
|
-
export interface ManagedPanel extends Omit<PanelAppPreview, "reviewToken" | "alreadyInstalled" | "warnings" | "source"> {
|
|
15
|
-
packageDigest?: string;
|
|
16
|
-
revision: string;
|
|
17
|
-
bound: boolean;
|
|
18
|
-
enabled: boolean;
|
|
19
|
-
globalDisabled: boolean;
|
|
20
|
-
updatable: boolean;
|
|
21
|
-
source: {
|
|
22
|
-
kind: "git" | "local";
|
|
23
|
-
label: string;
|
|
24
|
-
url?: string;
|
|
25
|
-
ref?: string;
|
|
26
|
-
commit?: string;
|
|
27
|
-
subdir?: string;
|
|
28
|
-
};
|
|
29
|
-
compatibility: PanelCompatibility;
|
|
30
|
-
}
|
|
31
|
-
export interface PanelPackageIssue {
|
|
32
|
-
id: string;
|
|
33
|
-
revision: string;
|
|
34
|
-
code: "package_unavailable";
|
|
35
|
-
version?: string;
|
|
36
|
-
packageDigest?: string;
|
|
37
|
-
bound: boolean;
|
|
38
|
-
globalDisabled: boolean;
|
|
39
|
-
}
|
|
40
|
-
export interface PanelSnapshot {
|
|
41
|
-
panels: ManagedPanel[];
|
|
42
|
-
issues?: PanelPackageIssue[];
|
|
43
|
-
workspace: string;
|
|
44
|
-
hasProject: boolean;
|
|
45
|
-
canRestorePackages?: boolean;
|
|
46
|
-
}
|
|
47
|
-
export interface PanelDiscovery {
|
|
48
|
-
panels: GitPanelAppDiscoveryCandidate[];
|
|
49
|
-
issues: GitPanelAppDiscoveryIssue[];
|
|
50
|
-
source: PanelGitSource;
|
|
51
|
-
}
|
|
52
|
-
export interface PanelReview {
|
|
53
|
-
reviewToken: string;
|
|
54
|
-
expiresAt: number;
|
|
55
|
-
kind: "install" | "update";
|
|
56
|
-
preview: Omit<PanelAppPreview, "reviewToken">;
|
|
57
|
-
source: PanelGitSource;
|
|
58
|
-
expectedRevision: string | null;
|
|
59
|
-
compatibility: PanelCompatibility;
|
|
60
|
-
/** The installed package selected for this project, when its manifest is available. */
|
|
61
|
-
installedVersion?: string;
|
|
62
|
-
/** Reviewed permissions of that same installed package; an empty array is a known baseline. */
|
|
63
|
-
installedPermissions?: PanelAppPreview["permissions"];
|
|
64
|
-
}
|
|
65
|
-
/** Trusted native hosts may review local sources without exposing paths to Web routes. */
|
|
66
|
-
export type PanelProjectReview = Omit<PanelReview, "source">;
|
|
67
|
-
export interface PanelOperationContext {
|
|
68
|
-
ownerId: string;
|
|
69
|
-
authorize: () => boolean | Promise<boolean>;
|
|
70
|
-
}
|
|
71
|
-
export interface PanelPackageVersion {
|
|
72
|
-
version: string;
|
|
73
|
-
packageDigest: string;
|
|
74
|
-
permissions: PanelAppPreview["permissions"];
|
|
75
|
-
compatibility: PanelCompatibility;
|
|
76
|
-
}
|
|
77
|
-
export interface PanelPackageHistory {
|
|
78
|
-
appId: string;
|
|
79
|
-
title: PanelAppPreview["title"];
|
|
80
|
-
expectedRevision: string;
|
|
81
|
-
current: {
|
|
82
|
-
version: string;
|
|
83
|
-
packageDigest?: string;
|
|
84
|
-
unavailable?: boolean;
|
|
85
|
-
};
|
|
86
|
-
versions: PanelPackageVersion[];
|
|
87
|
-
unavailablePackages: number;
|
|
88
|
-
}
|
|
89
|
-
export interface PanelPackageRestoreReview extends PanelPackageVersion {
|
|
90
|
-
appId: string;
|
|
91
|
-
title: PanelAppPreview["title"];
|
|
92
|
-
current: PanelPackageHistory["current"];
|
|
93
|
-
addedPermissions: PanelAppPreview["permissions"];
|
|
94
|
-
expectedRevision: string;
|
|
95
|
-
reviewToken: string;
|
|
96
|
-
expiresAt: number;
|
|
97
|
-
}
|
|
1
|
+
export type { PanelCompatibility, PanelGitSource, ManagedPanel, PanelPackageIssue, PanelSnapshot, PanelDiscovery, PanelReview, PanelProjectReview, PanelOperationContext, PanelPackageVersion, PanelPackageHistory, PanelPackageRestoreReview, } from "@cjhyy/code-shell-web/panels";
|
|
@@ -19,6 +19,14 @@ export interface DeviceRelayConnectorOptions {
|
|
|
19
19
|
/** Additional trusted CA for private installations; TLS verification stays enabled. */
|
|
20
20
|
ca?: ConnectionOptions["ca"];
|
|
21
21
|
onState?: (state: DeviceRelayState) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Opt-in: lets the directory mint a one-time phone pairing token for its
|
|
24
|
+
* owner. Omitted, the feature is neither advertised nor accepted.
|
|
25
|
+
*/
|
|
26
|
+
issuePairing?: () => {
|
|
27
|
+
token: string;
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
};
|
|
22
30
|
}
|
|
23
31
|
export interface DeviceRelayConnector {
|
|
24
32
|
/** Idempotent while running. Callers must await close before restarting. */
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { WebSocket } from "ws";
|
|
2
2
|
import { openRelayStream } from "./local-stream.js";
|
|
3
|
-
import { isRelayHostId, isRelayOrigin, isRelayToken, parseRelayControlMessage, RELAY_CONNECT_PATH, RELAY_CONTROL_MAX_BYTES, RELAY_MAX_STREAMS, RELAY_PING_INTERVAL_MS, RELAY_PONG_TIMEOUT_MS, RELAY_SETUP_TIMEOUT_MS, } from "./protocol.js";
|
|
3
|
+
import { isRelayHostId, isRelayOrigin, isRelayToken, parseRelayControlMessage, RELAY_CONNECT_PATH, RELAY_CONTROL_MAX_BYTES, RELAY_FEATURE_PAIRING, RELAY_FEATURES_HEADER, RELAY_MAX_STREAMS, RELAY_PING_INTERVAL_MS, RELAY_PONG_TIMEOUT_MS, RELAY_SETUP_TIMEOUT_MS, } from "./protocol.js";
|
|
4
4
|
export function createDeviceRelayConnector(options) {
|
|
5
5
|
if (!isRelayOrigin(options.relayOrigin) ||
|
|
6
6
|
!isRelayOrigin(options.publicOrigin) ||
|
|
7
7
|
!isRelayHostId(options.hostId) ||
|
|
8
8
|
(!options.getCredential && !isRelayToken(options.credential)) ||
|
|
9
9
|
(options.getCredential !== undefined && typeof options.getCredential !== "function") ||
|
|
10
|
+
(options.issuePairing !== undefined && typeof options.issuePairing !== "function") ||
|
|
10
11
|
!options.localHost ||
|
|
11
12
|
!Number.isInteger(options.localHost.port) ||
|
|
12
13
|
options.localHost.port < 1 ||
|
|
@@ -96,7 +97,10 @@ export function createDeviceRelayConnector(options) {
|
|
|
96
97
|
const url = new URL(RELAY_CONNECT_PATH, config.relayOrigin);
|
|
97
98
|
url.protocol = "wss:";
|
|
98
99
|
const ws = new WebSocket(url, {
|
|
99
|
-
headers: {
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${credential}`,
|
|
102
|
+
...(config.issuePairing ? { [RELAY_FEATURES_HEADER]: RELAY_FEATURE_PAIRING } : {}),
|
|
103
|
+
},
|
|
100
104
|
ca: config.ca,
|
|
101
105
|
followRedirects: false,
|
|
102
106
|
perMessageDeflate: false,
|
|
@@ -180,6 +184,29 @@ export function createDeviceRelayConnector(options) {
|
|
|
180
184
|
state("ready");
|
|
181
185
|
return;
|
|
182
186
|
}
|
|
187
|
+
if (message.type === "pair") {
|
|
188
|
+
if (!config.issuePairing || !leaseId || message.leaseId !== leaseId)
|
|
189
|
+
return ws.terminate();
|
|
190
|
+
let reply = {
|
|
191
|
+
type: "pair-failed",
|
|
192
|
+
v: 1,
|
|
193
|
+
leaseId,
|
|
194
|
+
requestId: message.requestId,
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
const issued = config.issuePairing();
|
|
198
|
+
if (isRelayToken(issued?.token) &&
|
|
199
|
+
Number.isSafeInteger(issued.expiresAt) &&
|
|
200
|
+
issued.expiresAt > Date.now())
|
|
201
|
+
reply = { ...reply, type: "paired", token: issued.token, expiresAt: issued.expiresAt };
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* Refused or unavailable: the directory reports failure to its owner. */
|
|
205
|
+
}
|
|
206
|
+
if (current() && ws.readyState === ws.OPEN)
|
|
207
|
+
ws.send(JSON.stringify(reply));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
183
210
|
if (message.type !== "open" ||
|
|
184
211
|
!leaseId ||
|
|
185
212
|
message.leaseId !== leaseId ||
|