feat(ql3): add secret-backed console automation

This commit is contained in:
whyour
2026-08-29 19:44:04 +08:00
parent 53d65abd8d
commit f46fb44ac9
26 changed files with 2272 additions and 39 deletions
@@ -52,7 +52,9 @@ export type LocalProfileStorageBootstrapResult =
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
readonly localSecretMetadata: LocalSqliteRuntimeDatabase['localSecretMetadata'];
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
readonly localSecretAdministrationForCredential: LocalSqliteRuntimeDatabase['localSecretAdministrationForCredential'];
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
@@ -153,7 +155,10 @@ export async function bootstrapLocalProfileStorage(
runAttemptLogRetention: database.runAttemptLogRetention,
runLostRetry: database.runLostRetry,
localSecrets: database.localSecrets,
localSecretMetadata: database.localSecretMetadata,
localSecretAdministration: database.localSecretAdministration,
localSecretAdministrationForCredential:
database.localSecretAdministrationForCredential,
projectPolicy: database.projectPolicy,
securityAudit: database.securityAudit,
apiCredentials: database.apiCredentials,
@@ -17,7 +17,10 @@ import type { LocalRunStartupRecoverySource } from '@qinglong/runtime-core/local
import type { LocalDispatchStore } from '@qinglong/runtime-core/local-dispatch';
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
import type { LocalSecretEnvelopeRepository } from '@qinglong/runtime-core/local-secret';
import type {
LocalSecretEnvelopeRepository,
LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import type { LocalSecretAdministrationRepository } from '@qinglong/runtime-core/local-secret-administration';
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
@@ -49,6 +52,7 @@ import type {
ProjectToolDefinitionSnapshotSourceRepository,
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
import { LocalSqliteSecretMetadataRepository } from '../security/secretMetadataRepository';
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
@@ -113,7 +117,11 @@ export interface LocalSqliteRuntimeDatabase {
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretMetadata: LocalSecretMetadataSource;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
localSecretAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): Promise<LocalSecretAdministrationRepository>;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly apiCredentials: ApiCredentialRepository;
@@ -184,6 +192,9 @@ export async function openLocalSqliteRuntimeDatabase(
const runRuntimeCapabilities =
createLocalSqliteRunRuntimeCapabilities(authority);
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
const localSecretMetadata = new LocalSqliteSecretMetadataRepository(
authority,
);
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
authority,
taskSpecSemanticRegistry,
@@ -326,7 +337,23 @@ export async function openLocalSqliteRuntimeDatabase(
runAttemptLogRetention,
runLostRetry,
localSecrets: securityAuthority,
localSecretMetadata,
localSecretAdministration: securityAuthority,
async localSecretAdministrationForCredential(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
) {
const { confirmLocalSqliteAuthenticatedUserCredentialFence } =
await import('../administration/packageManagement.js');
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
return new LocalSqliteSecurityAuthorityStore(authority, {
beforeAuthorizedLocalSecretMutation() {
confirmLocalSqliteAuthenticatedUserCredentialFence(
authority,
fence,
);
},
});
},
projectPolicy,
securityAudit: securityAuthority,
apiCredentials,
@@ -0,0 +1,133 @@
import {
LocalSecretMetadataUnavailableError,
MAX_LOCAL_SECRET_BATCH_SIZE,
assertLocalSecretName,
assertLocalSecretProjectId,
assertLocalSecretVersion,
type LocalSecretMetadata,
type LocalSecretMetadataPage,
type LocalSecretMetadataSource,
} from '@qinglong/runtime-core/local-secret';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new LocalSecretMetadataUnavailableError();
}
return value as number;
}
function text(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new LocalSecretMetadataUnavailableError();
}
return value;
}
function metadata(row: Row): Readonly<LocalSecretMetadata> {
try {
const projectId = text(row, 'projectId');
const name = text(row, 'name');
const currentVersion = integer(row, 'currentVersion');
const createdAtMs = integer(row, 'createdAtMs');
assertLocalSecretProjectId(projectId);
assertLocalSecretName(name);
assertLocalSecretVersion(currentVersion);
if (createdAtMs < 0) throw new Error('invalid Secret timestamp');
return Object.freeze({ projectId, name, currentVersion, createdAtMs });
} catch (error) {
if (error instanceof LocalSecretMetadataUnavailableError) throw error;
throw new LocalSecretMetadataUnavailableError();
}
}
export class LocalSqliteSecretMetadataRepository
implements LocalSecretMetadataSource
{
constructor(private readonly authority: LocalSqliteOperationAuthority) {
if (!(authority instanceof LocalSqliteOperationAuthority)) {
throw new TypeError('Local Secret metadata authority is invalid');
}
}
listLocalSecretMetadata(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly name: string }>;
}): Promise<Readonly<LocalSecretMetadataPage>> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) => !['after', 'limit', 'projectId'].includes(key),
) ||
!Object.hasOwn(options, 'limit') ||
!Object.hasOwn(options, 'projectId') ||
!Number.isSafeInteger(options.limit) ||
options.limit < 1 ||
options.limit > MAX_LOCAL_SECRET_BATCH_SIZE ||
(options.after !== undefined &&
(!options.after ||
typeof options.after !== 'object' ||
Array.isArray(options.after) ||
Object.keys(options.after).join('') !== 'name'))
) {
throw new TypeError('Local Secret metadata list options are invalid');
}
assertLocalSecretProjectId(options.projectId);
if (options.after) assertLocalSecretName(options.after.name);
return this.authority.enqueue(
async () => {
try {
const rows = this.authority.client
.prepare(
`SELECT secret."project_id" AS "projectId",
secret."secret_name" AS "name",
secret."version" AS "currentVersion",
secret."created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalSecretEnvelopes" AS secret
WHERE secret."project_id" = ?
AND secret."secret_name" > ?
AND secret."version" = (
SELECT MAX(current."version")
FROM "QingLong3LocalSecretEnvelopes" AS current
WHERE current."project_id" = secret."project_id"
AND current."secret_name" = secret."secret_name"
)
ORDER BY secret."secret_name"
LIMIT ?`,
)
.all(
options.projectId,
options.after?.name ?? '',
options.limit + 1,
) as Row[] | undefined;
if (!Array.isArray(rows)) {
throw new LocalSecretMetadataUnavailableError();
}
const truncated = rows.length > options.limit;
const secrets = Object.freeze(
rows.slice(0, options.limit).map(metadata),
);
const last = secrets.at(-1);
return Object.freeze({
secrets,
truncated,
...(truncated && last
? { next: Object.freeze({ name: last.name }) }
: {}),
});
} catch {
throw new LocalSecretMetadataUnavailableError();
}
},
() => new LocalSecretMetadataUnavailableError(),
);
}
}