dsh-codex-oauth 0.2.4 → 0.2.6

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.md CHANGED
@@ -61,6 +61,8 @@ The default is `~/.dsh/.openai-codex-auth.json`. The file is written atomically
61
61
  npm test
62
62
  ```
63
63
 
64
+ `npm test` rebuilds the Web bundle before running the smoke checks.
65
+
64
66
  The test requires the DSH and `pi-ai` peer dependencies supplied by a DSH profile.
65
67
 
66
68
  ## Limitations
package/README.zh.md CHANGED
@@ -74,6 +74,8 @@ dsh plugin --profile web exec dsh-openai-codex logout
74
74
  npm test
75
75
  ```
76
76
 
77
+ `npm test` 会先重新构建 Web bundle,再运行基础检查。
78
+
77
79
  测试需要当前 DSH profile 提供 DSH 和 `pi-ai` peer dependencies。
78
80
 
79
81
  ## 限制
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { authPath, login, logout, status } from "../lib/auth.js";
5
+ import { safeMessage } from "../lib/safe-message.js";
5
6
 
6
7
  function openBrowser(raw) {
7
8
  const url = new URL(raw);
@@ -10,11 +11,6 @@ function openBrowser(raw) {
10
11
  const child = spawn(command[0], command.slice(1), { detached: true, stdio: "ignore", windowsHide: true });
11
12
  child.on("error", () => {}); child.unref();
12
13
  }
13
- function safeMessage(error) {
14
- return String(error?.message ?? error)
15
- .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]")
16
- .replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]");
17
- }
18
14
  function help() {
19
15
  console.log("Usage: dsh-openai-codex <login|logout|status> [--device-code]");
20
16
  }
package/client/index.jsx CHANGED
@@ -8,7 +8,7 @@ const h = React.createElement;
8
8
  async function request(path, method = "GET") {
9
9
  const response = await fetch(path, { method, credentials: "same-origin", headers: { accept: "application/json" } });
10
10
  const value = await response.json().catch(() => ({}));
11
- if (!response.ok) throw new Error(value.error || `HTTP ${response.status}`);
11
+ if (!response.ok) throw new Error(value && typeof value === "object" && typeof value.error === "string" ? value.error : `HTTP ${response.status}`);
12
12
  return value;
13
13
  }
14
14
 
@@ -22,11 +22,18 @@ function CodexSettings() {
22
22
  const timer = setInterval(refresh, 1000);
23
23
  return () => clearInterval(timer);
24
24
  }, [refresh, state.status]);
