Secrets: make runtime activation auth loads read-only

This commit is contained in:
joshavant
2026-02-24 13:19:02 -06:00
committed by Peter Steinberger
parent 3dbb6be270
commit 8e33ebe471
7 changed files with 191 additions and 23 deletions

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
@@ -156,4 +159,49 @@ describe("secrets runtime snapshot", () => {
key: "sk-runtime",
});
});
it("does not write inherited auth stores during runtime secret activation", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-runtime-"));
const stateDir = path.join(root, ".openclaw");
const mainAgentDir = path.join(stateDir, "agents", "main", "agent");
const workerStorePath = path.join(stateDir, "agents", "worker", "agent", "auth-profiles.json");
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
try {
await fs.mkdir(mainAgentDir, { recursive: true });
await fs.writeFile(
path.join(mainAgentDir, "auth-profiles.json"),
JSON.stringify({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", id: "OPENAI_API_KEY" },
},
},
}),
"utf8",
);
process.env.OPENCLAW_STATE_DIR = stateDir;
await prepareSecretsRuntimeSnapshot({
config: {
agents: {
list: [{ id: "worker" }],
},
},
env: { OPENAI_API_KEY: "sk-runtime-worker" },
});
await expect(fs.access(workerStorePath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
if (prevStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = prevStateDir;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -3,7 +3,7 @@ import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForRuntime,
loadAuthProfileStoreForSecretsRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../agents/auth-profiles.js";
import {
@@ -14,6 +14,7 @@ import {
import { isSecretRef, type SecretRef } from "../config/types.secrets.js";
import { resolveUserPath } from "../utils.js";
import { readJsonPointer } from "./json-pointer.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "./shared.js";
import { decryptSopsJsonFile, DEFAULT_SOPS_TIMEOUT_MS } from "./sops.js";
type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT";
@@ -73,14 +74,6 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function decryptSopsFile(config: OpenClawConfig): Promise<unknown> {
const fileSource = config.secrets?.sources?.file;
if (!fileSource) {
@@ -93,10 +86,7 @@ async function decryptSopsFile(config: OpenClawConfig): Promise<unknown> {
}
const resolvedPath = resolveUserPath(fileSource.path);
const timeoutMs =
typeof fileSource.timeoutMs === "number" && Number.isFinite(fileSource.timeoutMs)
? Math.max(1, Math.floor(fileSource.timeoutMs))
: DEFAULT_SOPS_TIMEOUT_MS;
const timeoutMs = normalizePositiveInt(fileSource.timeoutMs, DEFAULT_SOPS_TIMEOUT_MS);
return await decryptSopsJsonFile({
path: resolvedPath,
timeoutMs,
@@ -275,7 +265,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
warnings,
});
const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForRuntime;
const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime;
const candidateDirs = params.agentDirs?.length
? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry)))]
: collectCandidateAgentDirs(resolvedConfig);

14
src/secrets/shared.ts Normal file
View File

@@ -0,0 +1,14 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function normalizePositiveInt(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(1, Math.floor(value));
}
return Math.max(1, Math.floor(fallback));
}

View File

@@ -2,15 +2,13 @@ import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { runExec } from "../process/exec.js";
import { normalizePositiveInt } from "./shared.js";
export const DEFAULT_SOPS_TIMEOUT_MS = 5_000;
const MAX_SOPS_OUTPUT_BYTES = 10 * 1024 * 1024;
function normalizeTimeoutMs(value: number | undefined): number {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(1, Math.floor(value));
}
return DEFAULT_SOPS_TIMEOUT_MS;
return normalizePositiveInt(value, DEFAULT_SOPS_TIMEOUT_MS);
}
function isTimeoutError(message: string | undefined): boolean {