dsh-vscode-workbench 0.1.7 → 0.1.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.
package/lib/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { access, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
3
- import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
4
4
  import chokidar from "chokidar";
5
5
  import { WebSocket, WebSocketServer } from "ws";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { spawn } from "node:child_process";
8
- import { constants } from "node:fs";
8
+ import { constants, realpathSync } from "node:fs";
9
+ import * as pty from "node-pty";
9
10
  //#region src/wire.ts
10
11
  var WorkbenchError = class extends Error {
11
12
  code;
@@ -67,23 +68,19 @@ function sendError(res, error) {
67
68
  }
68
69
  });
69
70
  }
70
- function trusted(req, allowed) {
71
- const host = req.headers.host;
72
- if (host === void 0 || req.headers["sec-fetch-site"] === "cross-site") return false;
73
- let hostUrl;
74
- try {
75
- hostUrl = new URL(`http://${host}`);
76
- } catch {
77
- return false;
78
- }
79
- if (!(hostUrl.hostname === "localhost" || hostUrl.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostUrl.hostname)) && !allowed.some((candidate) => candidate === host || candidate === hostUrl.hostname)) return false;
80
- const origin = req.headers.origin;
81
- if (origin === void 0) return true;
82
- try {
83
- return new URL(origin).host === hostUrl.host;
84
- } catch {
85
- return false;
86
- }
71
+ /** Reuse Harness authentication and its deployment-aware Host/Origin fence. */
72
+ function authorize(req, res, connection) {
73
+ const rejection = connection.requestRejection(req);
74
+ if (rejection === void 0) return true;
75
+ const code = rejection === 401 ? "unauthorized" : "forbidden";
76
+ send(res, rejection, {
77
+ ok: false,
78
+ error: {
79
+ code,
80
+ message: code
81
+ }
82
+ });
83
+ return false;
87
84
  }
88
85
  //#endregion
89
86
  //#region src/host.ts
@@ -91,8 +88,9 @@ const require = createRequire(import.meta.url);
91
88
  const TEXT_LIMIT = 5242880;
92
89
  const PROCESS_TIMEOUT = 3e4;
93
90
  function cwdFor(ctx, sessionId) {
94
- const cwd = ctx.sessions.get(sessionId)?.header.cwd;
95
- if (cwd === void 0 || cwd === "") return process.cwd();
91
+ const session = ctx.sessions.get(sessionId);
92
+ if (session === void 0) throw new WorkbenchError("session-not-found", "session not found", 404);
93
+ const cwd = ctx.workingDirectory.get(session);
96
94
  if (!isAbsolute(cwd)) throw new WorkbenchError("cwd-invalid", "session working directory is not absolute");
97
95
  return normalize(cwd);
98
96
  }
@@ -231,7 +229,7 @@ async function deleteEntry(cwd, path) {
231
229
  force: false
232
230
  });
233
231
  }
