426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
/**
|
|
* Persists OAuth credential material for a connector account and reads the
|
|
* resulting refs back out. `persistConnectorCredentialRefs` writes each secret
|
|
* to the first available durable vault (connector credential store, vault, or
|
|
* SECRETS) and records a `vaultRef` pointer on the account via storage; it
|
|
* refuses to proceed unless both a vault writer and a ref writer exist, so an
|
|
* account is never marked connected without durable credentials.
|
|
* `credentialRefRecordsFromMetadata` is the read side, extracting ref records
|
|
* from account metadata for the credential resolver. Consumed by the connector
|
|
* account provider on OAuth completion and by `DefaultGoogleCredentialResolver`.
|
|
*/
|
|
import {
|
|
CONNECTOR_ACCOUNT_STORAGE_SERVICE_TYPE,
|
|
type ConnectorAccountManager,
|
|
type IAgentRuntime,
|
|
} from "@elizaos/core";
|
|
|
|
type JsonValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| undefined
|
|
| JsonValue[]
|
|
| { readonly [key: string]: JsonValue };
|
|
type JsonRecord = Record<string, JsonValue>;
|
|
|
|
export interface ConnectorCredentialRefMetadata extends JsonRecord {
|
|
credentialType: string;
|
|
vaultRef: string;
|
|
expiresAt?: number;
|
|
metadata?: JsonRecord;
|
|
}
|
|
|
|
export interface ConnectorCredentialRefRecordLike {
|
|
credentialType: string;
|
|
vaultRef?: string | null;
|
|
metadata?: JsonRecord | null;
|
|
expiresAt?: number | string | Date | null;
|
|
updatedAt?: number | string | Date | null;
|
|
version?: string | number | null;
|
|
}
|
|
|
|
export interface ConnectorCredentialPersistResult {
|
|
refs: ConnectorCredentialRefMetadata[];
|
|
vaultAvailable: boolean;
|
|
storageAvailable: boolean;
|
|
}
|
|
|
|
interface ConnectorCredentialInput {
|
|
credentialType: string;
|
|
value: string;
|
|
expiresAt?: number;
|
|
metadata?: JsonRecord;
|
|
}
|
|
|
|
interface PersistConnectorCredentialRefsParams {
|
|
runtime: IAgentRuntime;
|
|
manager?: ConnectorAccountManager;
|
|
provider: string;
|
|
accountIdForRef: string;
|
|
storageAccountId?: string;
|
|
credentials: ConnectorCredentialInput[];
|
|
caller: string;
|
|
}
|
|
|
|
type VaultWriter = {
|
|
name: string;
|
|
write: (vaultRef: string, credential: ConnectorCredentialInput) => Promise<string>;
|
|
};
|
|
|
|
type CredentialRefWriter = {
|
|
name: string;
|
|
write: (ref: ConnectorCredentialRefMetadata) => Promise<void>;
|
|
};
|
|
|
|
export async function persistConnectorCredentialRefs(
|
|
params: PersistConnectorCredentialRefsParams
|
|
): Promise<ConnectorCredentialPersistResult> {
|
|
const refs: ConnectorCredentialRefMetadata[] = [];
|
|
const vaultWriters = resolveVaultWriters(params.runtime, {
|
|
provider: params.provider,
|
|
accountId: params.accountIdForRef,
|
|
caller: params.caller,
|
|
});
|
|
if (vaultWriters.length === 0) {
|
|
throw new Error(
|
|
`No durable connector credential store or vault writer is available for ${params.provider} account ${params.accountIdForRef}. Refusing to mark OAuth account connected without persisted credentials.`
|
|
);
|
|
}
|
|
if (!params.storageAccountId) {
|
|
throw new Error(
|
|
`No durable connector account id is available for ${params.provider} account ${params.accountIdForRef}. Refusing to mark OAuth account connected without persisted credential refs.`
|
|
);
|
|
}
|
|
const storageWriters = resolveCredentialRefWriters(
|
|
params.runtime,
|
|
params.manager,
|
|
params.storageAccountId
|
|
);
|
|
if (storageWriters.length === 0) {
|
|
throw new Error(
|
|
`No durable connector credential ref writer is available for ${params.provider} account ${params.storageAccountId}. Refusing to mark OAuth account connected without persisted credential refs.`
|
|
);
|
|
}
|
|
|
|
for (const credential of params.credentials) {
|
|
const plannedRef = buildConnectorCredentialVaultRef({
|
|
agentId: nonEmptyString(params.runtime.agentId) ?? "agent",
|
|
provider: params.provider,
|
|
accountId: params.accountIdForRef,
|
|
credentialType: credential.credentialType,
|
|
});
|
|
const vaultRef = await writeWithFirstAvailableVault(vaultWriters, plannedRef, credential);
|
|
refs.push({
|
|
credentialType: credential.credentialType,
|
|
vaultRef,
|
|
...(credential.expiresAt !== undefined ? { expiresAt: credential.expiresAt } : {}),
|
|
...(credential.metadata ? { metadata: credential.metadata } : {}),
|
|
});
|
|
}
|
|
|
|
if (refs.length > 0) {
|
|
await writeRefsToStorage(storageWriters, refs);
|
|
}
|
|
|
|
return {
|
|
refs,
|
|
vaultAvailable: vaultWriters.length > 0,
|
|
storageAvailable: storageWriters.length > 0,
|
|
};
|
|
}
|
|
|
|
export function credentialRefRecordsFromMetadata(
|
|
metadata: unknown
|
|
): ConnectorCredentialRefRecordLike[] {
|
|
const record = asRecord(metadata);
|
|
if (!record) return [];
|
|
|
|
const oauth = asRecord(record.oauth);
|
|
return [
|
|
...credentialRefsFromUnknown(record.credentialRefs),
|
|
...credentialRefsFromUnknown(record.oauthCredentialRefs),
|
|
...credentialRefsFromUnknown(oauth?.credentialRefs),
|
|
];
|
|
}
|
|
|
|
function credentialRefsFromUnknown(value: unknown): ConnectorCredentialRefRecordLike[] {
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((entry) => {
|
|
const ref = credentialRefFromRecord(asRecord(entry));
|
|
return ref ? [ref] : [];
|
|
});
|
|
}
|
|
|
|
const record = asRecord(value);
|
|
if (!record) return [];
|
|
return Object.entries(record).flatMap(([credentialType, entry]) => {
|
|
const entryRecord = asRecord(entry);
|
|
if (entryRecord) {
|
|
const ref = credentialRefFromRecord({
|
|
credentialType,
|
|
...entryRecord,
|
|
});
|
|
return ref ? [ref] : [];
|
|
}
|
|
const vaultRef = nonEmptyString(entry);
|
|
return vaultRef ? [{ credentialType, vaultRef }] : [];
|
|
});
|
|
}
|
|
|
|
function credentialRefFromRecord(
|
|
record: JsonRecord | undefined
|
|
): ConnectorCredentialRefRecordLike | null {
|
|
if (!record) return null;
|
|
const credentialType = nonEmptyString(record.credentialType ?? record.type ?? record.name);
|
|
const vaultRef = nonEmptyString(record.vaultRef ?? record.ref);
|
|
if (!credentialType || !vaultRef) return null;
|
|
return {
|
|
credentialType,
|
|
vaultRef,
|
|
metadata: asRecord(record.metadata) ?? null,
|
|
expiresAt: record.expiresAt as ConnectorCredentialRefRecordLike["expiresAt"],
|
|
updatedAt: record.updatedAt as ConnectorCredentialRefRecordLike["updatedAt"],
|
|
version: (record.version ??
|
|
record.credentialVersion) as ConnectorCredentialRefRecordLike["version"],
|
|
};
|
|
}
|
|
|
|
function resolveVaultWriters(
|
|
runtime: IAgentRuntime,
|
|
context: { provider: string; accountId: string; caller: string }
|
|
): VaultWriter[] {
|
|
const writers: VaultWriter[] = [];
|
|
const credentialStore = getFirstService(runtime, [
|
|
"connector_credential_store",
|
|
"CONNECTOR_CREDENTIAL_STORE",
|
|
"connectorCredentialStore",
|
|
"credential_store",
|
|
]) as {
|
|
putSecret?: (params: {
|
|
vaultRef?: string;
|
|
agentId: string;
|
|
provider: string;
|
|
accountId: string;
|
|
credentialType: string;
|
|
value: string;
|
|
caller?: string;
|
|
}) => Promise<string> | string;
|
|
} | null;
|
|
if (typeof credentialStore?.putSecret === "function") {
|
|
writers.push({
|
|
name: "connector_credential_store",
|
|
write: async (vaultRef, credential) =>
|
|
credentialStore.putSecret?.({
|
|
vaultRef,
|
|
agentId: nonEmptyString(runtime.agentId) ?? "agent",
|
|
provider: context.provider,
|
|
accountId: context.accountId,
|
|
credentialType: credential.credentialType,
|
|
value: credential.value,
|
|
caller: context.caller,
|
|
}) ?? vaultRef,
|
|
});
|
|
}
|
|
|
|
const vault = getFirstService(runtime, ["vault", "VAULT"]) as {
|
|
set?: (
|
|
key: string,
|
|
value: string,
|
|
options?: { sensitive?: boolean; caller?: string }
|
|
) => Promise<void> | void;
|
|
} | null;
|
|
if (typeof vault?.set === "function") {
|
|
writers.push({
|
|
name: "vault",
|
|
write: async (vaultRef, credential) => {
|
|
await vault.set?.(vaultRef, credential.value, {
|
|
sensitive: true,
|
|
caller: context.caller,
|
|
});
|
|
return vaultRef;
|
|
},
|
|
});
|
|
}
|
|
|
|
const secrets = getService(runtime, "SECRETS") as {
|
|
setGlobal?: (
|
|
key: string,
|
|
value: string,
|
|
config?: { sensitive?: boolean }
|
|
) => Promise<boolean> | boolean;
|
|
set?: (
|
|
key: string,
|
|
value: string,
|
|
context: JsonRecord,
|
|
config?: { sensitive?: boolean }
|
|
) => Promise<boolean> | boolean;
|
|
} | null;
|
|
if (typeof secrets?.setGlobal === "function" || typeof secrets?.set === "function") {
|
|
writers.push({
|
|
name: "SECRETS",
|
|
write: async (vaultRef, credential) => {
|
|
if (typeof secrets.setGlobal === "function") {
|
|
await secrets.setGlobal(vaultRef, credential.value, { sensitive: true });
|
|
return vaultRef;
|
|
}
|
|
await secrets.set?.(
|
|
vaultRef,
|
|
credential.value,
|
|
{ level: "global", agentId: runtime.agentId, requesterId: runtime.agentId },
|
|
{ sensitive: true }
|
|
);
|
|
return vaultRef;
|
|
},
|
|
});
|
|
}
|
|
|
|
return writers;
|
|
}
|
|
|
|
function resolveCredentialRefWriters(
|
|
runtime: IAgentRuntime,
|
|
manager: ConnectorAccountManager | undefined,
|
|
accountId: string
|
|
): CredentialRefWriter[] {
|
|
const candidates = [
|
|
manager?.getStorage?.(),
|
|
getService(runtime, CONNECTOR_ACCOUNT_STORAGE_SERVICE_TYPE),
|
|
(runtime as { adapter?: unknown }).adapter,
|
|
].filter(Boolean);
|
|
|
|
const writers: CredentialRefWriter[] = [];
|
|
for (const candidate of candidates) {
|
|
const writer = candidate as {
|
|
setConnectorAccountCredentialRef?: (params: {
|
|
accountId: string;
|
|
credentialType: string;
|
|
vaultRef: string;
|
|
metadata?: JsonRecord;
|
|
expiresAt?: number;
|
|
}) => Promise<unknown> | unknown;
|
|
setCredentialRef?: (params: {
|
|
accountId: string;
|
|
credentialType: string;
|
|
vaultRef: string;
|
|
metadata?: JsonRecord;
|
|
expiresAt?: number;
|
|
}) => Promise<unknown> | unknown;
|
|
};
|
|
if (typeof writer.setConnectorAccountCredentialRef === "function") {
|
|
writers.push({
|
|
name: "setConnectorAccountCredentialRef",
|
|
write: async (ref) => {
|
|
await writer.setConnectorAccountCredentialRef?.({
|
|
accountId,
|
|
credentialType: ref.credentialType,
|
|
vaultRef: ref.vaultRef,
|
|
...(ref.metadata ? { metadata: ref.metadata } : {}),
|
|
...(ref.expiresAt !== undefined ? { expiresAt: ref.expiresAt } : {}),
|
|
});
|
|
},
|
|
});
|
|
} else if (typeof writer.setCredentialRef === "function") {
|
|
writers.push({
|
|
name: "setCredentialRef",
|
|
write: async (ref) => {
|
|
await writer.setCredentialRef?.({
|
|
accountId,
|
|
credentialType: ref.credentialType,
|
|
vaultRef: ref.vaultRef,
|
|
...(ref.metadata ? { metadata: ref.metadata } : {}),
|
|
...(ref.expiresAt !== undefined ? { expiresAt: ref.expiresAt } : {}),
|
|
});
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return writers;
|
|
}
|
|
|
|
async function writeWithFirstAvailableVault(
|
|
writers: VaultWriter[],
|
|
plannedRef: string,
|
|
credential: ConnectorCredentialInput
|
|
): Promise<string> {
|
|
const errors: string[] = [];
|
|
for (const writer of writers) {
|
|
try {
|
|
return await writer.write(plannedRef, credential);
|
|
} catch (error) {
|
|
errors.push(`${writer.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
throw new Error(`Failed to persist connector credential ref ${plannedRef}: ${errors.join("; ")}`);
|
|
}
|
|
|
|
async function writeRefsToStorage(
|
|
writers: CredentialRefWriter[],
|
|
refs: ConnectorCredentialRefMetadata[]
|
|
): Promise<void> {
|
|
const errors: string[] = [];
|
|
for (const writer of writers) {
|
|
try {
|
|
for (const ref of refs) {
|
|
await writer.write(ref);
|
|
}
|
|
return;
|
|
} catch (error) {
|
|
errors.push(`${writer.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
throw new Error(`Failed to persist connector credential refs: ${errors.join("; ")}`);
|
|
}
|
|
|
|
function buildConnectorCredentialVaultRef(params: {
|
|
agentId: string;
|
|
provider: string;
|
|
accountId: string;
|
|
credentialType: string;
|
|
}): string {
|
|
return [
|
|
"connector",
|
|
normalizeVaultSegment(params.agentId),
|
|
normalizeVaultSegment(params.provider),
|
|
normalizeVaultSegment(params.accountId),
|
|
normalizeVaultSegment(params.credentialType),
|
|
].join(".");
|
|
}
|
|
|
|
function normalizeVaultSegment(value: string): string {
|
|
const normalized = value
|
|
.trim()
|
|
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
.replace(/^_+|_+$/g, "");
|
|
return (normalized || "unknown").slice(0, 64);
|
|
}
|
|
|
|
function getFirstService(runtime: IAgentRuntime, serviceTypes: readonly string[]): unknown {
|
|
for (const serviceType of serviceTypes) {
|
|
const service = getService(runtime, serviceType);
|
|
if (service) return service;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getService(runtime: IAgentRuntime, serviceType: string): unknown {
|
|
try {
|
|
return runtime.getService?.(serviceType) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): JsonRecord | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as JsonRecord)
|
|
: undefined;
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|