@cjhyy/code-shell-server 0.9.21 → 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 +9 -1
- package/dist/worker-bridge-core.js +45 -0
- package/package.json +15 -6
package/dist/links/service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import { CredentialStore, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
|
|
2
|
+
import { CredentialStore, beginRemoteLinkAuthorization, completeRemoteLinkAuthorization, revokeRemoteLinkAuthorization, isRemoteLinkCredential, connectCliLink, getCliLinkStatus, isCliLinkProvider, isCredentialSecretAvailable, summarizeOAuthCredentialSecret, validateLocalLinkToken, } from "@cjhyy/code-shell-core";
|
|
3
3
|
import { listDesktopLinkProviders } from "./catalog.js";
|
|
4
4
|
import { LinkDeviceOAuthBroker, isLocalBrowserLinkProvider, } from "./device-oauth.js";
|
|
5
5
|
const messages = {
|
|
@@ -48,10 +48,52 @@ export function createLinkService(options = {}) {
|
|
|
48
48
|
const revisions = new Map();
|
|
49
49
|
const operations = new Set();
|
|
50
50
|
const jobs = new Map();
|
|
51
|
+
const remoteJobs = new Map();
|
|
51
52
|
const revokedOwners = new Set();
|
|
52
53
|
let closed = false;
|
|
53
54
|
let snapshotSignature = "";
|
|
54
55
|
let snapshotRevision = randomUUID();
|
|
56
|
+
let cleanupRunning;
|
|
57
|
+
// One bounded, unref'd sweep per service. Store leases arbitrate other windows/processes.
|
|
58
|
+
const cleanupTimer = setInterval(() => {
|
|
59
|
+
void retryRemoteCleanup();
|
|
60
|
+
}, 30_000);
|
|
61
|
+
cleanupTimer.unref?.();
|
|
62
|
+
const cleanupStartup = setTimeout(() => {
|
|
63
|
+
void retryRemoteCleanup();
|
|
64
|
+
}, 0);
|
|
65
|
+
cleanupStartup.unref?.();
|
|
66
|
+
function retryRemoteCleanup() {
|
|
67
|
+
if (closed)
|
|
68
|
+
return Promise.resolve();
|
|
69
|
+
if (cleanupRunning)
|
|
70
|
+
return cleanupRunning;
|
|
71
|
+
cleanupRunning = (async () => {
|
|
72
|
+
if (!store.remoteLinkRetirementCount())
|
|
73
|
+
return;
|
|
74
|
+
for (let count = 0; count < 8 && !closed; count++) {
|
|
75
|
+
const claim = store.claimRemoteLinkRetirement(now());
|
|
76
|
+
if (!claim)
|
|
77
|
+
break;
|
|
78
|
+
let success = false;
|
|
79
|
+
try {
|
|
80
|
+
await revokeRemoteLinkAuthorization(claim.credential);
|
|
81
|
+
success = true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* Keep private custody and retry with persisted backoff. */
|
|
85
|
+
}
|
|
86
|
+
store.finishRemoteLinkRetirement(claim, success, now());
|
|
87
|
+
}
|
|
88
|
+
})()
|
|
89
|
+
.catch(() => {
|
|
90
|
+
// A locked/unreadable store remains untouched; the next sweep retries. No secrets in logs.
|
|
91
|
+
})
|
|
92
|
+
.finally(() => {
|
|
93
|
+
cleanupRunning = undefined;
|
|
94
|
+
});
|
|
95
|
+
return cleanupRunning;
|
|
96
|
+
}
|
|
55
97
|
function unavailable(context) {
|
|
56
98
|
if (closed)
|
|
57
99
|
throw new LinkServiceError(503, "unavailable");
|
|
@@ -95,33 +137,39 @@ export function createLinkService(options = {}) {
|
|
|
95
137
|
};
|
|
96
138
|
}
|
|
97
139
|
function isLink(credential) {
|
|
98
|
-
return (credential
|
|
99
|
-
credential.
|
|
100
|
-
|
|
140
|
+
return (isRemoteLinkCredential(credential) ||
|
|
141
|
+
(credential.type === "link" &&
|
|
142
|
+
credential.meta?.linkExecutionRuntime === "local" &&
|
|
143
|
+
providers.some((provider) => provider.id === credential.meta?.linkProvider)));
|
|
101
144
|
}
|
|
102
145
|
function masked(credential, scope) {
|
|
103
146
|
const meta = credential.meta;
|
|
104
147
|
const oauth = meta.linkAuthSource === "browser-oauth"
|
|
105
148
|
? summarizeOAuthCredentialSecret(credential.secret)
|
|
106
149
|
: undefined;
|
|
107
|
-
const
|
|
150
|
+
const remote = isRemoteLinkCredential(credential);
|
|
151
|
+
const status = remote && meta.linkRemoteState !== "connected"
|
|
108
152
|
? "unavailable"
|
|
109
|
-
:
|
|
110
|
-
? "
|
|
111
|
-
: oauth?.state === "
|
|
112
|
-
? "
|
|
113
|
-
: "
|
|
153
|
+
: !isCredentialSecretAvailable(credential.secret)
|
|
154
|
+
? "unavailable"
|
|
155
|
+
: oauth?.state === "expired"
|
|
156
|
+
? "expired"
|
|
157
|
+
: oauth?.state === "invalid" || oauth?.state === "missing"
|
|
158
|
+
? "invalid"
|
|
159
|
+
: "connected";
|
|
114
160
|
return {
|
|
115
161
|
id: credential.id,
|
|
116
162
|
providerId: meta.linkProvider,
|
|
117
163
|
methodId: meta.linkConnectionMethod ?? "",
|
|
118
164
|
label: credential.label,
|
|
119
|
-
runtime: "local",
|
|
120
|
-
authSource:
|
|
121
|
-
? "
|
|
122
|
-
: meta.
|
|
123
|
-
? "
|
|
124
|
-
: "
|
|
165
|
+
runtime: remote ? "server" : "local",
|
|
166
|
+
authSource: remote
|
|
167
|
+
? "remote-link"
|
|
168
|
+
: meta.linkExecutionBackend === "cli"
|
|
169
|
+
? "cli-session"
|
|
170
|
+
: meta.linkAuthSource === "browser-oauth"
|
|
171
|
+
? "browser-oauth"
|
|
172
|
+
: "manual-token",
|
|
125
173
|
status,
|
|
126
174
|
scope,
|
|
127
175
|
editable: scope === "user",
|
|
@@ -144,6 +192,7 @@ export function createLinkService(options = {}) {
|
|
|
144
192
|
for (const id of revisions.keys())
|
|
145
193
|
if (!ids.has(id))
|
|
146
194
|
revisions.delete(id);
|
|
195
|
+
const remoteConfig = options.remoteLink?.();
|
|
147
196
|
const result = {
|
|
148
197
|
providers: providers.map((provider) => ({
|
|
149
198
|
...provider,
|
|
@@ -154,7 +203,9 @@ export function createLinkService(options = {}) {
|
|
|
154
203
|
connections: current.credentials
|
|
155
204
|
.filter(isLink)
|
|
156
205
|
.map((credential) => masked(credential, current.projectIds.has(credential.id) ? "project" : "user")),
|
|
157
|
-
capabilities: { token: true, cliBinding: true, deviceAuth: true },
|
|
206
|
+
capabilities: { token: true, cliBinding: true, deviceAuth: true, remoteAuth: !!remoteConfig },
|
|
207
|
+
...(remoteConfig ? { remoteServer: { issuer: remoteConfig.issuer } } : {}),
|
|
208
|
+
remoteCleanupPending: store.remoteLinkRetirementCount(),
|
|
158
209
|
};
|
|
159
210
|
const signature = JSON.stringify(result);
|
|
160
211
|
if (signature !== snapshotSignature) {
|
|
@@ -228,11 +279,11 @@ export function createLinkService(options = {}) {
|
|
|
228
279
|
await authorize(context);
|
|
229
280
|
return result;
|
|
230
281
|
}
|
|
231
|
-
function swap(prepared, next) {
|
|
282
|
+
function swap(prepared, next, retirement) {
|
|
232
283
|
const current = records();
|
|
233
284
|
if (current.projectIds.has(prepared.id))
|
|
234
285
|
throw new LinkServiceError(403, "read_only");
|
|
235
|
-
if (!store.compareAndSwap("user", prepared.id, prepared.expected, next))
|
|
286
|
+
if (!store.compareAndSwap("user", prepared.id, prepared.expected, next, retirement))
|
|
236
287
|
throw new LinkServiceError(409, "conflict");
|
|
237
288
|
}
|
|
238
289
|
async function commit(context, prepared, validation, secret, authSource) {
|
|
@@ -388,6 +439,23 @@ export function createLinkService(options = {}) {
|
|
|
388
439
|
async function disconnect(context, id, expectedRevision) {
|
|
389
440
|
await authorize(context);
|
|
390
441
|
const credential = reviewed(safeId(id), expectedRevision);
|
|
442
|
+
if (isRemoteLinkCredential(credential)) {
|
|
443
|
+
// Disable local use before external I/O. Retain the disabled record if remote revocation
|
|
444
|
+
// fails so the user can retry; never report successful remote revocation on a timeout.
|
|
445
|
+
const disabled = {
|
|
446
|
+
...credential,
|
|
447
|
+
meta: { ...credential.meta, linkRemoteState: "reconnect" },
|
|
448
|
+
};
|
|
449
|
+
await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, disabled), true);
|
|
450
|
+
try {
|
|
451
|
+
await revokeRemoteLinkAuthorization(disabled);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
throw new LinkServiceError(503, "unavailable");
|
|
455
|
+
}
|
|
456
|
+
await change(context, () => swap({ id: credential.id, expected: disabled, input: {} }, null), true);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
391
459
|
// Revocation must work during an active action; the host's credential observer aborts it.
|
|
392
460
|
await change(context, () => swap({ id: credential.id, expected: credential, input: {} }, null), true);
|
|
393
461
|
}
|
|
@@ -523,9 +591,153 @@ export function createLinkService(options = {}) {
|
|
|
523
591
|
throw publicLinkError(error);
|
|
524
592
|
}
|
|
525
593
|
}
|
|
594
|
+
function pruneRemoteJobs() {
|
|
595
|
+
for (const [id, job] of remoteJobs)
|
|
596
|
+
if (job.attempt.expiresAt <= now())
|
|
597
|
+
remoteJobs.delete(id);
|
|
598
|
+
}
|
|
599
|
+
async function startRemoteAuth(context, input) {
|
|
600
|
+
await authorize(context);
|
|
601
|
+
pruneRemoteJobs();
|
|
602
|
+
if (remoteJobs.size >= 32 ||
|
|
603
|
+
[...remoteJobs.values()].filter((job) => job.context.ownerId === context.ownerId && job.public.state === "pending").length >= 2)
|
|
604
|
+
throw new LinkServiceError(409, "busy");
|
|
605
|
+
if (!input || input.providerId !== "github" || input.methodId !== "remote-link")
|
|
606
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
607
|
+
const config = options.remoteLink?.();
|
|
608
|
+
if (!config)
|
|
609
|
+
throw new LinkServiceError(503, "unavailable");
|
|
610
|
+
const id = input.connectionId === undefined ? `link-remote-${randomUUID()}` : safeId(input.connectionId);
|
|
611
|
+
let expected = null;
|
|
612
|
+
if (input.expectedRevision === null) {
|
|
613
|
+
if (records().credentials.some((credential) => credential.id === id))
|
|
614
|
+
throw new LinkServiceError(409, "conflict");
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
expected = reviewed(id, input.expectedRevision);
|
|
618
|
+
if (!isRemoteLinkCredential(expected))
|
|
619
|
+
throw new LinkServiceError(409, "conflict");
|
|
620
|
+
}
|
|
621
|
+
const prepared = { id, expected, input: { ...input, label: string(input.label) } };
|
|
622
|
+
let attempt;
|
|
623
|
+
try {
|
|
624
|
+
attempt = beginRemoteLinkAuthorization(config, now());
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
throw new LinkServiceError(400, "invalid_request");
|
|
628
|
+
}
|
|
629
|
+
const publicJob = {
|
|
630
|
+
id: randomUUID(),
|
|
631
|
+
providerId: "github",
|
|
632
|
+
state: "pending",
|
|
633
|
+
redirect: {
|
|
634
|
+
authorizationUrl: attempt.authorizationUrl,
|
|
635
|
+
expiresAt: new Date(attempt.expiresAt).toISOString(),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
remoteJobs.set(publicJob.id, {
|
|
639
|
+
context,
|
|
640
|
+
attempt,
|
|
641
|
+
prepared,
|
|
642
|
+
public: publicJob,
|
|
643
|
+
completing: false,
|
|
644
|
+
});
|
|
645
|
+
return structuredClone(publicJob);
|
|
646
|
+
}
|
|
647
|
+
async function completeRemoteAuth(context, id, callbackUrl) {
|
|
648
|
+
await authorize(context);
|
|
649
|
+
pruneRemoteJobs();
|
|
650
|
+
const job = remoteJobs.get(safeId(id));
|
|
651
|
+
if (!job || job.context.ownerId !== context.ownerId)
|
|
652
|
+
throw new LinkServiceError(404, "not_found");
|
|
653
|
+
if (job.completing || job.public.state !== "pending")
|
|
654
|
+
throw new LinkServiceError(409, "conflict");
|
|
655
|
+
const originalConfig = JSON.stringify(job.attempt.configuration);
|
|
656
|
+
const check = async () => {
|
|
657
|
+
await authorize(context);
|
|
658
|
+
await authorize(job.context);
|
|
659
|
+
if (remoteJobs.get(id) !== job ||
|
|
660
|
+
job.attempt.expiresAt <= now() ||
|
|
661
|
+
job.public.state !== "pending")
|
|
662
|
+
throw new LinkServiceError(409, "cancelled");
|
|
663
|
+
// Re-normalize exactly as at start; a changed client, callback, issuer or secret invalidates the attempt.
|
|
664
|
+
const config = options.remoteLink?.();
|
|
665
|
+
if (!config ||
|
|
666
|
+
JSON.stringify(beginRemoteLinkAuthorization(config, now()).configuration) !== originalConfig)
|
|
667
|
+
throw new LinkServiceError(409, "conflict");
|
|
668
|
+
};
|
|
669
|
+
job.completing = true;
|
|
670
|
+
let credential;
|
|
671
|
+
let staged;
|
|
672
|
+
try {
|
|
673
|
+
await check();
|
|
674
|
+
credential = await completeRemoteLinkAuthorization(job.attempt, string(callbackUrl, 16_384), job.prepared.id, job.prepared.input.label, {
|
|
675
|
+
now: now(),
|
|
676
|
+
onTokens: (minted) => {
|
|
677
|
+
credential = minted;
|
|
678
|
+
staged = store.stageRemoteLinkRetirement(minted, job.attempt.expiresAt + 60_000);
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
await check();
|
|
682
|
+
const guarded = {
|
|
683
|
+
ownerId: context.ownerId,
|
|
684
|
+
authorize: async () => {
|
|
685
|
+
await check();
|
|
686
|
+
return true;
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
const connection = await change(guarded, () => {
|
|
690
|
+
swap(job.prepared, credential, {
|
|
691
|
+
retireExpected: !!job.prepared.expected,
|
|
692
|
+
adopt: staged,
|
|
693
|
+
now: now(),
|
|
694
|
+
});
|
|
695
|
+
return masked(credential, "user");
|
|
696
|
+
});
|
|
697
|
+
job.public = { id, providerId: "github", state: "connected", connection };
|
|
698
|
+
// Replacing the active record and retaining its previous grant is one atomic write.
|
|
699
|
+
await retryRemoteCleanup();
|
|
700
|
+
if (store.remoteLinkRetirementCount())
|
|
701
|
+
job.public.previousGrantRevocationPending = true;
|
|
702
|
+
await authorize(context);
|
|
703
|
+
return structuredClone(job.public);
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
// If the callback minted a grant but its destination changed, do not leave it usable remotely.
|
|
707
|
+
if (staged) {
|
|
708
|
+
store.readyRemoteLinkRetirement(staged, now());
|
|
709
|
+
await retryRemoteCleanup();
|
|
710
|
+
}
|
|
711
|
+
else if (credential) {
|
|
712
|
+
// Storage itself failed. Best effort only: no durable write can be promised then.
|
|
713
|
+
try {
|
|
714
|
+
await revokeRemoteLinkAuthorization(credential);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
/* Report failure below. */
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
job.public = {
|
|
721
|
+
id,
|
|
722
|
+
providerId: "github",
|
|
723
|
+
state: "failed",
|
|
724
|
+
errorCode: error instanceof LinkServiceError ? error.code : "authorization_failed",
|
|
725
|
+
};
|
|
726
|
+
if (error instanceof LinkServiceError)
|
|
727
|
+
throw error;
|
|
728
|
+
throw new LinkServiceError(422, "authorization_failed");
|
|
729
|
+
}
|
|
730
|
+
}
|
|
526
731
|
async function authorization(context, id) {
|
|
527
732
|
await authorize(context);
|
|
528
733
|
pruneJobs();
|
|
734
|
+
pruneRemoteJobs();
|
|
735
|
+
const remote = remoteJobs.get(safeId(id));
|
|
736
|
+
if (remote) {
|
|
737
|
+
if (remote.context.ownerId !== context.ownerId)
|
|
738
|
+
throw new LinkServiceError(404, "not_found");
|
|
739
|
+
return structuredClone(remote.public);
|
|
740
|
+
}
|
|
529
741
|
const job = jobs.get(safeId(id));
|
|
530
742
|
if (!job || job.ownerId !== context.ownerId)
|
|
531
743
|
throw new LinkServiceError(404, "not_found");
|
|
@@ -533,6 +745,7 @@ export function createLinkService(options = {}) {
|
|
|
533
745
|
}
|
|
534
746
|
async function cancelAuthorization(context, id) {
|
|
535
747
|
await authorization(context, id);
|
|
748
|
+
remoteJobs.delete(id);
|
|
536
749
|
const job = jobs.get(id);
|
|
537
750
|
if (job)
|
|
538
751
|
cancelJob(job);
|
|
@@ -540,6 +753,9 @@ export function createLinkService(options = {}) {
|
|
|
540
753
|
}
|
|
541
754
|
function cancelOwner(ownerId) {
|
|
542
755
|
revokedOwners.add(ownerId);
|
|
756
|
+
for (const [id, job] of remoteJobs)
|
|
757
|
+
if (job.context.ownerId === ownerId)
|
|
758
|
+
remoteJobs.delete(id);
|
|
543
759
|
for (const operation of operations)
|
|
544
760
|
if (operation.ownerId === ownerId) {
|
|
545
761
|
operation.controller.abort();
|
|
@@ -553,6 +769,9 @@ export function createLinkService(options = {}) {
|
|
|
553
769
|
}
|
|
554
770
|
function close() {
|
|
555
771
|
closed = true;
|
|
772
|
+
clearInterval(cleanupTimer);
|
|
773
|
+
clearTimeout(cleanupStartup);
|
|
774
|
+
remoteJobs.clear();
|
|
556
775
|
for (const operation of operations)
|
|
557
776
|
operation.controller.abort();
|
|
558
777
|
operations.clear();
|
|
@@ -570,6 +789,9 @@ export function createLinkService(options = {}) {
|
|
|
570
789
|
rename,
|
|
571
790
|
disconnect,
|
|
572
791
|
startDeviceAuth,
|
|
792
|
+
startRemoteAuth,
|
|
793
|
+
completeRemoteAuth,
|
|
794
|
+
retryRemoteCleanup,
|
|
573
795
|
authorization,
|
|
574
796
|
cancelAuthorization,
|
|
575
797
|
cancelOwner,
|
|
@@ -21,6 +21,7 @@ export declare class MobileUploadService {
|
|
|
21
21
|
private readonly opts;
|
|
22
22
|
private readonly uploads;
|
|
23
23
|
private readonly activeTransfers;
|
|
24
|
+
private readonly revokedDevices;
|
|
24
25
|
private readonly now;
|
|
25
26
|
private readonly uploadTtlMs;
|
|
26
27
|
private readonly startupCleanup;
|
|
@@ -39,6 +40,7 @@ export declare class MobileUploadService {
|
|
|
39
40
|
expiresAt: number;
|
|
40
41
|
};
|
|
41
42
|
acceptPut(uploadId: string, req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
43
|
+
private receivePut;
|
|
42
44
|
/** Atomically move a ready upload into a request-owned lease. */
|
|
43
45
|
claim(deviceId: string, uploadId: string): ClaimedMobileUpload;
|
|
44
46
|
/** Release a failed staging/dispatch attempt for a bounded retry. */
|
|
@@ -46,6 +48,8 @@ export declare class MobileUploadService {
|
|
|
46
48
|
/** Finalize a successful turn; only the active claim owner may consume it. */
|
|
47
49
|
finalize(deviceId: string, uploadId: string, claimId: string): Promise<void>;
|
|
48
50
|
cleanupExpired(): Promise<void>;
|
|
51
|
+
/** Fence new tickets immediately; retain only leases already owned by a request. */
|
|
52
|
+
revokeDevice(deviceId: string): Promise<void>;
|
|
49
53
|
/** Abort all in-flight transfers and invalidate every outstanding ticket. */
|
|
50
54
|
cancelActiveTransfers(): Promise<void>;
|
|
51
55
|
/** Fully await timer shutdown, active aborts, and every spool unlink. */
|
|
@@ -22,16 +22,23 @@ function normalizedContentType(value) {
|
|
|
22
22
|
.toLowerCase();
|
|
23
23
|
}
|
|
24
24
|
function reply(res, status, message) {
|
|
25
|
-
if (res.headersSent || res.writableEnded)
|
|
25
|
+
if (res.destroyed || res.headersSent || res.writableEnded)
|
|
26
26
|
return;
|
|
27
27
|
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
|
|
28
28
|
res.end(message);
|
|
29
29
|
}
|
|
30
|
+
async function awaitCleanup(tasks) {
|
|
31
|
+
const results = await Promise.allSettled(tasks);
|
|
32
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
33
|
+
if (failure?.status === "rejected")
|
|
34
|
+
throw failure.reason;
|
|
35
|
+
}
|
|
30
36
|
/** One-time, device-bound HTTP upload tickets backed by a temporary spool. */
|
|
31
37
|
export class MobileUploadService {
|
|
32
38
|
opts;
|
|
33
39
|
uploads = new Map();
|
|
34
40
|
activeTransfers = new Map();
|
|
41
|
+
revokedDevices = new Map();
|
|
35
42
|
now;
|
|
36
43
|
uploadTtlMs;
|
|
37
44
|
startupCleanup;
|
|
@@ -56,6 +63,8 @@ export class MobileUploadService {
|
|
|
56
63
|
begin(deviceId, metadata) {
|
|
57
64
|
if (this.disposed)
|
|
58
65
|
throw new Error("upload service is shutting down");
|
|
66
|
+
if (this.revokedDevices.has(deviceId))
|
|
67
|
+
throw new Error("upload device was revoked");
|
|
59
68
|
void this.cleanupExpired().catch(() => undefined);
|
|
60
69
|
const mime = normalizedContentType(metadata.mime);
|
|
61
70
|
if (!deviceId.trim())
|
|
@@ -142,6 +151,23 @@ export class MobileUploadService {
|
|
|
142
151
|
// same .part path and reset/delete each other's record. The duplicate now
|
|
143
152
|
// receives the existing 409 above while this first request runs normally.
|
|
144
153
|
record.status = "uploading";
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const abortRequest = () => req.destroy();
|
|
156
|
+
controller.signal.addEventListener("abort", abortRequest, { once: true });
|
|
157
|
+
// Track preparation, transfer, rename and cleanup as one operation. A
|
|
158
|
+
// revocation must not finish while filesystem work can still create a spool.
|
|
159
|
+
const done = this.receivePut(uploadId, record, req, res, controller);
|
|
160
|
+
this.activeTransfers.set(uploadId, { controller, done });
|
|
161
|
+
try {
|
|
162
|
+
await done;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
controller.signal.removeEventListener("abort", abortRequest);
|
|
166
|
+
if (this.activeTransfers.get(uploadId)?.done === done)
|
|
167
|
+
this.activeTransfers.delete(uploadId);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async receivePut(uploadId, record, req, res, controller) {
|
|
145
171
|
try {
|
|
146
172
|
await this.ensurePrivateRoot();
|
|
147
173
|
}
|
|
@@ -152,6 +178,10 @@ export class MobileUploadService {
|
|
|
152
178
|
reply(res, 500, "upload storage is unavailable");
|
|
153
179
|
return;
|
|
154
180
|
}
|
|
181
|
+
if (controller.signal.aborted || this.uploads.get(uploadId) !== record) {
|
|
182
|
+
reply(res, 404, "upload ticket was revoked or expired");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
155
185
|
const partPath = join(this.opts.rootDir, `${uploadId}.part`);
|
|
156
186
|
const finalPath = join(this.opts.rootDir, `${uploadId}.upload`);
|
|
157
187
|
const hash = createHash("sha256");
|
|
@@ -168,7 +198,6 @@ export class MobileUploadService {
|
|
|
168
198
|
callback(null, chunk);
|
|
169
199
|
},
|
|
170
200
|
});
|
|
171
|
-
const controller = new AbortController();
|
|
172
201
|
const deadline = setTimeout(() => {
|
|
173
202
|
deadlineExpired = true;
|
|
174
203
|
controller.abort(new Error("upload ticket expired during transfer"));
|
|
@@ -177,7 +206,6 @@ export class MobileUploadService {
|
|
|
177
206
|
const transfer = pipeline(req, counter, createWriteStream(partPath, { flags: "wx", mode: 0o600 }), {
|
|
178
207
|
signal: controller.signal,
|
|
179
208
|
});
|
|
180
|
-
this.activeTransfers.set(uploadId, { controller, done: transfer });
|
|
181
209
|
try {
|
|
182
210
|
await transfer;
|
|
183
211
|
if (received !== record.size) {
|
|
@@ -223,13 +251,12 @@ export class MobileUploadService {
|
|
|
223
251
|
}
|
|
224
252
|
finally {
|
|
225
253
|
clearTimeout(deadline);
|
|
226
|
-
if (this.activeTransfers.get(uploadId)?.done === transfer) {
|
|
227
|
-
this.activeTransfers.delete(uploadId);
|
|
228
|
-
}
|
|
229
254
|
}
|
|
230
255
|
}
|
|
231
256
|
/** Atomically move a ready upload into a request-owned lease. */
|
|
232
257
|
claim(deviceId, uploadId) {
|
|
258
|
+
if (this.revokedDevices.has(deviceId))
|
|
259
|
+
throw new Error("upload device was revoked");
|
|
233
260
|
const record = this.uploads.get(uploadId);
|
|
234
261
|
if (!record || record.expiresAt <= this.now()) {
|
|
235
262
|
if (record)
|
|
@@ -263,7 +290,8 @@ export class MobileUploadService {
|
|
|
263
290
|
async release(deviceId, uploadId, claimId) {
|
|
264
291
|
const record = this.assertClaimOwner(deviceId, uploadId, claimId);
|
|
265
292
|
delete record.claimId;
|
|
266
|
-
if (
|
|
293
|
+
if (this.revokedDevices.has(deviceId) ||
|
|
294
|
+
record.claimAttempts >= MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS) {
|
|
267
295
|
this.uploads.delete(uploadId);
|
|
268
296
|
if (record.path)
|
|
269
297
|
await rm(record.path, { force: true }).catch(() => undefined);
|
|
@@ -289,6 +317,29 @@ export class MobileUploadService {
|
|
|
289
317
|
await Promise.allSettled(expired);
|
|
290
318
|
await this.cleanupOrphanSpools(now, false);
|
|
291
319
|
}
|
|
320
|
+
/** Fence new tickets immediately; retain only leases already owned by a request. */
|
|
321
|
+
revokeDevice(deviceId) {
|
|
322
|
+
const previous = this.revokedDevices.get(deviceId);
|
|
323
|
+
if (previous)
|
|
324
|
+
return previous;
|
|
325
|
+
const records = [...this.uploads].filter(([, record]) => record.deviceId === deviceId && record.status !== "claimed");
|
|
326
|
+
const transfers = records.flatMap(([uploadId]) => {
|
|
327
|
+
const transfer = this.activeTransfers.get(uploadId);
|
|
328
|
+
return transfer ? [transfer] : [];
|
|
329
|
+
});
|
|
330
|
+
// Queue cleanup after the synchronous fence/deletion/abort below. Keeping
|
|
331
|
+
// the promise also lets stop/dispose await (and report) failed unlinks.
|
|
332
|
+
const cleanup = Promise.resolve().then(async () => {
|
|
333
|
+
await Promise.allSettled(transfers.map((transfer) => transfer.done));
|
|
334
|
+
await awaitCleanup(records.map(([uploadId, record]) => this.cleanupRecordFiles(uploadId, record, true)));
|
|
335
|
+
});
|
|
336
|
+
this.revokedDevices.set(deviceId, cleanup);
|
|
337
|
+
for (const [uploadId] of records)
|
|
338
|
+
this.uploads.delete(uploadId);
|
|
339
|
+
for (const transfer of transfers)
|
|
340
|
+
transfer.controller.abort(new Error("upload device revoked"));
|
|
341
|
+
return cleanup;
|
|
342
|
+
}
|
|
292
343
|
/** Abort all in-flight transfers and invalidate every outstanding ticket. */
|
|
293
344
|
async cancelActiveTransfers() {
|
|
294
345
|
await this.ready();
|
|
@@ -299,7 +350,10 @@ export class MobileUploadService {
|
|
|
299
350
|
transfer.controller.abort(new Error("upload service stopped"));
|
|
300
351
|
}
|
|
301
352
|
await Promise.allSettled([...this.activeTransfers.values()].map((item) => item.done));
|
|
302
|
-
await
|
|
353
|
+
await awaitCleanup([
|
|
354
|
+
...this.revokedDevices.values(),
|
|
355
|
+
...records.map(([uploadId, record]) => this.cleanupRecordFiles(uploadId, record)),
|
|
356
|
+
]);
|
|
303
357
|
}
|
|
304
358
|
/** Fully await timer shutdown, active aborts, and every spool unlink. */
|
|
305
359
|
async dispose() {
|
|
@@ -324,12 +378,16 @@ export class MobileUploadService {
|
|
|
324
378
|
}
|
|
325
379
|
await this.cleanupRecordFiles(uploadId, record);
|
|
326
380
|
}
|
|
327
|
-
async cleanupRecordFiles(uploadId, record) {
|
|
328
|
-
|
|
381
|
+
async cleanupRecordFiles(uploadId, record, strict = false) {
|
|
382
|
+
const removals = [
|
|
329
383
|
rm(join(this.opts.rootDir, `${uploadId}.part`), { force: true }),
|
|
330
384
|
rm(join(this.opts.rootDir, `${uploadId}.upload`), { force: true }),
|
|
331
385
|
...(record?.path ? [rm(record.path, { force: true })] : []),
|
|
332
|
-
]
|
|
386
|
+
];
|
|
387
|
+
if (strict)
|
|
388
|
+
await awaitCleanup(removals);
|
|
389
|
+
else
|
|
390
|
+
await Promise.allSettled(removals);
|
|
333
391
|
}
|
|
334
392
|
async cleanupOrphanSpools(now, removeAll) {
|
|
335
393
|
try {
|
|
@@ -5,6 +5,7 @@ import type { TrustedDeviceStore } from "./trusted-device-store.js";
|
|
|
5
5
|
import type { MobileServerEvent } from "./types.js";
|
|
6
6
|
import type { MobileUploadService } from "./mobile-upload-service.js";
|
|
7
7
|
import type { DesktopWebHttpApi } from "../desktop-web/http-api.js";
|
|
8
|
+
import type { RelayLocalTarget } from "../remote-relay/connector.js";
|
|
8
9
|
/**
|
|
9
10
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
10
11
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -20,17 +21,18 @@ export interface RemoteHostStartOptions {
|
|
|
20
21
|
/**
|
|
21
22
|
* "lan" (default) keeps the existing LAN behaviour unchanged. "tunnel" binds
|
|
22
23
|
* 127.0.0.1 (cloudflared connects to loopback) and inserts a passcode gate in
|
|
23
|
-
* front of every HTTP route and the WS upgrade.
|
|
24
|
+
* front of every HTTP route and the WS upgrade. "relay" also requires an
|
|
25
|
+
* initialized passcode and a stable HTTPS origin before pairing/WS upgrades.
|
|
24
26
|
*/
|
|
25
|
-
mode?: "lan" | "tunnel";
|
|
26
|
-
/** Required in
|
|
27
|
+
mode?: "lan" | "tunnel" | "relay";
|
|
28
|
+
/** Required and initialized in relay mode; public gate for tunnel mode too. */
|
|
27
29
|
passcode?: AccessPasscode;
|
|
28
30
|
}
|
|
29
31
|
export interface RemoteHostStarted {
|
|
30
32
|
host: string;
|
|
31
33
|
port: number;
|
|
32
34
|
url: string;
|
|
33
|
-
mode: "lan" | "tunnel";
|
|
35
|
+
mode: "lan" | "tunnel" | "relay";
|
|
34
36
|
}
|
|
35
37
|
export interface RemoteHostManagerOptions {
|
|
36
38
|
devices: TrustedDeviceStore;
|
|
@@ -46,7 +48,7 @@ export interface RemoteHostManagerOptions {
|
|
|
46
48
|
*/
|
|
47
49
|
mobileDevUrl?: string;
|
|
48
50
|
/** One-time upload tickets. The route remains behind the tunnel passcode gate. */
|
|
49
|
-
uploads?: Pick<MobileUploadService, "acceptPut" | "cancelActiveTransfers">;
|
|
51
|
+
uploads?: Pick<MobileUploadService, "acceptPut" | "cancelActiveTransfers" | "revokeDevice">;
|
|
50
52
|
/** Optional shared Web HTTP facade. Pairing, uploads, and /mobile retain their routes. */
|
|
51
53
|
webApi?: DesktopWebHttpApi;
|
|
52
54
|
}
|
|
@@ -57,6 +59,7 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
57
59
|
private started?;
|
|
58
60
|
private starting?;
|
|
59
61
|
private stopping?;
|
|
62
|
+
private relayLifetime?;
|
|
60
63
|
private pairing;
|
|
61
64
|
/** ws → authenticated device id. A socket absent here is unauthenticated. */
|
|
62
65
|
private authed;
|
|
@@ -91,6 +94,8 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
91
94
|
* address, not the loopback bind). Pass undefined to clear it.
|
|
92
95
|
*/
|
|
93
96
|
setPublicBaseUrl(url?: string): void;
|
|
97
|
+
/** Fixed loopback authority, revoked synchronously when this Host stops. */
|
|
98
|
+
relayTarget(): RelayLocalTarget;
|
|
94
99
|
createPairingUrl(): {
|
|
95
100
|
token: string;
|
|
96
101
|
url: string;
|