Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
Nothing needs to be added to the .gitignore file since only a README.md file was modified and there are no build artifacts, dependencies, or temporary files in the changes.
# Build & tooling caches that must never be committed.
node_modules/
lib/tsconfig.tsbuildinfo
*.tsbuildinfo
50 changes: 50 additions & 0 deletions COOKIE-FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Cookie 解析修复记录(COOKIE-FIX)

> 分支:`fix/cookie-parse`(rebase 到最新 `origin/main` = `721dfeb`)
> 日期:2026-08-19(首次),2026-08-19(rebase + 调整)
> 性质:**只修 cookie 解析错误**;不新增本地化,但兼容已合入的中文解析

## 背景与决策(更新)

- 原项目(v587d,Shawn 维护)已通过 **PR #2**(`waknow/fix/zh-locale-parsing`)合并了**中文页面解析**(`滚动/每周/每月` 标签、`重置于` 短语),且另有 PR #3/#4 的 README 改动。最新 main = `721dfeb`。
- 本分支 `fix/cookie-parse` 已 **rebase 到最新 main**,在其之上再叠一个 cookie 解析修复。
- **只修 `normalizeCookie` 的解析错误**,不引入额外功能。解析层维持原项目(含其中文解析)。

## Bug 描述(根因)

原 `normalizeCookie`(`src/config.ts`)用 `/^auth=/` 判断整个字符串是否以 `auth=` 开头:

- 若以 `auth=` 开头 → 正常透传。
- 否则 → **把第一个分号段当成 auth 值**,拼成 `auth=<第一段>; ...`。

真实浏览器拷出的 cookie 通常带 locale 且 locale 可能排在前:

```
oc_locale=zh; desktop_promo_dismissed=1; auth=Fe26.2*...
```

它**不以 `auth=` 开头** → 原代码把 `oc_locale=zh` 当 auth → 写入 `auth=oc_locale=zh; oc_locale=en` → opencode.ai 拒绝 → 触发登录页重定向 → 插件报 `http302`("页面解析空")。

> 这个 bug 与原项目是否含中文解析**无关**:任何"auth 不在第一段"的 cookie 都会写坏(纯英文 cookie、带 `desktop_promo_dismissed` 等无关段的 cookie 同样触发)。PR #2 只修了解析层,**从未修过写入层的这个 bug**。

## 修复内容

`src/config.ts` → `normalizeCookie` 重写为**顺序无关 + 拒绝假值 + 保留用户 locale**:

1. **顺序无关**:在整串中查找 `auth=` 段(不再假设它在第一个);找不到时,若存在"裸 opaque token"(无 `=` 且长度 ≥ 8),自动补 `auth=`。
2. **拒绝假 cookie**:两者都没有 → 返回 `undefined`,调用方(`writeConfigFile`)拒绝写入,**绝不**拼出 `auth=oc_locale=zh`。
3. **保留用户 locale**:提取粘贴 cookie 里的 `oc_locale` 并原样保留(`zh` 保持 `zh`),缺省或非法(非 2-3 字母)时回退 `en`。
- 设计说明:既然解析层已支持中文页,保留 `oc_locale=zh` 可让 zh 用户继续看中文页(而非被强制英文)。若想强制英文页,可把这里改成固定 `en`。
4. 分隔符 `;` 与 `,` 都支持(浏览器 Cookie 头 / Set-Cookie 风格);丢弃无关 UI 段(`desktop_promo_dismissed` 等)。

## 验证

- `pnpm test`:44/44 通过(含新增回归用例:locale 在前的真实 cookie 不被污染、纯 locale 拒绝写入、逗号分隔、引号值、locale 保留/回退)。
- `pnpm typecheck` 通过。
- 真实验证(独立脚本,同 URL+同 cookie):`oc_locale=zh` cookie → opencode 返回中文页(`滚动用量`…),解析器可读;写坏修复后不再 302。

## 后续同步指引

