@langify-org/dsh-code-server 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -29,25 +29,26 @@ DSH の Web 版や Desktop 版では **Plugins** ページから追加できま
29
29
 
30
30
  ## 設定
31
31
 
32
- | 設定 | 既定値 | 意味 |
33
- | --------------- | ------------- | -------------------------------------------------------------- |
34
- | `command` | `code-server` | 実行ファイルの名前またはパス。 |
35
- | `userDataDir` | 空 | code-server の `--user-data-dir`。空なら code-server の既定。 |
36
- | `extensionsDir` | 空 | code-server の `--extensions-dir`。空なら code-server の既定。 |
32
+ | 設定 | 既定値 | 意味 |
33
+ | --------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `command` | `code-server` | 実行ファイルの名前またはパス。 |
35
+ | `userDataDir` | 空 | code-server の `--user-data-dir`。空なら code-server の既定。 |
36
+ | `extensionsDir` | 空 | code-server の `--extensions-dir`。空なら code-server の既定。 |
37
+ | `desktopPort` | `41820` | DSH Desktop がエディターを読み込む `127.0.0.1` のポート。固定のままにしてください。信頼済みフォルダーなどエディターの状態はオリジンごとに保存されます。 |
37
38
 
38
39
  既定のままなら、普段使いの code-server と設定・拡張を共有します。同じディレクトリを使う別の code-server を同時に動かさないでください。
39
40
 
40
41
  ## 仕組み
41
42
 
42
- - プラグインは必要になったときに DSH 用の code-server を1つ起動します。待ち受けは本人だけが開ける Unix socket(`0700` のディレクトリ内の `0600`)で、code-server 自身の認証は無効にしています。ネットワークのポートは開きません。
43
- - DSH は自分のオリジンの `/code-server` で code-server を配信します。すべてのリクエストと WebSocket は、先に DSH の認証とオリジンの検査を通ります。そのため code-server には、DSH に届く場所(Tailscale 経由など)からだけ届きます。
44
- - DSH Desktop のページは `dsh-app://app` にあり、code-server はその宛先に WebSocket をつなごうとします。中継は code-server のページに小さなスクリプトを差し込み、WebSocket を Desktop が示す Host の宛先(`__DSH_TRANSPORT__.streamBaseUrl`)に付け替えます。Desktop はそこへの接続に DSH の認証情報を付けます。ブラウザーではこのスクリプトは何もしません。
43
+ - プラグインは必要になったときに DSH 用の code-server を1つ起動します。待ち受けは本人だけが開ける Unix socket(`0700` のディレクトリ内の `0600`)で、code-server 自身の認証は無効にしています。code-server 自体はネットワークのポートを開きません。
44
+ - ブラウザーでは、DSH が自分のオリジンの `/code-server` でエディターを配信します。すべてのリクエストと WebSocket は、先に DSH の認証とオリジンの検査を通ります。そのためエディターには、DSH に届く場所(Tailscale 経由など)からしか届きません。
45
+ - DSH Desktop は DSH を `dsh-app://app` で表示します。code-server はここでは動きません。サーバーの宛先を自分のページのアドレスから作り、このスキームでは webview に必要な Service Worker も使えないためです。また Desktop は、自分以外のオリジンから Host への WebSocket を遮断します。そこで Desktop では、プラグイン自身が `127.0.0.1:<desktopPort>` でエディターを配信します。DSH とは別の、ふつうの http のオリジンです。待ち受けは、Desktop が初めて Code ページを開いたときに始めます。エディターのアドレスのパスには、プラグインの起動ごとに作るランダムなトークンが入ります。code-server はすべてをページからの相対パスで指すので、どのリクエストにもトークンが付きます。トークンを受け取れるのは、DSH が認証したページだけです。さらに、別のホスト名を名乗るリクエストは拒否し、DNS rebinding を防ぎます。
45
46
  - 起動のたびに、同梱の VS Code 拡張 **DSH Code Bridge**(`langify-org.dsh-code-bridge`)を code-server に入れ、古いものを置き換えます。
46
47
  - Code ページごとに専用の接続(binding)を作ります。ページは binding をエディターの URL の断片(サーバーには送られない部分)に入れ、そのエディター画面の拡張がそれを読み取って、別の非公開 socket でプラグインに接続します。同じフォルダーを複数のページで開いていても、ファイルはその画面にだけ開きます。
47
48
 
48
49
  ## セキュリティ
49
50
 
50
- code-server とそのターミナル・拡張は、あなたの権限で、DSH のサンドボックスや承認の外で動きます。さらに DSH と同じオリジンなので、code-server の中で動くコード(拡張やその webview を含む)は、あなたのセッションで DSH を操作することもできます。信頼できる拡張だけを入れてください。[SECURITY.ja.md](../../SECURITY.ja.md) も参照してください。
51
+ code-server とそのターミナル・拡張は、あなたの権限で、DSH のサンドボックスや承認の外で動きます。ブラウザーではエディターが DSH と同じオリジンなので、code-server の中で動くコード(拡張やその webview を含む)は、あなたのセッションで DSH を操作することもできます。信頼できる拡張だけを入れてください。[SECURITY.ja.md](../../SECURITY.ja.md) も参照してください。
51
52
 
52
53
  ## 手動確認
53
54
 
@@ -57,7 +58,8 @@ code-server とそのターミナル・拡張は、あなたの権限で、DSH
57
58
  2. **Workspace files** で選んだテキストファイルが Code ページの該当行で開く。画像は DSH のビューアーのまま開く。
