/** * File-based OAuth token storage. * * Tokens are persisted under a directory (default * `~/.kimi-code/credentials/`) as `.json` with mode 0600 (parent * dir 0700). Wire format uses snake_case to match the server contract. * * Write semantics: write to `.tmp..` → fsync → rename. * Atomic on POSIX; Windows best-effort. * * Load semantics: missing file → undefined. Corrupt JSON / wrong shape → * undefined (never throws). Callers treat undefined as "no token stored". */ import { randomBytes } from 'node:crypto'; import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeSync, } from 'node:fs'; import { basename, join } from 'node:path'; import type { TokenInfo, TokenInfoWire } from './types'; import { tokenFromWire, tokenToWire } from './types'; import { isRecord } from './utils'; export interface TokenStorage { load(name: string): Promise; save(name: string, token: TokenInfo): Promise; remove(name: string): Promise; list(): Promise; } export class FileTokenStorage implements TokenStorage { private readonly dir: string; constructor(dir: string) { this.dir = dir; } private ensureDir(): void { mkdirSync(this.dir, { recursive: true, mode: 0o700 }); // recursive=true with mode only applies on initial create; tighten after // the fact in case an existing dir had looser permissions. try { chmodSync(this.dir, 0o700); } catch { // best-effort; Windows / read-only FS may refuse } } private pathFor(name: string): string { // Guard against path traversal: caller-provided names (from config.toml // or slash commands) must not escape the credentials dir. `basename` // strips any `..` or `/` segments; if the sanitized value differs from // the input we refuse the request entirely rather than silently // writing to a different file than the caller asked for. const safe = basename(name); if (safe.length === 0 || safe !== name || safe.startsWith('.')) { throw new Error(`Invalid token name: "${name}"`); } return join(this.dir, `${safe}.json`); } async load(name: string): Promise { const file = this.pathFor(name); let raw: string; try { raw = readFileSync(file, 'utf-8'); } catch { return undefined; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return undefined; } if (!isRecord(parsed)) return undefined; return tokenFromWire(parsed as Partial); } async save(name: string, token: TokenInfo): Promise { this.ensureDir(); const target = this.pathFor(name); const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`; const data = Buffer.from(`${JSON.stringify(tokenToWire(token), null, 2)}\n`, 'utf-8'); const fd = openSync(tmp, 'w', 0o600); try { let written = 0; while (written < data.length) { written += writeSync(fd, data, written, data.length - written); } fsyncSync(fd); } finally { closeSync(fd); } try { // chmod again in case umask stripped bits during open chmodSync(tmp, 0o600); renameSync(tmp, target); } catch (error) { try { unlinkSync(tmp); } catch { /* ignore */ } throw error; } } async remove(name: string): Promise { try { unlinkSync(this.pathFor(name)); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } } async list(): Promise { let entries: string[]; try { entries = readdirSync(this.dir); } catch { return []; } return entries.filter((e) => e.endsWith('.json')).map((e) => e.slice(0, -'.json'.length)); } }