@goodandready/dsh-opencode-provider 0.5.11 → 0.5.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.13
4
+
5
+ - Removed the unused `LEGACY_CONSOLE_PROVIDER` constant and the dead `settings.models.provider-card` slot declaration left by the custom provider-card removal.
6
+ - Removed a stray zero-byte `$null` file from the repository root.
7
+ - No runtime behavior change; 100 tests, typecheck and build pass.
8
+
9
+ ## 0.5.12
10
+
11
+ - **Security**: read routes now require a literal loopback Host; `/state` is GET-only, `/health` and `/analytics` reject DNS-rebinding hosts, and updater status GET uses a loopback read guard. Added regression tests.
12
+ - **Routing correctness**: sticky selection and the success trace are recorded only after the stream completes; a mid-stream failure now records a failed trace, not a false success.
13
+ - **Onboarding**: auth-entry credential refs use injective hex encoding, so entries differing only by punctuation cannot overwrite each other. Added a collision test.
14
+ - **Client tests**: added React server-render smoke tests for analytics, heatmap, settings and quota panel; 100 tests.
15
+ - Read routes now work only from the local machine; a LAN-exposed web profile can no longer read plugin state (write routes were already loopback-only).
16
+
3
17
  ## 0.5.11
4
18
 
5
19
  - **Usage fetch retry**: the quota reader retries one transient Node/undici network failure before caching `fetch failed`, so a brief connect timeout no longer leaves the quota panel without usage data.
package/README.md CHANGED
@@ -130,12 +130,12 @@ npm pack
130
130
  Install the **packed tarball** into a profile:
131
131
 
132
132
  ```bash
133
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.11.tgz
133
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.13.tgz
134
134
  # DSH Desktop:
135
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.11.tgz
135
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.13.tgz
136
136
 
137
137
  # Or install the published package:
138
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.11
138
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.13
139
139
  ```
140
140
 
141
141
  Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
package/README.ru.md CHANGED
@@ -129,12 +129,12 @@ npm pack
129
129
  Установка из **упакованного tarball**:
130
130
 
131
131
  ```bash
132
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.11.tgz
132
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.13.tgz
133
133
  # DSH Desktop:
134
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.11.tgz
134
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.13.tgz
135
135
 
136
136
  # Либо установка опубликованного пакета из npm:
137
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.11
137
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.13
138
138
  ```
139
139
 
140
140
  После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
package/README.zh.md CHANGED
@@ -124,12 +124,12 @@ npm pack
124
124
  通过**打包文件 (.tgz)**安装:
125
125
 
126
126
  ```bash
127
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.11.tgz
127
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.13.tgz
128
128
  # DSH 桌面端:
129
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.11.tgz
129
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.13.tgz
130
130
 
131
131
  # 或从 npm 安装已发布版本:
132
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.11
132
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.13
133
133
  ```
134
134
 
135
135
  安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
package/lib/client.js CHANGED
@@ -1806,6 +1806,18 @@ window.__ModuleLoader__.load({
1806
1806
  });
1807
1807
  }
1808
1808
  //#endregion
1809
+ //#region src/client/auth-ref.ts
1810
+ /**
1811
+ * Collision-free credential ref for an OpenCode auth entry. Hex encoding is
1812
+ * injective (unlike replacing punctuation with underscores) and uses only
1813
+ * characters accepted by credential references.
1814
+ */
1815
+ function authRefForEntry(entry) {
1816
+ let hex = "";
1817
+ for (const byte of new TextEncoder().encode(entry)) hex += byte.toString(16).padStart(2, "0");
1818
+ return `OPENCODE_AUTH_${hex}`;
1819
+ }
1820
+ //#endregion
1809
1821
  //#region src/client/settings-page.tsx