58
59
  3. サインインしていないと `/code-server/` と `POST /api/langify-code-server/create` が 401 を返し、別オリジンからの WebSocket の upgrade は 403 を返す。
59
60
  4. セッションの作業フォルダーが変わると、エディターが開き直すかを尋ねる。未保存のファイルがあれば先に保存を求め、そのあとページが新しいフォルダーでエディターを開き直す。
60
- 5. Code タブを閉じると binding が閉じる。DSH を止めると code-server も止まり、socket が消える。
61
+ 5. DSH Desktop では、エディターが `http://127.0.0.1:41820/…` から読み込まれ、テーマとファイルアイコンが出て、Markdown のプレビューが開く。トークンが無いとポートは 404 を返す。
62
+ 6. Code タブを閉じると binding が閉じる。DSH を止めると code-server も止まり、socket が消える。
61
63
 
62
64
  ## 既知の制約
63
65
 
package/README.md CHANGED
@@ -29,25 +29,26 @@ Open the right sidebar, add a tab, and choose **Code**. The page opens the sessi
29
29
 
30
30
  ## Configuration
31
31
 
32
- | Setting | Default | Meaning |
33
- | --------------- | ------------- | ------------------------------------------------------------------- |
34
- | `command` | `code-server` | Executable name or path. |
35
- | `userDataDir` | empty | code-server's `--user-data-dir`. Empty uses code-server's default. |
36
- | `extensionsDir` | empty | code-server's `--extensions-dir`. Empty uses code-server's default. |
32
+ | Setting | Default | Meaning |
33
+ | --------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `command` | `code-server` | Executable name or path. |
35
+ | `userDataDir` | empty | code-server's `--user-data-dir`. Empty uses code-server's default. |
36
+ | `extensionsDir` | empty | code-server's `--extensions-dir`. Empty uses code-server's default. |
37
+ | `desktopPort` | `41820` | Port on `127.0.0.1` where DSH Desktop loads the editor. Keep it fixed: the editor keeps its state, such as trusted folders, per origin. |
37
38
 
38
39
  With the defaults, the editor shares settings and extensions with your own code-server. Do not run another code-server on the same directories at the same time.
39
40
 
40
41
  ## How it works
41
42
 
42
- - The plugin starts one code-server for DSH on demand, listening on a Unix socket that only you can open (mode `0600` in a `0700` directory), with code-server's own authentication off. It never opens a network port.
43
- - DSH serves code-server under `/code-server` on its own origin. Every request and WebSocket passes DSH's authentication and origin check first, so code-server is reachable wherever DSH is, for example over Tailscale, and nowhere else.
44
- - In DSH Desktop the page lives at `dsh-app://app`, and code-server would dial its WebSockets at that host. The relay adds a small script to code-server's page that sends them to the address Desktop gives its Host (`__DSH_TRANSPORT__.streamBaseUrl`), where Desktop adds DSH's credentials. In a browser the script changes nothing.
43
+ - The plugin starts one code-server for DSH on demand, listening on a Unix socket that only you can open (mode `0600` in a `0700` directory), with code-server's own authentication off. code-server itself never opens a network port.
44
+ - In a browser, DSH serves the editor under `/code-server` on its own origin. Every request and WebSocket passes DSH's authentication and origin check first, so the editor is reachable wherever DSH is, for example over Tailscale, and nowhere else.
45
+ - DSH Desktop shows DSH at `dsh-app://app`, where code-server cannot run: it builds its server's addresses from its own page's, and that scheme allows no service workers, which webviews need. Desktop also refuses WebSockets to its Host from any other origin. So in Desktop the plugin serves the editor itself, on `127.0.0.1:<desktopPort>`, a plain http origin apart from DSH's. It starts listening when Desktop first opens the Code page. The editor's address carries in its path a random token, made anew whenever the plugin starts; code-server addresses everything relative to its page, so every request carries it. Only pages DSH has authenticated receive the token. The port also refuses requests that name another host, which stops DNS rebinding.
45
46
  - Before each start, the plugin installs its bundled VS Code extension, **DSH Code Bridge** (`langify-org.dsh-code-bridge`), into code-server, replacing any older copy.
46
47
  - Each Code page gets its own connection ("binding"). The page puts the binding in the editor's URL fragment, which never reaches a server; the extension in that editor window reads it back and connects to the plugin over another private socket. Files open in that window only, even when several pages show the same folder.
47
48
 
48
49
  ## Security
49
50
 
50
- code-server runs as you, and so do its terminal and extensions, outside DSH's sandbox and approvals. Because it shares DSH's origin, code running in code-server, including extensions and their webviews, can also use DSH with your session. Install only extensions you trust. See also [SECURITY.md](../../SECURITY.md).
51
+ code-server runs as you, and so do its terminal and extensions, outside DSH's sandbox and approvals. In a browser the editor shares DSH's origin, so code running in code-server, including extensions and their webviews, can also use DSH with your session. In DSH Desktop the editor has its own origin, and other programs on this computer can reach its port but not without the token. Install only extensions you trust. See also [SECURITY.md](../../SECURITY.md).
51
52
 
52
53
  ## Manual checks
53
54
 
