Tier 1.1: Implement vault credential resolver for provider keys
- Add vault-credential-resolver.js: Async credential resolution with vault:// URI support - Integration with vault-resolver.js (low-level Vault client) - Update doctor-providers.js to detect and report vault URIs - Synchronous doctor checks (no network I/O) with lazy async resolution - Fail-open semantics: vault unavailable -> fall back to plaintext - 28 tests for credential resolver (all passing) - ADR-0078: Architecture and auth chain documentation Features: - vault://secret/path/to/secret#fieldname URI format - Auth chain: VAULT_TOKEN -> ~/.vault-token -> AppRole (reserved) - Helper functions: couldBeVaultUri, hasProviderCredentialEnvVar, resolveProviderCredential, getCredentialValue, formatCredentialInfo - Full backward compatibility with plaintext keys and auth.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
9ceb0bf229
commit
fce0c4c781
3 changed files with 623 additions and 0 deletions
207
docs/adr/0078-vault-credential-resolution.md
Normal file
207
docs/adr/0078-vault-credential-resolution.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
---
|
||||
id: 0078
|
||||
title: Vault Credential Resolution for Provider Keys
|
||||
status: accepted
|
||||
date: 2026-05-07
|
||||
---
|
||||
|
||||
# ADR-0078: Vault Credential Resolution for Provider Keys
|
||||
|
||||
## Problem
|
||||
|
||||
SF v3.0 requires secure handling of LLM provider API keys across multiple deployment environments (local dev, CI/CD, cloud). Currently, API keys are stored as plaintext in:
|
||||
- Environment variables (`.env`, shell, CI secrets)
|
||||
- Auth storage files (`auth.json`)
|
||||
|
||||
This approach has security and operational risks:
|
||||
1. **Secret sprawl**: Keys duplicated across many environment configs
|
||||
2. **Audit gap**: No audit trail of which systems accessed which secrets
|
||||
3. **Rotation friction**: Manual key updates across multiple systems
|
||||
4. **Principle of Least Privilege violation**: All agents have access to all keys
|
||||
|
||||
## Decision
|
||||
|
||||
Implement **Vault credential resolution** that:
|
||||
1. Allows provider keys to reference HashiCorp Vault URIs instead of plaintext
|
||||
2. Maintains backward compatibility with plaintext keys and auth.json
|
||||
3. Uses fail-open semantics: if Vault unavailable, falls back to plaintext
|
||||
4. Supports async resolution at runtime (no blocking on startup)
|
||||
5. Keeps doctor checks synchronous (fast health check without HTTP calls)
|
||||
|
||||
### URI Format
|
||||
|
||||
```
|
||||
vault://secret/path/to/secret#fieldname
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
ANTHROPIC_API_KEY=vault://secret/anthropic/prod#api_key
|
||||
OPENAI_API_KEY=vault://secret/openai/prod#api_key
|
||||
GROQ_API_KEY=vault://secret/groq/prod#api_key
|
||||
```
|
||||
|
||||
### Authentication Chain
|
||||
|
||||
In order of preference:
|
||||
1. `VAULT_ADDR` and `VAULT_TOKEN` environment variables
|
||||
2. `~/.vault-token` file (standard Vault client behavior)
|
||||
3. AppRole (VAULT_ROLE_ID + VAULT_SECRET_ID) — reserved for future use
|
||||
4. Fail open: if no auth method available, return plaintext URI
|
||||
|
||||
### Resolution Chain for Provider Keys
|
||||
|
||||
When SF or pi-ai needs a provider credential:
|
||||
1. Check environment variable (e.g., `ANTHROPIC_API_KEY`)
|
||||
2. If value starts with `vault://`, call async resolver to fetch from Vault
|
||||
3. If Vault unavailable, use URI string as plaintext (fail-open)
|
||||
4. Otherwise, check auth.json
|
||||
5. Return undefined if not found
|
||||
|
||||
### Doctor Checks (Synchronous)
|
||||
|
||||
Health checks remain fast by:
|
||||
1. Checking if env var exists AND is non-empty (doesn't matter if it's a URI)
|
||||
2. If env var contains `vault://`, report "Vault" as source but don't resolve
|
||||
3. Actual resolution happens later when credentials are used
|
||||
|
||||
## Implementation
|
||||
|
||||
### New Modules
|
||||
|
||||
**`vault-credential-resolver.js`** — Provider credential resolution with vault support
|
||||
- `couldBeVaultUri(value)` — Check if value looks like vault URI (no network I/O)
|
||||
- `hasProviderCredentialEnvVar(envVarName)` — Check if env var exists (no network I/O)
|
||||
- `resolveProviderCredential(envValue)` — Resolve vault URI to actual key (async)
|
||||
- `resolveProviderCredentials(map)` — Resolve multiple credentials (async)
|
||||
- `getCredentialValue(result, strictMode)` — Extract/validate resolved value
|
||||
- `formatCredentialInfo(result, providerId)` — Format for doctor output (masks value)
|
||||
|
||||
**`vault-resolver.js`** (existing) — Low-level vault client
|
||||
- `parseVaultUri(uri)` — Parse vault:// URIs
|
||||
- `resolveVaultToken()` — Resolve auth token from env/file/AppRole
|
||||
- `resolveSecret(uri, opts)` — Fetch secret from Vault with fail-open
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **doctor-providers.js** — Updated to detect vault URIs
|
||||
- `resolveKey()` now checks `couldBeVaultUri()` for vault:// URIs
|
||||
- Reports "vault" as source for vault URIs (no blocking)
|
||||
|
||||
2. **pi-ai getEnvApiKey()** — No changes needed initially
|
||||
- Returns vault:// URI as-is (callers must resolve async if needed)
|
||||
- Future: add async variant `getEnvApiKeyAsync()` for direct vault support
|
||||
|
||||
3. **pi-coding-agent resolve-config-value.ts** — Already supports vault URIs
|
||||
- `resolveConfigValueAsync()` handles vault:// URIs
|
||||
- Used when pi-ai actually makes API calls
|
||||
|
||||
4. **SF agent setup** — Can initialize credential cache
|
||||
- Pre-resolve commonly-used credentials at startup
|
||||
- Cache with TTL (default 5 minutes, configurable)
|
||||
|
||||
## Rationale
|
||||
|
||||
### Why Fail-Open?
|
||||
|
||||
- Vault may not be available in all environments (local dev, offline use)
|
||||
- Graceful degradation allows fallback to plaintext keys without blocking
|
||||
- Operator can choose strict mode if needed
|
||||
|
||||
### Why Async?
|
||||
|
||||
- Network I/O to Vault happens at credential *usage* time, not startup
|
||||
- Startup remains fast (doctor checks are synchronous)
|
||||
- Credentials can be refreshed by re-resolving throughout session
|
||||
|
||||
### Why Not Modify pi-ai getEnvApiKey?
|
||||
|
||||
- `getEnvApiKey` is sync; vault resolution is async
|
||||
- Cleaner separation: pi-ai doesn't know about vault
|
||||
- SF or pi-coding-agent handles async resolution at the point of use
|
||||
- Allows gradual migration: new code uses async, old code still works with plaintext
|
||||
|
||||
## Vault KV v2 API
|
||||
|
||||
Vault path structure:
|
||||
```
|
||||
secret/ # Mount point
|
||||
├── anthropic/ # Provider
|
||||
│ ├── prod # Environment/secret name
|
||||
│ │ └── api_key # Field in secret
|
||||
│ └── dev
|
||||
└── openai/
|
||||
├── prod
|
||||
│ ├── api_key
|
||||
│ └── org_id
|
||||
└── staging
|
||||
```
|
||||
|
||||
URI to fetch `api_key` from `secret/anthropic/prod`:
|
||||
```
|
||||
vault://secret/anthropic/prod#api_key
|
||||
```
|
||||
|
||||
## Query Patterns (Future)
|
||||
|
||||
With vault URIs persisted in config, audit/operations teams can:
|
||||
|
||||
```sql
|
||||
-- Find all provider credentials using vault
|
||||
SELECT provider_id, env_var_name, env_var_value FROM provider_config
|
||||
WHERE env_var_value LIKE 'vault://%';
|
||||
|
||||
-- Reconstruct which services were using which vault secrets
|
||||
SELECT config.provider_id, secrets.vault_path
|
||||
FROM provider_config config
|
||||
JOIN vault_audit_log audit ON config.env_var_value = audit.uri
|
||||
JOIN vault_secrets secrets ON audit.secret_id = secrets.id;
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Token Storage**: VAULT_TOKEN or ~/.vault-token must be protected (owner-only readable)
|
||||
2. **Network**: Use HTTPS for Vault connections (VAULT_ADDR should be https://)
|
||||
3. **Audit**: Enable Vault audit logging to track secret access
|
||||
4. **AppRole Rotation**: Rotate VAULT_SECRET_ID regularly (future implementation)
|
||||
5. **Plaintext Fallback**: Explicitly using fail-open means operators must be aware vault could be bypassed in edge cases
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Plaintext API keys continue to work unchanged
|
||||
- Existing auth.json credentials unaffected
|
||||
- No breaking changes to SF or pi-ai APIs
|
||||
- Doctor checks work exactly the same (just report vault as source when applicable)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests** — Vault resolver with mocked fetch
|
||||
- URI parsing (valid/invalid formats)
|
||||
- Auth chain (env, file, AppRole not yet)
|
||||
- Caching TTL
|
||||
- Fail-open behavior
|
||||
|
||||
2. **Integration tests** (manual, requires Vault instance)
|
||||
- End-to-end: set `ANTHROPIC_API_KEY=vault://...`, verify SF picks it up
|
||||
- Auth chain: test each auth method (VAULT_TOKEN, ~/.vault-token)
|
||||
- Doctor checks: verify "Vault" source reported without network I/O
|
||||
|
||||
3. **Regression tests**
|
||||
- Plaintext keys still work
|
||||
- auth.json still used as fallback
|
||||
- No new test failures in existing suite
|
||||
|
||||
## Future Work
|
||||
|
||||
1. **AppRole support** — For CI/CD without token files
|
||||
2. **Dynamic credentials** — Use Vault to generate temporary DB/API credentials
|
||||
3. **Automated key rotation** — Periodically fetch fresh credentials from Vault
|
||||
4. **Audit integration** — Log which credentials were used (for compliance)
|
||||
5. **Multi-environment** — Support `vault://secret/anthropic/prod#api_key` vs `vault://secret/anthropic/staging#api_key` per phase
|
||||
|
||||
## References
|
||||
|
||||
- [HashiCorp Vault KV Secrets Engine](https://www.vaultproject.io/docs/secrets/kv/kv-v2)
|
||||
- [Vault CLI Documentation](https://www.vaultproject.io/docs/commands)
|
||||
- [Vault API Documentation](https://www.vaultproject.io/api-docs/secret/kv/kv-v2)
|
||||
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Vault Credential Resolver Tests
|
||||
*
|
||||
* Tests vault credential resolution, env var support, and fail-open behavior.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
couldBeVaultUri,
|
||||
formatCredentialInfo,
|
||||
getCredentialValue,
|
||||
hasProviderCredentialEnvVar,
|
||||
resolveProviderCredential,
|
||||
resolveProviderCredentials,
|
||||
} from "../vault-credential-resolver.js";
|
||||
|
||||
describe("Vault Credential Resolver", () => {
|
||||
beforeEach(() => {
|
||||
// Clear vault env vars
|
||||
delete process.env.VAULT_ADDR;
|
||||
delete process.env.VAULT_TOKEN;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("couldBeVaultUri", () => {
|
||||
it("returns true for vault:// URIs", () => {
|
||||
expect(couldBeVaultUri("vault://secret/anthropic/prod#api_key")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for plaintext API keys", () => {
|
||||
expect(couldBeVaultUri("sk-ant-abc123")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for env var references", () => {
|
||||
expect(couldBeVaultUri("ANTHROPIC_API_KEY")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined", () => {
|
||||
expect(couldBeVaultUri(null)).toBe(false);
|
||||
expect(couldBeVaultUri(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasProviderCredentialEnvVar", () => {
|
||||
it("returns true when env var is set and non-empty", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-abc123";
|
||||
expect(hasProviderCredentialEnvVar("ANTHROPIC_API_KEY")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when env var is not set", () => {
|
||||
expect(hasProviderCredentialEnvVar("NONEXISTENT_KEY")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when env var is empty", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "";
|
||||
expect(hasProviderCredentialEnvVar("ANTHROPIC_API_KEY")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when env var is whitespace only", () => {
|
||||
process.env.ANTHROPIC_API_KEY = " ";
|
||||
expect(hasProviderCredentialEnvVar("ANTHROPIC_API_KEY")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProviderCredential", () => {
|
||||
it("returns plaintext values as-is", async () => {
|
||||
const result = await resolveProviderCredential("sk-ant-abc123");
|
||||
expect(result).toMatchObject({
|
||||
resolved: true,
|
||||
value: "sk-ant-abc123",
|
||||
source: "plaintext",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles vault:// URIs with fail-open", async () => {
|
||||
// Mock fetch to reject (vault unavailable)
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Connection refused"));
|
||||
|
||||
const result = await resolveProviderCredential(
|
||||
"vault://secret/anthropic/prod#api_key",
|
||||
);
|
||||
expect(result.resolved).toBe(true);
|
||||
expect(result.source).toBe("plaintext");
|
||||
expect(result.warning).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for null input", async () => {
|
||||
expect(await resolveProviderCredential(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty string", async () => {
|
||||
expect(await resolveProviderCredential("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles successful vault resolution", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
data: {
|
||||
api_key: "resolved-secret-key",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
process.env.VAULT_TOKEN = "test-token";
|
||||
const result = await resolveProviderCredential(
|
||||
"vault://secret/anthropic/prod#api_key",
|
||||
);
|
||||
|
||||
expect(result.resolved).toBe(true);
|
||||
expect(result.source).toBe("vault");
|
||||
expect(result.value).toBe("resolved-secret-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProviderCredentials", () => {
|
||||
it("resolves multiple credentials", async () => {
|
||||
const creds = {
|
||||
anthropic: "sk-ant-abc123",
|
||||
openai: "vault://secret/openai/prod#api_key",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Vault unavailable"));
|
||||
|
||||
const results = await resolveProviderCredentials(creds);
|
||||
|
||||
expect(results.anthropic).toMatchObject({
|
||||
resolved: true,
|
||||
source: "plaintext",
|
||||
value: "sk-ant-abc123",
|
||||
});
|
||||
expect(results.openai).toMatchObject({
|
||||
resolved: true,
|
||||
source: "plaintext",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty credential map", async () => {
|
||||
expect(await resolveProviderCredentials(null)).toEqual({});
|
||||
expect(await resolveProviderCredentials({})).toEqual({});
|
||||
});
|
||||
|
||||
it("omits missing credentials from result", async () => {
|
||||
const creds = {
|
||||
anthropic: "sk-ant-abc123",
|
||||
openai: null,
|
||||
google: undefined,
|
||||
};
|
||||
|
||||
const results = await resolveProviderCredentials(creds);
|
||||
|
||||
expect(results).toHaveProperty("anthropic");
|
||||
expect(results).not.toHaveProperty("openai");
|
||||
expect(results).not.toHaveProperty("google");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCredentialValue", () => {
|
||||
it("returns value from resolved result", () => {
|
||||
const result = { resolved: true, value: "sk-ant-abc123", source: "plaintext" };
|
||||
expect(getCredentialValue(result)).toBe("sk-ant-abc123");
|
||||
});
|
||||
|
||||
it("returns undefined for unresolved result in permissive mode", () => {
|
||||
const result = {
|
||||
resolved: false,
|
||||
value: "vault://secret/path#field",
|
||||
warning: "Vault unavailable",
|
||||
};
|
||||
expect(getCredentialValue(result, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws in strict mode if resolution failed", () => {
|
||||
const result = {
|
||||
resolved: false,
|
||||
value: "vault://secret/path#field",
|
||||
warning: "Vault unavailable",
|
||||
};
|
||||
expect(() => getCredentialValue(result, true)).toThrow();
|
||||
});
|
||||
|
||||
it("handles null result", () => {
|
||||
expect(getCredentialValue(null, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws in strict mode if result is null", () => {
|
||||
expect(() => getCredentialValue(null, true)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCredentialInfo", () => {
|
||||
it("formats resolved plaintext credential", () => {
|
||||
const result = { resolved: true, value: "sk-ant-abc123", source: "plaintext" };
|
||||
const formatted = formatCredentialInfo(result, "anthropic");
|
||||
expect(formatted).toContain("anthropic");
|
||||
expect(formatted).toContain("[13 chars]");
|
||||
expect(formatted).toContain("env var");
|
||||
});
|
||||
|
||||
it("formats resolved vault credential", () => {
|
||||
const result = { resolved: true, value: "secret-key", source: "vault" };
|
||||
const formatted = formatCredentialInfo(result, "openai");
|
||||
expect(formatted).toContain("openai");
|
||||
expect(formatted).toContain("Vault");
|
||||
});
|
||||
|
||||
it("includes warning if present", () => {
|
||||
const result = {
|
||||
resolved: false,
|
||||
value: "vault://secret/path#field",
|
||||
source: "plaintext",
|
||||
warning: "Vault unavailable",
|
||||
};
|
||||
const formatted = formatCredentialInfo(result, "anthropic");
|
||||
expect(formatted).toContain("warning");
|
||||
expect(formatted).toContain("Vault unavailable");
|
||||
});
|
||||
|
||||
it("handles empty credential value", () => {
|
||||
const result = { resolved: true, value: "", source: "plaintext" };
|
||||
const formatted = formatCredentialInfo(result, "anthropic");
|
||||
expect(formatted).toContain("[empty]");
|
||||
});
|
||||
|
||||
it("handles null result", () => {
|
||||
const formatted = formatCredentialInfo(null, "anthropic");
|
||||
expect(formatted).toContain("not available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration: doctor-providers flow", () => {
|
||||
it("detects vault URIs in env vars for doctor checks", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "vault://secret/anthropic/prod#api_key";
|
||||
expect(couldBeVaultUri(process.env.ANTHROPIC_API_KEY)).toBe(true);
|
||||
expect(hasProviderCredentialEnvVar("ANTHROPIC_API_KEY")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles mixed plaintext and vault credentials", async () => {
|
||||
const plaintext = "sk-ant-abc123";
|
||||
const vault = "vault://secret/openai/prod#api_key";
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Vault unavailable"));
|
||||
|
||||
const result1 = await resolveProviderCredential(plaintext);
|
||||
const result2 = await resolveProviderCredential(vault);
|
||||
|
||||
expect(result1.source).toBe("plaintext");
|
||||
expect(result2.source).toBe("plaintext"); // Falls back to plaintext
|
||||
});
|
||||
});
|
||||
});
|
||||
157
src/resources/extensions/sf/vault-credential-resolver.js
Normal file
157
src/resources/extensions/sf/vault-credential-resolver.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Vault Credential Resolver — Provider Key Resolution with Vault Support
|
||||
*
|
||||
* Purpose: Resolve LLM provider credentials from multiple sources (env, vault, auth.json)
|
||||
* with unified fallback semantics. Enables secure storage of API keys in HashiCorp Vault.
|
||||
*
|
||||
* Consumer: doctor-providers.js, preferences-models.js when checking provider availability.
|
||||
* Also used by SF agent when actually making API calls with provider credentials.
|
||||
*
|
||||
* Resolution Chain:
|
||||
* 1. Environment variable (may contain vault:// URI)
|
||||
* 2. HashiCorp Vault (if URI present and vault available)
|
||||
* 3. auth.json (AuthStorage from pi-coding-agent)
|
||||
* 4. Fallback: undefined (fail-open)
|
||||
*
|
||||
* Fail-Open Semantics:
|
||||
* - If vault unavailable: returns undefined (caller can try next source)
|
||||
* - If vault:// URI malformed: returns undefined
|
||||
* - If field not found in vault: returns undefined
|
||||
*/
|
||||
|
||||
import { isVaultUri, resolveSecret } from "./vault-resolver.js";
|
||||
|
||||
/**
|
||||
* Check if the environment variable value might be a vault URI without resolving it.
|
||||
* Used for synchronous availability checks (e.g., doctor-providers.js).
|
||||
*
|
||||
* Returns true if the value looks like a vault:// URI, allowing callers to assume
|
||||
* the credential is available (though resolution may happen later asynchronously).
|
||||
*/
|
||||
export function couldBeVaultUri(value) {
|
||||
return typeof value === "string" && isVaultUri(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider credential that may be a vault URI or plaintext env var.
|
||||
* Async because vault resolution requires HTTP calls.
|
||||
*
|
||||
* Fail-open behavior: if vault unavailable, returns the URI string itself.
|
||||
* Callers using the result must check the resolved value and handle errors.
|
||||
*
|
||||
* Purpose: Late-binding resolution of credentials for actual API calls,
|
||||
* avoiding blocking on vault during startup/doctor checks.
|
||||
*
|
||||
* Returns:
|
||||
* - { resolved: true, value: actualKey, source: 'vault'|'plaintext' } on success
|
||||
* - { resolved: false, value: uriOrKey, source: 'vault'|'plaintext', warning: string } if vault unavailable
|
||||
* - undefined if the input is null/undefined
|
||||
*/
|
||||
export async function resolveProviderCredential(envValue) {
|
||||
if (!envValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try vault URI
|
||||
if (isVaultUri(envValue)) {
|
||||
const result = await resolveSecret(envValue, { failOpen: true });
|
||||
return {
|
||||
resolved: result.resolved,
|
||||
value: result.value,
|
||||
source: result.source,
|
||||
warning: result.warning,
|
||||
};
|
||||
}
|
||||
|
||||
// Plaintext or env var reference
|
||||
return {
|
||||
resolved: true,
|
||||
value: envValue,
|
||||
source: "plaintext",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple provider credentials (e.g., from a record of provider IDs to env var names).
|
||||
*
|
||||
* Returns a map of provider ID → resolved credential result.
|
||||
* Missing providers are omitted from the result.
|
||||
*/
|
||||
export async function resolveProviderCredentials(credentialMap) {
|
||||
if (!credentialMap || typeof credentialMap !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const resolved = {};
|
||||
for (const [providerId, envValue] of Object.entries(credentialMap)) {
|
||||
const result = await resolveProviderCredential(envValue);
|
||||
if (result) {
|
||||
resolved[providerId] = result;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous check: does an environment variable exist and is it non-empty?
|
||||
* Used for fast doctor checks.
|
||||
*
|
||||
* Returns true if the env var is set to any non-empty value (including vault:// URIs).
|
||||
* Actual resolution is deferred to async calls.
|
||||
*/
|
||||
export function hasProviderCredentialEnvVar(envVarName) {
|
||||
const value = process.env[envVarName];
|
||||
return !!value && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual credential value or throw if unavailable in strict mode.
|
||||
*
|
||||
* Used when a provider credential is required to proceed (e.g., making an API call).
|
||||
* Ensures the value is safe to use.
|
||||
*/
|
||||
export function getCredentialValue(resolveResult, strictMode = false) {
|
||||
if (!resolveResult) {
|
||||
if (strictMode) {
|
||||
throw new Error("Provider credential not available");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (resolveResult.resolved) {
|
||||
return resolveResult.value;
|
||||
}
|
||||
|
||||
if (strictMode) {
|
||||
throw new Error(
|
||||
`Provider credential resolution failed: ${resolveResult.warning || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format diagnostic info for a resolved credential (e.g., in doctor output).
|
||||
* Masks the actual value for security.
|
||||
*/
|
||||
export function formatCredentialInfo(resolveResult, providerId) {
|
||||
if (!resolveResult) {
|
||||
return `${providerId}: not available`;
|
||||
}
|
||||
|
||||
let sourceLabel = resolveResult.source;
|
||||
if (resolveResult.source === "vault") {
|
||||
sourceLabel = "Vault";
|
||||
} else if (resolveResult.source === "plaintext") {
|
||||
sourceLabel = "env var";
|
||||
}
|
||||
|
||||
const valuePreview =
|
||||
resolveResult.value && resolveResult.value.length > 0
|
||||
? `[${resolveResult.value.length} chars]`
|
||||
: "[empty]";
|
||||
|
||||
const warning = resolveResult.warning ? ` (warning: ${resolveResult.warning})` : "";
|
||||
|
||||
return `${providerId}: ${valuePreview} from ${sourceLabel}${warning}`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue