feat: opt-in login shell env fallback
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
### Highlights
|
### Highlights
|
||||||
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
|
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
|
||||||
- Agent tools: new `image` tool routed to the image model (when configured).
|
- 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
|
### Fixes
|
||||||
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
|
- 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
|
- 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).
|
- 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: 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).
|
- 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`).
|
- 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).
|
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
|
||||||
|
|||||||
@@ -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.
|
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
|
### WhatsApp
|
||||||
|
|
||||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
|
import {
|
||||||
|
loadShellEnvFallback,
|
||||||
|
resolveShellEnvFallbackTimeoutMs,
|
||||||
|
shouldEnableShellEnvFallback,
|
||||||
|
} from "../infra/shell-env.js";
|
||||||
import {
|
import {
|
||||||
applyIdentityDefaults,
|
applyIdentityDefaults,
|
||||||
|
applyModelAliasDefaults,
|
||||||
applySessionDefaults,
|
applySessionDefaults,
|
||||||
applyTalkApiKey,
|
applyTalkApiKey,
|
||||||
} from "./defaults.js";
|
} from "./defaults.js";
|
||||||
@@ -23,6 +28,22 @@ import type {
|
|||||||
import { validateConfigObject } from "./validation.js";
|
import { validateConfigObject } from "./validation.js";
|
||||||
import { ClawdbotSchema } from "./zod-schema.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 =
|
export type ParseConfigJson5Result =
|
||||||
| { ok: true; parsed: unknown }
|
| { ok: true; parsed: unknown }
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
@@ -69,7 +90,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
|
|
||||||
function loadConfig(): ClawdbotConfig {
|
function loadConfig(): ClawdbotConfig {
|
||||||
try {
|
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 raw = deps.fs.readFileSync(configPath, "utf-8");
|
||||||
const parsed = deps.json5.parse(raw);
|
const parsed = deps.json5.parse(raw);
|
||||||
if (typeof parsed !== "object" || parsed === null) return {};
|
if (typeof parsed !== "object" || parsed === null) return {};
|
||||||
@@ -81,9 +113,28 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return applySessionDefaults(
|
const cfg = applyModelAliasDefaults(
|
||||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
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) {
|
} catch (err) {
|
||||||
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
||||||
return {};
|
return {};
|
||||||
@@ -93,7 +144,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||||
const exists = deps.fs.existsSync(configPath);
|
const exists = deps.fs.existsSync(configPath);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
const config = applyTalkApiKey(applySessionDefaults({}));
|
const config = applyTalkApiKey(
|
||||||
|
applyModelAliasDefaults(applySessionDefaults({})),
|
||||||
|
);
|
||||||
const legacyIssues: LegacyConfigIssue[] = [];
|
const legacyIssues: LegacyConfigIssue[] = [];
|
||||||
return {
|
return {
|
||||||
path: configPath,
|
path: configPath,
|
||||||
@@ -147,7 +200,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
raw,
|
raw,
|
||||||
parsed: parsedRes.parsed,
|
parsed: parsedRes.parsed,
|
||||||
valid: true,
|
valid: true,
|
||||||
config: applyTalkApiKey(applySessionDefaults(validated.config)),
|
config: applyTalkApiKey(
|
||||||
|
applyModelAliasDefaults(applySessionDefaults(validated.config)),
|
||||||
|
),
|
||||||
issues: [],
|
issues: [],
|
||||||
legacyIssues,
|
legacyIssues,
|
||||||
};
|
};
|
||||||
@@ -169,7 +224,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
await deps.fs.promises.mkdir(path.dirname(configPath), {
|
await deps.fs.promises.mkdir(path.dirname(configPath), {
|
||||||
recursive: true,
|
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");
|
await deps.fs.promises.writeFile(configPath, json, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -637,6 +637,14 @@ export type ModelsConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ClawdbotConfig = {
|
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?: {
|
identity?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
|||||||
@@ -274,6 +274,16 @@ const HooksGmailSchema = z
|
|||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
export const ClawdbotSchema = z.object({
|
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
|
identity: z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|||||||
91
src/infra/shell-env.test.ts
Normal file
91
src/infra/shell-env.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/infra/shell-env.ts
Normal file
105
src/infra/shell-env.ts
Normal file
@@ -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<typeof console, "warn">;
|
||||||
|
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<string, string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user