@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.
Files changed (104) hide show
  1. package/README.md +45 -1
  2. package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
  3. package/dist/bin/code-shell-settings-recovery.js +4 -0
  4. package/dist/desktop-web/http-api.js +14 -0
  5. package/dist/environment-identity.d.ts +4 -0
  6. package/dist/environment-identity.js +67 -0
  7. package/dist/hub/auth-store.d.ts +2 -0
  8. package/dist/hub/auth-store.js +7 -0
  9. package/dist/index.auth.d.ts +3 -0
  10. package/dist/index.auth.js +2 -0
  11. package/dist/index.links.d.ts +1 -0
  12. package/dist/index.links.js +1 -0
  13. package/dist/index.panels.d.ts +7 -0
  14. package/dist/index.panels.js +7 -0
  15. package/dist/index.remote-relay.d.ts +5 -0
  16. package/dist/index.remote-relay.js +6 -0
  17. package/dist/index.serve.d.ts +2 -0
  18. package/dist/index.serve.js +2 -0
  19. package/dist/links/http.d.ts +3 -0
  20. package/dist/links/http.js +11 -2
  21. package/dist/links/remote-configuration.d.ts +4 -0
  22. package/dist/links/remote-configuration.js +32 -0
  23. package/dist/links/service.d.ts +6 -1
  24. package/dist/links/service.js +241 -19
  25. package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
  26. package/dist/mobile-remote/mobile-upload-service.js +69 -11
  27. package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
  28. package/dist/mobile-remote/remote-host-manager.js +53 -5
  29. package/dist/panels/agent-task-service.js +9 -2
  30. package/dist/panels/automations.d.ts +49 -0
  31. package/dist/panels/automations.js +134 -0
  32. package/dist/panels/bridge-contract.d.ts +5 -3
  33. package/dist/panels/bridge-contract.js +11 -1
  34. package/dist/panels/directory-bookmarks.d.ts +9 -1
  35. package/dist/panels/directory-bookmarks.js +64 -5
  36. package/dist/panels/execution-gate.d.ts +26 -0
  37. package/dist/panels/execution-gate.js +70 -0
  38. package/dist/panels/http.d.ts +26 -2
  39. package/dist/panels/http.js +7 -1
  40. package/dist/panels/hub-automations.d.ts +31 -0
  41. package/dist/panels/hub-automations.js +352 -0
  42. package/dist/panels/management-http.d.ts +20 -2
  43. package/dist/panels/management-http.js +15 -2
  44. package/dist/panels/management.d.ts +26 -4
  45. package/dist/panels/management.js +382 -39
  46. package/dist/panels/package-mutation.d.ts +8 -0
  47. package/dist/panels/package-mutation.js +23 -0
  48. package/dist/panels/process-service.d.ts +3 -0
  49. package/dist/panels/process-service.js +77 -4
  50. package/dist/panels/resources/http.d.ts +11 -0
  51. package/dist/panels/resources/http.js +74 -0
  52. package/dist/panels/resources/references.d.ts +5 -0
  53. package/dist/panels/resources/references.js +26 -0
  54. package/dist/panels/runtime-services.js +16 -3
  55. package/dist/panels/runtime.d.ts +16 -4
  56. package/dist/panels/runtime.js +522 -64
  57. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  58. package/dist/panels/shared-tool-jobs.js +37 -0
  59. package/dist/panels/storage-store.d.ts +21 -0
  60. package/dist/panels/storage-store.js +43 -0
  61. package/dist/panels/task-cookie-host.d.ts +26 -0
  62. package/dist/panels/task-cookie-host.js +145 -0
  63. package/dist/panels/task-cookies.d.ts +51 -0
  64. package/dist/panels/task-cookies.js +183 -0
  65. package/dist/panels/tool-executor.d.ts +21 -5
  66. package/dist/panels/tool-executor.js +171 -30
  67. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  68. package/dist/panels/tool-jobs-storage.js +34 -0
  69. package/dist/panels/tool-jobs.d.ts +49 -3
  70. package/dist/panels/tool-jobs.js +270 -15
  71. package/dist/panels/types.d.ts +43 -0
  72. package/dist/project-runtime/backup.d.ts +26 -0
  73. package/dist/project-runtime/backup.js +500 -0
  74. package/dist/project-runtime/control-server.d.ts +4 -0
  75. package/dist/project-runtime/control-server.js +19 -1
  76. package/dist/project-runtime/docker-provider.d.ts +4 -0
  77. package/dist/project-runtime/docker-provider.js +16 -1
  78. package/dist/project-runtime/managed-entry.js +4 -0
  79. package/dist/project-runtime/registry.d.ts +2 -0
  80. package/dist/project-runtime/registry.js +15 -0
  81. package/dist/project-runtime/seccomp.d.ts +8 -0
  82. package/dist/project-runtime/seccomp.js +72 -0
  83. package/dist/project-runtime/types.d.ts +2 -0
  84. package/dist/remote-relay/byte-stream.d.ts +9 -0
  85. package/dist/remote-relay/byte-stream.js +71 -0
  86. package/dist/remote-relay/connector.d.ts +27 -0
  87. package/dist/remote-relay/connector.js +197 -0
  88. package/dist/remote-relay/local-stream.d.ts +10 -0
  89. package/dist/remote-relay/local-stream.js +120 -0
  90. package/dist/remote-relay/protocol.d.ts +39 -0
  91. package/dist/remote-relay/protocol.js +64 -0
  92. package/dist/serve/automation-worker.d.ts +16 -0
  93. package/dist/serve/automation-worker.js +126 -0
  94. package/dist/serve/cli.d.ts +3 -0
  95. package/dist/serve/cli.js +17 -0
  96. package/dist/serve/headless-server.d.ts +3 -0
  97. package/dist/serve/headless-server.js +140 -10
  98. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  99. package/dist/serve/settings-recovery-cli.js +128 -0
  100. package/dist/session-http-responses.d.ts +7 -0
  101. package/dist/session-http-responses.js +40 -0
  102. package/dist/worker-bridge-core.d.ts +9 -1
  103. package/dist/worker-bridge-core.js +45 -0
  104. package/package.json +15 -6
@@ -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.type === "link" &&
99
- credential.meta?.linkExecutionRuntime === "local" &&
100
- providers.some((provider) => provider.id === credential.meta?.linkProvider));
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 status = !isCredentialSecretAvailable(credential.secret)
150
+ const remote = isRemoteLinkCredential(credential);
151
+ const status = remote && meta.linkRemoteState !== "connected"
108
152
  ? "unavailable"
109
- : oauth?.state === "expired"
110
- ? "expired"
111
- : oauth?.state === "invalid" || oauth?.state === "missing"
112
- ? "invalid"
113
- : "connected";
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: meta.linkExecutionBackend === "cli"
121
- ? "cli-session"
122
- : meta.linkAuthSource === "browser-oauth"
123
- ? "browser-oauth"
124
- : "manual-token",
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 (record.claimAttempts >= MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS) {
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 Promise.allSettled(records.map(([uploadId, record]) => this.cleanupRecordFiles(uploadId, record)));
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
- await Promise.allSettled([
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 tunnel mode: the public access gate. Ignored in lan mode. */
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;