25
- const signIn = () => {
25
+ const signIn = async () => {
26
+ const popup = window.open("about:blank", "_blank");
27
+ if (!popup) { setState({ status: "error", message: "Please allow popups to sign in" }); return; }
28
+ popup.opener = null;
26
29
  setBusy(true); setState({ status: "signing-in" });
27
- const popup = window.open(LOGIN, "_blank", "noopener,noreferrer");
28
- if (!popup) { setBusy(false); setState({ status: "error", message: "Please allow popups to sign in" }); }
29
- else setTimeout(() => setBusy(false), 500);
30
+ try {
31
+ const challenge = await request(LOGIN, "POST");
32
+ popup.location.replace(challenge.url);
33
+ } catch (error) {
34
+ popup?.close();
35
+ setState({ status: "error", message: error.message });
36
+ } finally { setBusy(false); }
30
37
  };
31
38
  const signOut = async () => { setBusy(true); try { await request(LOGOUT, "POST"); setState({ status: "signed-out" }); } catch (error) { setState({ status: "error", message: error.message }); } finally { setBusy(false); } };
32
39
  const signedIn = state.status === "signed-in";
package/lib/adapter.js CHANGED
@@ -14,8 +14,9 @@ function tokenProvider(provider) {
14
14
  apiKey: {
15
15
  name: "OpenAI Codex OAuth bearer token",
16
16
  async resolve({ credential }) {
17
- return credential?.access
18
- ? { auth: { apiKey: credential.access }, source: "Pi OAuth" }
17
+ const access = credential?.access ?? credential?.key;
18
+ return access
19
+ ? { auth: { apiKey: access }, source: "DSH Codex OAuth" }
19
20
  : undefined;
20
21
  },
21
22
  },
package/lib/client.js CHANGED
@@ -43,7 +43,7 @@ var h = import_react.default.createElement;
43
43
  async function request(path, method = "GET") {
44
44
  const response = await fetch(path, { method, credentials: "same-origin", headers: { accept: "application/json" } });
45
45
  const value = await response.json().catch(() => ({}));
46
- if (!response.ok) throw new Error(value.error || `HTTP ${response.status}`);
46
+ if (!response.ok) throw new Error(value && typeof value === "object" && typeof value.error === "string" ? value.error : `HTTP ${response.status}`);
47
47
  return value;
48
48
  }
49
49
  function CodexSettings() {
@@ -58,14 +58,24 @@ function CodexSettings() {
58
58
  const timer = setInterval(refresh, 1e3);
59
59
  return () => clearInterval(timer);
60
60
  }, [refresh, state.status]);
61
- const signIn = () => {
61
+ const signIn = async () => {
62
+ const popup = window.open("about:blank", "_blank");
63
+ if (!popup) {
64
+ setState({ status: "error", message: "Please allow popups to sign in" });
65
+ return;
66
+ }
67
+ popup.opener = null;
62
68
  setBusy(true);
63
69
  setState({ status: "signing-in" });
64
- const popup = window.open(LOGIN, "_blank", "noopener,noreferrer");
65
- if (!popup) {
70
+ try {
71
+ const challenge = await request(LOGIN, "POST");
72
+ popup.location.replace(challenge.url);
73
+ } catch (error) {
74
+ popup?.close();
75
+ setState({ status: "error", message: error.message });
76
+ } finally {
66
77
  setBusy(false);
67
- setState({ status: "error", message: "Please allow popups to sign in" });
68
- } else setTimeout(() => setBusy(false), 500);
78
+ }
69
79
  };
70
80
  const signOut = async () => {
71
81
  setBusy(true);
@@ -0,0 +1,8 @@
1
+ export function safeMessage(error) {
2
+ return String(error?.message ?? error)
3
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]")
4
+ .replace(/([?&](?:code|token|refresh_token|access_token|id_token)=)[^&#\s]+/giu, "$1[redacted]")
5
+ .replace(/(["']?(?:access_token|refresh_token|id_token|authorization_code|code_verifier|token|code)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^,}\s]+)/giu, "$1[redacted]")
6
+ .replace(/(\bBearer\s+)[^\s,]+/giu, "$1[redacted]")
7
+ .slice(0, 500);
8
+ }
package/lib/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, stat } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
@@ -10,11 +10,22 @@ export function authPath(home = process.env.DSH_HOME || join(homedir(), ".dsh"))
10
10
  }
11
11
 
12
12
  function clone(value) { return value === undefined ? undefined : structuredClone(value); }
13
+ async function privateDirectory(filename) {
14
+ const directory = dirname(filename);
15
+ await mkdir(directory, { recursive: true, mode: 0o700 });
16
+ if (process.platform === "win32") return;
17
+ const info = await lstat(directory);
18
+ if (info.isSymbolicLink()) throw new Error(`${directory} must not be a symbolic link`);
19
+ if (info.mode & 0o077) await chmod(directory, 0o700);
20
+ }
13
21
  async function ownerOnly(filename) {
14
22
  if (process.platform === "win32") return;
15
- try {
16
- if ((await stat(filename)).mode & 0o077) throw new Error(`${filename} must be owner-only (chmod 600)`);
17
- } catch (error) { if (error?.code !== "ENOENT") throw error; }
23
+ for (const [path, label] of [[dirname(filename), "directory"], [filename, "file"]]) {
24
+ try {
25
+ const info = await lstat(path);
26
+ if (info.isSymbolicLink() || info.mode & 0o077) throw new Error(`${path} must be an owner-only ${label}`);
27
+ } catch (error) { if (error?.code !== "ENOENT") throw error; }
28
+ }
18
29
  }
19
30
  function parse(text, filename) {
20
31
  let value;
@@ -30,6 +41,7 @@ function parse(text, filename) {
30
41
  export class CodexCredentialStore {
31
42
  constructor(filename = authPath()) { this.filename = filename; }
32
43
  async readCurrent() {
44
+ await privateDirectory(this.filename);
33
45
  await ownerOnly(this.filename);
34
46
  try { return parse(await readFile(this.filename, "utf8"), this.filename); }
35
47
  catch (error) { if (error?.code === "ENOENT") return undefined; throw error; }
@@ -38,7 +50,7 @@ export class CodexCredentialStore {
38
50
  async list() { return (await this.readCurrent()) ? [{ providerId: OPENAI_CODEX_PROVIDER, type: "oauth" }] : []; }
39
51
  async modify(providerId, fn) {
40
52
  if (providerId !== OPENAI_CODEX_PROVIDER) throw new Error(`credential store does not own ${providerId}`);
41
- await mkdir(dirname(this.filename), { recursive: true, mode: 0o700 });
53
+ await privateDirectory(this.filename);
42
54
  return withFileLock(this.filename, async () => {
43
55
  const current = await this.readCurrent();
44
56
  const next = await fn(clone(current));
@@ -51,8 +63,9 @@ export class CodexCredentialStore {
51
63
  }
52
64
  async delete(providerId) {
53
65
  if (providerId !== OPENAI_CODEX_PROVIDER) return;
54
- await mkdir(dirname(this.filename), { recursive: true, mode: 0o700 });
66
+ await privateDirectory(this.filename);
55
67
  await withFileLock(this.filename, async () => {
68
+ await ownerOnly(this.filename);
56
69
  const { rm } = await import("node:fs/promises");
57
70
  await rm(this.filename, { force: true });
58
71
  });
package/lib/web-auth.js CHANGED
@@ -1,15 +1,10 @@
1
1
  import { login, logout, status } from "./auth.js";
2
+ import { safeMessage } from "./safe-message.js";
2
3
 
3
4
  export const AUTH_STATUS_PATH = "/plugins/dsh-codex-oauth/auth/status";
4
5
  export const AUTH_LOGIN_PATH = "/plugins/dsh-codex-oauth/auth/login";
5
6
  export const AUTH_LOGOUT_PATH = "/plugins/dsh-codex-oauth/auth/logout";
6
7
 
7
- function safeMessage(error) {
8
- return String(error?.message ?? error)
9
- .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]")
10
- .replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]")
11
- .slice(0, 500);
12
- }
13
8
  function trusted(req) {
14
9
  const remote = req.socket.remoteAddress;
15
10
  if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote)) return false;
@@ -26,42 +21,78 @@ function json(res, statusCode, value) {
26
21
 
27
22
  export function registerWebAuth(ctx, store) {
28
23
  let operation;
24
+ let cancellation;
29
25
  let challenge;
30
26
  let state = { status: "signed-out" };
31
- const cancellation = new AbortController();
32
- const update = () => status(store).then((value) => { state = value.authenticated ? { status: "signed-in" } : { status: "signed-out" }; return state; });
27
+ const update = async () => {
28
+ const value = await status(store);
29
+ state = value.authenticated ? { status: "signed-in" } : { status: "signed-out" };
30
+ return state;
31
+ };
33
32
  const start = () => {
34
33
  if (operation) return;
34
+ const controller = new AbortController();
35
+ cancellation = controller;
35
36
  state = { status: "signing-in" };
36
37
  challenge = undefined;
37
- operation = login(store, {
38
- signal: cancellation.signal,
39
- prompt: async (prompt) => prompt.type === "select" ? "browser" : new Promise((_resolve, reject) => cancellation.signal.addEventListener("abort", () => reject(cancellation.signal.reason), { once: true })),
40
- notify: (event) => { if (event.type === "auth_url") challenge = { url: event.url }; },
41
- }).then(update, (error) => { state = { status: "error", message: safeMessage(error) }; }).finally(() => { operation = undefined; });
38
+ operation = (async () => {
39
+ try {
40
+ await login(store, {
41
+ signal: controller.signal,
42
+ prompt: async (prompt) => prompt.type === "select"
43
+ ? "browser"
44
+ : new Promise((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true })),
45
+ notify: (event) => {
46
+ if (event.type !== "auth_url") return;
47
+ try {
48
+ const url = new URL(event.url);
49
+ if (url.protocol !== "https:") throw new Error("OAuth URL must use HTTPS");
50
+ challenge = { url: url.href };
51
+ } catch (error) { controller.abort(error); }
52
+ },
53
+ });
54
+ await update();
55
+ } catch (error) {
56
+ state = controller.signal.aborted ? { status: "signed-out" } : { status: "error", message: safeMessage(error) };
57
+ } finally {
58
+ if (cancellation === controller) cancellation = undefined;
59
+ operation = undefined;
60
+ }
61
+ })();
42
62
  };
43
63
  ctx.inject(["webServer"], (web) => ctx.effect(() => {
44
64
  const routes = [
45
65
  web.webServer.register({ kind: "exact", path: AUTH_STATUS_PATH, handler: async (req, res) => {
46
66
  if (req.method !== "GET" || !trusted(req)) return json(res, req.method === "GET" ? 403 : 405, { error: "forbidden" });
47
- if (!operation) await update();
48
- json(res, 200, state);
67
+ try {
68
+ if (!operation) await update();
69
+ json(res, 200, state);
70
+ } catch (error) { json(res, 500, { error: safeMessage(error) }); }
49
71
  }}),
50
72
  web.webServer.register({ kind: "exact", path: AUTH_LOGIN_PATH, handler: async (req, res) => {
51
- if (!trusted(req) || !["GET", "POST"].includes(req.method)) return json(res, req.method === "GET" || req.method === "POST" ? 403 : 405, { error: "forbidden" });
73
+ if (req.method !== "POST" || !trusted(req)) return json(res, req.method === "POST" ? 403 : 405, { error: "forbidden" });
52
74
  try {
53
75
  start();
54
76
  for (let i = 0; i < 50 && !challenge && operation; i++) await new Promise((resolve) => setTimeout(resolve, 100));
55
77
  if (!challenge) throw new Error("OAuth URL was not created");
56
- if (req.method === "GET") { res.writeHead(302, { location: challenge.url, "cache-control": "no-store" }); res.end(); }
57
- else json(res, 200, challenge);
78
+ json(res, 200, challenge);
58
79
  } catch (error) { json(res, 500, { error: safeMessage(error) }); }
59
80
  }}),
60
81
  web.webServer.register({ kind: "exact", path: AUTH_LOGOUT_PATH, handler: async (req, res) => {
61
82
  if (req.method !== "POST" || !trusted(req)) return json(res, req.method === "POST" ? 403 : 405, { error: "forbidden" });
62
- cancellation.abort(new Error("sign-in cancelled")); await operation?.catch(() => {}); await logout(store); state = { status: "signed-out" }; json(res, 200, { ok: true });
83
+ try {
84
+ cancellation?.abort(new Error("sign-in cancelled"));
85
+ await operation?.catch(() => {});
86
+ await logout(store);
87
+ state = { status: "signed-out" };
88
+ json(res, 200, { ok: true });
89
+ } catch (error) { json(res, 500, { error: safeMessage(error) }); }
63
90
  }}),
64
91
  ];
65
- return async () => { cancellation.abort(new Error("plugin disposed")); await operation?.catch(() => {}); for (const dispose of routes) dispose(); };
92
+ return async () => {
93
+ cancellation?.abort(new Error("plugin disposed"));
94
+ await operation?.catch(() => {});
95
+ for (const dispose of routes) dispose();
96
+ };
66
97
  }, "dsh-codex-oauth: Web OAuth routes"));
67
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codex-oauth",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Use a ChatGPT Codex subscription in DeepSeek Harness through standalone OAuth",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "keywords": ["dsh-plugin", "deepseek-harness", "openai-codex", "oauth"],
14
14
  "license": "MIT",
15
- "scripts": { "test": "node test/smoke.mjs" },
15
+ "scripts": { "build": "node scripts/build-client.mjs", "test": "npm run build && node test/smoke.mjs && node test/web-auth.mjs" },
16
16
  "type": "module",
17
17
  "main": "lib/index.js",
18
18
  "bin": { "dsh-openai-codex": "bin/dsh-openai-codex.js" },
@@ -25,6 +25,7 @@
25
25
  "platform": "web"
26
26
  }
27
27
  },
28
+ "devDependencies": { "esbuild": "^0.28.2" },
28
29
  "peerDependencies": {
29
30
  "@earendil-works/pi-ai": "^0.84.1",
30
31
  "@deepseek-ai/cordis": "^4.0.1",