feat: opt-in login shell env fallback
This commit is contained in:
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