mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(secrets): finalize mode rename and validated exec docs
This commit is contained in:
committed by
Peter Steinberger
parent
ba2eb583c0
commit
06290b49b2
@@ -271,7 +271,7 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
|
||||
|
||||
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
|
||||
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
|
||||
- `openclaw secrets configure` — interactive helper to build SecretRef plan and preflight/apply safely.
|
||||
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
|
||||
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
|
||||
|
||||
## Plugins
|
||||
|
||||
@@ -53,15 +53,28 @@ Exit behavior:
|
||||
|
||||
## Configure (interactive helper)
|
||||
|
||||
Build SecretRef changes interactively, run preflight, and optionally apply:
|
||||
Build provider + SecretRef changes interactively, run preflight, and optionally apply:
|
||||
|
||||
```bash
|
||||
openclaw secrets configure
|
||||
openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets configure --apply --yes
|
||||
openclaw secrets configure --providers-only
|
||||
openclaw secrets configure --skip-provider-setup
|
||||
openclaw secrets configure --json
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
- Provider setup first (`add/edit/remove` for `secrets.providers` aliases).
|
||||
- Credential mapping second (select fields and assign `{source, provider, id}` refs).
|
||||
- Preflight and optional apply last.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
|
||||
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
|
||||
|
||||
Notes:
|
||||
|
||||
- `configure` targets secret-bearing fields in `openclaw.json`.
|
||||
|
||||
@@ -2423,7 +2423,7 @@ Validation:
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/secrets.json",
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
vault: {
|
||||
@@ -2443,8 +2443,9 @@ Validation:
|
||||
|
||||
Notes:
|
||||
|
||||
- `file` provider supports `mode: "jsonPointer"` and `mode: "raw"` (`id` must be `"value"` in raw mode).
|
||||
- `exec` provider requires an absolute `command` path and uses protocol payloads on stdin/stdout.
|
||||
- `file` provider supports `mode: "json"` and `mode: "singleValue"` (`id` must be `"value"` in singleValue mode).
|
||||
- `exec` provider requires an absolute non-symlink `command` path and uses protocol payloads on stdin/stdout.
|
||||
- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`.
|
||||
- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,7 @@ Define providers under `secrets.providers`:
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/secrets.json",
|
||||
mode: "jsonPointer", // or "raw"
|
||||
mode: "json", // or "singleValue"
|
||||
},
|
||||
vault: {
|
||||
source: "exec",
|
||||
@@ -119,13 +119,14 @@ Define providers under `secrets.providers`:
|
||||
### File provider
|
||||
|
||||
- Reads local file from `path`.
|
||||
- `mode: "jsonPointer"` expects JSON object payload and resolves `id` as pointer.
|
||||
- `mode: "raw"` expects ref id `"value"` and returns file contents.
|
||||
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
|
||||
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
|
||||
- Path must pass ownership/permission checks.
|
||||
|
||||
### Exec provider
|
||||
|
||||
- Runs configured absolute binary path, no shell.
|
||||
- `command` must point to a regular file (not a symlink).
|
||||
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
|
||||
- Request payload (stdin):
|
||||
|
||||
@@ -149,6 +150,121 @@ Optional per-id errors:
|
||||
}
|
||||
```
|
||||
|
||||
## Validated exec integration examples
|
||||
|
||||
The patterns below were validated end-to-end with `openclaw secrets audit --json` and `unresolvedRefCount=0`.
|
||||
|
||||
### 1Password (`op`)
|
||||
|
||||
1. Create a wrapper script (non-symlink command path):
|
||||
|
||||
```bash
|
||||
cat >/usr/local/libexec/openclaw/op-openai.sh <<'SH'
|
||||
#!/bin/sh
|
||||
exec /opt/homebrew/bin/op read 'op://Personal/OpenClaw QA API Key/password'
|
||||
SH
|
||||
chmod 700 /usr/local/libexec/openclaw/op-openai.sh
|
||||
```
|
||||
|
||||
2. Configure provider + ref:
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
onepassword_openai: {
|
||||
source: "exec",
|
||||
command: "/usr/local/libexec/openclaw/op-openai.sh",
|
||||
passEnv: ["HOME"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### HashiCorp Vault CLI
|
||||
|
||||
1. Wrapper script:
|
||||
|
||||
```bash
|
||||
cat >/usr/local/libexec/openclaw/vault-openai.sh <<'SH'
|
||||
#!/bin/sh
|
||||
exec /opt/homebrew/opt/vault/bin/vault kv get -field=OPENAI_API_KEY secret/openclaw
|
||||
SH
|
||||
chmod 700 /usr/local/libexec/openclaw/vault-openai.sh
|
||||
```
|
||||
|
||||
2. Provider + ref:
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
vault_openai: {
|
||||
source: "exec",
|
||||
command: "/usr/local/libexec/openclaw/vault-openai.sh",
|
||||
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `sops`
|
||||
|
||||
1. Wrapper script:
|
||||
|
||||
```bash
|
||||
cat >/usr/local/libexec/openclaw/sops-openai.sh <<'SH'
|
||||
#!/bin/sh
|
||||
exec /opt/homebrew/bin/sops -d --extract '["providers"]["openai"]["apiKey"]' /path/to/secrets.enc.json
|
||||
SH
|
||||
chmod 700 /usr/local/libexec/openclaw/sops-openai.sh
|
||||
```
|
||||
|
||||
2. Provider + ref:
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
sops_openai: {
|
||||
source: "exec",
|
||||
command: "/usr/local/libexec/openclaw/sops-openai.sh",
|
||||
passEnv: ["SOPS_AGE_KEY_FILE"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## In-scope fields (v1)
|
||||
|
||||
### `~/.openclaw/openclaw.json`
|
||||
@@ -231,11 +347,17 @@ Findings include:
|
||||
|
||||
Interactive helper that:
|
||||
|
||||
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
|
||||
- lets you select secret-bearing fields in `openclaw.json`
|
||||
- captures SecretRef details (`source`, `provider`, `id`)
|
||||
- runs preflight resolution
|
||||
- can apply immediately
|
||||
|
||||
Helpful modes:
|
||||
|
||||
- `openclaw secrets configure --providers-only`
|
||||
- `openclaw secrets configure --skip-provider-setup`
|
||||
|
||||
`configure` apply defaults to:
|
||||
|
||||
- scrub matching static creds from `auth-profiles.json` for targeted providers
|
||||
|
||||
@@ -20,6 +20,8 @@ type SecretsConfigureOptions = {
|
||||
apply?: boolean;
|
||||
yes?: boolean;
|
||||
planOut?: string;
|
||||
providersOnly?: boolean;
|
||||
skipProviderSetup?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
type SecretsApplyOptions = {
|
||||
@@ -112,14 +114,23 @@ export function registerSecretsCli(program: Command) {
|
||||
|
||||
secrets
|
||||
.command("configure")
|
||||
.description("Interactive SecretRef helper with preflight validation")
|
||||
.description("Interactive secrets helper (provider setup + SecretRef mapping + preflight)")
|
||||
.option("--apply", "Apply changes immediately after preflight", false)
|
||||
.option("--yes", "Skip apply confirmation prompt", false)
|
||||
.option("--providers-only", "Configure secrets.providers only, skip credential mapping", false)
|
||||
.option(
|
||||
"--skip-provider-setup",
|
||||
"Skip provider setup and only map credential fields to existing providers",
|
||||
false,
|
||||
)
|
||||
.option("--plan-out <path>", "Write generated plan JSON to a file")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: SecretsConfigureOptions) => {
|
||||
try {
|
||||
const configured = await runSecretsConfigureInteractive();
|
||||
const configured = await runSecretsConfigureInteractive({
|
||||
providersOnly: Boolean(opts.providersOnly),
|
||||
skipProviderSetup: Boolean(opts.skipProviderSetup),
|
||||
});
|
||||
if (opts.planOut) {
|
||||
fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8");
|
||||
}
|
||||
@@ -143,7 +154,11 @@ export function registerSecretsCli(program: Command) {
|
||||
defaultRuntime.log(`- warning: ${warning}`);
|
||||
}
|
||||
}
|
||||
defaultRuntime.log(`Plan targets: ${configured.plan.targets.length}`);
|
||||
const providerUpserts = Object.keys(configured.plan.providerUpserts ?? {}).length;
|
||||
const providerDeletes = configured.plan.providerDeletes?.length ?? 0;
|
||||
defaultRuntime.log(
|
||||
`Plan: targets=${configured.plan.targets.length}, providerUpserts=${providerUpserts}, providerDeletes=${providerDeletes}.`,
|
||||
);
|
||||
if (opts.planOut) {
|
||||
defaultRuntime.log(`Plan written to ${opts.planOut}`);
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "/tmp/does-not-exist-secrets.json",
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -176,11 +176,11 @@ async function resolveApiKeyRefForOnboarding(params: {
|
||||
}
|
||||
const idPrompt =
|
||||
providerEntry.source === "file"
|
||||
? "Secret id (JSON pointer for jsonPointer mode, or 'value' for raw mode)"
|
||||
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
|
||||
: "Secret id for the exec provider";
|
||||
const idDefault =
|
||||
providerEntry.source === "file"
|
||||
? providerEntry.mode === "raw"
|
||||
? providerEntry.mode === "singleValue"
|
||||
? "value"
|
||||
: defaultFilePointer
|
||||
: `${params.provider}/apiKey`;
|
||||
@@ -195,17 +195,17 @@ async function resolveApiKeyRefForOnboarding(params: {
|
||||
}
|
||||
if (
|
||||
providerEntry.source === "file" &&
|
||||
providerEntry.mode !== "raw" &&
|
||||
providerEntry.mode !== "singleValue" &&
|
||||
!isValidFileSecretRefId(candidate)
|
||||
) {
|
||||
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
|
||||
}
|
||||
if (
|
||||
providerEntry.source === "file" &&
|
||||
providerEntry.mode === "raw" &&
|
||||
providerEntry.mode === "singleValue" &&
|
||||
candidate !== "value"
|
||||
) {
|
||||
return 'Raw file mode expects id "value".';
|
||||
return 'singleValue mode expects id "value".';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
@@ -775,7 +775,7 @@ describe("applyAuthChoice", () => {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "/tmp/openclaw-missing-secrets.json",
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("promptCustomApiConfig", () => {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "/tmp/openclaw-missing-provider.json",
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("config secret refs schema", () => {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/secrets.json",
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
timeoutMs: 10_000,
|
||||
},
|
||||
vault: {
|
||||
@@ -65,14 +65,14 @@ describe("config secret refs schema", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts file refs with id "value" for raw mode providers', () => {
|
||||
it('accepts file refs with id "value" for singleValue mode providers', () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
secrets: {
|
||||
providers: {
|
||||
rawfile: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/token.txt",
|
||||
mode: "raw",
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -106,7 +106,7 @@ export type EnvSecretProviderConfig = {
|
||||
allowlist?: string[];
|
||||
};
|
||||
|
||||
export type FileSecretProviderMode = "raw" | "jsonPointer";
|
||||
export type FileSecretProviderMode = "singleValue" | "json";
|
||||
|
||||
export type FileSecretProviderConfig = {
|
||||
source: "file";
|
||||
|
||||
@@ -50,7 +50,7 @@ const FileSecretRefSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
isValidFileSecretRefId,
|
||||
'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for raw mode.',
|
||||
'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for singleValue mode.',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
@@ -92,7 +92,7 @@ const SecretsFileProviderSchema = z
|
||||
.object({
|
||||
source: z.literal("file"),
|
||||
path: z.string().min(1),
|
||||
mode: z.union([z.literal("raw"), z.literal("jsonPointer")]).optional(),
|
||||
mode: z.union([z.literal("singleValue"), z.literal("json")]).optional(),
|
||||
timeoutMs: z.number().int().positive().max(120000).optional(),
|
||||
maxBytes: z
|
||||
.number()
|
||||
|
||||
@@ -176,4 +176,63 @@ describe("secrets apply", () => {
|
||||
expect(second.changed).toBe(false);
|
||||
expect(second.changedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies provider upserts and deletes from plan", async () => {
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
envmain: { source: "env" },
|
||||
fileold: { source: "file", path: "/tmp/old-secrets.json", mode: "json" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const plan: SecretsApplyPlan = {
|
||||
version: 1,
|
||||
protocolVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: "manual",
|
||||
providerUpserts: {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "/tmp/new-secrets.json",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
providerDeletes: ["fileold"],
|
||||
targets: [],
|
||||
};
|
||||
|
||||
const result = await runSecretsApply({ plan, env, write: true });
|
||||
expect(result.changed).toBe(true);
|
||||
|
||||
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
secrets?: {
|
||||
providers?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
expect(nextConfig.secrets?.providers?.fileold).toBeUndefined();
|
||||
expect(nextConfig.secrets?.providers?.filemain).toEqual({
|
||||
source: "file",
|
||||
path: "/tmp/new-secrets.json",
|
||||
mode: "json",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import { resolveStateDir, type OpenClawConfig } from "../config/config.js";
|
||||
import type { ConfigWriteOptions } from "../config/io.js";
|
||||
import type { SecretProviderConfig } from "../config/types.secrets.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { createSecretsConfigIO } from "./config-io.js";
|
||||
import { type SecretsApplyPlan, normalizeSecretsPlanOptions } from "./plan.js";
|
||||
@@ -209,6 +210,48 @@ function resolveGoogleChatRefPath(pathLabel: string): string {
|
||||
throw new Error(`Google Chat target path must end with ".serviceAccount": ${pathLabel}`);
|
||||
}
|
||||
|
||||
function applyProviderPlanMutations(params: {
|
||||
config: OpenClawConfig;
|
||||
upserts: Record<string, SecretProviderConfig> | undefined;
|
||||
deletes: string[] | undefined;
|
||||
}): boolean {
|
||||
const currentProviders = isRecord(params.config.secrets?.providers)
|
||||
? structuredClone(params.config.secrets?.providers)
|
||||
: {};
|
||||
let changed = false;
|
||||
|
||||
for (const providerAlias of params.deletes ?? []) {
|
||||
if (!Object.prototype.hasOwnProperty.call(currentProviders, providerAlias)) {
|
||||
continue;
|
||||
}
|
||||
delete currentProviders[providerAlias];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const [providerAlias, providerConfig] of Object.entries(params.upserts ?? {})) {
|
||||
const previous = currentProviders[providerAlias];
|
||||
if (isDeepStrictEqual(previous, providerConfig)) {
|
||||
continue;
|
||||
}
|
||||
currentProviders[providerAlias] = structuredClone(providerConfig);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
params.config.secrets ??= {};
|
||||
if (Object.keys(currentProviders).length === 0) {
|
||||
if ("providers" in params.config.secrets) {
|
||||
delete params.config.secrets.providers;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
params.config.secrets.providers = currentProviders;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function projectPlanState(params: {
|
||||
plan: SecretsApplyPlan;
|
||||
env: NodeJS.ProcessEnv;
|
||||
@@ -225,6 +268,16 @@ async function projectPlanState(params: {
|
||||
const warnings: string[] = [];
|
||||
const scrubbedValues = new Set<string>();
|
||||
const providerTargets = new Set<string>();
|
||||
const configPath = resolveUserPath(snapshot.path);
|
||||
|
||||
const providerConfigChanged = applyProviderPlanMutations({
|
||||
config: nextConfig,
|
||||
upserts: params.plan.providerUpserts,
|
||||
deletes: params.plan.providerDeletes,
|
||||
});
|
||||
if (providerConfigChanged) {
|
||||
changedFiles.add(configPath);
|
||||
}
|
||||
|
||||
for (const target of params.plan.targets) {
|
||||
if (target.type === "channels.googlechat.serviceAccount") {
|
||||
@@ -236,7 +289,7 @@ async function projectPlanState(params: {
|
||||
const wroteRef = setByDotPath(nextConfig, refPath, target.ref);
|
||||
const deletedLegacy = deleteByDotPath(nextConfig, target.path);
|
||||
if (wroteRef || deletedLegacy) {
|
||||
changedFiles.add(resolveUserPath(snapshot.path));
|
||||
changedFiles.add(configPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -247,7 +300,7 @@ async function projectPlanState(params: {
|
||||
}
|
||||
const wroteRef = setByDotPath(nextConfig, target.path, target.ref);
|
||||
if (wroteRef) {
|
||||
changedFiles.add(resolveUserPath(snapshot.path));
|
||||
changedFiles.add(configPath);
|
||||
}
|
||||
if (target.type === "models.providers.apiKey" && target.providerId) {
|
||||
providerTargets.add(normalizeProviderId(target.providerId));
|
||||
@@ -400,7 +453,7 @@ async function projectPlanState(params: {
|
||||
|
||||
return {
|
||||
nextConfig,
|
||||
configPath: resolveUserPath(snapshot.path),
|
||||
configPath,
|
||||
configWriteOptions: writeOptions,
|
||||
authStoreByPath,
|
||||
authJsonByPath,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { confirm, select, text } from "@clack/prompts";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SecretRef, SecretRefSource } from "../config/types.secrets.js";
|
||||
import type { SecretProviderConfig, SecretRef, SecretRefSource } from "../config/types.secrets.js";
|
||||
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
||||
import { runSecretsApply, type SecretsApplyResult } from "./apply.js";
|
||||
import { createSecretsConfigIO } from "./config-io.js";
|
||||
import { type SecretsApplyPlan } from "./plan.js";
|
||||
@@ -20,6 +23,106 @@ export type SecretsConfigureResult = {
|
||||
preflight: SecretsApplyResult;
|
||||
};
|
||||
|
||||
const PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
||||
const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
|
||||
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
||||
|
||||
function isAbsolutePathValue(value: string): boolean {
|
||||
return (
|
||||
path.isAbsolute(value) ||
|
||||
WINDOWS_ABS_PATH_PATTERN.test(value) ||
|
||||
WINDOWS_UNC_PATH_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function parseCsv(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(value: string, max: number): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > max) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getSecretProviders(config: OpenClawConfig): Record<string, SecretProviderConfig> {
|
||||
if (!isRecord(config.secrets?.providers)) {
|
||||
return {};
|
||||
}
|
||||
return config.secrets.providers;
|
||||
}
|
||||
|
||||
function setSecretProvider(
|
||||
config: OpenClawConfig,
|
||||
providerAlias: string,
|
||||
providerConfig: SecretProviderConfig,
|
||||
): void {
|
||||
config.secrets ??= {};
|
||||
if (!isRecord(config.secrets.providers)) {
|
||||
config.secrets.providers = {};
|
||||
}
|
||||
config.secrets.providers[providerAlias] = providerConfig;
|
||||
}
|
||||
|
||||
function removeSecretProvider(config: OpenClawConfig, providerAlias: string): boolean {
|
||||
if (!isRecord(config.secrets?.providers)) {
|
||||
return false;
|
||||
}
|
||||
const providers = config.secrets.providers;
|
||||
if (!Object.prototype.hasOwnProperty.call(providers, providerAlias)) {
|
||||
return false;
|
||||
}
|
||||
delete providers[providerAlias];
|
||||
if (Object.keys(providers).length === 0) {
|
||||
delete config.secrets?.providers;
|
||||
}
|
||||
|
||||
if (isRecord(config.secrets?.defaults)) {
|
||||
const defaults = config.secrets.defaults;
|
||||
if (defaults?.env === providerAlias) {
|
||||
delete defaults.env;
|
||||
}
|
||||
if (defaults?.file === providerAlias) {
|
||||
delete defaults.file;
|
||||
}
|
||||
if (defaults?.exec === providerAlias) {
|
||||
delete defaults.exec;
|
||||
}
|
||||
if (
|
||||
defaults &&
|
||||
defaults.env === undefined &&
|
||||
defaults.file === undefined &&
|
||||
defaults.exec === undefined
|
||||
) {
|
||||
delete config.secrets?.defaults;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function providerHint(provider: SecretProviderConfig): string {
|
||||
if (provider.source === "env") {
|
||||
return provider.allowlist?.length ? `env (${provider.allowlist.length} allowlisted)` : "env";
|
||||
}
|
||||
if (provider.source === "file") {
|
||||
return `file (${provider.mode ?? "json"})`;
|
||||
}
|
||||
return `exec (${provider.jsonOnly === false ? "json+text" : "json"})`;
|
||||
}
|
||||
|
||||
function buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
|
||||
const out: ConfigureCandidate[] = [];
|
||||
const providers = config.models?.providers as Record<string, unknown> | undefined;
|
||||
@@ -81,7 +184,10 @@ function toSourceChoices(config: OpenClawConfig): Array<{ value: SecretRefSource
|
||||
const hasSource = (source: SecretRefSource) =>
|
||||
Object.values(config.secrets?.providers ?? {}).some((provider) => provider?.source === source);
|
||||
const choices: Array<{ value: SecretRefSource; label: string }> = [
|
||||
{ value: "env", label: "env" },
|
||||
{
|
||||
value: "env",
|
||||
label: "env",
|
||||
},
|
||||
];
|
||||
if (hasSource("file")) {
|
||||
choices.push({ value: "file", label: "file" });
|
||||
@@ -99,14 +205,505 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function promptProviderAlias(params: { existingAliases: Set<string> }): Promise<string> {
|
||||
const alias = assertNoCancel(
|
||||
await text({
|
||||
message: "Provider alias",
|
||||
initialValue: "default",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
|
||||
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
|
||||
}
|
||||
if (params.existingAliases.has(trimmed)) {
|
||||
return "Alias already exists";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
return String(alias).trim();
|
||||
}
|
||||
|
||||
async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRefSource> {
|
||||
const source = assertNoCancel(
|
||||
await select({
|
||||
message: "Provider source",
|
||||
options: [
|
||||
{ value: "env", label: "env" },
|
||||
{ value: "file", label: "file" },
|
||||
{ value: "exec", label: "exec" },
|
||||
],
|
||||
initialValue: initial,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
return source as SecretRefSource;
|
||||
}
|
||||
|
||||
async function promptEnvProvider(
|
||||
base?: Extract<SecretProviderConfig, { source: "env" }>,
|
||||
): Promise<Extract<SecretProviderConfig, { source: "env" }>> {
|
||||
const allowlistRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Env allowlist (comma-separated, blank for unrestricted)",
|
||||
initialValue: base?.allowlist?.join(",") ?? "",
|
||||
validate: (value) => {
|
||||
const entries = parseCsv(String(value ?? ""));
|
||||
for (const entry of entries) {
|
||||
if (!ENV_NAME_PATTERN.test(entry)) {
|
||||
return `Invalid env name: ${entry}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const allowlist = parseCsv(String(allowlistRaw ?? ""));
|
||||
return {
|
||||
source: "env",
|
||||
...(allowlist.length > 0 ? { allowlist } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function promptFileProvider(
|
||||
base?: Extract<SecretProviderConfig, { source: "file" }>,
|
||||
): Promise<Extract<SecretProviderConfig, { source: "file" }>> {
|
||||
const filePath = assertNoCancel(
|
||||
await text({
|
||||
message: "File path (absolute)",
|
||||
initialValue: base?.path ?? "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
if (!isAbsolutePathValue(trimmed)) {
|
||||
return "Must be an absolute path";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const mode = assertNoCancel(
|
||||
await select({
|
||||
message: "File mode",
|
||||
options: [
|
||||
{ value: "json", label: "json" },
|
||||
{ value: "singleValue", label: "singleValue" },
|
||||
],
|
||||
initialValue: base?.mode ?? "json",
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const timeoutMsRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Timeout ms (blank for default)",
|
||||
initialValue: base?.timeoutMs ? String(base.timeoutMs) : "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
|
||||
return "Must be an integer between 1 and 120000";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const maxBytesRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Max bytes (blank for default)",
|
||||
initialValue: base?.maxBytes ? String(base.maxBytes) : "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (parseOptionalPositiveInt(trimmed, 20 * 1024 * 1024) === undefined) {
|
||||
return "Must be an integer between 1 and 20971520";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const timeoutMs = parseOptionalPositiveInt(String(timeoutMsRaw ?? ""), 120000);
|
||||
const maxBytes = parseOptionalPositiveInt(String(maxBytesRaw ?? ""), 20 * 1024 * 1024);
|
||||
|
||||
return {
|
||||
source: "file",
|
||||
path: String(filePath).trim(),
|
||||
mode,
|
||||
...(timeoutMs ? { timeoutMs } : {}),
|
||||
...(maxBytes ? { maxBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function parseArgsInput(rawValue: string): Promise<string[] | undefined> {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
|
||||
throw new Error("args must be a JSON array of strings");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function promptExecProvider(
|
||||
base?: Extract<SecretProviderConfig, { source: "exec" }>,
|
||||
): Promise<Extract<SecretProviderConfig, { source: "exec" }>> {
|
||||
const command = assertNoCancel(
|
||||
await text({
|
||||
message: "Command path (absolute)",
|
||||
initialValue: base?.command ?? "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
if (!isAbsolutePathValue(trimmed)) {
|
||||
return "Must be an absolute path";
|
||||
}
|
||||
if (!isSafeExecutableValue(trimmed)) {
|
||||
return "Command value is not allowed";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const argsRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Args JSON array (blank for none)",
|
||||
initialValue: JSON.stringify(base?.args ?? []),
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
|
||||
return "Must be a JSON array of strings";
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Must be valid JSON";
|
||||
}
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const timeoutMsRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Timeout ms (blank for default)",
|
||||
initialValue: base?.timeoutMs ? String(base.timeoutMs) : "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
|
||||
return "Must be an integer between 1 and 120000";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const noOutputTimeoutMsRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "No-output timeout ms (blank for default)",
|
||||
initialValue: base?.noOutputTimeoutMs ? String(base.noOutputTimeoutMs) : "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
|
||||
return "Must be an integer between 1 and 120000";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const maxOutputBytesRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Max output bytes (blank for default)",
|
||||
initialValue: base?.maxOutputBytes ? String(base.maxOutputBytes) : "",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (parseOptionalPositiveInt(trimmed, 20 * 1024 * 1024) === undefined) {
|
||||
return "Must be an integer between 1 and 20971520";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const jsonOnly = assertNoCancel(
|
||||
await confirm({
|
||||
message: "Require JSON-only response?",
|
||||
initialValue: base?.jsonOnly ?? true,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const passEnvRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Pass-through env vars (comma-separated, blank for none)",
|
||||
initialValue: base?.passEnv?.join(",") ?? "",
|
||||
validate: (value) => {
|
||||
const entries = parseCsv(String(value ?? ""));
|
||||
for (const entry of entries) {
|
||||
if (!ENV_NAME_PATTERN.test(entry)) {
|
||||
return `Invalid env name: ${entry}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const trustedDirsRaw = assertNoCancel(
|
||||
await text({
|
||||
message: "Trusted dirs (comma-separated absolute paths, blank for none)",
|
||||
initialValue: base?.trustedDirs?.join(",") ?? "",
|
||||
validate: (value) => {
|
||||
const entries = parseCsv(String(value ?? ""));
|
||||
for (const entry of entries) {
|
||||
if (!isAbsolutePathValue(entry)) {
|
||||
return `Trusted dir must be absolute: ${entry}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const allowInsecurePath = assertNoCancel(
|
||||
await confirm({
|
||||
message: "Allow insecure command path checks?",
|
||||
initialValue: base?.allowInsecurePath ?? false,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const args = await parseArgsInput(String(argsRaw ?? ""));
|
||||
const timeoutMs = parseOptionalPositiveInt(String(timeoutMsRaw ?? ""), 120000);
|
||||
const noOutputTimeoutMs = parseOptionalPositiveInt(String(noOutputTimeoutMsRaw ?? ""), 120000);
|
||||
const maxOutputBytes = parseOptionalPositiveInt(
|
||||
String(maxOutputBytesRaw ?? ""),
|
||||
20 * 1024 * 1024,
|
||||
);
|
||||
const passEnv = parseCsv(String(passEnvRaw ?? ""));
|
||||
const trustedDirs = parseCsv(String(trustedDirsRaw ?? ""));
|
||||
|
||||
return {
|
||||
source: "exec",
|
||||
command: String(command).trim(),
|
||||
...(args && args.length > 0 ? { args } : {}),
|
||||
...(timeoutMs ? { timeoutMs } : {}),
|
||||
...(noOutputTimeoutMs ? { noOutputTimeoutMs } : {}),
|
||||
...(maxOutputBytes ? { maxOutputBytes } : {}),
|
||||
...(jsonOnly ? { jsonOnly } : { jsonOnly: false }),
|
||||
...(passEnv.length > 0 ? { passEnv } : {}),
|
||||
...(trustedDirs.length > 0 ? { trustedDirs } : {}),
|
||||
...(allowInsecurePath ? { allowInsecurePath: true } : {}),
|
||||
...(isRecord(base?.env) ? { env: base.env } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function promptProviderConfig(
|
||||
source: SecretRefSource,
|
||||
current?: SecretProviderConfig,
|
||||
): Promise<SecretProviderConfig> {
|
||||
if (source === "env") {
|
||||
return await promptEnvProvider(current?.source === "env" ? current : undefined);
|
||||
}
|
||||
if (source === "file") {
|
||||
return await promptFileProvider(current?.source === "file" ? current : undefined);
|
||||
}
|
||||
return await promptExecProvider(current?.source === "exec" ? current : undefined);
|
||||
}
|
||||
|
||||
async function configureProvidersInteractive(config: OpenClawConfig): Promise<void> {
|
||||
while (true) {
|
||||
const providers = getSecretProviders(config);
|
||||
const providerEntries = Object.entries(providers).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
|
||||
const actionOptions: Array<{ value: string; label: string; hint?: string }> = [
|
||||
{
|
||||
value: "add",
|
||||
label: "Add provider",
|
||||
hint: "Define a new env/file/exec provider",
|
||||
},
|
||||
];
|
||||
if (providerEntries.length > 0) {
|
||||
actionOptions.push({
|
||||
value: "edit",
|
||||
label: "Edit provider",
|
||||
hint: "Update an existing provider",
|
||||
});
|
||||
actionOptions.push({
|
||||
value: "remove",
|
||||
label: "Remove provider",
|
||||
hint: "Delete a provider alias",
|
||||
});
|
||||
}
|
||||
actionOptions.push({
|
||||
value: "continue",
|
||||
label: "Continue",
|
||||
hint: "Move to credential mapping",
|
||||
});
|
||||
|
||||
const action = assertNoCancel(
|
||||
await select({
|
||||
message:
|
||||
providerEntries.length > 0
|
||||
? "Configure secret providers"
|
||||
: "Configure secret providers (only env refs are available until file/exec providers are added)",
|
||||
options: actionOptions,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
if (action === "continue") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "add") {
|
||||
const source = await promptProviderSource();
|
||||
const alias = await promptProviderAlias({
|
||||
existingAliases: new Set(providerEntries.map(([providerAlias]) => providerAlias)),
|
||||
});
|
||||
const providerConfig = await promptProviderConfig(source);
|
||||
setSecretProvider(config, alias, providerConfig);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const alias = assertNoCancel(
|
||||
await select({
|
||||
message: "Select provider to edit",
|
||||
options: providerEntries.map(([providerAlias, providerConfig]) => ({
|
||||
value: providerAlias,
|
||||
label: providerAlias,
|
||||
hint: providerHint(providerConfig),
|
||||
})),
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const current = providers[alias];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const source = await promptProviderSource(current.source);
|
||||
const nextProviderConfig = await promptProviderConfig(source, current);
|
||||
if (!isDeepStrictEqual(current, nextProviderConfig)) {
|
||||
setSecretProvider(config, alias, nextProviderConfig);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === "remove") {
|
||||
const alias = assertNoCancel(
|
||||
await select({
|
||||
message: "Select provider to remove",
|
||||
options: providerEntries.map(([providerAlias, providerConfig]) => ({
|
||||
value: providerAlias,
|
||||
label: providerAlias,
|
||||
hint: providerHint(providerConfig),
|
||||
})),
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
const shouldRemove = assertNoCancel(
|
||||
await confirm({
|
||||
message: `Remove provider "${alias}"?`,
|
||||
initialValue: false,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
if (shouldRemove) {
|
||||
removeSecretProvider(config, alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectProviderPlanChanges(params: { original: OpenClawConfig; next: OpenClawConfig }): {
|
||||
upserts: Record<string, SecretProviderConfig>;
|
||||
deletes: string[];
|
||||
} {
|
||||
const originalProviders = getSecretProviders(params.original);
|
||||
const nextProviders = getSecretProviders(params.next);
|
||||
|
||||
const upserts: Record<string, SecretProviderConfig> = {};
|
||||
const deletes: string[] = [];
|
||||
|
||||
for (const [providerAlias, nextProviderConfig] of Object.entries(nextProviders)) {
|
||||
const current = originalProviders[providerAlias];
|
||||
if (isDeepStrictEqual(current, nextProviderConfig)) {
|
||||
continue;
|
||||
}
|
||||
upserts[providerAlias] = structuredClone(nextProviderConfig);
|
||||
}
|
||||
|
||||
for (const providerAlias of Object.keys(originalProviders)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(nextProviders, providerAlias)) {
|
||||
deletes.push(providerAlias);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upserts,
|
||||
deletes: deletes.toSorted(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSecretsConfigureInteractive(
|
||||
params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
providersOnly?: boolean;
|
||||
skipProviderSetup?: boolean;
|
||||
} = {},
|
||||
): Promise<SecretsConfigureResult> {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("secrets configure requires an interactive TTY.");
|
||||
}
|
||||
if (params.providersOnly && params.skipProviderSetup) {
|
||||
throw new Error("Cannot combine --providers-only with --skip-provider-setup.");
|
||||
}
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const io = createSecretsConfigIO({ env });
|
||||
const { snapshot } = await io.readConfigFileSnapshotForWrite();
|
||||
@@ -114,97 +711,122 @@ export async function runSecretsConfigureInteractive(
|
||||
throw new Error("Cannot run interactive secrets configure because config is invalid.");
|
||||
}
|
||||
|
||||
const candidates = buildCandidates(snapshot.config);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("No configurable secret-bearing fields found in openclaw.json.");
|
||||
const stagedConfig = structuredClone(snapshot.config);
|
||||
if (!params.skipProviderSetup) {
|
||||
await configureProvidersInteractive(stagedConfig);
|
||||
}
|
||||
|
||||
const providerChanges = collectProviderPlanChanges({
|
||||
original: snapshot.config,
|
||||
next: stagedConfig,
|
||||
});
|
||||
|
||||
const selectedByPath = new Map<string, ConfigureCandidate & { ref: SecretRef }>();
|
||||
const sourceChoices = toSourceChoices(snapshot.config);
|
||||
if (!params.providersOnly) {
|
||||
const candidates = buildCandidates(stagedConfig);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("No configurable secret-bearing fields found in openclaw.json.");
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const options = candidates.map((candidate) => ({
|
||||
value: candidate.path,
|
||||
label: candidate.label,
|
||||
hint: candidate.path,
|
||||
}));
|
||||
if (selectedByPath.size > 0) {
|
||||
options.unshift({
|
||||
value: "__done__",
|
||||
label: "Done",
|
||||
hint: "Finish and run preflight",
|
||||
const sourceChoices = toSourceChoices(stagedConfig);
|
||||
|
||||
while (true) {
|
||||
const options = candidates.map((candidate) => ({
|
||||
value: candidate.path,
|
||||
label: candidate.label,
|
||||
hint: candidate.path,
|
||||
}));
|
||||
if (selectedByPath.size > 0) {
|
||||
options.unshift({
|
||||
value: "__done__",
|
||||
label: "Done",
|
||||
hint: "Finish and run preflight",
|
||||
});
|
||||
}
|
||||
|
||||
const selectedPath = assertNoCancel(
|
||||
await select({
|
||||
message: "Select credential field",
|
||||
options,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
|
||||
if (selectedPath === "__done__") {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidate = candidates.find((entry) => entry.path === selectedPath);
|
||||
if (!candidate) {
|
||||
throw new Error(`Unknown configure target: ${selectedPath}`);
|
||||
}
|
||||
|
||||
const source = assertNoCancel(
|
||||
await select({
|
||||
message: "Secret source",
|
||||
options: sourceChoices,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
) as SecretRefSource;
|
||||
|
||||
const defaultAlias = resolveDefaultSecretProviderAlias(stagedConfig, source, {
|
||||
preferFirstProviderForSource: true,
|
||||
});
|
||||
}
|
||||
const provider = assertNoCancel(
|
||||
await text({
|
||||
message: "Provider alias",
|
||||
initialValue: defaultAlias,
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
|
||||
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const id = assertNoCancel(
|
||||
await text({
|
||||
message: "Secret id",
|
||||
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const ref: SecretRef = {
|
||||
source,
|
||||
provider: String(provider).trim(),
|
||||
id: String(id).trim(),
|
||||
};
|
||||
|
||||
const selectedPath = assertNoCancel(
|
||||
await select({
|
||||
message: "Select credential field",
|
||||
options,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const next = {
|
||||
...candidate,
|
||||
ref,
|
||||
};
|
||||
selectedByPath.set(candidate.path, next);
|
||||
|
||||
if (selectedPath === "__done__") {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidate = candidates.find((entry) => entry.path === selectedPath);
|
||||
if (!candidate) {
|
||||
throw new Error(`Unknown configure target: ${selectedPath}`);
|
||||
}
|
||||
|
||||
const source = assertNoCancel(
|
||||
await select({
|
||||
message: "Secret source",
|
||||
options: sourceChoices,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
) as SecretRefSource;
|
||||
|
||||
const defaultAlias = resolveDefaultSecretProviderAlias(snapshot.config, source, {
|
||||
preferFirstProviderForSource: true,
|
||||
});
|
||||
const provider = assertNoCancel(
|
||||
await text({
|
||||
message: "Provider alias",
|
||||
initialValue: defaultAlias,
|
||||
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const id = assertNoCancel(
|
||||
await text({
|
||||
message: "Secret id",
|
||||
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
const ref: SecretRef = {
|
||||
source,
|
||||
provider: String(provider).trim(),
|
||||
id: String(id).trim(),
|
||||
};
|
||||
|
||||
const next = {
|
||||
...candidate,
|
||||
ref,
|
||||
};
|
||||
selectedByPath.set(candidate.path, next);
|
||||
|
||||
const addMore = assertNoCancel(
|
||||
await confirm({
|
||||
message: "Configure another credential?",
|
||||
initialValue: true,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
if (!addMore) {
|
||||
break;
|
||||
const addMore = assertNoCancel(
|
||||
await confirm({
|
||||
message: "Configure another credential?",
|
||||
initialValue: true,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
);
|
||||
if (!addMore) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedByPath.size === 0) {
|
||||
throw new Error("No secrets were selected.");
|
||||
if (
|
||||
selectedByPath.size === 0 &&
|
||||
Object.keys(providerChanges.upserts).length === 0 &&
|
||||
providerChanges.deletes.length === 0
|
||||
) {
|
||||
throw new Error("No secrets changes were selected.");
|
||||
}
|
||||
|
||||
const plan: SecretsApplyPlan = {
|
||||
@@ -219,6 +841,10 @@ export async function runSecretsConfigureInteractive(
|
||||
...(entry.providerId ? { providerId: entry.providerId } : {}),
|
||||
...(entry.accountId ? { accountId: entry.accountId } : {}),
|
||||
})),
|
||||
...(Object.keys(providerChanges.upserts).length > 0
|
||||
? { providerUpserts: providerChanges.upserts }
|
||||
: {}),
|
||||
...(providerChanges.deletes.length > 0 ? { providerDeletes: providerChanges.deletes } : {}),
|
||||
options: {
|
||||
scrubEnv: true,
|
||||
scrubAuthProfilesForProviderTargets: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SecretRef } from "../config/types.secrets.js";
|
||||
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
|
||||
|
||||
export type SecretsPlanTargetType =
|
||||
| "models.providers.apiKey"
|
||||
@@ -28,6 +28,8 @@ export type SecretsApplyPlan = {
|
||||
protocolVersion: 1;
|
||||
generatedAt: string;
|
||||
generatedBy: "openclaw secrets configure" | "manual";
|
||||
providerUpserts?: Record<string, SecretProviderConfig>;
|
||||
providerDeletes?: string[];
|
||||
targets: SecretsPlanTarget[];
|
||||
options?: {
|
||||
scrubEnv?: boolean;
|
||||
@@ -36,6 +38,72 @@ export type SecretsApplyPlan = {
|
||||
};
|
||||
};
|
||||
|
||||
const PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function isSecretProviderConfigShape(value: unknown): value is SecretProviderConfig {
|
||||
if (!isObjectRecord(value) || typeof value.source !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.source === "env") {
|
||||
if (value.allowlist !== undefined && !isStringArray(value.allowlist)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.source === "file") {
|
||||
if (typeof value.path !== "string" || value.path.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (value.mode !== undefined && value.mode !== "json" && value.mode !== "singleValue") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.source === "exec") {
|
||||
if (typeof value.command !== "string" || value.command.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (value.args !== undefined && !isStringArray(value.args)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.passEnv !== undefined &&
|
||||
(!Array.isArray(value.passEnv) || !value.passEnv.every((entry) => typeof entry === "string"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.trustedDirs !== undefined &&
|
||||
(!Array.isArray(value.trustedDirs) ||
|
||||
!value.trustedDirs.every((entry) => typeof entry === "string"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (value.env !== undefined) {
|
||||
if (!isObjectRecord(value.env)) {
|
||||
return false;
|
||||
}
|
||||
if (!Object.values(value.env).every((entry) => typeof entry === "string")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
@@ -67,6 +135,30 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typed.providerUpserts !== undefined) {
|
||||
if (!isObjectRecord(typed.providerUpserts)) {
|
||||
return false;
|
||||
}
|
||||
for (const [providerAlias, providerValue] of Object.entries(typed.providerUpserts)) {
|
||||
if (!PROVIDER_ALIAS_PATTERN.test(providerAlias)) {
|
||||
return false;
|
||||
}
|
||||
if (!isSecretProviderConfigShape(providerValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typed.providerDeletes !== undefined) {
|
||||
if (
|
||||
!Array.isArray(typed.providerDeletes) ||
|
||||
typed.providerDeletes.some(
|
||||
(providerAlias) =>
|
||||
typeof providerAlias !== "string" || !PROVIDER_ALIAS_PATTERN.test(providerAlias),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/;
|
||||
|
||||
export const RAW_FILE_REF_ID = "value";
|
||||
export const SINGLE_VALUE_FILE_REF_ID = "value";
|
||||
|
||||
export type SecretRefDefaultsCarrier = {
|
||||
secrets?: {
|
||||
@@ -53,7 +53,7 @@ export function resolveDefaultSecretProviderAlias(
|
||||
}
|
||||
|
||||
export function isValidFileSecretRefId(value: string): boolean {
|
||||
if (value === RAW_FILE_REF_ID) {
|
||||
if (value === SINGLE_VALUE_FILE_REF_ID) {
|
||||
return true;
|
||||
}
|
||||
if (!value.startsWith("/")) {
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("secret ref resolver", () => {
|
||||
expect(value).toBe("sk-env-value");
|
||||
});
|
||||
|
||||
it("resolves file refs in jsonPointer mode", async () => {
|
||||
it("resolves file refs in json mode", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ describe("secret ref resolver", () => {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: filePath,
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -253,11 +253,11 @@ describe("secret ref resolver", () => {
|
||||
).rejects.toThrow("returned invalid JSON");
|
||||
});
|
||||
|
||||
it("supports file raw mode with id=value", async () => {
|
||||
it("supports file singleValue mode with id=value", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-raw-"));
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-single-value-"));
|
||||
cleanupRoots.push(root);
|
||||
const filePath = path.join(root, "token.txt");
|
||||
await writeSecureFile(filePath, "raw-token-value\n");
|
||||
@@ -271,7 +271,7 @@ describe("secret ref resolver", () => {
|
||||
rawfile: {
|
||||
source: "file",
|
||||
path: filePath,
|
||||
mode: "raw",
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -320,7 +320,7 @@ describe("secret ref resolver", () => {
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: filePath,
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
timeoutMs: 5,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import { resolveUserPath } from "../utils.js";
|
||||
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
|
||||
import { readJsonPointer } from "./json-pointer.js";
|
||||
import {
|
||||
RAW_FILE_REF_ID,
|
||||
SINGLE_VALUE_FILE_REF_ID,
|
||||
resolveDefaultSecretProviderAlias,
|
||||
secretRefKey,
|
||||
} from "./ref-contract.js";
|
||||
@@ -194,7 +194,7 @@ async function readFileProviderPayload(params: {
|
||||
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
|
||||
}
|
||||
const text = payload.toString("utf8");
|
||||
if (params.providerConfig.mode === "raw") {
|
||||
if (params.providerConfig.mode === "singleValue") {
|
||||
return text.replace(/\r?\n$/, "");
|
||||
}
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
@@ -257,13 +257,13 @@ async function resolveFileRefs(params: {
|
||||
providerConfig: params.providerConfig,
|
||||
cache: params.cache,
|
||||
});
|
||||
const mode = params.providerConfig.mode ?? "jsonPointer";
|
||||
const mode = params.providerConfig.mode ?? "json";
|
||||
const resolved = new Map<string, unknown>();
|
||||
if (mode === "raw") {
|
||||
if (mode === "singleValue") {
|
||||
for (const ref of params.refs) {
|
||||
if (ref.id !== RAW_FILE_REF_ID) {
|
||||
if (ref.id !== SINGLE_VALUE_FILE_REF_ID) {
|
||||
throw new Error(
|
||||
`Raw file provider "${params.providerName}" expects ref id "${RAW_FILE_REF_ID}".`,
|
||||
`singleValue file provider "${params.providerName}" expects ref id "${SINGLE_VALUE_FILE_REF_ID}".`,
|
||||
);
|
||||
}
|
||||
resolved.set(ref.id, payload);
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("secrets runtime snapshot", () => {
|
||||
default: {
|
||||
source: "file",
|
||||
path: secretsPath,
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
@@ -163,7 +163,7 @@ describe("secrets runtime snapshot", () => {
|
||||
default: {
|
||||
source: "file",
|
||||
path: secretsPath,
|
||||
mode: "jsonPointer",
|
||||
mode: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user