@@ -57,7 +58,8 @@ These need a browser and a real code-server, so they are not part of `pnpm check
57
58
  2. A text file chosen in **Workspace files** opens in the Code page at its line; an image keeps DSH's viewer.
58
59
  3. Without signing in, `/code-server/` and `POST /api/langify-code-server/create` answer 401, and a WebSocket upgrade from another origin answers 403.
59
60
  4. After the session's working folder changes, the editor offers to reopen; with an unsaved file it asks to save first, then the page reloads the editor on the new folder.
60
- 5. Closing the Code tab closes its binding; stopping DSH stops code-server and removes its sockets.
61
+ 5. In DSH Desktop, the editor loads from `http://127.0.0.1:41820/…` with your theme and file icons, and a Markdown preview opens. The port answers 404 without the token.
62
+ 6. Closing the Code tab closes its binding; stopping DSH stops code-server and removes its sockets.
61
63
 
62
64
  ## Known limitations
63
65
 
package/README.zh.md CHANGED
@@ -29,25 +29,26 @@ dsh plugin --profile <profile> add @langify-org/dsh-code-server
29
29
 
30
30
  ## 配置
31
31
 
32
- | 设置 | 默认值 | 含义 |
33
- | --------------- | ------------- | ------------------------------------------------------- |
34
- | `command` | `code-server` | 可执行文件的名称或路径。 |
35
- | `userDataDir` | 空 | code-server 的 `--user-data-dir`。为空时使用其默认值。 |
36
- | `extensionsDir` | 空 | code-server 的 `--extensions-dir`。为空时使用其默认值。 |
32
+ | 设置 | 默认值 | 含义 |
33
+ | --------------- | ------------- | ----------------------------------------------------------------------------------------------------- |
34
+ | `command` | `code-server` | 可执行文件的名称或路径。 |
35
+ | `userDataDir` | 空 | code-server 的 `--user-data-dir`。为空时使用其默认值。 |
36
+ | `extensionsDir` | 空 | code-server 的 `--extensions-dir`。为空时使用其默认值。 |
37
+ | `desktopPort` | `41820` | DSH Desktop 加载编辑器所用的 `127.0.0.1` 端口。请保持固定:编辑器的状态(如受信任的文件夹)按源保存。 |
37
38
 
38
39
  使用默认值时,编辑器会与你平时使用的 code-server 共享设置和扩展。请不要同时运行使用相同目录的另一个 code-server。
39
40
 
40
41
  ## 工作原理
41
42
 
42
- - 插件在需要时为 DSH 启动一个 code-server,监听只有你能打开的 Unix 套接字(位于 `0700` 目录中,权限为 `0600`),并关闭 code-server 自身的认证。它不会打开任何网络端口。
43
- - DSH 在自己的源下通过 `/code-server` 提供 code-server。每个请求和 WebSocket 都会先经过 DSH 的认证和来源检查,因此只有能访问 DSH 的地方(例如通过 Tailscale)才能访问 code-server。
44
- - 在 DSH Desktop 中,页面位于 `dsh-app://app`,code-server 会向该地址建立 WebSocket。中继会向 code-server 的页面注入一段小脚本,把 WebSocket 改为发往 Desktop 提供的 Host 地址(`__DSH_TRANSPORT__.streamBaseUrl`),Desktop 会为发往该地址的连接附加 DSH 的凭据。在浏览器中,该脚本不做任何改动。
43
+ - 插件在需要时为 DSH 启动一个 code-server,监听只有你能打开的 Unix 套接字(位于 `0700` 目录中,权限为 `0600`),并关闭 code-server 自身的认证。code-server 本身不会打开任何网络端口。
44
+ - 在浏览器中,DSH 在自己的源下通过 `/code-server` 提供编辑器。每个请求和 WebSocket 都会先经过 DSH 的认证和来源检查,因此只有能访问 DSH 的地方(例如通过 Tailscale)才能访问编辑器。
45
+ - DSH Desktop 在 `dsh-app://app` 显示 DSH,code-server 无法在那里运行:它根据自身页面的地址构造服务器地址,而且该协议不支持 webview 所需的 Service Worker。Desktop 还会拒绝来自其他源、发往其 Host 的 WebSocket。因此在 Desktop 中,插件自己在 `127.0.0.1:<desktopPort>` 提供编辑器,这是一个独立于 DSH 的普通 http 源。Desktop 第一次打开 Code 页面时才开始监听。编辑器地址的路径中带有一个随机令牌,插件每次启动时重新生成;code-server 的所有地址都相对于其页面,因此每个请求都会带上它。只有经过 DSH 认证的页面才能拿到该令牌。该端口还会拒绝声明为其他主机名的请求,以防 DNS rebinding。
45
46
  - 每次启动前,插件都会把内置的 VS Code 扩展 **DSH Code Bridge**(`langify-org.dsh-code-bridge`)安装到 code-server 中,替换旧版本。
46
47
  - 每个 Code 页面都有自己的连接(binding)。页面把 binding 放在编辑器 URL 的片段中(这一部分不会发送到服务器),该编辑器窗口中的扩展读取它,并通过另一个私有套接字连接到插件。即使多个页面打开了同一个文件夹,文件也只会在对应的窗口中打开。
47
48
 
48
49
  ## 安全
49
50
 
50
- code-server 及其终端和扩展以你的身份运行,不受 DSH 沙箱和审批的约束。由于它与 DSH 同源,在 code-server 中运行的代码(包括扩展及其 webview)也能以你的会话使用 DSH。请只安装你信任的扩展。另请参阅 [SECURITY.zh.md](../../SECURITY.zh.md)。
51
+ code-server 及其终端和扩展以你的身份运行,不受 DSH 沙箱和审批的约束。在浏览器中,编辑器与 DSH 同源,因此在 code-server 中运行的代码(包括扩展及其 webview)也能以你的会话使用 DSH。请只安装你信任的扩展。另请参阅 [SECURITY.zh.md](../../SECURITY.zh.md)。
51
52
 
