termdock 1.4.235 → 1.4.237

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.
@@ -41,25 +41,43 @@ function mapContext(context, map) {
41
41
  * No cookies, browser device keys, or direct HTTP business requests. */
42
42
  export class CollaborationRpc {
43
43
  channel;
44
+ receive;
44
45
  pending = new Map();
45
46
  closed = false;
46
- constructor(channel) {
47
+ constructor(channel, receive, externalReader = false) {
47
48
  this.channel = channel;
48
- void this.read();
49
+ this.receive = receive;
50
+ if (!externalReader)
51
+ void this.read();
49
52
  void channel.done.catch(error => this.close(error));
50
53
  }
54
+ accept(packet) {
55
+ if (packet.type !== 'result' && packet.type !== 'error')
56
+ return false;
57
+ const request = this.pending.get(packet.id);
58
+ if (!request)
59
+ return false;
60
+ clearTimeout(request.timer);
61
+ this.pending.delete(packet.id);
62
+ if (packet.type === 'error')
63
+ request.reject(new Error(String(packet.error)));
64
+ else
65
+ request.resolve(packet);
66
+ return true;
67
+ }
51
68
  async read() {
52
69
  try {
53
70
  for await (const packet of this.channel.read()) {
54
- const request = this.pending.get(packet.id);
55
- if (!request)
71
+ if (this.accept(packet) || packet.type === 'result' || packet.type === 'error')
56
72
  continue;
57
- clearTimeout(request.timer);
58
- this.pending.delete(packet.id);
59
- if (packet.type === 'error')
60
- request.reject(new Error(String(packet.error)));
61
- else
62
- request.resolve(packet);
73
+ try {
74
+ if (!this.receive || !['collaboration-service', 'collaboration-exchange'].includes(packet.type))
75
+ throw new Error('COLLABORATION_OPERATION_UNSUPPORTED');
76
+ this.channel.send({ ...this.receive(packet), type: 'result', id: packet.id });
77
+ }
78
+ catch (error) {
79
+ this.channel.send({ type: 'error', id: packet.id, error: error instanceof Error ? error.message : 'REQUEST_FAILED' });
80
+ }
63
81
  }
64
82
  }
65
83
  catch (error) {
@@ -98,7 +116,7 @@ export class CollaborationRpc {
98
116
  this.pending.clear();
99
117
  }
100
118
  }
101
- export async function connectCollaborationRpc(identity, peer, endpoint) {
119
+ export async function connectCollaborationRpc(identity, peer, endpoint, receive) {
102
120
  validateCollaborationNode(peer);
103
121
  const ca = peer.caFingerprint256 ? await readPinnedCertificateAuthority(peer.origin, peer.caFingerprint256) : undefined;
104
122
  const url = endpoint ?? `${peer.origin.replace(/^https:/, 'wss:')}/api/federation/secure`;
@@ -114,7 +132,21 @@ export async function connectCollaborationRpc(identity, peer, endpoint) {
114
132
  if (relay)
115
133
  await relay.ready;
116
134
  const secured = await secureConnection({ identity, duplex, initiator: true, targetPinnedPeerId: peer.serviceId, signal: AbortSignal.timeout(5000) });
117
- return new CollaborationRpc(new PacketChannel(secured.duplex));
135
+ const rpc = new CollaborationRpc(new PacketChannel(secured.duplex), receive);
136
+ if (receive) {
137
+ try {
138
+ await rpc.request({ type: 'collaboration-connect' });
139
+ }
140
+ catch (error) {
141
+ // Older servers may lack duplex support; ordinary requests remain gated.
142
+ // Missing peer authorization must reconnect after enrollment completes.
143
+ if (!(error instanceof Error) || !['UNKNOWN_PACKET', 'COLLABORATION_UPGRADE_REQUIRED'].includes(error.message)) {
144
+ rpc.close();
145
+ throw error;
146
+ }
147
+ }
148
+ }
149
+ return rpc;
118
150
  }
119
151
  catch (error) {
120
152
  socket.terminate();
@@ -201,10 +233,10 @@ export class CollaborationPeerTransport {
201
233
  }
202
234
  catch { /* First run has no server authorizations. */ }
203
235
  }
204
- notify() {
236
+ notify(serviceId) {
205
237
  for (const b of this.bindings) {
206
238
  const key = `${b.groupId}:${b.peer.serviceId}`;
207
- if (!this.options.store.federationSnapshot().messages.some(m => m.groupId === b.groupId && m.status === 'pending'
239
+ if (b.peer.serviceId === serviceId || !this.options.store.federationSnapshot().messages.some(m => m.groupId === b.groupId && m.status === 'pending'
208
240
  && this.options.store.diagnostic(m.id)?.last_error))
209
241
  this.nextAttempt.delete(key);
210
242
  }
@@ -333,6 +365,9 @@ export class CollaborationPeerTransport {
333
365
  }
334
366
  }
335
367
  async client(b) {
368
+ const reverse = this.options.reverse?.(b.peer.serviceId);
369
+ if (reverse && !reverse.closed)
370
+ return reverse;
336
371
  const key = b.peer.serviceId;
337
372
  let pending = this.clients.get(key);
338
373
  if (pending) {
@@ -108,7 +108,7 @@ export class CollaborationService {
108
108
  throw new Error('PAIRING_EXPIRED_OR_INVALID');
109
109
  validateCollaborationNode(invitation.node);
110
110
  this.setOrigin(origin);
111
- const rpc = await this.options.connect(invitation.node);
111
+ const rpc = await (this.options.pairConnect ?? this.options.connect)(invitation.node);
112
112
  try {
113
113
  await rpc.request({ type: 'collaboration-service', action: 'pair', code: invitation.code, node: this.node() });
114
114
  this.remember(invitation.node);
@@ -139,6 +139,8 @@ export class CollaborationService {
139
139
  const peer = this.document.peers.find(node => node.serviceId === subject);
140
140
  if (!peer)
141
141
  throw new Error('COLLABORATION_PAIRING_REQUIRED');
142
+ if (packet.action === 'duplex')
143
+ return { ok: true };
142
144
  if (packet.action === 'directory')
143
145
  return this.snapshot(peer.origin);
144
146
  if (packet.action === 'group') {
@@ -207,6 +209,9 @@ export class CollaborationService {
207
209
  close() { this.stopped = true; clearInterval(this.timer); for (const rpc of this.clients.values())
208
210
  void rpc.then(client => client.close()).catch(() => { }); this.clients.clear(); }
209
211
  async client(peer) {
212
+ const reverse = this.options.reverse?.(peer.serviceId);
213
+ if (reverse && !reverse.closed)
214
+ return reverse;
210
215
  let pending = this.clients.get(peer.serviceId);
211
216
  if (pending && (await pending).closed) {
212
217
  this.clients.delete(peer.serviceId);
@@ -408,6 +408,8 @@ export function startServer(options = {}) {
408
408
  runtimeMonitor,
409
409
  });
410
410
  const { server, scheme } = createServerForApp(app, options);
411
+ const collaborationChannels = new Map();
412
+ const reverseCollaboration = (id) => [...(collaborationChannels.get(id) ?? [])].find(rpc => !rpc.closed);
411
413
  let collaborationService;
412
414
  let collaborationTransport;
413
415
  let entrySubjectAllowed = (_subjectId) => false;
@@ -447,6 +449,15 @@ export function startServer(options = {}) {
447
449
  const federation = createFederationRuntime(app, path.join(homedir(), '.termdock', 'federation'), {
448
450
  terminal: handleTerminalWebSocket, control: handleControlWebSocket,
449
451
  }, {
452
+ collaborationConnected: (subjectId, rpc) => {
453
+ const channels = collaborationChannels.get(subjectId) ?? new Set();
454
+ channels.add(rpc);
455
+ collaborationChannels.set(subjectId, channels);
456
+ collaborationTransport?.notify(subjectId);
457
+ setImmediate(() => { void collaborationService?.refresh(); });
458
+ return () => { channels.delete(rpc); if (!channels.size)
459
+ collaborationChannels.delete(subjectId); };
460
+ },
450
461
  collaborationDescriptor: () => {
451
462
  if (!collaborationService)
452
463
  throw new Error('COLLABORATION_UNAVAILABLE');
@@ -499,12 +510,25 @@ export function startServer(options = {}) {
499
510
  setPushTargetPeerId(runtime.serviceId);
500
511
  refreshDirectTargets();
501
512
  app.locals.passwordRuntime = runtime;
513
+ const receiveCollaboration = (subjectId, packet) => {
514
+ if (packet.type === 'collaboration-service' && collaborationService)
515
+ return collaborationService.receive(subjectId, packet);
516
+ if (packet.type === 'collaboration-exchange' && collaborationTransport)
517
+ return collaborationTransport.receive(subjectId, packet);
518
+ throw new Error('COLLABORATION_UNAVAILABLE');
519
+ };
520
+ const connectPeer = (peer) => {
521
+ if (['localhost', '127.0.0.1', '[::1]'].includes(new URL(peer.origin).hostname))
522
+ throw new Error('WAITING_FOR_PEER_CONNECTION');
523
+ return connectCollaborationRpc(runtime.identity, peer, undefined, packet => receiveCollaboration(peer.serviceId, packet));
524
+ };
502
525
  collaborationTransport = new CollaborationPeerTransport({
526
+ reverse: reverseCollaboration,
503
527
  file: path.join(homedir(), '.termdock', 'federation', 'collaboration-peers.json'), serviceId: runtime.serviceId,
504
528
  store: collaborationStore, deliver: deliverPeerCollaboration, activity: collaborationLocalActivity,
505
529
  connect: async (peer, via) => {
506
530
  if (!via)
507
- return connectCollaborationRpc(runtime.identity, peer);
531
+ return connectPeer(peer);
508
532
  const entry = await connectCollaborationRpc(runtime.identity, via);
509
533
  try {
510
534
  const ticket = await entry.request({ type: 'route-ticket', serviceId: peer.serviceId });
@@ -513,7 +537,7 @@ export function startServer(options = {}) {
513
537
  const url = new URL('/api/federation/relay', via.origin.replace(/^https:/, 'wss:'));
514
538
  url.searchParams.set('routeToken', ticket.routeToken);
515
539
  // TLS authenticates the entry; Noise pins the final destination.
516
- return await connectCollaborationRpc(runtime.identity, { ...via, serviceId: peer.serviceId }, url.href);
540
+ return await connectCollaborationRpc(runtime.identity, { ...via, serviceId: peer.serviceId }, url.href, packet => receiveCollaboration(peer.serviceId, packet));
517
541
  }
518
542
  finally {
519
543
  entry.close();
@@ -530,7 +554,8 @@ export function startServer(options = {}) {
530
554
  app.locals.collaborationNode = { serviceId: runtime.serviceId, ...(caFingerprint256 ? { caFingerprint256 } : {}) };
531
555
  collaborationService = new CollaborationService({ file: path.join(homedir(), '.termdock', 'federation', 'collaboration-services.json'),
532
556
  store: collaborationStore, transport: collaborationTransport, node: () => app.locals.collaborationNode,
533
- sessions: collaborationDirectorySessions, connect: peer => connectCollaborationRpc(runtime.identity, peer) });
557
+ sessions: collaborationDirectorySessions, reverse: reverseCollaboration, connect: connectPeer,
558
+ pairConnect: peer => connectCollaborationRpc(runtime.identity, peer) });
534
559
  app.locals.collaborationService = collaborationService;
535
560
  collaborationService.start();
536
561
  server.once('close', () => collaborationService?.close());
@@ -1,3 +1,4 @@
1
+ import { CollaborationRpc } from '../agent/collaborationPeerTransport.js';
1
2
  import { PasswordBootstrapServer } from './passwordBootstrap.js';
2
3
  import { DeviceProfiles } from './deviceProfiles.js';
3
4
  import { DeviceNames } from './deviceNames.js';
@@ -122,6 +123,8 @@ export async function createFederationRuntime(app, directory, handlers, options
122
123
  return Array.isArray(inventory.clientSessions) ? inventory.clientSessions : [];
123
124
  }
124
125
  async function accept(socket, context = {}) {
126
+ let collaborationRpc;
127
+ let unregisterCollaboration;
125
128
  const open = (action) => openAccessAllowed(context.allowOpenAccess === true, isAuthEnabled(), action);
126
129
  const allowed = (subjectId, action, sessionId) => open(action) || store.authorize({ subjectId, serviceId, action, sessionId }).allowed;
127
130
  const full = (subjectId) => open() || store.hasFullServiceAccess({ subjectId, serviceId });
@@ -251,7 +254,19 @@ export async function createFederationRuntime(app, directory, handlers, options
251
254
  try {
252
255
  if (packet.id.length > 128)
253
256
  throw new Error('INVALID_ID');
254
- if (packet.type === 'collaboration-descriptor') {
257
+ if (collaborationRpc?.accept(packet))
258
+ continue;
259
+ if (packet.type === 'collaboration-connect') {
260
+ if (!options.collaborationService || !options.collaborationConnected)
261
+ throw new Error('COLLABORATION_UPGRADE_REQUIRED');
262
+ options.collaborationService(subjectId, { type: 'collaboration-service', id: packet.id, action: 'duplex' });
263
+ if (!collaborationRpc) {
264
+ collaborationRpc = new CollaborationRpc(channel, undefined, true);
265
+ unregisterCollaboration = options.collaborationConnected(subjectId, collaborationRpc);
266
+ }
267
+ send({ type: 'result', id: packet.id, ok: true });
268
+ }
269
+ else if (packet.type === 'collaboration-descriptor') {
255
270
  if (!options.collaborationDescriptor)
256
271
  throw new Error('COLLABORATION_UPGRADE_REQUIRED');
257
272
  send({ type: 'result', id: packet.id, node: options.collaborationDescriptor() });
@@ -542,6 +557,8 @@ export async function createFederationRuntime(app, directory, handlers, options
542
557
  socket.close(4003, 'Secure channel closed');
543
558
  }
544
559
  finally {
560
+ unregisterCollaboration?.();
561
+ collaborationRpc?.close();
545
562
  clearInterval(revokeTimer);
546
563
  for (const operation of operations.values())
547
564
  cancelHttp(operation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.235",
3
+ "version": "1.4.237",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.235",
4
+ "version": "1.4.237",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-K4KyYJuVoQDpZsBM8ZX1Rw3MddOxAzVdp/cPm0iGZz0=",
9
- "serverBundleHash": "sha256-N6VdNhxaWAlCuV8LabMbD3Ro9U9s54H5leeRD5KpGwc=",
9
+ "serverBundleHash": "sha256-nRIwkH5+bg0aR08alnrQVcN5Q10duk4wtzWPNtdJAFU=",
10
10
  "clientBundleHash": "sha256-trS+ETMzb9TMz5LAjLxWuftR8zROhre6jdKGsnMbg60=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"