1810
1822
  function OpenCodeSettingsPage(props) {
1811
1823
  const { t } = props;
@@ -1875,8 +1887,7 @@ window.__ModuleLoader__.load({
1875
1887
  };
1876
1888
  const importAllAuth = async () => {
1877
1889
  if (state === null || draft === null) return;
1878
- const refFor = (entry) => `OPENCODE_AUTH_${entry.replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}`;
1879
- const missing = state.auth.entries.filter((entry) => !draft.accounts.some((account) => account.label === entry || account.apiKeyEnv === refFor(entry)));
1890
+ const missing = state.auth.entries.filter((entry) => !draft.accounts.some((account) => account.label === entry || account.apiKeyEnv === authRefForEntry(entry)));
1880
1891
  if (missing.length === 0) {
1881
1892
  setNotice(t("importedAll"));
1882
1893
  return;
@@ -1884,10 +1895,10 @@ window.__ModuleLoader__.load({
1884
1895
  setImporting(true);
1885
1896
  setError(null);
1886
1897
  try {
1887
- for (const entry of missing) await importAuth(entry, refFor(entry));
1898
+ for (const entry of missing) await importAuth(entry, authRefForEntry(entry));
1888
1899
  update({ accounts: [...draft.accounts, ...missing.map((entry) => ({
1889
1900
  label: entry,
1890
- apiKeyEnv: refFor(entry)
1901
+ apiKeyEnv: authRefForEntry(entry)
1891
1902
  }))] });
1892
1903
  setNotice(t("importedAll"));
1893
1904
  } catch (caught) {
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
17
17
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
18
18
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
19
19
  const DEFAULT_AUTH_ENTRY = "opencode-go";
20
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.11";
20
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.13";
21
21
  const DEFAULT_CONTEXT_WINDOW = 262144;
22
22
  const DEFAULT_MAX_TOKENS = 32768;
23
23
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -3261,6 +3261,12 @@ var OpenCodeAdapter = class extends LlmAdapter {
3261
3261
  }
3262
3262
  throw error;
3263
3263
  }
3264
+ for await (const chunk of this.parse(response, model.transport, config, options.signal)) {
3265
+ if (firstChunkAt === void 0) firstChunkAt = Date.now();
3266
+ started = true;
3267
+ yield chunk;
3268
+ }
3269
+ logAttempt(true);
3264
3270
  this.rememberSticky(sessionId, options, entry);
3265
3271
  this.pushTrace({
3266
3272
  at: Date.now(),
@@ -3271,17 +3277,23 @@ var OpenCodeAdapter = class extends LlmAdapter {
3271
3277
  fallback,
3272
3278
  ok: true
3273
3279
  });
3274
- for await (const chunk of this.parse(response, model.transport, config, options.signal)) {
3275
- if (firstChunkAt === void 0) firstChunkAt = Date.now();
3276
- started = true;
3277
- yield chunk;
3278
- }
3279
- logAttempt(true);
3280
3280
  return;
3281
3281
  } catch (error) {
3282
3282
  const classified = error instanceof LlmError ? error : new LlmError(error instanceof Error ? error.message : "OpenCode request failed", "TRANSPORT", { cause: error });
3283
3283
  logAttempt(false, classified.code);
3284
- if (started || options.signal?.aborted === true) throw error;
3284
+ if (started || options.signal?.aborted === true) {
3285
+ this.pushTrace({
3286
+ at: Date.now(),
3287
+ provider: options.provider,
3288
+ model: options.model,
3289
+ account: entry.label ?? "default",
3290
+ reason: attemptReason,
3291
+ fallback,
3292
+ ok: false,
3293
+ error: classified.code
3294
+ });
3295
+ throw error;
3296
+ }
3285
3297
  lastError = classified;
3286
3298
  const transient = this.isTransient(classified);
3287
3299
  if (transient && !responseFailure && !fallback) this.markCooldown(entry.key, 0);
@@ -3870,6 +3882,27 @@ function isTrustedUpdateRequest(request) {
3870
3882
  return false;
3871
3883
  }
3872
3884
  }
3885
+ /** Local, loopback-only reads for the update card; blocks DNS-rebinding hosts. */
3886
+ function isTrustedReadRequest(request) {
3887
+ if (!isLoopback(request.socket?.remoteAddress)) return false;
3888
+ const host = header(request, "host");
3889
+ if (host === void 0) return false;
3890
+ try {
3891
+ if (!isLoopback(new URL(`http://${host}`).hostname)) return false;
3892
+ } catch {
3893
+ return false;
3894
+ }
3895
+ const site = header(request, "sec-fetch-site");
3896
+ if (site !== void 0 && site !== "same-origin") return false;
3897
+ const origin = header(request, "origin");
3898
+ if (origin === void 0) return true;
3899
+ try {
3900
+ const url = new URL(origin);
3901
+ return (url.protocol === "http:" || url.protocol === "https:") && isLoopback(url.hostname) && url.host === host;
3902
+ } catch {
3903
+ return false;
3904
+ }
3905
+ }
3873
3906
  function validProfileName(value) {
3874
3907
  return typeof value === "string" && value !== "" && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\") && !/[\0-\x1f\x7f]/.test(value);
3875
3908
  }
@@ -4075,6 +4108,10 @@ function registerPluginUpdater(ctx, options) {
4075
4108
  try {
4076
4109
  const target = runtime();
4077
4110
  if (request.method === "GET" || request.method === "HEAD") {
4111
+ if (!isTrustedReadRequest(request)) {
4112
+ json$1(response, 403, { error: "Rejected non-local update status request." });
4113
+ return;
4114
+ }
4078
4115
  const payload = await status(options, target);
4079
4116
  response.writeHead(200, {
4080
4117
  "content-type": "application/json; charset=utf-8",
@@ -4732,10 +4769,14 @@ function applyRoutes(ctx, web, deps) {
4732
4769
  }));
4733
4770
  };
4734
4771
  route("/dsh-opencode/state", async (req, res) => {
4735
- if (req.method !== "GET" && req.method !== "POST") {
4772
+ if (req.method !== "GET") {
4736
4773
  json(res, 405, { error: "GET required" });
4737
4774
  return;
4738
4775
  }
4776
+ if (!loopbackHost(req)) {
4777
+ json(res, 403, { error: "non-loopback Host refused" });
4778
+ return;
4779
+ }
4739
4780
  if (!allowedOrigin(req)) {
4740
4781
  json(res, 403, { error: "cross-origin request refused" });
4741
4782
  return;
@@ -4751,6 +4792,10 @@ function applyRoutes(ctx, web, deps) {
4751
4792
  json(res, 405, { error: "GET required" });
4752
4793
  return;
4753
4794
  }
4795
+ if (!loopbackHost(req)) {
4796
+ json(res, 403, { error: "non-loopback Host refused" });
4797
+ return;
4798
+ }
4754
4799
  if (!allowedOrigin(req)) {
4755
4800
  json(res, 403, { error: "cross-origin request refused" });
4756
4801
  return;
@@ -4773,6 +4818,10 @@ function applyRoutes(ctx, web, deps) {
4773
4818
  json(res, 405, { error: "GET required" });
4774
4819
  return;
4775
4820
  }
4821
+ if (!loopbackHost(req)) {
4822
+ json(res, 403, { error: "non-loopback Host refused" });
4823
+ return;
4824
+ }
4776
4825
  if (!allowedOrigin(req)) {
4777
4826
  json(res, 403, { error: "cross-origin request refused" });
4778
4827
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -68,7 +68,7 @@
68
68
  "build": "tsdown",
69
69
  "prepare": "tsdown",
70
70
  "typecheck": "tsc --noEmit",
71
- "test": "node --import tsx --test tests/core.test.ts tests/routes.test.ts tests/limits.test.ts tests/analytics.test.ts tests/adapter.test.ts tests/usage.test.ts tests/request-log.test.ts tests/account-rules.test.ts tests/commands.test.ts tests/tool-schema.test.ts tests/tui-settings.test.ts tests/plan-filter.test.ts tests/image-budget.test.ts tests/tools.test.ts tests/plugin-updater.test.ts",
71
+ "test": "node --import tsx --test tests/core.test.ts tests/routes.test.ts tests/limits.test.ts tests/analytics.test.ts tests/adapter.test.ts tests/usage.test.ts tests/request-log.test.ts tests/account-rules.test.ts tests/commands.test.ts tests/tool-schema.test.ts tests/tui-settings.test.ts tests/plan-filter.test.ts tests/image-budget.test.ts tests/tools.test.ts tests/plugin-updater.test.ts tests/client-render.test.tsx",
72
72
  "check": "npm run typecheck && npm test && npm run build",
73
73
  "release": "npm run check && npm pack",
74
74
  "sync:models": "node scripts/sync-models.mjs"
@@ -114,7 +114,9 @@
114
114
  "@deepseek-ai/schemastery": "~3.18.5",
115
115
  "@types/node": "^22.0.0",
116
116
  "@types/react": "^18.3.1",
117
+ "@types/react-dom": "^18.3.0",
117
118
  "react": "^18.3.1",
119
+ "react-dom": "^18.3.1",
118
120
  "tsdown": "^0.22.2",
119
121
  "tsx": "^4.23.12",
120
122
  "typescript": "^5.7.2"