52
53
  ## 手动检查
53
54
 
@@ -57,7 +58,8 @@ code-server 及其终端和扩展以你的身份运行,不受 DSH 沙箱和审
57
58
  2. 在 **Workspace files** 中选择的文本文件会在 Code 页面的对应行打开;图片仍使用 DSH 的查看器。
58
59
  3. 未登录时,`/code-server/` 和 `POST /api/langify-code-server/create` 返回 401,来自其他源的 WebSocket upgrade 返回 403。
59
60
  4. 会话的工作文件夹变化后,编辑器会询问是否重新打开;如有未保存的文件,会先要求保存,然后页面在新文件夹中重新打开编辑器。
60
- 5. 关闭 Code 标签页会关闭其 binding;停止 DSH 会停止 code-server 并删除其套接字。
61
+ 5. 在 DSH Desktop 中,编辑器从 `http://127.0.0.1:41820/…` 加载,显示你的主题和文件图标,Markdown 预览可以打开;没有令牌时该端口返回 404。
62
+ 6. 关闭 Code 标签页会关闭其 binding;停止 DSH 会停止 code-server 并删除其套接字。
61
63
 
62
64
  ## 已知限制
63
65
 
package/dist/client.cjs CHANGED
@@ -35,6 +35,7 @@ window.__ModuleLoader__.load({
35
35
  "error.different_host": "This session runs on another file system, which Code cannot open.",
36
36
  "error.code_server_not_found": "code-server was not found. Install it, or set its command in the plugin settings.",
37
37
  "error.code_server_failed": "code-server failed to start: {message}",
38
+ "error.desktop_port_in_use": "The editor's port for DSH Desktop is taken: {message}. Choose another one with the plugin's desktopPort setting.",
38
39
  "error.unknown_binding": "This editor connection is gone. Reconnect.",
39
40
  "error.not_ready": "The editor is still starting.",
40
41
  "error.binding_disconnected": "The editor window disconnected. Reconnect.",
@@ -79,6 +80,7 @@ window.__ModuleLoader__.load({
79
80
  "error.different_host": "此会话运行在其他文件系统上,Code 无法打开。",
80
81
  "error.code_server_not_found": "未找到 code-server。请安装它,或在插件设置中指定其命令。",
81
82
  "error.code_server_failed": "code-server 启动失败:{message}",
83
+ "error.desktop_port_in_use": "供 DSH Desktop 使用的编辑器端口已被占用:{message}。请在插件设置 desktopPort 中改用其他端口。",
82
84
  "error.unknown_binding": "与此编辑器的连接已不存在。请重新连接。",
83
85
  "error.not_ready": "编辑器仍在启动中。",
84
86
  "error.binding_disconnected": "编辑器窗口已断开。请重新连接。",
@@ -123,6 +125,7 @@ window.__ModuleLoader__.load({
123
125
  "error.different_host": "このセッションは別のファイルシステムで動いているため、Code では開けません。",
124
126
  "error.code_server_not_found": "code-server が見つかりません。インストールするか、プラグインの設定でコマンドを指定してください。",
125
127
  "error.code_server_failed": "code-server を起動できませんでした: {message}",
128
+ "error.desktop_port_in_use": "DSH Desktop 用のエディターのポートが使われています: {message}。プラグインの設定 desktopPort で別の番号にしてください。",
126
129
  "error.unknown_binding": "このエディターとの接続はもうありません。再接続してください。",
127
130
  "error.not_ready": "エディターはまだ起動中です。",
128
131
  "error.binding_disconnected": "エディターの画面が切断されました。再接続してください。",
@@ -148,6 +151,12 @@ window.__ModuleLoader__.load({
148
151
  /** DSH's shared RPC channel, and the prefix of the endpoints this plugin owns on it. */
149
152
  const RPC_CHANNEL = "/api";
150
153
  const RPC_PREFIX = "langify-code-server/";
154
+ /**
155
+ * Where the page loads the editor from. `dsh` serves it under DSH's own origin;
156
+ * `desktop` is the plugin's own address on 127.0.0.1, for DSH Desktop, whose
157
+ * page is not on http(s) (see host/ingress/).
158
+ */
159
+ const INGRESSES = ["dsh", "desktop"];
151
160
  const id = zod.z.string().min(1).max(256).refine((value) => !value.includes("\0"));
152
161
  const position = zod.z.int().min(1).max(1e6);
153
162
  const binding = {
@@ -156,7 +165,8 @@ window.__ModuleLoader__.load({
156
165
  };
157
166
  zod.z.strictObject({
158
167
  sessionId: id,
159
- ownerId: id
168
+ ownerId: id,
169
+ ingress: zod.z.enum(INGRESSES)
160
170
  }), zod.z.strictObject({
161
171
  ...binding,
162
172
  revision: zod.z.int().min(-1)
@@ -311,6 +321,11 @@ window.__ModuleLoader__.load({
311
321
  }
312
322
  // src/client/code-page.tsx
313
323
  const SANDBOX = "allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-downloads";
324
+ /**
325
+ * code-server runs only on an http(s) page. DSH Desktop's page is not one, so
326
+ * the editor comes from the plugin's own address instead (host/ingress/).
327
+ */
328
+ const INGRESS = location.protocol === "http:" || location.protocol === "https:" ? "dsh" : "desktop";
314
329
  /** After these, the binding is gone and the page must reconnect. */
315
330
  const FINAL_ERRORS = /* @__PURE__ */ new Set([
316
331
  "unknown_binding",
@@ -378,7 +393,10 @@ window.__ModuleLoader__.load({
378
393
  };
379
394
  (async () => {
380
395
  try {
381
- binding = await call("create", { ownerId: crypto.randomUUID() });
396
+ binding = await call("create", {
397
+ ownerId: crypto.randomUUID(),
398
+ ingress: INGRESS
399
+ });
382
400
  if (disposed) {
383
401
  call("close", { bindingId: binding.bindingId }).catch(() => {});
384
402
  return;
package/dist/index.mjs CHANGED
@@ -4,19 +4,24 @@ import { isAbsolute, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import Schema from "@deepseek-ai/schemastery";
6
6
  import { z } from "zod";
7
- import { randomUUID } from "node:crypto";
7
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
8
  import { createServer } from "node:net";
9
- import { STATUS_CODES, get, request } from "node:http";
9
+ import { STATUS_CODES, createServer as createServer$1, get, request } from "node:http";
10
10
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
11
  import { clientRequestSchema } from "@deepseek-ai/dsh-client-connection";
12
12
  import { SessionId } from "@deepseek-ai/dsh-session/types";
13
13
  import { parseFileAddress } from "@deepseek-ai/dsh-util-workspace-path";
14
- import { buffer } from "node:stream/consumers";
15
14
  // src/rpc.ts
16
15
  /** DSH's shared RPC channel, and the prefix of the endpoints this plugin owns on it. */
17
16
  const RPC_CHANNEL = "/api";
18
17
  const RPC_PREFIX = "langify-code-server/";
19
18
  /**
19
+ * Where the page loads the editor from. `dsh` serves it under DSH's own origin;
20
+ * `desktop` is the plugin's own address on 127.0.0.1, for DSH Desktop, whose
21
+ * page is not on http(s) (see host/ingress/).
22
+ */
23
+ const INGRESSES = ["dsh", "desktop"];
24
+ /**
20
25
  * Why the extension could not open a file. `outcome_unknown` means the editor
21
26
  * was already asked and may still show the file later.
22
27
  */
@@ -38,6 +43,7 @@ const HOST_ERROR_CODES = [
38
43
  "different_host",
39
44
  "code_server_not_found",
40
45
  "code_server_failed",
46
+ "desktop_port_in_use",
41
47
  "unknown_binding",
42
48
  "not_ready",
43
49
  "binding_disconnected",
@@ -54,7 +60,8 @@ const binding = {
54
60
  const requestSchemas = {
55
61
  create: z.strictObject({
56
62
  sessionId: id,
57
- ownerId: id
63
+ ownerId: id,
64
+ ingress: z.enum(INGRESSES)
58
65
  }),
59
66
  watch: z.strictObject({
60
67
  ...binding,
@@ -585,151 +592,24 @@ function healthy(socketPath) {
585
592
  }).on("error", () => resolve(false));
586
593
  });
587
594
  }
588
- // src/host/desktop-websocket.ts
589
- /** Self-contained: it is serialized into {@link DESKTOP_WEBSOCKET_SCRIPT}. */
590
- function retargetWebSockets(page) {
591
- let base;
592
- try {
593
- base = page.parent.__DSH_TRANSPORT__?.streamBaseUrl;
594
- } catch {
595
- return;
596
- }
597
- if (typeof base !== "string") return;
598
- const host = new URL(base);
599
- const scheme = host.protocol === "https:" ? "wss:" : "ws:";
600
- const Native = page.WebSocket;
601
- page.WebSocket = class extends Native {
602
- constructor(url, protocols) {
603
- const target = new URL(url, page.location.href);
604
- if (target.hostname === page.location.hostname) {
605
- target.protocol = scheme;
606
- target.host = host.host;
607
- }
608
- super(target, protocols);
609
- }
610
- };
611
- }
612
- const DESKTOP_WEBSOCKET_SCRIPT = `(${retargetWebSockets.toString()})(window)\n`;
613
- // src/host/relay.ts
614
- /** Path prefix code-server is served under, relative to the DSH page. */
615
- const RELAY_PREFIX = "/code-server";
616
- /** Served by the relay itself, below the prefix; code-server has no such route. */
617
- const SCRIPT_PATH = "/_dsh/desktop-websocket.js";
618
- /** DSH's credentials are not code-server's business. */
619
- const PRIVATE_HEADERS = /* @__PURE__ */ new Set(["cookie", "authorization"]);
620
- function upstreamHeaders(headers) {
621
- return Object.fromEntries(Object.entries(headers).filter(([name]) => !PRIVATE_HEADERS.has(name)));
622
- }
623
- function upstreamPath(url = "/") {
624
- const path = url.slice(12);
625
- return path.startsWith("/") ? path : `/${path}`;
626
- }
627
- /** Handler for HTTP requests below {@link RELAY_PREFIX}. */
628
- function relayRequests(admission, start) {
629
- return async (req, res) => {
630
- const rejection = admission({ headers: req.headers });
631
- if (rejection !== void 0) {
632
- res.writeHead(rejection).end();
633
- return;
634
- }
635
- let server;
636
- try {
637
- server = await start();
638
- } catch (error) {
639
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
640
- res.end(error instanceof Error ? error.message : "code-server is unavailable");
641
- return;
642
- }
643
- const path = upstreamPath(req.url);
644
- const pathname = new URL(path, "http://code-server").pathname;
645
- if (req.method === "GET" && pathname === SCRIPT_PATH) {
646
- res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
647
- res.end(DESKTOP_WEBSOCKET_SCRIPT);
648
- return;
649
- }
650
- const page = req.method === "GET" && pathname === "/";
651
- const headers = upstreamHeaders(req.headers);
652
- const upstream = request({
653
- socketPath: server.socketPath,
654
- method: req.method,
655
- path,
656
- headers: page ? {
657
- ...headers,
658
- "accept-encoding": "identity"
659
- } : headers
660
- }, (response) => {
661
- if (page && response.headers["content-type"]?.startsWith("text/html") === true) {
662
- sendPage(response, res).catch(() => res.destroy());
663
- return;
664
- }
665
- res.writeHead(response.statusCode ?? 502, response.statusMessage, response.headers);
666
- response.pipe(res);
667
- });
668
- upstream.on("error", () => {
669
- if (res.headersSent) res.destroy();
670
- else res.writeHead(502).end();
671
- });
672
- res.on("close", () => upstream.destroy());
673
- req.pipe(upstream);
674
- };
675
- }
676
- /** Send code-server's page with the script ahead of code-server's own. */
677
- async function sendPage(response, res) {
678
- const html = (await buffer(response)).toString("utf8").replace(/<head[^>]*>/i, (head) => `${head}<script src=".${SCRIPT_PATH}"><\/script>`);
679
- const { "content-length": _length, ...headers } = response.headers;
680
- res.writeHead(response.statusCode ?? 502, response.statusMessage, headers).end(html);
681
- }
682
- /** Handler for code-server's WebSocket upgrades, which arrive on one exact path. */
683
- function relayUpgrades(admission, socketPath) {
684
- return (req, socket, head) => {
685
- const rejection = admission({ headers: req.headers });
686
- if (rejection !== void 0) {
687
- socket.end(`HTTP/1.1 ${rejection} ${STATUS_CODES[rejection] ?? ""}\r\n\r\n`);
688
- return;
689
- }
690
- const upstream = request({
691
- socketPath,
692
- method: req.method,
693
- path: upstreamPath(req.url),
694
- headers: upstreamHeaders(req.headers)
695
- });
696
- upstream.on("upgrade", (response, upstreamSocket, upstreamHead) => {
697
- const lines = [`HTTP/1.1 ${response.statusCode ?? 101} ${response.statusMessage ?? ""}`];
698
- for (let index = 0; index < response.rawHeaders.length; index += 2) lines.push(`${response.rawHeaders[index] ?? ""}: ${response.rawHeaders[index + 1] ?? ""}`);
699
- socket.write(`${lines.join("\r\n")}\r\n\r\n`);
700
- if (upstreamHead.length > 0) socket.write(upstreamHead);
701
- if (head.length > 0) upstreamSocket.write(head);
702
- upstreamSocket.pipe(socket).pipe(upstreamSocket);
703
- upstreamSocket.on("error", () => socket.destroy());
704
- upstreamSocket.on("close", () => socket.destroy());
705
- socket.on("close", () => upstreamSocket.destroy());
706
- });
707
- upstream.on("response", (response) => {
708
- response.resume();
709
- socket.end(`HTTP/1.1 ${response.statusCode ?? 502} ${response.statusMessage ?? ""}\r\n\r\n`);
710
- });
711
- upstream.on("error", () => socket.destroy());
712
- socket.on("error", () => upstream.destroy());
713
- upstream.end();
714
- };
715
- }
716
595
  // src/host/handlers.ts
717
596
  /**
718
597
  * One exact route per method on DSH's shared RPC channel. DSH authenticates the
719
598
  * browser before a request reaches these routes.
720
599
  */
721
- function rpcRoutes(services, bridge, codeServer) {
600
+ function rpcRoutes(services, bridge, codeServer, editors) {
722
601
  const handlers = {
723
- async create({ sessionId, ownerId }, signal) {
602
+ async create({ sessionId, ownerId, ingress }, signal) {
724
603
  const { cwd } = await sessionFolders(services, sessionId, signal);
725
604
  const folder = await localPath(services, cwd, cwd, "directory", signal);
726
605
  await codeServer.ensure();
606
+ const base = await editors[ingress]();
727
607
  signal.throwIfAborted();
728
608
  const bindingId = bridge.create(sessionId, ownerId, folder);
729
609
  return {
730
610
  bindingId,
731
611
  cwd: folder,
732
- url: editorUrl(bindingId, folder)
612
+ url: editorUrl(base, bindingId, folder)
733
613
  };
734
614
  },
735
615
  async watch({ sessionId, bindingId, revision }, signal) {
@@ -796,10 +676,9 @@ function rpcRoutes(services, bridge, codeServer) {
796
676
  }
797
677
  }));
798
678
  }
799
- /** The iframe URL, relative to the DSH page; its fragment tells the window's extension its binding. */
800
- function editorUrl(bindingId, cwd) {
801
- const query = new URLSearchParams({ folder: cwd });
802
- return `${RELAY_PREFIX.slice(1)}/?${query.toString()}#${BINDING_FRAGMENT}=${bindingId}`;
679
+ /** The iframe URL; its fragment tells the window's extension its binding. */
680
+ function editorUrl(base, bindingId, cwd) {
681
+ return `${base}?${new URLSearchParams({ folder: cwd }).toString()}#${BINDING_FRAGMENT}=${bindingId}`;
803
682
  }
804
683
  function asCodeServerError(error) {
805
684
  if (error instanceof CodeServerError) return error;
@@ -842,6 +721,165 @@ async function localPath(services, path, base, type, signal) {
842
721
  if (services.fs.processPathFromHostPath(processPath) !== processPath) throw new CodeServerError("different_host", "The session runs on another file system");
843
722
  return realpath(processPath);
844
723
  }
724
+ // src/host/relay.ts
725
+ /** Credentials belong to the ingress, not to code-server. */
726
+ const PRIVATE_HEADERS = /* @__PURE__ */ new Set(["cookie", "authorization"]);
727
+ function forward(req, prefix, socketPath) {
728
+ const path = (req.url ?? "/").slice(prefix.length);
729
+ return request({
730
+ socketPath,
731
+ method: req.method,
732
+ path: path.startsWith("/") ? path : `/${path}`,
733
+ headers: Object.fromEntries(Object.entries(req.headers).filter(([name]) => !PRIVATE_HEADERS.has(name)))
734
+ });
735
+ }
736
+ /** Handler for HTTP requests below the prefix. */
737
+ function relayRequests({ prefix, admit, start }) {
738
+ return async (req, res) => {
739
+ const refusal = admit(req);
740
+ if (refusal !== void 0) {
741
+ res.writeHead(refusal).end();
742
+ return;
743
+ }
744
+ let server;
745
+ try {
746
+ server = await start();
747
+ } catch (error) {
748
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
749
+ res.end(error instanceof Error ? error.message : "code-server is unavailable");
750
+ return;
751
+ }
752
+ const upstream = forward(req, prefix, server.socketPath);
753
+ upstream.on("response", (response) => {
754
+ res.writeHead(response.statusCode ?? 502, response.statusMessage, response.headers);
755
+ response.pipe(res);
756
+ });
757
+ upstream.on("error", () => {
758
+ if (res.headersSent) res.destroy();
759
+ else res.writeHead(502).end();
760
+ });
761
+ res.on("close", () => upstream.destroy());
762
+ req.pipe(upstream);
763
+ };
764
+ }
765
+ function refuse(socket, status) {
766
+ socket.end(`HTTP/1.1 ${status} ${STATUS_CODES[status] ?? ""}\r\n\r\n`);
767
+ }
768
+ /** Handler for WebSocket upgrades below the prefix. */
769
+ function relayUpgrades({ prefix, admit, start }) {
770
+ return (req, socket, head) => {
771
+ const refusal = admit(req);
772
+ if (refusal !== void 0) return refuse(socket, refusal);
773
+ socket.on("error", () => socket.destroy());
774
+ start().then(({ socketPath }) => {
775
+ if (socket.destroyed) return;
776
+ const upstream = forward(req, prefix, socketPath);
777
+ upstream.on("upgrade", (response, upstreamSocket, upstreamHead) => {
778
+ const lines = [`HTTP/1.1 ${response.statusCode ?? 101} ${response.statusMessage ?? ""}`];
779
+ for (let index = 0; index < response.rawHeaders.length; index += 2) lines.push(`${response.rawHeaders[index] ?? ""}: ${response.rawHeaders[index + 1] ?? ""}`);
780
+ socket.write(`${lines.join("\r\n")}\r\n\r\n`);
781
+ if (upstreamHead.length > 0) socket.write(upstreamHead);
782
+ if (head.length > 0) upstreamSocket.write(head);
783
+ upstreamSocket.pipe(socket).pipe(upstreamSocket);
784
+ upstreamSocket.on("error", () => socket.destroy());
785
+ upstreamSocket.on("close", () => socket.destroy());
786
+ socket.on("close", () => upstreamSocket.destroy());
787
+ });
788
+ upstream.on("response", (response) => {
789
+ response.resume();
790
+ refuse(socket, response.statusCode ?? 502);
791
+ });
792
+ upstream.on("error", () => socket.destroy());
793
+ socket.on("close", () => upstream.destroy());
794
+ upstream.end();
795
+ }, () => refuse(socket, 502));
796
+ };
797
+ }
798
+ // src/host/ingress/desktop.ts
799
+ const HOST = "127.0.0.1";
800
+ var DesktopIngress = class {
801
+ #port;
802
+ #start;
803
+ #token = randomBytes(32).toString("base64url");
804
+ #listening;
805
+ /** @param port - 0 picks a free port, which changes the editor's origin on every run. */
806
+ constructor(port, start) {
807
+ this.#port = port;
808
+ this.#start = start;
809
+ }
810
+ /** The editor's address, listening from the first call on. */
811
+ async base() {
812
+ this.#listening ??= this.#listen().catch((error) => {
813
+ this.#listening = void 0;
814
+ throw error;
815
+ });
816
+ return `${(await this.#listening).origin}/${this.#token}/`;
817
+ }
818
+ async dispose() {
819
+ const listener = await this.#listening?.catch(() => void 0);
820
+ this.#listening = void 0;
821
+ if (listener === void 0) return;
822
+ const { server } = listener;
823
+ const closed = new Promise((resolve) => server.close(() => resolve()));
824
+ server.closeAllConnections();
825
+ await closed;
826
+ }
827
+ async #listen() {
828
+ let host = "";
829
+ const relay = {
830
+ prefix: `/${this.#token}`,
831
+ admit: (req) => this.#admit(req, host),
832
+ start: this.#start
833
+ };
834
+ const handle = relayRequests(relay);
835
+ const server = createServer$1((req, res) => void handle(req, res));
836
+ server.on("upgrade", relayUpgrades(relay));
837
+ await new Promise((resolve, reject) => {
838
+ server.once("error", reject);
839
+ server.listen(this.#port, HOST, () => {
840
+ server.off("error", reject);
841
+ resolve();
842
+ });
843
+ }).catch((error) => {
844
+ if ((error instanceof Error && "code" in error ? error.code : void 0) !== "EADDRINUSE") throw error;
845
+ throw new CodeServerError("desktop_port_in_use", `${HOST}:${this.#port}`, { cause: error });
846
+ });
847
+ const address = server.address();
848
+ host = `${HOST}:${typeof address === "object" && address !== null ? address.port : this.#port}`;
849
+ return {
850
+ server,
851
+ origin: `http://${host}`
852
+ };
853
+ }
854
+ /** @param host - the listener's own host and port. */
855
+ #admit(req, host) {
856
+ if (req.headers.host !== host) return 403;
857
+ const token = Buffer.from((req.url ?? "").split("/", 2)[1] ?? "");
858
+ const expected = Buffer.from(this.#token);
859
+ return token.length === expected.length && timingSafeEqual(token, expected) ? void 0 : 404;
860
+ }
861
+ };
862
+ // src/host/ingress/dsh.ts
863
+ const PREFIX = "/code-server";
864
+ function dshIngress(ctx, start) {
865
+ const relay = {
866
+ prefix: PREFIX,
867
+ admit: (req) => ctx.connection.requestRejection({ headers: req.headers }),
868
+ start
869
+ };
870
+ ctx.effect(() => ctx.webServer.register({
871
+ kind: "prefix",
872
+ path: PREFIX,
873
+ handler: relayRequests(relay)
874
+ }), "code-server: DSH ingress");
875
+ return {
876
+ base: `${PREFIX.slice(1)}/`,
877
+ serveWebSocket: ({ commit }) => ctx.effect(() => ctx.webServer.registerUpgrade({
878
+ path: `${PREFIX}/stable-${commit}`,
879
+ handler: relayUpgrades(relay)
880
+ }), "code-server: DSH ingress WebSocket")
881
+ };
882
+ }
845
883
  // src/host/services.ts
846
884
  const inject = [
847
885
  "connection",
@@ -858,7 +896,8 @@ const name = "langify-code-server";
858
896
  const Config = Schema.object({
859
897
  command: Schema.string().default("code-server").description("code-server executable name or path."),
860
898
  userDataDir: Schema.string().default("").description("code-server's --user-data-dir. Empty uses its default."),
861
- extensionsDir: Schema.string().default("").description("code-server's --extensions-dir. Empty uses its default.")
899
+ extensionsDir: Schema.string().default("").description("code-server's --extensions-dir. Empty uses its default."),
900
+ desktopPort: Schema.natural().max(65535).default(41820).description("Port on 127.0.0.1 where DSH Desktop loads the editor.")
862
901
  });
863
902
  async function apply(ctx, config) {
864
903
  const runtimeDir = await mkdtemp(join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), "dsh-code-server-"));
@@ -868,22 +907,21 @@ async function apply(ctx, config) {
868
907
  }), "code-server: runtime directory");
869
908
  const bridge = await Bridge.listen(join(runtimeDir, "bridge.sock"));
870
909
  ctx.effect(() => () => bridge.dispose(), "code-server: bridge");
871
- const admission = (request) => ctx.connection.requestRejection(request);
910
+ const start = () => codeServer.ensure();
911
+ const dsh = dshIngress(ctx, start);
912
+ const desktop = new DesktopIngress(config.desktopPort, start);
913
+ ctx.effect(() => () => desktop.dispose(), "code-server: Desktop ingress");
872
914
  const codeServer = new CodeServer(ctx.subprocess, {
873
915
  command: config.command,
874
916
  userDataDir: config.userDataDir || void 0,
875
917
  extensionsDir: config.extensionsDir || void 0,
876
918
  env: { [SOCKET_ENV]: bridge.socketPath }
877
- }, join(runtimeDir, "code-server.sock"), fileURLToPath(new URL("../extension/dsh-code-bridge.vsix", import.meta.url)), ({ socketPath, commit }) => ctx.effect(() => ctx.webServer.registerUpgrade({
878
- path: `${RELAY_PREFIX}/stable-${commit}`,
879
- handler: relayUpgrades(admission, socketPath)
880
- }), "code-server: WebSocket relay"));
919
+ }, join(runtimeDir, "code-server.sock"), fileURLToPath(new URL("../extension/dsh-code-bridge.vsix", import.meta.url)), dsh.serveWebSocket);
881
920
  ctx.effect(() => () => codeServer.dispose(), "code-server: process");
882
- ctx.effect(() => ctx.webServer.register({
883
- kind: "prefix",
884
- path: RELAY_PREFIX,
885
- handler: relayRequests(admission, () => codeServer.ensure())
886
- }), "code-server: HTTP relay");
887
- for (const route of rpcRoutes(ctx, bridge, codeServer)) ctx.effect(() => ctx.connection.fetch.register(route), `code-server: ${route.path}`);
921
+ const editors = {
922
+ dsh: async () => dsh.base,
923
+ desktop: () => desktop.base()
924
+ };
925
+ for (const route of rpcRoutes(ctx, bridge, codeServer, editors)) ctx.effect(() => ctx.connection.fetch.register(route), `code-server: ${route.path}`);
888
926
  }
889
927
  export { Config, apply, inject, name };
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langify-org/dsh-code-server",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Edit the session's working folder in code-server inside DeepSeek Harness, served through DSH's authenticated web server",
5
5
  "keywords": [
6
6
  "code-server",