234
- const IMAGE_TYPES = {
232
+ const PREVIEW_TYPES = {
235
233
  ".avif": "image/avif",
236
234
  ".bmp": "image/bmp",
237
235
  ".gif": "image/gif",
@@ -240,18 +238,29 @@ const IMAGE_TYPES = {
240
238
  ".jpg": "image/jpeg",
241
239
  ".png": "image/png",
242
240
  ".svg": "image/svg+xml",
243
- ".webp": "image/webp"
241
+ ".webp": "image/webp",
242
+ ".pdf": "application/pdf",
243
+ ".doc": "application/msword",
244
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
245
+ ".odt": "application/vnd.oasis.opendocument.text"
244
246
  };
245
- /** Resolve an image inside the workspace for protected browser preview. */
246
- async function workspaceImage(cwd, path) {
247
+ /** Resolve a supported preview file inside the workspace. */
248
+ async function workspacePreviewFile(cwd, path) {
247
249
  const resolved = await workspacePath(cwd, path);
248
- const type = IMAGE_TYPES[extname(resolved.absolute).toLowerCase()];
249
- if (type === void 0) throw new WorkbenchError("unsupported-media", "file is not a supported image", 415);
250
+ const type = PREVIEW_TYPES[extname(resolved.absolute).toLowerCase()];
251
+ if (type === void 0) throw new WorkbenchError("unsupported-media", "file is not a supported preview format", 415);
250
252
  return {
251
253
  path: resolved.absolute,
252
254
  type
253
255
  };
254
256
  }
257
+ /** Resolve the PDF.js worker shipped with this plugin's runtime dependency. */
258
+ function pdfWorkerAsset() {
259
+ return {
260
+ path: require.resolve("pdfjs-dist/legacy/build/pdf.worker.min.mjs"),
261
+ type: "text/javascript; charset=utf-8"
262
+ };
263
+ }
255
264
  function run(command, args, cwd, timeout = PROCESS_TIMEOUT, signal) {
256
265
  return new Promise((resolvePromise, reject) => {
257
266
  const child = spawn(command, args, {
@@ -692,13 +701,229 @@ function monacoAsset(raw) {
692
701
  };
693
702
  }
694
703
  //#endregion
704
+ //#region src/terminal.ts
705
+ const SENSITIVE = /(api[_-]?key|token|credential|secret|password|passwd|private[_-]?key|^dsh_.*(?:key|token|secret))/i;
706
+ const MAX_TERMINALS = 8;
707
+ const REPLAY_BYTES = 2097152;
708
+ /** Remove credentials while retaining ordinary interactive-shell variables. */
709
+ function sanitizedTerminalEnv(env) {
710
+ return Object.fromEntries(Object.entries(env).filter(([key, value]) => value !== void 0 && !SENSITIVE.test(key)));
711
+ }
712
+ /** Select the user's interactive shell with platform-specific fallbacks. */
713
+ function defaultTerminalShell(platform = process.platform, env = process.env) {
714
+ if (platform === "win32") return {
715
+ shell: env.COMSPEC?.toLowerCase().includes("powershell") ? env.COMSPEC : "powershell.exe",
716
+ args: ["-NoLogo"]
717
+ };
718
+ return {
719
+ shell: env.SHELL || (platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
720
+ args: ["-l"]
721
+ };
722
+ }
723
+ /** Own interactive terminals by their canonical workspace directory. */
724
+ var TerminalManager = class {
725
+ workspaces = /* @__PURE__ */ new Map();
726
+ workspace(cwd) {
727
+ return realpathSync(cwd);
728
+ }
729
+ list(cwd) {
730
+ return [...this.workspaces.get(this.workspace(cwd))?.values() ?? []].map((handle) => ({ ...handle.info }));
731
+ }
732
+ create(cwd, title) {
733
+ const key = this.workspace(cwd);
734
+ const terminals = this.workspaces.get(key) ?? /* @__PURE__ */ new Map();
735
+ this.workspaces.set(key, terminals);
736
+ if (terminals.size >= MAX_TERMINALS) throw new Error(`terminal limit reached (${MAX_TERMINALS})`);
737
+ const { shell, args } = defaultTerminalShell();
738
+ const child = pty.spawn(shell, args, {
739
+ name: "xterm-256color",
740
+ cols: 80,
741
+ rows: 24,
742
+ cwd: key,
743
+ env: sanitizedTerminalEnv(process.env)
744
+ });
745
+ const info = {
746
+ id: randomUUID(),
747
+ title: title || basename(shell),
748
+ cwd: key,
749
+ running: true,
750
+ sequence: 0
751
+ };
752
+ const handle = {
753
+ info,
754
+ process: child,
755
+ replay: "",
756
+ listeners: /* @__PURE__ */ new Set()
757
+ };
758
+ terminals.set(info.id, handle);
759
+ child.onData((data) => {
760
+ info.sequence++;
761
+ handle.replay += data;
762
+ if (Buffer.byteLength(handle.replay) > REPLAY_BYTES) handle.replay = handle.replay.slice(-2097152);
763
+ for (const listener of handle.listeners) listener({
764
+ sequence: info.sequence,
765
+ data
766
+ });
767
+ });
768
+ child.onExit(({ exitCode }) => {
769
+ info.running = false;
770
+ info.exitCode = exitCode;
771
+ info.sequence++;
772
+ const data = `\r\n[process exited with code ${exitCode}]\r\n`;
773
+ for (const listener of handle.listeners) listener({
774
+ sequence: info.sequence,
775
+ data
776
+ });
777
+ });
778
+ return { ...info };
779
+ }
780
+ get(cwd, id) {
781
+ return this.workspaces.get(this.workspace(cwd))?.get(id);
782
+ }
783
+ close(cwd, id) {
784
+ const key = this.workspace(cwd);
785
+ const terminals = this.workspaces.get(key);
786
+ const handle = terminals?.get(id);
787
+ if (handle === void 0 || terminals === void 0) return;
788
+ terminals.delete(id);
789
+ try {
790
+ handle.process.kill();
791
+ } catch {}
792
+ if (terminals.size === 0) this.workspaces.delete(key);
793
+ }
794
+ rename(cwd, id, title) {
795
+ const handle = this.get(cwd, id);
796
+ if (handle === void 0) throw new Error("terminal not found");
797
+ handle.info.title = title;
798
+ return { ...handle.info };
799
+ }
800
+ dispose() {
801
+ for (const [cwd, terminals] of this.workspaces) for (const id of terminals.keys()) this.close(cwd, id);
802
+ }
803
+ };
804
+ function terminalRequestAllowed(req) {
805
+ const host = req.headers.host;
806
+ if (host === void 0 || req.headers["sec-fetch-site"] === "cross-site") return false;
807
+ let hostname;
808
+ try {
809
+ hostname = new URL(`http://${host}`).hostname;
810
+ } catch {
811
+ return false;
812
+ }
813
+ if (hostname !== "localhost" && hostname !== "[::1]" && !hostname.startsWith("127.")) return false;
814
+ const origin = req.headers.origin;
815
+ try {
816
+ return origin === void 0 || new URL(origin).host === host;
817
+ } catch {
818
+ return false;
819
+ }
820
+ }
821
+ /** Register the private local terminal API and WebSocket transport. */
822
+ function installTerminal(ctx) {
823
+ const manager = new TerminalManager();
824
+ ctx.effect(() => ctx.webServer.register({
825
+ kind: "prefix",
826
+ path: "/dsh-vscode-terminal/api",
827
+ handler: async (req, res) => {
828
+ if (!authorize(req, res, ctx.connection)) return;
829
+ if (!terminalRequestAllowed(req)) return send(res, 403, {
830
+ ok: false,
831
+ error: { message: "forbidden" }
832
+ });
833
+ if (req.method !== "POST") return send(res, 405, {
834
+ ok: false,
835
+ error: { message: "POST required" }
836
+ });
837
+ try {
838
+ const body = await readBody(req);
839
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : "";
840
+ if (sessionId === "") throw new Error("sessionId required");
841
+ const cwd = cwdFor(ctx, sessionId);
842
+ const method = new URL(req.url ?? "/", "http://dsh.local").pathname.slice(25);
843
+ let value;
844
+ if (method === "list") value = manager.list(cwd);
845
+ else if (method === "create") value = manager.create(cwd, typeof body.title === "string" ? body.title : void 0);
846
+ else if (method === "close") {
847
+ manager.close(cwd, typeof body.id === "string" ? body.id : "");
848
+ value = manager.list(cwd);
849
+ } else if (method === "rename") value = manager.rename(cwd, typeof body.id === "string" ? body.id : "", typeof body.title === "string" ? body.title : "");
850
+ else throw new Error("unknown terminal method");
851
+ send(res, 200, {
852
+ ok: true,
853
+ value
854
+ });
855
+ } catch (error) {
856
+ sendError(res, error);
857
+ }
858
+ }
859
+ }), "dsh-vscode-workbench: terminal API");
860
+ const webSocket = new WebSocketServer({ noServer: true });
861
+ ctx.effect(() => ctx.webServer.registerUpgrade({
862
+ path: "/dsh-vscode-terminal/ws",
863
+ handler(req, socket, head) {
864
+ if (ctx.connection.requestRejection(req) !== void 0 || !terminalRequestAllowed(req)) {
865
+ socket.destroy();
866
+ return;
867
+ }
868
+ webSocket.handleUpgrade(req, socket, head, (connection) => {
869
+ const url = new URL(req.url ?? "/", "http://dsh.local");
870
+ const sessionId = url.searchParams.get("sessionId");
871
+ const id = url.searchParams.get("id");
872
+ if (sessionId === null || id === null) {
873
+ connection.close(1008, "sessionId and id required");
874
+ return;
875
+ }
876
+ let handle;
877
+ try {
878
+ handle = manager.get(cwdFor(ctx, sessionId), id);
879
+ } catch {
880
+ connection.close(1008, "session workspace unavailable");
881
+ return;
882
+ }
883
+ if (handle === void 0) {
884
+ connection.close(1008, "terminal not found");
885
+ return;
886
+ }
887
+ if (handle.replay !== "") connection.send(JSON.stringify({
888
+ type: "replay",
889
+ sequence: handle.info.sequence,
890
+ data: handle.replay
891
+ }));
892
+ const listener = (output) => {
893
+ if (connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify({
894
+ type: "output",
895
+ ...output
896
+ }));
897
+ };
898
+ handle.listeners.add(listener);
899
+ connection.on("message", (raw) => {
900
+ const text = raw.toString();
901
+ try {
902
+ const message = JSON.parse(text);
903
+ if (message !== null && typeof message === "object" && "type" in message && message.type === "input" && "data" in message && typeof message.data === "string") handle.process.write(message.data);
904
+ else if (message !== null && typeof message === "object" && "type" in message && message.type === "resize" && "cols" in message && "rows" in message && typeof message.cols === "number" && typeof message.rows === "number") handle.process.resize(Math.max(2, Math.floor(message.cols)), Math.max(2, Math.floor(message.rows)));
905
+ } catch {
906
+ handle.process.write(text);
907
+ }
908
+ });
909
+ connection.on("close", () => handle.listeners.delete(listener));
910
+ });
911
+ }
912
+ }), "dsh-vscode-workbench: terminal WebSocket");
913
+ ctx.effect(() => () => {
914
+ webSocket.close();
915
+ manager.dispose();
916
+ }, "dsh-vscode-workbench: terminal teardown");
917
+ }
918
+ //#endregion
695
919
  //#region src/index.ts
696
920
  /** DSH host plugin for the VS Code workbench. */
697
921
  const name = "dsh-vscode-workbench";
698
922
  const inject = [
699
923
  "webServer",
700
924
  "sessions",
701
- "webRuntime"
925
+ "connection",
926
+ "workingDirectory"
702
927
  ];
703
928
  async function dispatch(ctx, method, body, signal) {
704
929
  const cwd = cwdFor(ctx, stringField(body, "sessionId"));
@@ -720,15 +945,12 @@ async function dispatch(ctx, method, body, signal) {
720
945
  }
721
946
  }
722
947
  function apply(ctx) {
948
+ installTerminal(ctx);
723
949
  ctx.effect(() => ctx.webServer.register({
724
950
  kind: "exact",
725
951
  path: "/dsh-vscode/codicons/codicon.ttf",
726
952
  handler: async (req, res) => {
727
- if (!trusted(req, ctx.webRuntime.trustedHosts)) {
728
- res.writeHead(403);
729
- res.end();
730
- return;
731
- }
953
+ if (!authorize(req, res, ctx.connection)) return;
732
954
  try {
733
955
  const asset = codiconAsset();
734
956
  res.writeHead(200, {
@@ -741,17 +963,28 @@ function apply(ctx) {
741
963
  }
742
964
  }
743
965
  }), "dsh-vscode-workbench: Codicon font");
966
+ ctx.effect(() => ctx.webServer.register({
967
+ kind: "exact",
968
+ path: "/dsh-vscode/pdfjs/worker.mjs",
969
+ handler: async (req, res) => {
970
+ if (!authorize(req, res, ctx.connection)) return;
971
+ try {
972
+ const asset = pdfWorkerAsset();
973
+ res.writeHead(200, {
974
+ "content-type": asset.type,
975
+ "cache-control": "public, max-age=31536000, immutable"
976
+ });
977
+ res.end(await readFile(asset.path));
978
+ } catch (error) {
979
+ sendError(res, error);
980
+ }
981
+ }
982
+ }), "dsh-vscode-workbench: PDF.js worker");
744
983
  ctx.effect(() => ctx.webServer.register({
745
984
  kind: "prefix",
746
985
  path: "/dsh-vscode/api",
747
986
  handler: async (req, res) => {
748
- if (!trusted(req, ctx.webRuntime.trustedHosts)) return send(res, 403, {
749
- ok: false,
750
- error: {
751
- code: "forbidden",
752
- message: "forbidden"
753
- }
754
- });
987
+ if (!authorize(req, res, ctx.connection)) return;
755
988
  if (req.method !== "POST") return send(res, 405, {
756
989
  ok: false,
757
990
  error: {
@@ -775,13 +1008,7 @@ function apply(ctx) {
775
1008
  kind: "exact",
776
1009
  path: "/dsh-vscode/upload",
777
1010
  handler: async (req, res) => {
778
- if (!trusted(req, ctx.webRuntime.trustedHosts)) return send(res, 403, {
779
- ok: false,
780
- error: {
781
- code: "forbidden",
782
- message: "forbidden"
783
- }
784
- });
1011
+ if (!authorize(req, res, ctx.connection)) return;
785
1012
  if (req.method !== "POST") return send(res, 405, {
786
1013
  ok: false,
787
1014
  error: {
@@ -808,17 +1035,13 @@ function apply(ctx) {
808
1035
  kind: "prefix",
809
1036
  path: "/dsh-vscode/file",
810
1037
  handler: async (req, res) => {
811
- if (!trusted(req, ctx.webRuntime.trustedHosts)) {
812
- res.writeHead(403);
813
- res.end();
814
- return;
815
- }
1038
+ if (!authorize(req, res, ctx.connection)) return;
816
1039
  try {
817
1040
  const url = new URL(req.url ?? "/", "http://dsh.local");
818
1041
  const sessionId = url.searchParams.get("sessionId");
819
1042
  const path = url.searchParams.get("path");
820
1043
  if (sessionId === null || path === null) throw new WorkbenchError("bad-request", "sessionId and path are required");
821
- const asset = await workspaceImage(cwdFor(ctx, sessionId), path);
1044
+ const asset = await workspacePreviewFile(cwdFor(ctx, sessionId), path);
822
1045
  res.writeHead(200, {
823
1046
  "content-type": asset.type,
824
1047
  "x-content-type-options": "nosniff",
@@ -829,16 +1052,12 @@ function apply(ctx) {
829
1052
  sendError(res, error);
830
1053
  }
831
1054
  }
832
- }), "dsh-vscode-workbench: image preview");
1055
+ }), "dsh-vscode-workbench: file preview");
833
1056
  ctx.effect(() => ctx.webServer.register({
834
1057
  kind: "prefix",
835
1058
  path: "/dsh-vscode/monaco",
836
1059
  handler: async (req, res) => {
837
- if (!trusted(req, ctx.webRuntime.trustedHosts)) {
838
- res.writeHead(403);
839
- res.end();
840
- return;
841
- }
1060
+ if (!authorize(req, res, ctx.connection)) return;
842
1061
  try {
843
1062
  const asset = monacoAsset(decodeURIComponent(new URL(req.url ?? "/", "http://dsh.local").pathname.slice(19)));
844
1063
  res.writeHead(200, {
@@ -855,7 +1074,7 @@ function apply(ctx) {
855
1074
  ctx.effect(() => ctx.webServer.registerUpgrade({
856
1075
  path: "/dsh-vscode/ws/files",
857
1076
  handler(req, socket, head) {
858
- if (!trusted(req, ctx.webRuntime.trustedHosts)) {
1077
+ if (ctx.connection.requestRejection(req) !== void 0) {
859
1078
  socket.destroy();
860
1079
  return;
861
1080
  }
@@ -865,7 +1084,13 @@ function apply(ctx) {
865
1084
  ws.close(1008, "sessionId required");
866
1085
  return;
867
1086
  }
868
- const workspace = cwdFor(ctx, sessionId);
1087
+ let workspace;
1088
+ try {
1089
+ workspace = cwdFor(ctx, sessionId);
1090
+ } catch {
1091
+ ws.close(1008, "session workspace unavailable");
1092
+ return;
1093
+ }
869
1094
  const watcher = chokidar.watch(workspace, {
870
1095
  ignoreInitial: true,
871
1096
  ignored: /(^|[/\\])(\.git|node_modules|\.next|\.nuxt|\.turbo|\.vite|coverage)([/\\]|$)/,