Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4a78fc
Add secure shared auth-store module
MrFok Jun 17, 2026
91495a6
Rename auth provider and add login/logout CLI
MrFok Jun 17, 2026
d7d3e81
Refresh sidebar auth status and polish TUI auth/model UI
MrFok Jun 17, 2026
07e45cd
Cover provider rename, secure writes, and menu state in tests
MrFok Jun 17, 2026
22cfca0
Document secure auth flow, model picker, and bump to 0.1.5
MrFok Jun 17, 2026
497c00c
Suppress readline echo during interactive login prompt
MrFok Jun 17, 2026
6382d70
Rewrite README to cover sidebar, slash commands, skip patterns, troub…
MrFok Jun 17, 2026
d3ff2fc
Rotate away stale legacy TTC entries on login
MrFok Jun 17, 2026
1dff861
Use shared getAuthStorePath in the transform; document RMW race
MrFok Jun 17, 2026
e83811a
Fix sidebar metrics across session switches
MrFok Jun 17, 2026
11a2a13
fix: clear stale sidebar state after logout
MrFok Jun 17, 2026
d4f96eb
fix: keep plugin self-contained for legacy opencode-ttc-plugin install
MrFok Jun 17, 2026
9933cb5
fix: recognize TTC_API_KEY env as valid auth in TUI and sidebar
MrFok Jun 17, 2026
9a68d83
test: cover TTC_API_KEY env as valid auth in loadAuthStatus + hasAuth…
MrFok Jun 17, 2026
6de830a
remove custom TTC API key prompt from TUI; single native flow only
MrFok Jun 17, 2026
d8267c6
fix double prompt on opencode auth login: use default API key entry f…
MrFok Jun 17, 2026
8370585
fix env auth menu and symlink logout edge cases
MrFok Jun 18, 2026
6387ae6
fix reviewer auth and metrics edge cases
MrFok Jun 18, 2026
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
339 changes: 225 additions & 114 deletions README.md

Large diffs are not rendered by default.

217 changes: 217 additions & 0 deletions lib/auth-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { readFile, writeFile, rename, mkdir, rm, chmod, lstat } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join } from "node:path";
import { randomBytes } from "node:crypto";

export const AUTH_PROVIDER_ID = "opencode-ttc-plugin";
export const LEGACY_AUTH_PROVIDER_IDS = ["the-token-company-plugin"];
const AUTH_FILE_MODE = 0o600;
const AUTH_DIR_MODE = 0o700;

export function getAuthStorePath(env = process.env) {
const raw = String(env.XDG_DATA_HOME ?? "").trim();
if (raw && isAbsolute(raw)) {
return join(raw, "opencode", "auth.json");
}
return join(homedir(), ".local", "share", "opencode", "auth.json");
Comment on lines +13 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share auth path resolution with the transform

When XDG_DATA_HOME is set to a relative path, login/TUI now use this helper and fall back to ~/.local/share, but the message transform still computes the auth path with its own getAuthStorePath in opencode-plugins/ttc-message-transform.js:436-440, which reads from the relative path instead. In that environment opencode-ttc-plugin login, doctor, and the sidebar can report a saved key while compression continues with missing auth; update the transform to use this shared helper or the same fallback.

Useful? React with 👍 / 👎.

}

export async function readAuthStore({
authFilePath = getAuthStorePath(),
readFileImpl = readFile,
lstatImpl = lstat
} = {}) {
let stats;
try {
stats = await lstatImpl(authFilePath);
} catch (error) {
if (error?.code === "ENOENT") return { parsed: {}, source: "missing" };
throw error;
}
if (!stats.isFile()) {
const err = new Error("auth store path is not a regular file");
err.code = "AUTH_STORE_NOT_REGULAR_FILE";
throw err;
}

const content = await readFileImpl(authFilePath, "utf8");
const trimmed = String(content ?? "").trim();
if (!trimmed) return { parsed: {}, source: "empty" };

let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (error) {
const err = new Error("auth store JSON is corrupt and cannot be parsed");
err.code = "AUTH_STORE_CORRUPT";
err.cause = error;
err.rawContent = trimmed;
throw err;
}

if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
const err = new Error("auth store JSON is not an object");
err.code = "AUTH_STORE_NOT_OBJECT";
throw err;
}
return { parsed, source: "parsed" };
}

export function findAuthEntry(parsed, providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS]) {
if (!parsed || typeof parsed !== "object") return null;
for (const candidateID of providerIDs) {
const auth = parsed[candidateID];
if (auth && typeof auth === "object" && auth.type === "api" && String(auth.key ?? "").trim()) {
return { providerID: candidateID, key: String(auth.key).trim() };
}
}
return null;
}

export async function hasAuthEntry({
authFilePath = getAuthStorePath(),
providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS],
env = process.env,
readFileImpl = readFile,
lstatImpl = lstat
} = {}) {
// TTC_API_KEY in env is a supported first-class auth source
if (String(env.TTC_API_KEY ?? "").trim()) {
return { hasKey: true, providerID: "env" };
}
try {
const { parsed } = await readAuthStore({ authFilePath, readFileImpl, lstatImpl });
const entry = findAuthEntry(parsed, providerIDs);
return { hasKey: Boolean(entry), providerID: entry?.providerID ?? null };
} catch {
return { hasKey: false, providerID: null };
}
}

async function atomicWriteJson(authFilePath, parsed, {
writeFileImpl = writeFile,
renameImpl = rename,
mkdirImpl = mkdir,
chmodImpl = chmod,
rmImpl = rm
} = {}) {
await mkdirImpl(dirname(authFilePath), { recursive: true, mode: AUTH_DIR_MODE });
try {
await chmodImpl(dirname(authFilePath), AUTH_DIR_MODE);
} catch {
// best-effort; some filesystems ignore directory chmod
}

const suffix = `${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`;
const tempPath = `${authFilePath}.${suffix}.tmp`;
const body = `${JSON.stringify(parsed, null, 2)}\n`;
try {
await writeFileImpl(tempPath, body, { encoding: "utf8", mode: AUTH_FILE_MODE });
await chmodImpl(tempPath, AUTH_FILE_MODE);
await renameImpl(tempPath, authFilePath);
try {
await chmodImpl(authFilePath, AUTH_FILE_MODE);
} catch {
// best-effort; rename may have already applied the mode from the temp file
}
} finally {
await rmImpl(tempPath, { force: true }).catch(() => {});
}
}

export async function writeAuthEntry({
apiKey,
providerID = AUTH_PROVIDER_ID,
legacyProviderIDs = LEGACY_AUTH_PROVIDER_IDS,
authFilePath = getAuthStorePath(),
readFileImpl = readFile,
writeFileImpl = writeFile,
renameImpl = rename,
mkdirImpl = mkdir,
chmodImpl = chmod,
rmImpl = rm,
lstatImpl = lstat
} = {}) {
const trimmed = String(apiKey ?? "").trim();
if (!trimmed) return { ok: false, reason: "empty_key", authFilePath };

let parsed;
try {
const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl });
parsed = result.parsed;
} catch (error) {
if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") {
return { ok: false, reason: "auth_store_corrupt", authFilePath, error };
}
if (error?.code === "AUTH_STORE_NOT_REGULAR_FILE") {
return { ok: false, reason: "auth_store_not_regular_file", authFilePath, error };
}
return { ok: false, reason: "auth_store_read_failed", authFilePath, error };
}

const removedLegacyIDs = [];
for (const candidateID of legacyProviderIDs) {
if (candidateID === providerID) continue;
if (Object.prototype.hasOwnProperty.call(parsed, candidateID)) {
delete parsed[candidateID];
removedLegacyIDs.push(candidateID);
}
}

parsed[providerID] = { type: "api", key: trimmed };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove legacy TTC keys when saving a replacement

For users upgrading from the old the-token-company-plugin provider ID, logging in with the new flow writes the replacement key under opencode-ttc-plugin but leaves the old TTC key in auth.json. Since the runtime now prefers the new entry and logout explicitly removes both IDs, key rotation through opencode-ttc-plugin login leaves a stale secret on disk indefinitely; delete the legacy provider entries before writing the current one.

Useful? React with 👍 / 👎.


try {
await atomicWriteJson(authFilePath, parsed, { writeFileImpl, renameImpl, mkdirImpl, chmodImpl, rmImpl });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize auth-store read-modify-writes

During a concurrent auth change, this writes the snapshot read earlier over the whole auth.json. If another provider is added or rotated after readAuthStore but before this rename, the temp-file rename atomically replaces the file with stale contents and drops that provider; temp+rename prevents partial writes but does not serialize or merge concurrent read-modify-write operations. Use a lock or re-read/merge before replacing the file to avoid losing unrelated credentials.

Useful? React with 👍 / 👎.

} catch (error) {
return { ok: false, reason: "auth_store_write_failed", authFilePath, error };
}

return { ok: true, authFilePath, providerID, removedLegacyIDs };
}

export async function removeAuthEntry({
providerIDs = [AUTH_PROVIDER_ID, ...LEGACY_AUTH_PROVIDER_IDS],
authFilePath = getAuthStorePath(),
readFileImpl = readFile,
writeFileImpl = writeFile,
renameImpl = rename,
mkdirImpl = mkdir,
chmodImpl = chmod,
rmImpl = rm,
lstatImpl = lstat
} = {}) {
let parsed;
try {
const result = await readAuthStore({ authFilePath, readFileImpl, lstatImpl });
parsed = result.parsed;
} catch (error) {
if (error?.code === "ENOENT") {
return { ok: true, authFilePath, removedAny: false };
}
if (error?.code === "AUTH_STORE_NOT_REGULAR_FILE") {
return { ok: false, reason: "auth_store_not_regular_file", authFilePath, error };
}
if (error?.code === "AUTH_STORE_CORRUPT" || error?.code === "AUTH_STORE_NOT_OBJECT") {
return { ok: false, reason: "auth_store_corrupt", authFilePath, error };
}
return { ok: false, reason: "auth_store_read_failed", authFilePath, error };
}

let removedAny = false;
for (const candidateID of providerIDs) {
if (Object.prototype.hasOwnProperty.call(parsed, candidateID)) {
delete parsed[candidateID];
removedAny = true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

if (!removedAny) return { ok: true, authFilePath, removedAny: false };

try {
await atomicWriteJson(authFilePath, parsed, { writeFileImpl, renameImpl, mkdirImpl, chmodImpl, rmImpl });
} catch (error) {
return { ok: false, reason: "auth_store_write_failed", authFilePath, error };
}

return { ok: true, authFilePath, removedAny: true };
}
4 changes: 4 additions & 0 deletions opencode-plugins/ttc-message-transform-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export function createSessionStats(...args) {
return testApi.createSessionStats(...args);
}

export function hydrateSessionStatsFromSidebarState(...args) {
return testApi.hydrateSessionStatsFromSidebarState(...args);
}

export function resetLastMessageStats(...args) {
return testApi.resetLastMessageStats(...args);
}
Expand Down
Loading