@lemoncat7/dsh-ssh 1.1.8 → 1.3.0-alpha.9

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 (126) hide show
  1. package/ARCHITECTURE.md +77 -0
  2. package/README.md +39 -18
  3. package/lib/activity-panel.d.ts +17 -0
  4. package/lib/activity-panel.d.ts.map +1 -0
  5. package/lib/activity-panel.js +160 -0
  6. package/lib/activity-panel.js.map +1 -0
  7. package/lib/adaptive-workspace.d.ts.map +1 -1
  8. package/lib/adaptive-workspace.js +19 -4
  9. package/lib/adaptive-workspace.js.map +1 -1
  10. package/lib/api.d.ts +8 -0
  11. package/lib/api.d.ts.map +1 -1
  12. package/lib/api.js +306 -14
  13. package/lib/api.js.map +1 -1
  14. package/lib/client-api.d.ts +86 -0
  15. package/lib/client-api.d.ts.map +1 -1
  16. package/lib/client-api.js +27 -0
  17. package/lib/client-api.js.map +1 -1
  18. package/lib/client.d.ts.map +1 -1
  19. package/lib/client.js +4070 -21049
  20. package/lib/client.js.map +4 -4
  21. package/lib/connector.js +2 -2
  22. package/lib/connector.js.map +1 -1
  23. package/lib/credentials.d.ts +9 -0
  24. package/lib/credentials.d.ts.map +1 -1
  25. package/lib/credentials.js +16 -0
  26. package/lib/credentials.js.map +1 -1
  27. package/lib/domain.d.ts +58 -1
  28. package/lib/domain.d.ts.map +1 -1
  29. package/lib/domain.js +47 -0
  30. package/lib/domain.js.map +1 -1
  31. package/lib/endpoint-session-manager.d.ts +16 -0
  32. package/lib/endpoint-session-manager.d.ts.map +1 -0
  33. package/lib/endpoint-session-manager.js +70 -0
  34. package/lib/endpoint-session-manager.js.map +1 -0
  35. package/lib/file-transfer-manager.d.ts +50 -0
  36. package/lib/file-transfer-manager.d.ts.map +1 -0
  37. package/lib/file-transfer-manager.js +257 -0
  38. package/lib/file-transfer-manager.js.map +1 -0
  39. package/lib/file-transfer-tools.d.ts +6 -0
  40. package/lib/file-transfer-tools.d.ts.map +1 -0
  41. package/lib/file-transfer-tools.js +143 -0
  42. package/lib/file-transfer-tools.js.map +1 -0
  43. package/lib/file-transfer-workspace.d.ts +10 -0
  44. package/lib/file-transfer-workspace.d.ts.map +1 -0
  45. package/lib/file-transfer-workspace.js +299 -0
  46. package/lib/file-transfer-workspace.js.map +1 -0
  47. package/lib/ftp-adapter.d.ts +18 -0
  48. package/lib/ftp-adapter.d.ts.map +1 -0
  49. package/lib/ftp-adapter.js +240 -0
  50. package/lib/ftp-adapter.js.map +1 -0
  51. package/lib/ftp-profile-editor.d.ts +9 -0
  52. package/lib/ftp-profile-editor.d.ts.map +1 -0
  53. package/lib/ftp-profile-editor.js +89 -0
  54. package/lib/ftp-profile-editor.js.map +1 -0
  55. package/lib/index.d.ts.map +1 -1
  56. package/lib/index.js +17 -2
  57. package/lib/index.js.map +1 -1
  58. package/lib/network-dialer.d.ts +12 -0
  59. package/lib/network-dialer.d.ts.map +1 -0
  60. package/lib/network-dialer.js +27 -0
  61. package/lib/network-dialer.js.map +1 -0
  62. package/lib/profile-editor.d.ts +16 -0
  63. package/lib/profile-editor.d.ts.map +1 -0
  64. package/lib/profile-editor.js +143 -0
  65. package/lib/profile-editor.js.map +1 -0
  66. package/lib/project-session-dialog.d.ts +15 -0
  67. package/lib/project-session-dialog.d.ts.map +1 -0
  68. package/lib/project-session-dialog.js +60 -0
  69. package/lib/project-session-dialog.js.map +1 -0
  70. package/lib/proxy.d.ts +3 -3
  71. package/lib/proxy.d.ts.map +1 -1
  72. package/lib/proxy.js +11 -7
  73. package/lib/proxy.js.map +1 -1
  74. package/lib/remote-file-systems.d.ts +9 -0
  75. package/lib/remote-file-systems.d.ts.map +1 -0
  76. package/lib/remote-file-systems.js +23 -0
  77. package/lib/remote-file-systems.js.map +1 -0
  78. package/lib/remote-files.d.ts +51 -0
  79. package/lib/remote-files.d.ts.map +1 -0
  80. package/lib/remote-files.js +36 -0
  81. package/lib/remote-files.js.map +1 -0
  82. package/lib/remote-workspace-tree.d.ts +28 -0
  83. package/lib/remote-workspace-tree.d.ts.map +1 -0
  84. package/lib/remote-workspace-tree.js +117 -0
  85. package/lib/remote-workspace-tree.js.map +1 -0
  86. package/lib/resizable-split.d.ts +10 -0
  87. package/lib/resizable-split.d.ts.map +1 -0
  88. package/lib/resizable-split.js +115 -0
  89. package/lib/resizable-split.js.map +1 -0
  90. package/lib/session-access-channel.d.ts +6 -0
  91. package/lib/session-access-channel.d.ts.map +1 -0
  92. package/lib/session-access-channel.js +10 -0
  93. package/lib/session-access-channel.js.map +1 -0
  94. package/lib/session-access.d.ts +18 -0
  95. package/lib/session-access.d.ts.map +1 -0
  96. package/lib/session-access.js +102 -0
  97. package/lib/session-access.js.map +1 -0
  98. package/lib/sftp-adapter.d.ts +13 -0
  99. package/lib/sftp-adapter.d.ts.map +1 -0
  100. package/lib/sftp-adapter.js +28 -0
  101. package/lib/sftp-adapter.js.map +1 -0
  102. package/lib/sftp-client.d.ts +5 -1
  103. package/lib/sftp-client.d.ts.map +1 -1
  104. package/lib/sftp-client.js +85 -31
  105. package/lib/sftp-client.js.map +1 -1
  106. package/lib/sftp.d.ts +7 -0
  107. package/lib/sftp.d.ts.map +1 -1
  108. package/lib/sftp.js +215 -6
  109. package/lib/sftp.js.map +1 -1
  110. package/lib/store.d.ts +5 -1
  111. package/lib/store.d.ts.map +1 -1
  112. package/lib/store.js +83 -3
  113. package/lib/store.js.map +1 -1
  114. package/lib/terminal.d.ts +2 -0
  115. package/lib/terminal.d.ts.map +1 -1
  116. package/lib/terminal.js +10 -2
  117. package/lib/terminal.js.map +1 -1
  118. package/lib/tools.d.ts +3 -1
  119. package/lib/tools.d.ts.map +1 -1
  120. package/lib/tools.js +4 -1
  121. package/lib/tools.js.map +1 -1
  122. package/lib/ui-components.d.ts +25 -0
  123. package/lib/ui-components.d.ts.map +1 -0
  124. package/lib/ui-components.js +27 -0
  125. package/lib/ui-components.js.map +1 -0
  126. package/package.json +8 -2
package/lib/api.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { pipeline } from 'node:stream/promises';
3
3
  import { HostKeyRequiredError } from './connector.js';
4
- import { normalizeCredentialEntryDraft, normalizeForwardDraft, normalizeProfileDraft, normalizeProxyEntryDraft, normalizeSecrets, } from './domain.js';
4
+ import { normalizeCredentialEntryDraft, normalizeForwardDraft, normalizeFtpProfileDraft, normalizeProfileDraft, normalizeProxyEntryDraft, normalizeRemoteProjectDraft, normalizeSecrets, } from './domain.js';
5
5
  import { setSessionDirectory } from './directory.js';
6
6
  import { streamTerminalOutput } from './terminal-stream.js';
7
7
  import { listSftpDirectory, openSftpFile, readSftpFilePreview, uploadSftpFile } from './sftp.js';
8
8
  import { streamActivityEvents } from './activity-events.js';
9
9
  import { listLocalWorkspace, openLocalWorkspaceFile, readLocalWorkspacePreview } from './local-workspace.js';
10
+ import { connectFtpProfile } from './ftp-adapter.js';
10
11
  const MAX_BODY_BYTES = 1_048_576;
11
12
  const MAX_SFTP_UPLOAD_BYTES = 512 * 1024 * 1024;
12
13
  export function registerSshApi(webServer, prefix, runtime) {
@@ -30,7 +31,138 @@ async function dispatch(req, res, prefix, runtime) {
30
31
  const segments = relative ? relative.split('/').map(decodeURIComponent) : [];
31
32
  const method = req.method ?? 'GET';
32
33
  if (method === 'GET' && segments[0] === 'health')
33
- return sendJson(res, 200, { ok: true, service: 'dsh-ssh', schemaVersion: 2 });
34
+ return sendJson(res, 200, { ok: true, service: 'dsh-ssh', schemaVersion: 5 });
35
+ if (segments[0] === 'file-transfer') {
36
+ if (method === 'GET' && segments[1] === 'endpoints' && segments.length === 2)
37
+ return sendJson(res, 200, runtime.files.endpoints());
38
+ if (method === 'GET' && segments[1] === 'directory' && segments.length === 2) {
39
+ const endpointId = requireText(url.searchParams.get('endpointId'), 'endpointId', 110);
40
+ const paneId = requireText(url.searchParams.get('paneId'), 'paneId', 100);
41
+ return sendJson(res, 200, await runtime.fileSessions.run(paneId, endpointId, session => session.list(url.searchParams.get('path') ?? session.endpoint.initialPath)));
42
+ }
43
+ if (method === 'POST' && segments[1] === 'delete' && segments.length === 2) {
44
+ requireMutationHeader(req);
45
+ const request = parseFileDeleteRequest(await readObject(req));
46
+ await runtime.fileSessions.run(request.paneId, request.endpointId, async (session) => {
47
+ const directory = await session.list(request.directory);
48
+ const available = new Set(directory.entries.map(entry => entry.path));
49
+ for (const path of request.paths)
50
+ if (!available.has(path))
51
+ throw httpError(404, `remote path ${path} is not a direct child of ${directory.path}`);
52
+ for (const path of request.paths)
53
+ await session.remove(path, true);
54
+ });
55
+ return sendJson(res, 204, undefined);
56
+ }
57
+ if (segments[1] === 'jobs') {
58
+ if (method === 'GET' && segments.length === 2)
59
+ return sendJson(res, 200, runtime.transfers.list());
60
+ if (method === 'POST' && segments.length === 2) {
61
+ requireMutationHeader(req);
62
+ const body = await readObject(req);
63
+ return sendJson(res, 202, runtime.transfers.start('ui', parseTransferRequest(body)));
64
+ }
65
+ const jobId = segments[2];
66
+ if (jobId !== undefined && method === 'GET' && segments.length === 3)
67
+ return sendJson(res, 200, runtime.transfers.get(jobId));
68
+ if (jobId !== undefined && method === 'DELETE' && segments.length === 3) {
69
+ requireMutationHeader(req);
70
+ runtime.transfers.cancel(jobId);
71
+ return sendJson(res, 204, undefined);
72
+ }
73
+ }
74
+ }
75
+ if (segments[0] === 'ftp-profiles') {
76
+ if (method === 'GET' && segments.length === 1)
77
+ return sendJson(res, 200, await ftpProfileViews(runtime));
78
+ if (method === 'POST' && segments[1] === 'test-draft' && segments.length === 2) {
79
+ requireMutationHeader(req);
80
+ const body = await readObject(req);
81
+ const draft = normalizeFtpProfileDraft(body.profile);
82
+ const id = body.profileId === undefined ? createId('ftp-preview') : requiredFtpProfile(runtime.store, requireText(body.profileId, 'profileId', 100)).id;
83
+ const previous = body.profileId === undefined ? {} : await runtime.credentials.readFtp(id);
84
+ const secrets = { ...previous, ...normalizeSecrets(body.secrets) };
85
+ if (!secrets.password && draft.credentialId === undefined)
86
+ throw httpError(400, 'FTP password is required');
87
+ const credentialEntry = draft.credentialId === undefined ? undefined : requiredPasswordCredential(runtime.store, draft.credentialId);
88
+ const password = credentialEntry === undefined ? secrets.password : (await runtime.credentials.readEntry(credentialEntry.id)).password;
89
+ if (!password)
90
+ throw httpError(400, 'FTP password credential is not configured');
91
+ const now = Date.now();
92
+ const profile = { ...draft, id, username: credentialEntry?.username ?? draft.username, port: draft.port ?? defaultFtpPort(draft.protocol), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/', connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, createdAt: now, updatedAt: now };
93
+ const session = await connectFtpProfile(profile, password, runtime.dialer);
94
+ try {
95
+ await session.list(profile.initialPath);
96
+ return sendJson(res, 200, { ok: true });
97
+ }
98
+ finally {
99
+ session.close();
100
+ }
101
+ }
102
+ if (method === 'POST' && segments.length === 1) {
103
+ requireMutationHeader(req);
104
+ const body = await readObject(req);
105
+ const draft = normalizeFtpProfileDraft(body.profile);
106
+ if (draft.credentialId !== undefined)
107
+ requiredPasswordCredential(runtime.store, draft.credentialId);
108
+ const secrets = normalizeSecrets(body.secrets);
109
+ if (draft.credentialId === undefined && !secrets.password)
110
+ throw httpError(400, 'FTP password is required');
111
+ const now = Date.now();
112
+ const profile = { ...draft, id: createId('ftp'), port: draft.port ?? defaultFtpPort(draft.protocol), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/', connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, createdAt: now, updatedAt: now };
113
+ if (profile.credentialId === undefined)
114
+ await runtime.credentials.replaceFtp(profile.id, { password: secrets.password });
115
+ try {
116
+ await runtime.store.update(state => { state.ftpProfiles.push(profile); });
117
+ }
118
+ catch (error) {
119
+ if (profile.credentialId === undefined)
120
+ await runtime.credentials.deleteFtp(profile.id).catch(() => { });
121
+ throw error;
122
+ }
123
+ return sendJson(res, 201, await ftpProfileView(runtime, profile));
124
+ }
125
+ const id = segments[1];
126
+ if (id !== undefined && method === 'PUT' && segments.length === 2) {
127
+ requireMutationHeader(req);
128
+ const previous = requiredFtpProfile(runtime.store, id);
129
+ const body = await readObject(req);
130
+ const draft = normalizeFtpProfileDraft(body.profile);
131
+ if (draft.credentialId !== undefined)
132
+ requiredPasswordCredential(runtime.store, draft.credentialId);
133
+ const secrets = normalizeSecrets(body.secrets);
134
+ const next = { ...previous, ...draft, id, port: draft.port ?? defaultFtpPort(draft.protocol), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/', connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, createdAt: previous.createdAt, updatedAt: Date.now() };
135
+ if (draft.group === undefined)
136
+ delete next.group;
137
+ if (draft.tlsServerName === undefined)
138
+ delete next.tlsServerName;
139
+ const oldSecrets = await runtime.credentials.readFtp(id);
140
+ if (next.credentialId === undefined && !secrets.password && !oldSecrets.password)
141
+ throw httpError(400, 'FTP password is required when switching to connection-specific credentials');
142
+ if (next.credentialId === undefined && secrets.password)
143
+ await runtime.credentials.writeFtp(id, { password: secrets.password });
144
+ try {
145
+ await runtime.store.update(state => { state.ftpProfiles = state.ftpProfiles.map(profile => profile.id === id ? next : profile); });
146
+ }
147
+ catch (error) {
148
+ await runtime.credentials.replaceFtp(id, oldSecrets).catch(() => { });
149
+ throw error;
150
+ }
151
+ if (next.credentialId !== undefined && previous.credentialId === undefined)
152
+ await runtime.credentials.deleteFtp(id).catch(() => { });
153
+ return sendJson(res, 200, await ftpProfileView(runtime, next));
154
+ }
155
+ if (id !== undefined && method === 'DELETE' && segments.length === 2) {
156
+ requireMutationHeader(req);
157
+ requiredFtpProfile(runtime.store, id);
158
+ await runtime.store.update(state => {
159
+ state.ftpProfiles = state.ftpProfiles.filter(profile => profile.id !== id);
160
+ state.injections = state.injections.map(injection => ({ ...injection, fileEndpointIds: injection.fileEndpointIds.filter(endpointId => endpointId !== `ftp:${id}`) }));
161
+ });
162
+ await runtime.credentials.deleteFtp(id);
163
+ return sendJson(res, 204, undefined);
164
+ }
165
+ }
34
166
  if (segments[0] === 'vault') {
35
167
  if (method === 'GET' && segments.length === 1)
36
168
  return sendJson(res, 200, await credentialEntryViews(runtime));
@@ -76,9 +208,9 @@ async function dispatch(req, res, prefix, runtime) {
76
208
  if (id !== undefined && method === 'DELETE' && segments.length === 2) {
77
209
  requireMutationHeader(req);
78
210
  requiredCredentialEntry(runtime.store, id);
79
- const references = runtime.store.profiles().filter(profile => profile.credentialId === id);
80
- if (references.length > 0)
81
- throw httpError(409, `credential entry is used by ${references.length} SSH profile(s)`);
211
+ const references = runtime.store.profiles().filter(profile => profile.credentialId === id).length + runtime.store.ftpProfiles().filter(profile => profile.credentialId === id).length;
212
+ if (references > 0)
213
+ throw httpError(409, `credential entry is used by ${references} connection(s)`);
82
214
  await runtime.store.update(state => { state.credentialEntries = state.credentialEntries.filter(entry => entry.id !== id); });
83
215
  await runtime.credentials.deleteEntry(id);
84
216
  return sendJson(res, 204, undefined);
@@ -127,9 +259,9 @@ async function dispatch(req, res, prefix, runtime) {
127
259
  if (id !== undefined && method === 'DELETE' && segments.length === 2) {
128
260
  requireMutationHeader(req);
129
261
  requiredProxyEntry(runtime.store, id);
130
- const references = runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === id);
131
- if (references.length > 0)
132
- throw httpError(409, `proxy entry is used by ${references.length} SSH profile(s)`);
262
+ const references = runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === id).length + runtime.store.ftpProfiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === id).length;
263
+ if (references > 0)
264
+ throw httpError(409, `proxy entry is used by ${references} connection(s)`);
133
265
  await runtime.store.update(state => { state.proxyEntries = state.proxyEntries.filter(entry => entry.id !== id); });
134
266
  await runtime.credentials.deleteProxyEntry(id);
135
267
  return sendJson(res, 204, undefined);
@@ -178,6 +310,49 @@ async function dispatch(req, res, prefix, runtime) {
178
310
  return sendJson(res, 201, await profileView(runtime, profile));
179
311
  }
180
312
  const id = segments[1];
313
+ if (id !== undefined && segments[2] === 'projects') {
314
+ requiredProfile(runtime.store, id);
315
+ if (method === 'GET' && segments.length === 3)
316
+ return sendJson(res, 200, runtime.store.remoteProjects(id));
317
+ if (method === 'POST' && segments.length === 3) {
318
+ requireMutationHeader(req);
319
+ const draft = normalizeRemoteProjectDraft((await readObject(req)).project);
320
+ const now = Date.now();
321
+ const project = { ...draft, id: createId('project'), profileId: id, createdAt: now, updatedAt: now };
322
+ await runtime.store.update(state => { state.remoteProjects.push(project); });
323
+ return sendJson(res, 201, project);
324
+ }
325
+ const projectId = segments[3];
326
+ if (projectId !== undefined && method === 'PUT' && segments.length === 4) {
327
+ requireMutationHeader(req);
328
+ const previous = requiredRemoteProject(runtime.store, projectId, id);
329
+ const draft = normalizeRemoteProjectDraft((await readObject(req)).project);
330
+ const next = { ...previous, ...draft, updatedAt: Date.now() };
331
+ await runtime.store.update(state => {
332
+ state.remoteProjects = state.remoteProjects.map(project => project.id === projectId ? next : project);
333
+ state.injections = state.injections.map(injection => {
334
+ if (injection.workingProjectIds[previous.profileId] !== projectId || injection.workingDirectories[previous.profileId] !== previous.path)
335
+ return injection;
336
+ return { ...injection, workingDirectories: { ...injection.workingDirectories, [previous.profileId]: next.path } };
337
+ });
338
+ });
339
+ return sendJson(res, 200, next);
340
+ }
341
+ if (projectId !== undefined && method === 'DELETE' && segments.length === 4) {
342
+ requireMutationHeader(req);
343
+ requiredRemoteProject(runtime.store, projectId, id);
344
+ await runtime.store.update(state => {
345
+ state.remoteProjects = state.remoteProjects.filter(project => project.id !== projectId);
346
+ state.injections = state.injections.map(injection => {
347
+ if (injection.workingProjectIds[id] !== projectId)
348
+ return injection;
349
+ const { [id]: _removed, ...workingProjectIds } = injection.workingProjectIds;
350
+ return { ...injection, workingProjectIds };
351
+ });
352
+ });
353
+ return sendJson(res, 204, undefined);
354
+ }
355
+ }
181
356
  if (id !== undefined && segments[2] === 'sftp') {
182
357
  requiredProfile(runtime.store, id);
183
358
  const operation = segments[3];
@@ -236,10 +411,12 @@ async function dispatch(req, res, prefix, runtime) {
236
411
  await Promise.all(related.map(rule => runtime.forwards.stop(rule.id).catch(() => { })));
237
412
  await runtime.store.update(state => {
238
413
  state.profiles = state.profiles.filter(profile => profile.id !== id);
414
+ state.remoteProjects = (state.remoteProjects ?? []).filter(project => project.profileId !== id);
239
415
  state.forwardRules = state.forwardRules.filter(rule => rule.profileId !== id);
240
416
  state.injections = state.injections.map(item => {
241
417
  const { [id]: _removed, ...workingDirectories } = item.workingDirectories;
242
- return { ...item, profileIds: item.profileIds.filter(profileId => profileId !== id), workingDirectories };
418
+ const { [id]: _removedProject, ...workingProjectIds } = item.workingProjectIds;
419
+ return { ...item, profileIds: item.profileIds.filter(profileId => profileId !== id), fileEndpointIds: (item.fileEndpointIds ?? []).filter(endpointId => endpointId !== `sftp:${id}`), workingDirectories, workingProjectIds };
243
420
  });
244
421
  });
245
422
  await runtime.credentials.delete(id);
@@ -329,19 +506,33 @@ async function dispatch(req, res, prefix, runtime) {
329
506
  if (sessionId !== undefined && method === 'PUT' && segments.length === 2) {
330
507
  requireMutationHeader(req);
331
508
  const body = await readObject(req);
509
+ const previous = runtime.store.injection(sessionId);
332
510
  const profileIds = parseProfileIds(body.profileIds, runtime.store);
511
+ const fileEndpointIds = body.fileEndpointIds === undefined ? previous?.fileEndpointIds ?? [] : parseFileEndpointIds(body.fileEndpointIds, runtime.files);
512
+ const filePermission = body.filePermission === undefined ? previous?.filePermission ?? 'browse' : body.filePermission === 'browse' ? 'browse' : body.filePermission === 'transfer' ? 'transfer' : undefined;
513
+ if (filePermission === undefined)
514
+ throw httpError(400, 'filePermission must be browse or transfer');
333
515
  const permission = body.permission === 'exec' ? 'exec' : body.permission === 'terminal' ? 'terminal' : undefined;
334
516
  if (permission === undefined)
335
517
  throw httpError(400, 'permission must be exec or terminal');
336
- const previousDirectories = runtime.store.injection(sessionId)?.workingDirectories ?? {};
337
- const workingDirectories = Object.fromEntries(Object.entries(previousDirectories).filter(([profileId]) => profileIds.includes(profileId)));
338
- const injection = { sessionId, profileIds, permission, requireCommandApproval: body.requireCommandApproval !== false, workingDirectories, updatedAt: Date.now() };
518
+ const workingDirectories = parseWorkingDirectories(body.workingDirectories, profileIds);
519
+ const workingProjectIds = parseWorkingProjectIds(body.workingProjectIds, profileIds, runtime.store);
520
+ const injection = {
521
+ sessionId, profileIds, fileEndpointIds, filePermission, requireFileApproval: body.requireFileApproval === undefined ? previous?.requireFileApproval ?? true : body.requireFileApproval !== false,
522
+ permission, requireCommandApproval: body.requireCommandApproval !== false, workingDirectories, workingProjectIds, updatedAt: Date.now(),
523
+ };
339
524
  await runtime.store.update(state => { state.injections = [...state.injections.filter(item => item.sessionId !== sessionId), injection]; });
525
+ const revoked = previous?.profileIds.filter(profileId => !profileIds.includes(profileId)) ?? [];
526
+ if (permission !== 'terminal')
527
+ await runtime.aiTerminals.closeOwner(sessionId);
528
+ else
529
+ await Promise.all(revoked.map(profileId => runtime.aiTerminals.closeProfile(sessionId, profileId)));
340
530
  return sendJson(res, 200, injection);
341
531
  }
342
532
  if (sessionId !== undefined && method === 'DELETE' && segments.length === 2) {
343
533
  requireMutationHeader(req);
344
534
  await runtime.store.update(state => { state.injections = state.injections.filter(item => item.sessionId !== sessionId); });
535
+ await runtime.aiTerminals.closeOwner(sessionId);
345
536
  return sendJson(res, 204, undefined);
346
537
  }
347
538
  }
@@ -554,14 +745,24 @@ async function credentialEntryViews(runtime) {
554
745
  async function credentialEntryView(runtime, entry) {
555
746
  const credential = await runtime.credentials.describeEntry(entry.id);
556
747
  const requiredField = entry.authType === 'password' ? 'password' : 'privateKey';
557
- return { ...entry, credential: { ...credential, configured: credential.fields.includes(requiredField) }, references: runtime.store.profiles().filter(profile => profile.credentialId === entry.id).length };
748
+ return { ...entry, credential: { ...credential, configured: credential.fields.includes(requiredField) }, references: runtime.store.profiles().filter(profile => profile.credentialId === entry.id).length + runtime.store.ftpProfiles().filter(profile => profile.credentialId === entry.id).length };
558
749
  }
559
750
  async function proxyEntryViews(runtime) {
560
751
  return Promise.all(runtime.store.proxyEntries().map(entry => proxyEntryView(runtime, entry)));
561
752
  }
562
753
  async function proxyEntryView(runtime, entry) {
563
754
  const credential = await runtime.credentials.describeProxyEntry(entry.id);
564
- return { ...entry, credential, references: runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === entry.id).length };
755
+ return { ...entry, credential, references: runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === entry.id).length + runtime.store.ftpProfiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === entry.id).length };
756
+ }
757
+ async function ftpProfileViews(runtime) { return Promise.all(runtime.store.ftpProfiles().map(profile => ftpProfileView(runtime, profile))); }
758
+ async function ftpProfileView(runtime, profile) {
759
+ if (profile.credentialId !== undefined) {
760
+ const entry = requiredPasswordCredential(runtime.store, profile.credentialId);
761
+ const credential = await runtime.credentials.describeEntry(entry.id);
762
+ return { ...profile, username: entry.username, credential: { ...credential, configured: credential.fields.includes('password'), source: 'vault', entryId: entry.id, entryName: entry.name } };
763
+ }
764
+ const credential = await runtime.credentials.describeFtp(profile.id);
765
+ return { ...profile, credential: { ...credential, configured: credential.fields.includes('password'), source: 'profile' } };
565
766
  }
566
767
  function requiredProfile(store, id) {
567
768
  const profile = store.profile(id);
@@ -569,6 +770,24 @@ function requiredProfile(store, id) {
569
770
  throw httpError(404, 'SSH profile was not found');
570
771
  return profile;
571
772
  }
773
+ function requiredFtpProfile(store, id) {
774
+ const profile = store.ftpProfile(id);
775
+ if (profile === undefined)
776
+ throw httpError(404, 'FTP profile was not found');
777
+ return profile;
778
+ }
779
+ function requiredPasswordCredential(store, id) {
780
+ const entry = requiredCredentialEntry(store, id);
781
+ if (entry.authType !== 'password')
782
+ throw httpError(400, 'FTP connections require a password credential');
783
+ return entry;
784
+ }
785
+ function requiredRemoteProject(store, id, profileId) {
786
+ const project = store.remoteProject(id);
787
+ if (project === undefined || project.profileId !== profileId)
788
+ throw httpError(404, 'remote project was not found');
789
+ return project;
790
+ }
572
791
  function requiredForward(store, id) {
573
792
  const rule = store.forward(id);
574
793
  if (rule === undefined)
@@ -616,6 +835,79 @@ function parseProfileIds(value, store) {
616
835
  throw httpError(400, 'profileIds must be an array');
617
836
  return [...new Set(value.map(item => requireText(item, 'profileId', 100)))].map(id => requiredProfile(store, id).id);
618
837
  }
838
+ function parseWorkingDirectories(value, profileIds) {
839
+ if (value === undefined)
840
+ return {};
841
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
842
+ throw httpError(400, 'workingDirectories must be an object');
843
+ const result = {};
844
+ for (const [profileId, rawPath] of Object.entries(value)) {
845
+ if (!profileIds.includes(profileId))
846
+ continue;
847
+ const path = requireRawText(rawPath, 'working directory', 4096).trim();
848
+ if (path.length > 0)
849
+ result[profileId] = path;
850
+ }
851
+ return result;
852
+ }
853
+ function parseWorkingProjectIds(value, profileIds, store) {
854
+ if (value === undefined)
855
+ return {};
856
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
857
+ throw httpError(400, 'workingProjectIds must be an object');
858
+ const result = {};
859
+ for (const [profileId, rawProjectId] of Object.entries(value)) {
860
+ if (!profileIds.includes(profileId))
861
+ continue;
862
+ const projectId = requireText(rawProjectId, 'working project id', 100);
863
+ result[profileId] = requiredRemoteProject(store, projectId, profileId).id;
864
+ }
865
+ return result;
866
+ }
867
+ function parseFileEndpointIds(value, files) {
868
+ if (value === undefined)
869
+ return [];
870
+ if (!Array.isArray(value) || value.length > 100)
871
+ throw httpError(400, 'fileEndpointIds must be an array');
872
+ return [...new Set(value.map(item => requireText(item, 'fileEndpointId', 110)))].map(id => {
873
+ if (files.endpoint(id) === undefined)
874
+ throw httpError(404, `file endpoint ${id} was not found`);
875
+ return id;
876
+ });
877
+ }
878
+ function parseTransferRequest(value) {
879
+ const conflict = value.conflictPolicy;
880
+ if (conflict !== 'fail' && conflict !== 'skip' && conflict !== 'overwrite' && conflict !== 'rename')
881
+ throw httpError(400, 'invalid conflictPolicy');
882
+ if (!Array.isArray(value.sourcePaths))
883
+ throw httpError(400, 'sourcePaths must be an array');
884
+ return {
885
+ sourceEndpointId: requireText(value.sourceEndpointId, 'sourceEndpointId', 110),
886
+ sourcePaths: value.sourcePaths.map(path => requireRawText(path, 'source path', 4096)),
887
+ destinationEndpointId: requireText(value.destinationEndpointId, 'destinationEndpointId', 110),
888
+ destinationDirectory: requireRawText(value.destinationDirectory, 'destinationDirectory', 4096),
889
+ conflictPolicy: conflict,
890
+ };
891
+ }
892
+ function parseFileDeleteRequest(value) {
893
+ if (!Array.isArray(value.paths) || value.paths.length < 1 || value.paths.length > 100)
894
+ throw httpError(400, 'paths must contain 1-100 entries');
895
+ const paths = [...new Set(value.paths.map(path => requireRawText(path, 'remote path', 4096)))];
896
+ for (const path of paths)
897
+ assertDeletableRemotePath(path);
898
+ return {
899
+ paneId: requireText(value.paneId, 'paneId', 100),
900
+ endpointId: requireText(value.endpointId, 'endpointId', 110),
901
+ directory: requireRawText(value.directory, 'directory', 4096),
902
+ paths,
903
+ };
904
+ }
905
+ function assertDeletableRemotePath(value) {
906
+ const normalized = value.trim().replaceAll('\\', '/').replace(/\/+$/, '') || '/';
907
+ if (normalized === '/' || normalized === '.' || normalized === '~' || /^[A-Za-z]:$/.test(normalized))
908
+ throw httpError(400, 'refusing to delete a remote filesystem root');
909
+ }
910
+ function defaultFtpPort(protocol) { return protocol === 'ftps-implicit' ? 990 : 21; }
619
911
  function createId(prefix) { return `${prefix}-${randomBytes(10).toString('hex')}`; }
620
912
  function assertSameOrigin(req) {
621
913
  const origin = req.headers.origin;