feat: add secure multi-runtime controls

Make agent backends configurable and governable with encrypted credentials, explicit context sharing, and tool approval boundaries.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-30 10:52:35 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 1b6178b841
commit 698a15ad14
27 changed files with 2465 additions and 91 deletions
+222
View File
@@ -0,0 +1,222 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { z } from 'zod'
import {
defaultRuntimeSettings,
runtimeProviderSchema,
toolApprovalPolicySchema,
RuntimeSettings,
type RuntimeSettingsInput
} from '../shared/contracts'
const storedSettingsSchema = z.object({
version: z.literal(1),
provider: runtimeProviderSchema,
bigtokenBaseUrl: z.string(),
bigtokenModel: z.string(),
credential: z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
ciphertextBase64: z.string()
})
.optional(),
toolApproval: toolApprovalPolicySchema
})
type StoredSettings = z.infer<typeof storedSettingsSchema>
const credentialPayloadSchema = z.object({
version: z.literal(1),
apiKey: z.string(),
origin: z.string()
})
export type CredentialCipher = {
isAvailable: () => boolean
encrypt: (value: string) => Buffer
decrypt: (value: Buffer) => string
}
export type ResolvedRuntimeSettings = {
provider: RuntimeSettings['provider']
bigtokenBaseUrl: string
bigtokenModel: string
apiKey?: string
toolApproval: RuntimeSettings['toolApproval']
}
const defaultSettings: StoredSettings = {
version: 1,
...defaultRuntimeSettings
}
export class RuntimeSettingsStore {
private settings?: StoredSettings
private updateQueue: Promise<void> = Promise.resolve()
constructor(
private readonly filePath: string,
private readonly cipher: CredentialCipher,
private readonly environment: NodeJS.ProcessEnv = process.env
) {}
private async load(): Promise<StoredSettings> {
if (this.settings) {
return this.settings
}
try {
const contents = await readFile(this.filePath, 'utf8')
this.settings = storedSettingsSchema.parse(JSON.parse(contents))
} catch {
this.settings = { ...defaultSettings }
}
return this.settings
}
private getStoredApiKey(settings: StoredSettings): string | undefined {
if (!settings.credential || !this.cipher.isAvailable()) {
return undefined
}
try {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(settings.credential.ciphertextBase64, 'base64')
)
)
)
return payload.origin === new URL(settings.bigtokenBaseUrl).origin
? payload.apiKey
: undefined
} catch {
return undefined
}
}
private getEnvironmentApiKey(): string | undefined {
return this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim() || undefined
}
private resolveEffectiveBigtokenSettings(settings: StoredSettings): {
apiKey?: string
baseUrl: string
model: string
credentialSource: RuntimeSettings['credentialSource']
} {
const environmentApiKey = this.getEnvironmentApiKey()
const storedApiKey = this.getStoredApiKey(settings)
const environmentBaseUrl =
this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim()
const environmentModel = this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim()
return {
apiKey: environmentApiKey ?? storedApiKey,
baseUrl: environmentApiKey
? environmentBaseUrl || defaultSettings.bigtokenBaseUrl
: settings.bigtokenBaseUrl,
model: environmentApiKey
? environmentModel || defaultSettings.bigtokenModel
: settings.bigtokenModel,
credentialSource: environmentApiKey
? 'environment'
: storedApiKey
? 'encrypted'
: 'none'
}
}
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
const effective = this.resolveEffectiveBigtokenSettings(settings)
return {
provider: settings.provider,
bigtokenBaseUrl: effective.baseUrl,
bigtokenModel: effective.model,
apiKeyConfigured: Boolean(effective.apiKey),
credentialSource: effective.credentialSource,
secureStorageAvailable: this.cipher.isAvailable(),
toolApproval: settings.toolApproval
}
}
async getPublicSettings(): Promise<RuntimeSettings> {
return this.toPublicSettings(await this.load())
}
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
const settings = await this.load()
const effective = this.resolveEffectiveBigtokenSettings(settings)
return {
provider: settings.provider,
bigtokenBaseUrl: effective.baseUrl,
bigtokenModel: effective.model,
apiKey: effective.apiKey,
toolApproval: settings.toolApproval
}
}
update(input: RuntimeSettingsInput): Promise<RuntimeSettings> {
const operation = this.updateQueue.then(() => this.performUpdate(input))
this.updateQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
private async performUpdate(
input: RuntimeSettingsInput
): Promise<RuntimeSettings> {
const current = await this.load()
const normalizedOrigin = new URL(input.bigtokenBaseUrl).origin
const previousOrigin = new URL(current.bigtokenBaseUrl).origin
if (
input.apiKey.action === 'keep' &&
current.credential &&
previousOrigin !== normalizedOrigin
) {
throw new Error('服务地址已更改,请重新输入或清除已保存的 API Key')
}
const next: StoredSettings = {
...current,
provider: input.provider,
bigtokenBaseUrl: normalizedOrigin,
bigtokenModel: input.bigtokenModel,
toolApproval: input.toolApproval
}
if (input.apiKey.action === 'clear') {
delete next.credential
} else if (input.apiKey.action === 'replace') {
if (!this.cipher.isAvailable()) {
throw new Error(
'当前系统安全存储不可用,API Key 未保存。请启用系统密钥服务或使用环境变量。'
)
}
next.credential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: this.cipher
.encrypt(
JSON.stringify({
version: 1,
apiKey: input.apiKey.value,
origin: normalizedOrigin
})
)
.toString('base64')
}
}
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
await rename(temporaryPath, this.filePath)
this.settings = next
return this.toPublicSettings(next)
}
}