From 3f5d017ef91d1995e46ff32d2c0fd0038447974c Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 08:20:53 +0400 Subject: [PATCH 1/2] feat: enhance credential loading with hex-encoded JSON support - Added a new function to parse credentials from hex-encoded JSON format, improving compatibility with macOS keychain items. - Updated the credential loading logic to utilize the new parsing function for both file and keychain sources. - Added a test case to verify the correct handling of hex-encoded JSON credentials. --- plugins/claude/plugin.js | 32 ++++++++++++++++++++++++++++++-- plugins/claude/plugin.test.js | 17 +++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 70779891..e80047f2 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -7,12 +7,39 @@ const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration + function tryParseCredentialJSON(text) { + if (!text) return null + const trimmed = String(text).trim() + if (!trimmed) return null + try { + return JSON.parse(trimmed) + } catch {} + + // Some macOS keychain items are returned by `security ... -w` as hex-encoded UTF-8 bytes. + // Example prefix: "7b0a" ( "{\\n" ). + // Support both plain hex and "0x..." forms. + let hex = trimmed + if (hex.startsWith("0x") || hex.startsWith("0X")) hex = hex.slice(2) + if (!hex || hex.length % 2 !== 0) return null + if (!/^[0-9a-fA-F]+$/.test(hex)) return null + try { + let decoded = "" + for (let i = 0; i < hex.length; i += 2) { + decoded += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16)) + } + return JSON.parse(decoded) + } catch {} + + return null + } + function loadCredentials(ctx) { // Try file first if (ctx.host.fs.exists(CRED_FILE)) { try { const text = ctx.host.fs.readText(CRED_FILE) - const parsed = JSON.parse(text) + const parsed = tryParseCredentialJSON(text) + if (!parsed) return null const oauth = parsed.claudeAiOauth if (oauth && oauth.accessToken) { return { oauth, source: "file", fullData: parsed } @@ -25,7 +52,8 @@ try { const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) if (keychainValue) { - const parsed = JSON.parse(keychainValue) + const parsed = tryParseCredentialJSON(keychainValue) + if (!parsed) return null const oauth = parsed.claudeAiOauth if (oauth && oauth.accessToken) { return { oauth, source: "keychain", fullData: parsed } diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index 247aeda1..a4812895 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -139,6 +139,23 @@ describe("claude plugin", () => { expect(result.lines.find((line) => line.label === "Extra usage")).toBeTruthy() }) + it("uses keychain credentials when value is hex-encoded JSON", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => false + const json = JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } }, null, 2) + const hex = Buffer.from(json, "utf8").toString("hex") + ctx.host.keychain.readGenericPassword.mockReturnValue(hex) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() + }) + it("throws on http errors and parse failures", async () => { const ctx = makeCtx() ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } }) From 159cffa8f5460595e631d4f6b23eb27abc93fc2f Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 08:35:25 +0400 Subject: [PATCH 2/2] feat: implement UTF-8 decoding for hex-encoded JSON credentials - Added a new function to decode UTF-8 from hex-encoded byte arrays, enhancing the handling of non-ASCII characters in credentials. - Updated the credential loading logic to utilize the new decoding function for improved robustness. - Added tests to verify the correct decoding of hex-encoded JSON, including scenarios with corrupt files and non-ASCII characters. --- plugins/claude/plugin.js | 112 +++++++++++++++++++++++++++++++--- plugins/claude/plugin.test.js | 34 +++++++++++ 2 files changed, 136 insertions(+), 10 deletions(-) diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index e80047f2..7a6128c4 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -7,6 +7,95 @@ const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration + function utf8DecodeBytes(bytes) { + // Prefer native TextDecoder when available (QuickJS may not expose it). + if (typeof TextDecoder !== "undefined") { + try { + return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes)) + } catch {} + } + + // Minimal UTF-8 decoder (replacement char on invalid sequences). + let out = "" + for (let i = 0; i < bytes.length; ) { + const b0 = bytes[i] & 0xff + if (b0 < 0x80) { + out += String.fromCharCode(b0) + i += 1 + continue + } + + // 2-byte + if (b0 >= 0xc2 && b0 <= 0xdf) { + if (i + 1 >= bytes.length) { + out += "\ufffd" + break + } + const b1 = bytes[i + 1] & 0xff + if ((b1 & 0xc0) !== 0x80) { + out += "\ufffd" + i += 1 + continue + } + const cp = ((b0 & 0x1f) << 6) | (b1 & 0x3f) + out += String.fromCharCode(cp) + i += 2 + continue + } + + // 3-byte + if (b0 >= 0xe0 && b0 <= 0xef) { + if (i + 2 >= bytes.length) { + out += "\ufffd" + break + } + const b1 = bytes[i + 1] & 0xff + const b2 = bytes[i + 2] & 0xff + const validCont = (b1 & 0xc0) === 0x80 && (b2 & 0xc0) === 0x80 + const notOverlong = !(b0 === 0xe0 && b1 < 0xa0) + const notSurrogate = !(b0 === 0xed && b1 >= 0xa0) + if (!validCont || !notOverlong || !notSurrogate) { + out += "\ufffd" + i += 1 + continue + } + const cp = ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f) + out += String.fromCharCode(cp) + i += 3 + continue + } + + // 4-byte + if (b0 >= 0xf0 && b0 <= 0xf4) { + if (i + 3 >= bytes.length) { + out += "\ufffd" + break + } + const b1 = bytes[i + 1] & 0xff + const b2 = bytes[i + 2] & 0xff + const b3 = bytes[i + 3] & 0xff + const validCont = (b1 & 0xc0) === 0x80 && (b2 & 0xc0) === 0x80 && (b3 & 0xc0) === 0x80 + const notOverlong = !(b0 === 0xf0 && b1 < 0x90) + const notTooHigh = !(b0 === 0xf4 && b1 > 0x8f) + if (!validCont || !notOverlong || !notTooHigh) { + out += "\ufffd" + i += 1 + continue + } + const cp = + ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f) + const n = cp - 0x10000 + out += String.fromCharCode(0xd800 + ((n >> 10) & 0x3ff), 0xdc00 + (n & 0x3ff)) + i += 4 + continue + } + + out += "\ufffd" + i += 1 + } + return out + } + function tryParseCredentialJSON(text) { if (!text) return null const trimmed = String(text).trim() @@ -23,10 +112,11 @@ if (!hex || hex.length % 2 !== 0) return null if (!/^[0-9a-fA-F]+$/.test(hex)) return null try { - let decoded = "" + const bytes = [] for (let i = 0; i < hex.length; i += 2) { - decoded += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16)) + bytes.push(parseInt(hex.slice(i, i + 2), 16)) } + const decoded = utf8DecodeBytes(bytes) return JSON.parse(decoded) } catch {} @@ -39,10 +129,11 @@ try { const text = ctx.host.fs.readText(CRED_FILE) const parsed = tryParseCredentialJSON(text) - if (!parsed) return null - const oauth = parsed.claudeAiOauth - if (oauth && oauth.accessToken) { - return { oauth, source: "file", fullData: parsed } + if (parsed) { + const oauth = parsed.claudeAiOauth + if (oauth && oauth.accessToken) { + return { oauth, source: "file", fullData: parsed } + } } } catch (e) { } @@ -53,10 +144,11 @@ const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) if (keychainValue) { const parsed = tryParseCredentialJSON(keychainValue) - if (!parsed) return null - const oauth = parsed.claudeAiOauth - if (oauth && oauth.accessToken) { - return { oauth, source: "keychain", fullData: parsed } + if (parsed) { + const oauth = parsed.claudeAiOauth + if (oauth && oauth.accessToken) { + return { oauth, source: "keychain", fullData: parsed } + } } } } catch (e) { diff --git a/plugins/claude/plugin.test.js b/plugins/claude/plugin.test.js index a4812895..f9e86b7d 100644 --- a/plugins/claude/plugin.test.js +++ b/plugins/claude/plugin.test.js @@ -90,6 +90,24 @@ describe("claude plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Not logged in") }) + it("falls back to keychain when credentials file is corrupt", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => true + ctx.host.fs.readText = () => "{bad json" + ctx.host.keychain.readGenericPassword.mockReturnValue( + JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "pro" } }) + ) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 10, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() + }) + it("renders usage lines from response", async () => { const ctx = makeCtx() ctx.host.fs.readText = () => @@ -156,6 +174,22 @@ describe("claude plugin", () => { expect(result.lines.find((line) => line.label === "Session")).toBeTruthy() }) + it("decodes hex-encoded UTF-8 correctly (non-ascii json)", async () => { + const ctx = makeCtx() + ctx.host.fs.exists = () => false + const json = JSON.stringify({ claudeAiOauth: { accessToken: "token", subscriptionType: "prĂ³" } }, null, 2) + const hex = Buffer.from(json, "utf8").toString("hex") + ctx.host.keychain.readGenericPassword.mockReturnValue(hex) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + five_hour: { utilization: 1, resets_at: "2099-01-01T00:00:00.000Z" }, + }), + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).not.toThrow() + }) + it("throws on http errors and parse failures", async () => { const ctx = makeCtx() ctx.host.fs.readText = () => JSON.stringify({ claudeAiOauth: { accessToken: "token" } })