From 7a63b4995b8df254294dce36d77580fc14d656a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 00:59:25 +0100 Subject: [PATCH] feat: opt-in login shell env fallback --- CHANGELOG.md | 2 + README.md | 2 + src/config/io.ts | 71 +++++++++++++++++++++--- src/config/types.ts | 8 +++ src/config/zod-schema.ts | 10 ++++ src/infra/shell-env.test.ts | 91 +++++++++++++++++++++++++++++++ src/infra/shell-env.ts | 105 ++++++++++++++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 src/infra/shell-env.test.ts create mode 100644 src/infra/shell-env.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f1485e7..22eda1075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Highlights - Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support. - Agent tools: new `image` tool routed to the image model (when configured). +- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`). ### Fixes - Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids @@ -16,6 +17,7 @@ - WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj - Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT). - Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. +- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env). - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). - Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). diff --git a/README.md b/README.md index 8bbb8257c..26ea0a582 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,8 @@ Minimal `~/.clawdbot/clawdbot.json`: Env vars: loaded from `.env` in the current working directory, plus a global fallback at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`) without overriding existing values. +Optional: import missing keys from your login shell env (sources your shell profile) via `env.shellEnv.enabled` (or `CLAWDBOT_LOAD_SHELL_ENV=1`). Timeout default: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`. + ### WhatsApp - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`). diff --git a/src/config/io.ts b/src/config/io.ts index 9240120c3..ee4fb4cf5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -3,9 +3,14 @@ import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; - +import { + loadShellEnvFallback, + resolveShellEnvFallbackTimeoutMs, + shouldEnableShellEnvFallback, +} from "../infra/shell-env.js"; import { applyIdentityDefaults, + applyModelAliasDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; @@ -23,6 +28,22 @@ import type { import { validateConfigObject } from "./validation.js"; import { ClawdbotSchema } from "./zod-schema.js"; +const SHELL_ENV_EXPECTED_KEYS = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_OAUTH_TOKEN", + "GEMINI_API_KEY", + "ZAI_API_KEY", + "MINIMAX_API_KEY", + "ELEVENLABS_API_KEY", + "TELEGRAM_BOT_TOKEN", + "DISCORD_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "CLAWDBOT_GATEWAY_TOKEN", + "CLAWDBOT_GATEWAY_PASSWORD", +]; + export type ParseConfigJson5Result = | { ok: true; parsed: unknown } | { ok: false; error: string }; @@ -69,7 +90,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { function loadConfig(): ClawdbotConfig { try { - if (!deps.fs.existsSync(configPath)) return {}; + if (!deps.fs.existsSync(configPath)) { + if (shouldEnableShellEnvFallback(deps.env)) { + loadShellEnvFallback({ + enabled: true, + env: deps.env, + expectedKeys: SHELL_ENV_EXPECTED_KEYS, + logger: deps.logger, + timeoutMs: resolveShellEnvFallbackTimeoutMs(deps.env), + }); + } + return {}; + } const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = deps.json5.parse(raw); if (typeof parsed !== "object" || parsed === null) return {}; @@ -81,9 +113,28 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } return {}; } - return applySessionDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), + const cfg = applyModelAliasDefaults( + applySessionDefaults( + applyIdentityDefaults(validated.data as ClawdbotConfig), + ), ); + + const enabled = + shouldEnableShellEnvFallback(deps.env) || + cfg.env?.shellEnv?.enabled === true; + if (enabled) { + loadShellEnvFallback({ + enabled: true, + env: deps.env, + expectedKeys: SHELL_ENV_EXPECTED_KEYS, + logger: deps.logger, + timeoutMs: + cfg.env?.shellEnv?.timeoutMs ?? + resolveShellEnvFallbackTimeoutMs(deps.env), + }); + } + + return cfg; } catch (err) { deps.logger.error(`Failed to read config at ${configPath}`, err); return {}; @@ -93,7 +144,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { async function readConfigFileSnapshot(): Promise { const exists = deps.fs.existsSync(configPath); if (!exists) { - const config = applyTalkApiKey(applySessionDefaults({})); + const config = applyTalkApiKey( + applyModelAliasDefaults(applySessionDefaults({})), + ); const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, @@ -147,7 +200,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { raw, parsed: parsedRes.parsed, valid: true, - config: applyTalkApiKey(applySessionDefaults(validated.config)), + config: applyTalkApiKey( + applyModelAliasDefaults(applySessionDefaults(validated.config)), + ), issues: [], legacyIssues, }; @@ -169,7 +224,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { await deps.fs.promises.mkdir(path.dirname(configPath), { recursive: true, }); - const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); + const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2) + .trimEnd() + .concat("\n"); await deps.fs.promises.writeFile(configPath, json, "utf-8"); } diff --git a/src/config/types.ts b/src/config/types.ts index be7458e35..0eec44357 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -637,6 +637,14 @@ export type ModelsConfig = { }; export type ClawdbotConfig = { + env?: { + /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ + shellEnv?: { + enabled?: boolean; + /** Timeout for the login shell exec (ms). Default: 15000. */ + timeoutMs?: number; + }; + }; identity?: { name?: string; theme?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8ae6b56e9..8a598ff5c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -274,6 +274,16 @@ const HooksGmailSchema = z .optional(); export const ClawdbotSchema = z.object({ + env: z + .object({ + shellEnv: z + .object({ + enabled: z.boolean().optional(), + timeoutMs: z.number().int().nonnegative().optional(), + }) + .optional(), + }) + .optional(), identity: z .object({ name: z.string().optional(), diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts new file mode 100644 index 000000000..a223e8232 --- /dev/null +++ b/src/infra/shell-env.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + loadShellEnvFallback, + resolveShellEnvFallbackTimeoutMs, + shouldEnableShellEnvFallback, +} from "./shell-env.js"; + +describe("shell env fallback", () => { + it("is disabled by default", () => { + expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false); + expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "0" })).toBe( + false, + ); + expect(shouldEnableShellEnvFallback({ CLAWDBOT_LOAD_SHELL_ENV: "1" })).toBe( + true, + ); + }); + + it("resolves timeout from env with default fallback", () => { + expect(resolveShellEnvFallbackTimeoutMs({} as NodeJS.ProcessEnv)).toBe( + 15000, + ); + expect( + resolveShellEnvFallbackTimeoutMs({ CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "42" }), + ).toBe(42); + expect( + resolveShellEnvFallbackTimeoutMs({ + CLAWDBOT_SHELL_ENV_TIMEOUT_MS: "nope", + }), + ).toBe(15000); + }); + + it("skips when already has an expected key", () => { + const env: NodeJS.ProcessEnv = { OPENAI_API_KEY: "set" }; + const exec = vi.fn(() => Buffer.from("")); + + const res = loadShellEnvFallback({ + enabled: true, + env, + expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"], + exec: exec as unknown as Parameters< + typeof loadShellEnvFallback + >[0]["exec"], + }); + + expect(res.ok).toBe(true); + expect(res.applied).toEqual([]); + expect(res.ok && res.skippedReason).toBe("already-has-keys"); + expect(exec).not.toHaveBeenCalled(); + }); + + it("imports expected keys without overriding existing env", () => { + const env: NodeJS.ProcessEnv = {}; + const exec = vi.fn(() => + Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord\0"), + ); + + const res1 = loadShellEnvFallback({ + enabled: true, + env, + expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"], + exec: exec as unknown as Parameters< + typeof loadShellEnvFallback + >[0]["exec"], + }); + + expect(res1.ok).toBe(true); + expect(env.OPENAI_API_KEY).toBe("from-shell"); + expect(env.DISCORD_BOT_TOKEN).toBe("discord"); + expect(exec).toHaveBeenCalledTimes(1); + + env.OPENAI_API_KEY = "from-parent"; + const exec2 = vi.fn(() => + Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord2\0"), + ); + const res2 = loadShellEnvFallback({ + enabled: true, + env, + expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"], + exec: exec2 as unknown as Parameters< + typeof loadShellEnvFallback + >[0]["exec"], + }); + + expect(res2.ok).toBe(true); + expect(env.OPENAI_API_KEY).toBe("from-parent"); + expect(env.DISCORD_BOT_TOKEN).toBe("discord"); + expect(exec2).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts new file mode 100644 index 000000000..157cc1a4c --- /dev/null +++ b/src/infra/shell-env.ts @@ -0,0 +1,105 @@ +import { execFileSync } from "node:child_process"; + +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; + +function isTruthy(raw: string | undefined): boolean { + if (!raw) return false; + const value = raw.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function resolveShell(env: NodeJS.ProcessEnv): string { + const shell = env.SHELL?.trim(); + return shell && shell.length > 0 ? shell : "/bin/sh"; +} + +export type ShellEnvFallbackResult = + | { ok: true; applied: string[]; skippedReason?: never } + | { ok: true; applied: []; skippedReason: "already-has-keys" | "disabled" } + | { ok: false; error: string; applied: [] }; + +export type ShellEnvFallbackOptions = { + enabled: boolean; + env: NodeJS.ProcessEnv; + expectedKeys: string[]; + logger?: Pick; + timeoutMs?: number; + exec?: typeof execFileSync; +}; + +export function loadShellEnvFallback( + opts: ShellEnvFallbackOptions, +): ShellEnvFallbackResult { + const logger = opts.logger ?? console; + const exec = opts.exec ?? execFileSync; + + if (!opts.enabled) + return { ok: true, applied: [], skippedReason: "disabled" }; + + const hasAnyKey = opts.expectedKeys.some((key) => + Boolean(opts.env[key]?.trim()), + ); + if (hasAnyKey) { + return { ok: true, applied: [], skippedReason: "already-has-keys" }; + } + + const timeoutMs = + typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) + ? Math.max(0, opts.timeoutMs) + : DEFAULT_TIMEOUT_MS; + + const shell = resolveShell(opts.env); + + let stdout: Buffer; + try { + stdout = exec(shell, ["-l", "-c", "env -0"], { + encoding: "buffer", + timeout: timeoutMs, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: opts.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn(`[clawdbot] shell env fallback failed: ${msg}`); + return { ok: false, error: msg, applied: [] }; + } + + const shellEnv = new Map(); + const parts = stdout.toString("utf8").split("\0"); + for (const part of parts) { + if (!part) continue; + const eq = part.indexOf("="); + if (eq <= 0) continue; + const key = part.slice(0, eq); + const value = part.slice(eq + 1); + if (!key) continue; + shellEnv.set(key, value); + } + + const applied: string[] = []; + for (const key of opts.expectedKeys) { + if (opts.env[key]?.trim()) continue; + const value = shellEnv.get(key); + if (!value?.trim()) continue; + opts.env[key] = value; + applied.push(key); + } + + return { ok: true, applied }; +} + +export function shouldEnableShellEnvFallback(env: NodeJS.ProcessEnv): boolean { + return isTruthy(env.CLAWDBOT_LOAD_SHELL_ENV); +} + +export function resolveShellEnvFallbackTimeoutMs( + env: NodeJS.ProcessEnv, +): number { + const raw = env.CLAWDBOT_SHELL_ENV_TIMEOUT_MS?.trim(); + if (!raw) return DEFAULT_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS; + return Math.max(0, parsed); +}