- 分支 `fix/cookie-parse` 即本修复的载体,后续改动直接在此分支上做,或从此分支 cherry-pick / rebase 到目标。
- 已放弃的历史分支(`rebase-work` / `feat/cookie-robustness` / `pr-zh` / `pr2`)已删除;仅存的 `main` 与 `fix/cookie-parse`。
- dsh 安装目录同步:`repo-tmp/lib` → `~/.dsh/profiles/web/node_modules/dsh-ocgo-usage/lib`(构建后覆盖)。
182 changes: 182 additions & 0 deletions lib/config-B4sfdLCs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
//#region src/config.ts
/**
* Configuration loader for dsh-ocgo-usage
*
* Priority: env vars > config file ($DSH_HOME/ocgo-usage.json) > built-in defaults
*
* The cookie is NEVER logged. If the config file is missing or unparseable,
* we silently fall back to env vars + defaults — the browser readout shows a
* clean `noconfig` error if neither source provides a usable value.
*
* Env var names match the pi-ocgo-usage extension so one shell profile works
* for both agents.
*
* The browser config editor (`/api/ocgo-usage/config`) reads a MASKED view
* (never the full cookie) and writes back through {@link writeConfigFile}.
* @module dsh-ocgo-usage/config
*/
const ENV_COOKIE = "OPENCODE_GO_COOKIE";
const ENV_WORKSPACE_ID = "OPENCODE_GO_WORKSPACE_ID";
const ENV_CACHE_TTL = "OPENCODE_GO_CACHE_TTL";
const ENV_TIMEOUT_MS = "OPENCODE_GO_TIMEOUT_MS";
const DEFAULT_BASE_URL = "https://opencode.ai";
const DEFAULT_TIMEOUT_MS = 1e4;
const MAX_CACHE_TTL = 3600;
/** Resolve the DSH home directory ($DSH_HOME or ~/.dsh). */
function dshHome() {
const explicit = process.env.DSH_HOME;
if (typeof explicit === "string" && explicit.length > 0) return explicit;
return join(homedir(), ".dsh");
}
/** Resolved location of the plugin config file. */
function configFilePath() {
return join(dshHome(), "ocgo-usage.json");
}
/**
* Load and merge config from file + env vars.
* Returns a fully resolved OcgoConfig; never throws.
*/
function loadConfig() {
const fileConfig = readFileConfig();
return {
cookie: normalizeCookie(pickString(process.env[ENV_COOKIE], asString(fileConfig?.cookie))),
workspaceID: pickString(process.env[ENV_WORKSPACE_ID], asString(fileConfig?.workspaceID)),
baseUrl: pickString(process.env["OPENCODE_GO_BASE_URL"], asString(fileConfig?.baseUrl)) || "https://opencode.ai",
cacheTTL: clamp(pickNumber(process.env[ENV_CACHE_TTL], asNumber(fileConfig?.cacheTTL), 300), 60, MAX_CACHE_TTL),
timeoutMs: Math.max(0, pickNumber(process.env[ENV_TIMEOUT_MS], asNumber(fileConfig?.timeoutMs), DEFAULT_TIMEOUT_MS))
};
}
/** Mask the last 4 characters of a secret for the browser (full value when ≤ 4 chars). */
function maskSecret(value) {
if (value === void 0 || value.length === 0) return {
set: false,
tail: ""
};
return {
set: true,
tail: value.length <= 4 ? value : value.slice(-4)
};
}
/** The browser-facing masked config view (never reveals the full cookie). */
function maskedConfigView() {
const cfg = loadConfig();
return {
workspaceID: maskSecret(cfg.workspaceID),
cookie: maskSecret(cfg.cookie)
};
}
/**
* Write cookie / workspaceID into the config file (preserving any other
* fields), chmod 600, and return the updated masked view. Values are
* normalized like env input (cookie gets `auth=` prefixed when pasted bare).
* Empty/absent fields are left untouched; pass `null` to clear a field.
*/
function writeConfigFile(partial) {
const next = { ...readFileConfig() ?? {} };
if (partial.workspaceID !== void 0) {
const v = typeof partial.workspaceID === "string" ? partial.workspaceID.trim() : "";
if (v.length > 0) next.workspaceID = v;
else delete next.workspaceID;
}
if (partial.cookie !== void 0) {
const v = typeof partial.cookie === "string" ? normalizeCookie(partial.cookie) : void 0;
if (v !== void 0 && v.length > 0) next.cookie = v;
else delete next.cookie;
}
const path = configFilePath();
try {
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 384 });
} catch {
return maskedConfigView();
}
return {
workspaceID: maskSecret(typeof next.workspaceID === "string" ? next.workspaceID : void 0),
cookie: maskSecret(typeof next.cookie === "string" ? next.cookie : void 0)
};
}
function readFileConfig() {
const path = configFilePath();
if (!existsSync(path)) return null;
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
return null;
} catch {
return null;
}
}
function pickString(envVal, fileVal) {
if (envVal && envVal.length > 0) return envVal;
if (fileVal && fileVal.length > 0) return fileVal;
}
/**
* Normalize a user-provided cookie string into a valid `Cookie:` header value
* for the opencode console HTTP request.
*
* Accepts, order-independently:
* 1. Full header: "auth=Fe26.2*...; oc_locale=zh" (passthrough)
* 2. Single bare value: "Fe26.2*..." (auto-prefix "auth=")
* 3. Two-segment value+locale: "Fe26.2*...; oc_locale=zh"
* 4. Locale + auth in any order (incl. `oc_locale=zh` BEFORE `auth=`).
*
* The original implementation decided "the first segment is the auth value"
* whenever the string did not start with `auth=`. That silently corrupted
* real browser cookies like `oc_locale=zh; desktop_promo_dismissed=1;
* auth=Fe26.2*...` into `auth=oc_locale=zh; ...` — a fake cookie that
* opencode.ai rejects with a redirect to the login page.
*
* Fixes:
* - The `auth=` segment is located anywhere in the string, not assumed to
* be first.
* - If no `auth=` pair and no bare opaque token is present, `undefined` is
* returned so the caller REFUSES to persist a broken cookie rather than
* fabricating `auth=<locale>`.
* - The `oc_locale` is preserved from the pasted cookie (so a zh user keeps
* the Chinese console page, which the parser now supports), defaulting to
* `en` when absent. Only a well-formed short locale (e.g. `en`, `zh`, `ja`)
* is kept; anything malformed falls back to `en`.
* - All other segments (UI prefs like `desktop_promo_dismissed`) are
* dropped; only the auth token and the locale are ever sent.
*/
function normalizeCookie(input) {
if (!input) return void 0;
const trimmed = input.trim();
if (!trimmed) return void 0;
const segments = trimmed.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
let auth = segments.find((s) => /^auth=/i.test(s));
if (auth === void 0) {
const bare = segments.find((s) => !s.includes("=") && s.length >= 8);
if (bare !== void 0) auth = `auth=${bare}`;
}
if (auth === void 0) return void 0;
const authValue = auth.slice(auth.indexOf("=") + 1).trim().replace(/^"|"$/g, "");
if (authValue.length === 0) return void 0;
const localeSeg = segments.find((s) => /^oc_locale=/i.test(s));
const rawLocale = localeSeg ? localeSeg.slice(localeSeg.indexOf("=") + 1).trim() : "";
return `auth=${authValue}; oc_locale=${/^[A-Za-z]{2,3}$/.test(rawLocale) ? rawLocale.toLowerCase() : "en"}`;
}
function pickNumber(envVal, fileVal, fallback) {
const fromEnv = envVal ? Number.parseInt(envVal, 10) : NaN;
if (Number.isFinite(fromEnv)) return fromEnv;
if (fileVal !== void 0 && Number.isFinite(fileVal)) return fileVal;
return fallback;
}
function asString(v) {
return typeof v === "string" && v.length > 0 ? v : void 0;
}
function asNumber(v) {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const n = Number.parseInt(v, 10);
if (Number.isFinite(n)) return n;
}
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
//#endregion
export { maskedConfigView as a, loadConfig as i, DEFAULT_TIMEOUT_MS as n, normalizeCookie as o, configFilePath as r, writeConfigFile as s, DEFAULT_BASE_URL as t };
2 changes: 1 addition & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { a as maskedConfigView, i as loadConfig, o as normalizeCookie, r as configFilePath, s as writeConfigFile } from "./config-KWbOD-EK.js";
import { a as maskedConfigView, i as loadConfig, o as normalizeCookie, r as configFilePath, s as writeConfigFile } from "./config-B4sfdLCs.js";
import { Service } from "@deepseek-ai/cordis";
//#region src/routes.ts
/** Browser-facing base path of the usage API. */
Expand Down
2 changes: 1 addition & 1 deletion lib/invariant.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { n as DEFAULT_TIMEOUT_MS } from "./config-KWbOD-EK.js";
import { n as DEFAULT_TIMEOUT_MS } from "./config-B4sfdLCs.js";
//#region src/invariant.ts
/**
* Package invariants — cheap structural checks run at import time on the
Expand Down
31 changes: 24 additions & 7 deletions lib/types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,33 @@ export declare function writeConfigFile(partial: {
workspaceID?: string | null;
}): MaskedConfigView;
/**
* Normalize a user-provided cookie string into a valid `Cookie:` header value.
* Normalize a user-provided cookie string into a valid `Cookie:` header value
* for the opencode console HTTP request.
*
* Accepts three forms:
* Accepts, order-independently:
* 1. Full header: "auth=Fe26.2*...; oc_locale=zh" (passthrough)
* 2. Single value: "Fe26.2*..." (auto-prefix "auth=")
* 3. Two-segment: "Fe26.2*...; oc_locale=zh" (auto-prefix "auth=",
* keep oc_locale)
* 2. Single bare value: "Fe26.2*..." (auto-prefix "auth=")
* 3. Two-segment value+locale: "Fe26.2*...; oc_locale=zh"
* 4. Locale + auth in any order (incl. `oc_locale=zh` BEFORE `auth=`).
*
* Strips leading/trailing whitespace, collapses internal whitespace, and
* defaults `oc_locale=en` when only the auth value is present.
* The original implementation decided "the first segment is the auth value"
* whenever the string did not start with `auth=`. That silently corrupted
* real browser cookies like `oc_locale=zh; desktop_promo_dismissed=1;
* auth=Fe26.2*...` into `auth=oc_locale=zh; ...` — a fake cookie that
* opencode.ai rejects with a redirect to the login page.
*
* Fixes:
* - The `auth=` segment is located anywhere in the string, not assumed to
* be first.
* - If no `auth=` pair and no bare opaque token is present, `undefined` is
* returned so the caller REFUSES to persist a broken cookie rather than
* fabricating `auth=<locale>`.
* - The `oc_locale` is preserved from the pasted cookie (so a zh user keeps
* the Chinese console page, which the parser now supports), defaulting to
* `en` when absent. Only a well-formed short locale (e.g. `en`, `zh`, `ja`)
* is kept; anything malformed falls back to `en`.
* - All other segments (UI prefs like `desktop_promo_dismissed`) are
* dropped; only the auth token and the locale are ever sent.
*/
export declare function normalizeCookie(input: string | undefined): string | undefined;
//# sourceMappingURL=config.d.ts.map
2 changes: 1 addition & 1 deletion lib/types/config.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading