diff --git a/.gitignore b/.gitignore index a876c49..5526212 100644 --- a/.gitignore +++ b/.gitignore @@ -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. \ No newline at end of file +# Build & tooling caches that must never be committed. +node_modules/ +lib/tsconfig.tsbuildinfo +*.tsbuildinfo diff --git a/COOKIE-FIX.md b/COOKIE-FIX.md new file mode 100644 index 0000000..12c7c90 --- /dev/null +++ b/COOKIE-FIX.md @@ -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`(构建后覆盖)。 diff --git a/lib/config-B4sfdLCs.js b/lib/config-B4sfdLCs.js new file mode 100644 index 0000000..d1aae66 --- /dev/null +++ b/lib/config-B4sfdLCs.js @@ -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=`. +* - 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 }; diff --git a/lib/index.js b/lib/index.js index a47113b..0829320 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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. */ diff --git a/lib/invariant.js b/lib/invariant.js index a956d7b..ed930f8 100644 --- a/lib/invariant.js +++ b/lib/invariant.js @@ -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 diff --git a/lib/types/config.d.ts b/lib/types/config.d.ts index 1ce5cc7..75d1f95 100644 --- a/lib/types/config.d.ts +++ b/lib/types/config.d.ts @@ -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=`. + * - 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 \ No newline at end of file diff --git a/lib/types/config.d.ts.map b/lib/types/config.d.ts.map index 82e6784..345879f 100644 --- a/lib/types/config.d.ts.map +++ b/lib/types/config.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5E,eAAO,MAAM,UAAU,uBAAuB,CAAA;AAC9C,eAAO,MAAM,gBAAgB,6BAA6B,CAAA;AAC1D,eAAO,MAAM,YAAY,yBAAyB,CAAA;AAClD,eAAO,MAAM,aAAa,0BAA0B,CAAA;AACpD,eAAO,MAAM,cAAc,2BAA2B,CAAA;AAEtD,eAAO,MAAM,gBAAgB,wBAAwB,CAAA;AACrD,eAAO,MAAM,iBAAiB,MAAM,CAAA;AACpC,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B,eAAO,MAAM,aAAa,OAAO,CAAA;AAEjC,4DAA4D;AAC5D,wBAAgB,OAAO,IAAI,MAAM,CAIhC;AAED,mDAAmD;AACnD,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAUD;;;GAGG;AACH,wBAAgB,UAAU,IAAI,UAAU,CA6BvC;AAED,0FAA0F;AAC1F,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,CAGlE;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,IAAI,gBAAgB,CAMnD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,GAAG,gBAAgB,CAyBnB;AAyBD;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CA0B7E"} \ No newline at end of file +{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5E,eAAO,MAAM,UAAU,uBAAuB,CAAA;AAC9C,eAAO,MAAM,gBAAgB,6BAA6B,CAAA;AAC1D,eAAO,MAAM,YAAY,yBAAyB,CAAA;AAClD,eAAO,MAAM,aAAa,0BAA0B,CAAA;AACpD,eAAO,MAAM,cAAc,2BAA2B,CAAA;AAEtD,eAAO,MAAM,gBAAgB,wBAAwB,CAAA;AACrD,eAAO,MAAM,iBAAiB,MAAM,CAAA;AACpC,eAAO,MAAM,kBAAkB,QAAS,CAAA;AACxC,eAAO,MAAM,aAAa,KAAK,CAAA;AAC/B,eAAO,MAAM,aAAa,OAAO,CAAA;AAEjC,4DAA4D;AAC5D,wBAAgB,OAAO,IAAI,MAAM,CAIhC;AAED,mDAAmD;AACnD,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAUD;;;GAGG;AACH,wBAAgB,UAAU,IAAI,UAAU,CA6BvC;AAED,0FAA0F;AAC1F,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,CAGlE;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,IAAI,gBAAgB,CAMnD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE;IACvC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,GAAG,gBAAgB,CAyBnB;AAyBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CA0B7E"} \ No newline at end of file diff --git a/lib/types/config.js b/lib/types/config.js index 69b74d7..2bf97fd 100644 --- a/lib/types/config.js +++ b/lib/types/config.js @@ -134,42 +134,60 @@ function pickString(envVal, fileVal) { return undefined; } /** - * 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=`. + * - 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 function normalizeCookie(input) { if (!input) return undefined; - const trimmed = input.trim().replace(/\s+/g, ' '); + const trimmed = input.trim(); if (!trimmed) return undefined; - const hasAuthPrefix = /^auth=/.test(trimmed); - const segments = trimmed.split(/;\s*/).filter(Boolean); - const ocLocale = segments.find((s) => s.startsWith('oc_locale=')); - if (hasAuthPrefix) { - // Already valid: just ensure oc_locale exists. Re-stitch from - // segments so any extra whitespace in the original gets normalized. - const authSeg = (segments.find((s) => s.startsWith('auth=')) ?? segments[0] ?? '').trim(); - const ocSeg = ocLocale ?? 'oc_locale=en'; - return `${authSeg}; ${ocSeg}`; + const segments = trimmed.split(/[;,]/).map((s) => s.trim()).filter(Boolean); + // 1) Auth token — order-independent. + let auth = segments.find((s) => /^auth=/i.test(s)); + if (auth === undefined) { + // A bare token (no "=") that looks like an opaque auth value. + const bare = segments.find((s) => !s.includes('=') && s.length >= 8); + if (bare !== undefined) + auth = `auth=${bare}`; } - // User pasted just the auth value (possibly with oc_locale appended). - // The first segment is the auth value; prepend "auth=". - const authValue = (segments[0] ?? '').trim(); - const extras = segments - .slice(1) - .map((s) => s.trim()) - .filter(Boolean); - const ocLocale2 = extras.find((s) => s.startsWith('oc_locale=')) ?? 'oc_locale=en'; - return `auth=${authValue}; ${ocLocale2}`; + if (auth === undefined) + return undefined; + const authValue = auth.slice(auth.indexOf('=') + 1).trim().replace(/^"|"$/g, ''); + if (authValue.length === 0) + return undefined; + // 2) Locale — preserve the pasted one (the zh parser understands zh pages), + // fall back to `en` when absent or malformed. + const localeSeg = segments.find((s) => /^oc_locale=/i.test(s)); + const rawLocale = localeSeg ? localeSeg.slice(localeSeg.indexOf('=') + 1).trim() : ''; + const locale = /^[A-Za-z]{2,3}$/.test(rawLocale) ? rawLocale.toLowerCase() : 'en'; + return `auth=${authValue}; oc_locale=${locale}`; } function pickNumber(envVal, fileVal, fallback) { const fromEnv = envVal ? Number.parseInt(envVal, 10) : NaN; diff --git a/src/config.test.ts b/src/config.test.ts index b6619a6..896e664 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -43,7 +43,7 @@ function restoreEnv(saved: Record): void { } describe('normalizeCookie', () => { - it('passes through a full header and guarantees oc_locale', () => { + it('passes through a full header and preserves its locale', () => { expect(normalizeCookie('auth=Fe26.2*abc; oc_locale=zh')).toBe('auth=Fe26.2*abc; oc_locale=zh') expect(normalizeCookie('auth=Fe26.2*abc')).toBe('auth=Fe26.2*abc; oc_locale=en') }) @@ -52,8 +52,46 @@ describe('normalizeCookie', () => { expect(normalizeCookie('Fe26.2*abc')).toBe('auth=Fe26.2*abc; oc_locale=en') }) - it('keeps oc_locale when pasted with the auth value', () => { + it('preserves a pasted locale (zh stays zh, ja stays ja)', () => { expect(normalizeCookie('Fe26.2*abc; oc_locale=zh')).toBe('auth=Fe26.2*abc; oc_locale=zh') + expect(normalizeCookie('Fe26.2*abc; oc_locale=ja')).toBe('auth=Fe26.2*abc; oc_locale=ja') + }) + + it('falls back to en when the locale is absent or malformed', () => { + expect(normalizeCookie('auth=Fe26.2*abc')).toBe('auth=Fe26.2*abc; oc_locale=en') + expect(normalizeCookie('auth=Fe26.2*abc; oc_locale=')).toBe('auth=Fe26.2*abc; oc_locale=en') + expect(normalizeCookie('auth=Fe26.2*abc; oc_locale=verylonglocale')).toBe( + 'auth=Fe26.2*abc; oc_locale=en', + ) + }) + + it('is order-independent and never corrupts locale-first cookies (regression)', () => { + // The old code turned "oc_locale=zh; ...; auth=..." into + // "auth=oc_locale=zh; ...". This must never happen. + expect(normalizeCookie('oc_locale=zh; auth=Fe26.2*abc')).toBe('auth=Fe26.2*abc; oc_locale=zh') + expect(normalizeCookie('oc_locale=en; desktop_promo_dismissed=1; auth=Fe26.2*abc')).toBe( + 'auth=Fe26.2*abc; oc_locale=en', + ) + }) + + it('accepts comma-separated cookies (Set-Cookie style)', () => { + expect(normalizeCookie('oc_locale=zh, desktop_promo_dismissed=1, auth=Fe26.2*abc')).toBe( + 'auth=Fe26.2*abc; oc_locale=zh', + ) + }) + + it('accepts a quoted auth value', () => { + expect(normalizeCookie('auth="Fe26.2*quoted"; oc_locale=en')).toBe( + 'auth=Fe26.2*quoted; oc_locale=en', + ) + }) + + it('rejects input with no real auth token (no fake auth=)', () => { + // Regression: the old code fabricated "auth=oc_locale=zh; ..." from a + // locale-only paste. Now it refuses instead. + expect(normalizeCookie('oc_locale=zh')).toBeUndefined() + expect(normalizeCookie('zh')).toBeUndefined() + expect(normalizeCookie('desktop_promo_dismissed=1; auth=')).toBeUndefined() }) it('normalizes whitespace and rejects empty input', () => { @@ -161,7 +199,9 @@ describe('masked config view + write', () => { process.env[ENV_COOKIE] = cookie process.env[ENV_WORKSPACE_ID] = ws const view = maskedConfigView() - expect(view.cookie).toEqual({ set: true, tail: cookie.slice(-4) }) + // The env cookie is normalized on load: locale is preserved as zh. + const normalized = 'auth=Fe26.2*secret-cookie-9abc; oc_locale=zh' + expect(view.cookie).toEqual({ set: true, tail: normalized.slice(-4) }) expect(view.workspaceID).toEqual({ set: true, tail: ws.slice(-4) }) expect(JSON.stringify(view)).not.toContain('secret-cookie') }) diff --git a/src/config.ts b/src/config.ts index 6b74523..8993f83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -162,43 +162,60 @@ function pickString(envVal: string | undefined, fileVal: string | undefined): st } /** - * 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=`. + * - 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 function normalizeCookie(input: string | undefined): string | undefined { if (!input) return undefined - const trimmed = input.trim().replace(/\s+/g, ' ') + const trimmed = input.trim() if (!trimmed) return undefined - const hasAuthPrefix = /^auth=/.test(trimmed) - const segments = trimmed.split(/;\s*/).filter(Boolean) - const ocLocale = segments.find((s) => s.startsWith('oc_locale=')) + const segments = trimmed.split(/[;,]/).map((s) => s.trim()).filter(Boolean) - if (hasAuthPrefix) { - // Already valid: just ensure oc_locale exists. Re-stitch from - // segments so any extra whitespace in the original gets normalized. - const authSeg = (segments.find((s) => s.startsWith('auth=')) ?? segments[0] ?? '').trim() - const ocSeg = ocLocale ?? 'oc_locale=en' - return `${authSeg}; ${ocSeg}` + // 1) Auth token — order-independent. + let auth = segments.find((s) => /^auth=/i.test(s)) + if (auth === undefined) { + // A bare token (no "=") that looks like an opaque auth value. + const bare = segments.find((s) => !s.includes('=') && s.length >= 8) + if (bare !== undefined) auth = `auth=${bare}` } + if (auth === undefined) return undefined + + const authValue = auth.slice(auth.indexOf('=') + 1).trim().replace(/^"|"$/g, '') + if (authValue.length === 0) return undefined + + // 2) Locale — preserve the pasted one (the zh parser understands zh pages), + // fall back to `en` when absent or malformed. + const localeSeg = segments.find((s) => /^oc_locale=/i.test(s)) + const rawLocale = localeSeg ? localeSeg.slice(localeSeg.indexOf('=') + 1).trim() : '' + const locale = /^[A-Za-z]{2,3}$/.test(rawLocale) ? rawLocale.toLowerCase() : 'en' - // User pasted just the auth value (possibly with oc_locale appended). - // The first segment is the auth value; prepend "auth=". - const authValue = (segments[0] ?? '').trim() - const extras = segments - .slice(1) - .map((s) => s.trim()) - .filter(Boolean) - const ocLocale2 = extras.find((s) => s.startsWith('oc_locale=')) ?? 'oc_locale=en' - return `auth=${authValue}; ${ocLocale2}` + return `auth=${authValue}; oc_locale=${locale}` } function pickNumber(