import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; const PLUGIN_ID = "shell-security"; const PROVIDER_ID = "kilocode_shell_security"; /** * Minimal structural type for the parts of the OpenClaw plugin API this * module touches. We don't want to import the full SDK type surface * (resolved at runtime by the plugin host), but we also don't want to * leak `any` into callers. This interface documents the contract we * rely on. * * Method shorthand (not arrow property) is used on purpose so the * parameter types are bivariant, letting the SDK's concrete * OpenClawConfig satisfy our `unknown` parameter without requiring us * to import the internal SDK type. */ export type PluginRuntimeConfig = { loadConfig(): unknown; writeConfigFile(cfg: unknown): Promise; }; export type PluginLogger = { info?: (msg: string) => void; warn?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void; }; export type TokenStoreApi = { runtime: { config: PluginRuntimeConfig; }; }; export function secretFilePath(): string { return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-auth-token`); } /** * True when the raw openclaw.json has a SecretRef at * `plugins.entries.shell-security.config.authToken` that points at * OUR provider (`kilocode_shell_security`). That shape is only ever * written by writeStoredToken() — a user configuring the plugin by * hand would use a plain string or a SecretRef aimed at a different * provider. * * The plugin can't tell from `api.pluginConfig.authToken` alone * whether the resolved string came from a user-typed value or from * OpenClaw resolving our own SecretRef. Callers use this helper to * treat those cases differently (see `runShellSecurityFlow` in * index.ts): plugin-managed tokens should auto re-auth on 401, but * user-managed ones should surface an "update your openclaw.json" * message. */ export function isPluginManagedAuthToken(config: unknown): boolean { const root = (config && typeof config === "object" ? config : {}) as Record< string, unknown >; const plugins = ( root.plugins && typeof root.plugins === "object" ? root.plugins : {} ) as Record; const entries = ( plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {} ) as Record; const entry = ( entries[PLUGIN_ID] && typeof entries[PLUGIN_ID] === "object" ? entries[PLUGIN_ID] : {} ) as Record; const entryConfig = ( entry.config && typeof entry.config === "object" ? entry.config : {} ) as Record; const authToken = entryConfig.authToken; if (!authToken || typeof authToken !== "object") return false; const ref = authToken as Record; return ( ref.source === "file" && ref.provider === PROVIDER_ID && typeof ref.id === "string" ); } function pendingCodeFilePath(): string { return join(homedir(), ".openclaw", "secrets", `${PLUGIN_ID}-pending-code`); } async function ensureSecretsDir(): Promise { await mkdir(join(homedir(), ".openclaw", "secrets"), { recursive: true }); } /** * Persist the auth token acquired from device auth: * 1. Write the raw token value to a secrets file * 2. Register a file-based SecretRef provider in config * 3. Point the plugin authToken config at that provider * * The config write does NOT trigger a gateway restart: the plugin * declares `reload.noopPrefixes` for * `plugins.entries..config.authToken` in index.ts, which shadows * the gateway reload planner's default `plugins.* → restart` rule for * just that one field. Other `.config.*` fields (e.g. `apiBaseUrl`) * intentionally still hit the default restart rule so runtime edits * take effect. The plugin reads the token directly from the secrets * file via readTokenFromFile() on every invocation, so no hot-resolve * of api.pluginConfig.authToken is needed — the SecretRef in * openclaw.json exists for discoverability (so operators inspecting * config can see where the token lives) and to align with openclaw's * SecretRef direction. */ export async function writeStoredToken( api: TokenStoreApi, token: string, ): Promise { const filePath = secretFilePath(); // 1. Write token to secrets file (mode 600, owner read/write only) await ensureSecretsDir(); await writeFile(filePath, token, { mode: 0o600 }); // 2. Patch config: add file provider + SecretRef pointing at it const current = api.runtime.config.loadConfig(); const next = patchConfig(current, filePath); await api.runtime.config.writeConfigFile(next); } export function patchConfig(cfg: unknown, filePath: string): unknown { const root = (cfg && typeof cfg === "object" ? cfg : {}) as Record< string, unknown >; // Patch secrets.providers. const secrets = ( root.secrets && typeof root.secrets === "object" ? root.secrets : {} ) as Record; const providers = ( secrets.providers && typeof secrets.providers === "object" ? secrets.providers : {} ) as Record; const nextSecrets = { ...secrets, providers: { ...providers, [PROVIDER_ID]: { source: "file", path: filePath, mode: "singleValue", }, }, }; // Patch plugins.entries..config.authToken with SecretRef const plugins = ( root.plugins && typeof root.plugins === "object" ? root.plugins : {} ) as Record; const entries = ( plugins.entries && typeof plugins.entries === "object" ? plugins.entries : {} ) as Record; const existing = ( entries[PLUGIN_ID] && typeof entries[PLUGIN_ID] === "object" ? entries[PLUGIN_ID] : {} ) as Record; const existingConfig = ( existing.config && typeof existing.config === "object" ? existing.config : {} ) as Record; const nextPlugins = { ...plugins, entries: { ...entries, [PLUGIN_ID]: { ...existing, config: { ...existingConfig, authToken: { source: "file", provider: PROVIDER_ID, id: "value", }, }, }, }, }; return { ...root, secrets: nextSecrets, plugins: nextPlugins }; } /** * Read the token directly from the secrets file. * Reliable at any point. No dependency on OpenClaw's SecretRef resolution timing. */ export async function readTokenFromFile(): Promise { try { const content = await readFile(secretFilePath(), "utf-8"); const trimmed = content.trim(); return trimmed.length > 0 ? trimmed : null; } catch (err) { // Missing file is the expected "no saved token" state. Anything // else (permissions, stale NFS handle, IO error) should surface // instead of silently falling through to device auth with no // indication of why the token couldn't be read. if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null; throw err; } } /** * Delete the stored token file. Called when the server rejects a saved * token (expired/revoked) so the next flow invocation falls through to * device auth instead of endlessly retrying a dead token. * * The openclaw.json config still points at the (now missing) SecretRef, * but since the plugin reads tokens via readTokenFromFile() directly * (not via api.pluginConfig.authToken), a missing file is equivalent to * "no token" and Path C1 (device auth) kicks in naturally. */ export async function clearStoredToken(): Promise { try { await unlink(secretFilePath()); } catch (err) { // File already missing is the target state. Any other error // (permissions, stale NFS handle, etc.) needs to surface. if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { throw err; } } } // --- Pending device-auth code --- // // Persisted to a small file next to the token so a gateway restart // during the two-step device auth flow doesn't lose the code the user // is actively looking at. The file contains JSON: // { code: string, expiresAtMs: number } // // Expiry is tracked client-side to match the server TTL (10 min). An // expired file is treated as "no pending code" and cleaned up. const PENDING_CODE_TTL_MS = 10 * 60 * 1_000; type PendingCodeFile = { code: string; expiresAtMs: number; }; export async function writePendingCode(code: string): Promise { await ensureSecretsDir(); const payload: PendingCodeFile = { code, expiresAtMs: Date.now() + PENDING_CODE_TTL_MS, }; await writeFile(pendingCodeFilePath(), JSON.stringify(payload), { mode: 0o600, }); } export async function readPendingCode(): Promise { let content: string; try { content = await readFile(pendingCodeFilePath(), "utf-8"); } catch (err) { // Missing file is the expected "no pending code" state. Anything // else (permissions, stale NFS handle, IO error) should surface // instead of silently looping the user back through device auth. if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null; throw err; } let parsed: PendingCodeFile; try { parsed = JSON.parse(content) as PendingCodeFile; } catch { // Corrupt file. Treat as missing and clean up. await clearPendingCode(); return null; } if ( typeof parsed?.code !== "string" || typeof parsed?.expiresAtMs !== "number" ) { await clearPendingCode(); return null; } if (Date.now() > parsed.expiresAtMs) { // Expired locally. The server code is also dead, so clean up. await clearPendingCode(); return null; } return parsed.code; } export async function clearPendingCode(): Promise { try { await unlink(pendingCodeFilePath()); } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") { throw err; } } }