Files
qinglong/packages/ql3-runtime-core/src/plugin-package/pluginPackageResourceMaterialization.ts
T

1452 lines
46 KiB
TypeScript

import { createHash } from 'node:crypto';
import {
MAX_PLUGIN_PACKAGE_MANIFEST_BYTES,
normalizePluginPackageManifest,
type PluginPackageManifest,
} from './pluginPackage';
import {
pluginPackageContentTreeDigest,
type PluginPackageContentEntryDescriptor,
} from './pluginPackageBundle';
import {
normalizePluginPackageLock,
pluginPackageManifestDigest,
serializePluginPackageManifest,
type PluginPackageLock,
} from './installation/pluginPackageInstall';
import {
normalizePluginPackageResourceGeneration,
pluginPackageResourceReferencesFromContents,
type PluginPackageResourceGeneration,
type PluginPackageResourceGenerationSource,
type PluginPackageResourceKind,
type PluginPackageResourceReference,
} from './pluginPackageResourceGeneration';
import {
assertPluginPackageSecretBindingMatches,
type PluginPackageSecretBinding,
type PluginPackageSecretBindingRepository,
} from './secret-binding/binding';
import {
normalizeTaskDefinitionLabels,
normalizeTaskDefinitionSpec,
type TaskDefinitionJson,
type TaskDefinitionKind,
type TaskDefinitionSpec,
} from '../task-definition/taskDefinition';
import {
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
TaskSpecSemanticRegistry,
} from '../task-definition/taskSpecSemantic';
import {
MAX_TOOL_DEFINITIONS,
normalizeToolDefinition,
type ToolDefinition,
} from '../tool-execution/tool-registry/toolRegistry';
export const PLUGIN_PACKAGE_TASK_RESOURCE_SCHEMA =
'qinglong/plugin-package-task-resource@v1' as const;
export const PLUGIN_PACKAGE_WORKFLOW_RESOURCE_SCHEMA =
'qinglong/plugin-package-workflow-resource@v1' as const;
export const PLUGIN_PACKAGE_PROMPT_RESOURCE_SCHEMA =
'qinglong/plugin-package-prompt-resource@v1' as const;
export const PLUGIN_PACKAGE_TOOL_RESOURCE_SCHEMA =
'qinglong/plugin-package-tool-resource@v1' as const;
export const PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA =
'qinglong/plugin-package-materialized-revision@v1' as const;
export const MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES = 1024 * 1024;
export const MAX_PLUGIN_PACKAGE_MATERIALIZED_TOTAL_BYTES = 8 * 1024 * 1024;
export const MAX_PLUGIN_PACKAGE_MATERIALIZED_REVISION_JSON_BYTES =
24 * 1024 * 1024;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS = 128;
export const MAX_PLUGIN_PACKAGE_PROMPT_PARAMETERS = 64;
export const MAX_PLUGIN_PACKAGE_PROMPT_TEMPLATE_BYTES = 512 * 1024;
export interface PluginPackageTaskResource {
readonly schema: typeof PLUGIN_PACKAGE_TASK_RESOURCE_SCHEMA;
readonly id: string;
readonly name: string;
readonly description?: string;
readonly labels: Readonly<Record<string, string>>;
readonly enabled: boolean;
readonly kind: TaskDefinitionKind;
readonly spec: TaskDefinitionSpec;
}
export interface PluginPackageWorkflowStep {
readonly id: string;
readonly task: string;
readonly needs: readonly string[];
}
export interface PluginPackageWorkflowResource {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_RESOURCE_SCHEMA;
readonly id: string;
readonly name: string;
readonly description?: string;
readonly enabled: boolean;
readonly steps: readonly Readonly<PluginPackageWorkflowStep>[];
}
export interface PluginPackagePromptParameter {
readonly name: string;
readonly description?: string;
readonly required: boolean;
}
export interface PluginPackagePromptResource {
readonly schema: typeof PLUGIN_PACKAGE_PROMPT_RESOURCE_SCHEMA;
readonly id: string;
readonly name: string;
readonly description?: string;
readonly template: string;
readonly parameters: readonly Readonly<PluginPackagePromptParameter>[];
}
export interface PluginPackageToolResource {
readonly schema: typeof PLUGIN_PACKAGE_TOOL_RESOURCE_SCHEMA;
readonly definition: Readonly<ToolDefinition>;
}
export type PluginPackageMaterializedResourceValue =
| Readonly<PluginPackageTaskResource>
| Readonly<PluginPackageWorkflowResource>
| Readonly<PluginPackagePromptResource>
| Readonly<PluginPackageToolResource>;
export interface PluginPackageMaterializedResource {
readonly kind: PluginPackageResourceKind;
readonly path: string;
readonly sourceBytes: number;
readonly sourceDigest: string;
readonly value: PluginPackageMaterializedResourceValue;
}
export interface PluginPackageMaterializedRevision {
readonly schema: typeof PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA;
readonly generation: Readonly<PluginPackageResourceGeneration>;
readonly lock: Readonly<PluginPackageLock>;
readonly manifest: Readonly<PluginPackageManifest>;
readonly manifestDigest: string;
readonly secretBinding?: Readonly<PluginPackageSecretBinding>;
readonly resources: readonly Readonly<PluginPackageMaterializedResource>[];
readonly revisionDigest: string;
}
export interface PluginPackageResourceMaterializationEntry {
readonly reference: Readonly<PluginPackageResourceReference>;
readonly bytes: Uint8Array;
}
export interface MaterializePluginPackageResourcesInput {
readonly generation: Readonly<PluginPackageResourceGeneration>;
readonly lock: Readonly<PluginPackageLock>;
readonly manifestBytes: Uint8Array;
readonly secretBinding?: Readonly<PluginPackageSecretBinding>;
readonly resources: readonly Readonly<PluginPackageResourceMaterializationEntry>[];
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
}
export interface PluginPackageResourceLockSource {
findLock(lockDigest: string): Promise<Readonly<PluginPackageLock> | null>;
}
export interface PluginPackageResourceByteReader {
/**
* Reads one immutable staged entry. The reader must apply maximumBytes before
* buffering the complete entry and stay bound to the generation it opened.
*/
read(path: string, maximumBytes: number): Promise<Uint8Array>;
close(): void | Promise<void>;
}
export interface PluginPackageResourceByteSource {
/**
* Opens one bounded, caller-owned read session. Implementations must not
* retain watchers, timers or authoritative cache after close.
*/
open(
generation: Readonly<PluginPackageResourceGeneration>,
): Promise<PluginPackageResourceByteReader>;
}
export interface MaterializeActivePluginPackageResourcesOptions {
readonly projectId: string;
readonly packageName: string;
readonly generationSource: PluginPackageResourceGenerationSource;
readonly lockSource: PluginPackageResourceLockSource;
readonly byteSource: PluginPackageResourceByteSource;
readonly secretBindingSource?: Pick<
PluginPackageSecretBindingRepository,
'find'
>;
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
}
export interface PluginPackageMaterializedRevisionRepository {
find(
generationDigest: string,
): Promise<Readonly<PluginPackageMaterializedRevision> | null>;
publish(revision: Readonly<PluginPackageMaterializedRevision>): Promise<
Readonly<{
status: 'created' | 'existing';
revision: Readonly<PluginPackageMaterializedRevision>;
}>
>;
}
export interface PluginPackageTaskDefinitionDraft {
readonly projectId: string;
readonly taskId: string;
readonly name: string;
readonly description?: string;
readonly kind: TaskDefinitionKind;
readonly spec: TaskDefinitionSpec;
readonly labels: Readonly<Record<string, string>>;
readonly enabled: boolean;
}
export class InvalidPluginPackageResourceMaterializationError extends TypeError {
readonly code = 'PLUGIN_PACKAGE_RESOURCE_MATERIALIZATION_INVALID';
constructor(message: string) {
super(`Plugin Package resource materialization is invalid: ${message}`);
this.name = 'InvalidPluginPackageResourceMaterializationError';
}
}
export class PluginPackageResourceMaterializationConflictError extends Error {
readonly code = 'PLUGIN_PACKAGE_RESOURCE_MATERIALIZATION_CONFLICT';
constructor(message: string) {
super(
`Plugin Package resource materialization conflicts with state: ${message}`,
);
this.name = 'PluginPackageResourceMaterializationConflictError';
}
}
export class PluginPackageResourceMaterializationUnavailableError extends Error {
readonly code = 'PLUGIN_PACKAGE_RESOURCE_MATERIALIZATION_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Plugin Package resource materialization is unavailable', options);
this.name = 'PluginPackageResourceMaterializationUnavailableError';
}
}
const DIGEST = /^[0-9a-f]{64}$/;
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const RESOURCE_ID = /^[a-z][a-z0-9-]{0,62}$/;
const STEP_ID = /^[a-z][a-z0-9-]{0,62}$/;
const PARAMETER_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
const CONTROL = /[\u0000-\u001f\u007f]/;
const REVISION_DIGEST_DOMAIN = Buffer.from(
'qinglong/plugin-package-materialized-revision-digest@v1\0',
'utf8',
);
const PROJECT_PERMISSION_TO_PACKAGE_PERMISSION: Readonly<
Record<string, string>
> = Object.freeze({
'artifact.read': 'artifact.read',
'run.read': 'run.read',
'run.start': 'run.start',
'run.stop': 'run.stop',
'run.retry': 'run.retry',
'secret.use': 'secret.use',
'task.read': 'task.read',
'task.update': 'task.update',
});
function invalid(message: string): never {
throw new InvalidPluginPackageResourceMaterializationError(message);
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
return invalid(`${label} must be an object`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
return invalid(`${label} must contain enumerable data properties`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
required: readonly string[],
optional: readonly string[],
label: string,
): void {
const actual = Reflect.ownKeys(value);
const strings = actual.filter(
(key): key is string => typeof key === 'string',
);
const allowed = new Set([...required, ...optional]);
if (
actual.length !== strings.length ||
required.some((key) => !strings.includes(key)) ||
strings.some((key) => !allowed.has(key))
) {
invalid(`${label} shape is invalid`);
}
}
function boundedDenseArray(
value: unknown,
maximum: number,
label: string,
): readonly unknown[] {
if (!Array.isArray(value) || value.length > maximum) {
return invalid(`${label} is invalid`);
}
const keys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (
ownKeys.length !== value.length + 1 ||
!ownKeys.includes('length') ||
keys.length !== value.length ||
keys.some((key, index) => key !== String(index))
) {
return invalid(`${label} must be a dense data array`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
keys.some((key) => {
const descriptor = descriptors[key];
return (
descriptor === undefined ||
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true
);
})
) {
return invalid(`${label} must be a dense data array`);
}
return value;
}
function assertDataTree(
value: unknown,
budget: { nodes: number },
depth = 0,
seen = new Set<object>(),
): void {
budget.nodes += 1;
if (budget.nodes > 100_000 || depth > 32) {
invalid('materialized revision exceeds its structure budget');
}
if (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value))
) {
return;
}
if (!value || typeof value !== 'object' || seen.has(value)) {
invalid('materialized revision contains a non-data or cyclic value');
}
seen.add(value);
if (Array.isArray(value)) {
const entries = boundedDenseArray(value, 100_000, 'revision array');
for (const entry of entries) {
assertDataTree(entry, budget, depth + 1, seen);
}
seen.delete(value);
return;
}
const record = dataRecord(value, 'revision object');
for (const entry of Object.values(record)) {
assertDataTree(entry, budget, depth + 1, seen);
}
seen.delete(value);
}
function boundedText(
value: unknown,
label: string,
maximumBytes: number,
allowEmpty = false,
): string {
if (
typeof value !== 'string' ||
(!allowEmpty && value.length === 0) ||
value.includes('\0') ||
CONTROL.test(value) ||
Buffer.byteLength(value, 'utf8') > maximumBytes
) {
return invalid(`${label} is invalid`);
}
return value;
}
function boundedTemplate(value: unknown): string {
if (
typeof value !== 'string' ||
value.includes('\0') ||
/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_PLUGIN_PACKAGE_PROMPT_TEMPLATE_BYTES
) {
return invalid('Prompt template is invalid');
}
return value;
}
function optionalDescription(value: unknown): string | undefined {
return value === undefined
? undefined
: boundedText(value, 'description', 4096);
}
function resourceId(value: unknown, label = 'resource id'): string {
const id = boundedText(value, label, 63);
if (!RESOURCE_ID.test(id)) return invalid(`${label} is invalid`);
return id;
}
function digest(value: unknown, label: string): string {
if (typeof value !== 'string' || !DIGEST.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function byteCount(value: unknown, label: string): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
(value as number) > MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function bytes(value: unknown, maximum: number, label: string): Buffer {
if (
!(value instanceof Uint8Array) ||
value.byteLength < 1 ||
value.byteLength > maximum
) {
return invalid(`${label} is invalid`);
}
return Buffer.from(value);
}
function parseJson(value: Buffer, label: string): unknown {
let text: string;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(value);
} catch {
return invalid(`${label} is not strict UTF-8`);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return invalid(`${label} is not JSON`);
}
return parsed;
}
function canonicalJson(value: unknown): string {
if (
value === null ||
typeof value === 'boolean' ||
typeof value === 'number' ||
typeof value === 'string'
) {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
}
const record = dataRecord(value, 'canonical value');
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',')}}`;
}
function materializedRevisionDigest(
value: Omit<PluginPackageMaterializedRevision, 'revisionDigest'>,
): string {
return createHash('sha256')
.update(REVISION_DIGEST_DOMAIN)
.update(canonicalJson(value), 'utf8')
.digest('hex');
}
function taskIdentity(packageName: string, id: string): string {
const taskId = `pkg:${packageName}:${id}`;
if (Buffer.byteLength(taskId, 'utf8') > 128) {
return invalid('package Task identity exceeds the TaskDefinition budget');
}
return taskId;
}
function normalizeTaskResource(
value: unknown,
packageName: string,
projectId: string,
manifest: Readonly<PluginPackageManifest>,
registry: TaskSpecSemanticRegistry,
secretBinding: Readonly<PluginPackageSecretBinding> | undefined,
source: boolean,
): Readonly<PluginPackageTaskResource> {
const task = dataRecord(value, 'Task resource');
exactKeys(
task,
['schema', 'id', 'name', 'labels', 'enabled', 'kind', 'spec'],
['description'],
'Task resource',
);
if (task.schema !== PLUGIN_PACKAGE_TASK_RESOURCE_SCHEMA) {
return invalid('Task resource schema is unsupported');
}
const id = resourceId(task.id, 'Task id');
const taskId = taskIdentity(packageName, id);
const kind = task.kind;
let spec = normalizeTaskDefinitionSpec(task.spec as TaskDefinitionSpec);
if (kind !== 'command' || spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA) {
return invalid(
'Task resource v1 only supports qinglong/command@v1 command tasks',
);
}
if (!manifest.spec.permissions.tools.includes('system.command')) {
return invalid(
'command Task requires the approved system.command permission',
);
}
if (!(registry instanceof TaskSpecSemanticRegistry)) {
return invalid('TaskSpec semantic registry is invalid');
}
if (source) {
spec = compilePackageSecretEnvironment(spec, secretBinding);
}
let normalizedSpec: TaskDefinitionSpec;
try {
normalizedSpec = registry.normalize({
projectId,
taskId,
kind,
spec,
});
} catch {
return invalid('Task resource spec semantics are invalid or unsupported');
}
assertResolvedPackageSecretEnvironment(normalizedSpec, secretBinding);
const description = optionalDescription(task.description);
if (typeof task.enabled !== 'boolean') {
return invalid('Task enabled is invalid');
}
return Object.freeze({
schema: PLUGIN_PACKAGE_TASK_RESOURCE_SCHEMA,
id,
name: boundedText(task.name, 'Task name', 255),
...(description === undefined ? {} : { description }),
labels: normalizeTaskDefinitionLabels(
task.labels as Readonly<Record<string, string>>,
),
enabled: task.enabled,
kind,
spec: normalizedSpec,
});
}
function compilePackageSecretEnvironment(
spec: Readonly<TaskDefinitionSpec>,
secretBinding: Readonly<PluginPackageSecretBinding> | undefined,
): Readonly<TaskDefinitionSpec> {
const environment = spec.config.environment;
if (!Array.isArray(environment)) return spec;
const entries = new Map(
(secretBinding?.entries ?? []).map((entry) => [entry.name, entry]),
);
const compiled = environment.flatMap((value) => {
const entry = dataRecord(value, 'Task environment');
if (entry.kind === 'secret') {
return invalid('Package Task source cannot contain a direct SecretRef');
}
if (entry.kind !== 'package-secret') return [value];
exactKeys(
entry,
['kind', 'name', 'requirement'],
[],
'Package Secret environment',
);
const requirement = boundedText(
entry.requirement,
'Package Secret requirement',
128,
);
const binding = entries.get(requirement);
if (!binding) {
return invalid(
'Package Task references an undeclared Secret requirement',
);
}
if (binding.secretRef === null) return [];
return [
Object.freeze({
name: entry.name as TaskDefinitionJson,
kind: 'secret' as const,
secretRef: binding.secretRef,
}),
];
});
return normalizeTaskDefinitionSpec(
Object.freeze({
schema: spec.schema,
config: Object.freeze({
...spec.config,
environment: Object.freeze(compiled),
}),
}),
);
}
function assertResolvedPackageSecretEnvironment(
spec: Readonly<TaskDefinitionSpec>,
secretBinding: Readonly<PluginPackageSecretBinding> | undefined,
): void {
const allowed = new Set(
(secretBinding?.entries ?? []).flatMap((entry) =>
entry.secretRef === null ? [] : [entry.secretRef],
),
);
const environment = spec.config.environment;
if (!Array.isArray(environment)) return;
for (const value of environment) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const entry = value as Readonly<Record<string, TaskDefinitionJson>>;
if (entry.kind === 'secret' && !allowed.has(entry.secretRef as string)) {
invalid('Task SecretRef is not authorized by the Package binding');
}
}
}
function normalizeWorkflowResource(
value: unknown,
): Readonly<PluginPackageWorkflowResource> {
const workflow = dataRecord(value, 'Workflow resource');
exactKeys(
workflow,
['schema', 'id', 'name', 'enabled', 'steps'],
['description'],
'Workflow resource',
);
if (workflow.schema !== PLUGIN_PACKAGE_WORKFLOW_RESOURCE_SCHEMA) {
return invalid('Workflow resource schema is unsupported');
}
if (typeof workflow.enabled !== 'boolean') {
return invalid('Workflow enabled is invalid');
}
const ids = new Set<string>();
const steps = boundedDenseArray(
workflow.steps,
MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS,
'Workflow steps',
).map((stepValue) => {
const step = dataRecord(stepValue, 'Workflow step');
exactKeys(step, ['id', 'task', 'needs'], [], 'Workflow step');
const id = boundedText(step.id, 'Workflow step id', 63);
if (!STEP_ID.test(id) || ids.has(id)) {
return invalid('Workflow step id is invalid or duplicated');
}
ids.add(id);
const task = resourceId(step.task, 'Workflow Task reference');
const needs = boundedDenseArray(
step.needs,
MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS,
'Workflow step needs',
).map((need) => {
const dependency = boundedText(need, 'Workflow step dependency', 63);
if (!STEP_ID.test(dependency)) {
return invalid('Workflow step dependency is invalid');
}
return dependency;
});
if (new Set(needs).size !== needs.length || needs.includes(id)) {
return invalid('Workflow step dependencies are duplicated or recursive');
}
return Object.freeze({
id,
task,
needs: Object.freeze([...needs].sort()),
});
});
const byId = new Map(steps.map((step) => [step.id, step]));
for (const step of steps) {
if (step.needs.some((need) => !byId.has(need))) {
return invalid('Workflow step references an unknown dependency');
}
}
const pending = new Map(steps.map((step) => [step.id, new Set(step.needs)]));
const ready = [...pending.entries()]
.filter(([, needs]) => needs.size === 0)
.map(([id]) => id);
let visited = 0;
while (ready.length > 0) {
const current = ready.pop()!;
if (!pending.delete(current)) continue;
visited += 1;
for (const [id, needs] of pending) {
if (needs.delete(current) && needs.size === 0) ready.push(id);
}
}
if (visited !== steps.length) {
return invalid('Workflow graph contains a cycle');
}
const description = optionalDescription(workflow.description);
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_RESOURCE_SCHEMA,
id: resourceId(workflow.id, 'Workflow id'),
name: boundedText(workflow.name, 'Workflow name', 255),
...(description === undefined ? {} : { description }),
enabled: workflow.enabled,
steps: Object.freeze(
[...steps].sort((left, right) => left.id.localeCompare(right.id)),
),
});
}
function normalizePromptResource(
value: unknown,
): Readonly<PluginPackagePromptResource> {
const prompt = dataRecord(value, 'Prompt resource');
exactKeys(
prompt,
['schema', 'id', 'name', 'template', 'parameters'],
['description'],
'Prompt resource',
);
if (prompt.schema !== PLUGIN_PACKAGE_PROMPT_RESOURCE_SCHEMA) {
return invalid('Prompt resource schema is unsupported');
}
const names = new Set<string>();
const parameters = boundedDenseArray(
prompt.parameters,
MAX_PLUGIN_PACKAGE_PROMPT_PARAMETERS,
'Prompt parameters',
).map((parameterValue) => {
const parameter = dataRecord(parameterValue, 'Prompt parameter');
exactKeys(
parameter,
['name', 'required'],
['description'],
'Prompt parameter',
);
const name = boundedText(parameter.name, 'Prompt parameter name', 64);
if (!PARAMETER_NAME.test(name) || names.has(name)) {
return invalid('Prompt parameter name is invalid or duplicated');
}
if (typeof parameter.required !== 'boolean') {
return invalid('Prompt parameter required flag is invalid');
}
names.add(name);
const description = optionalDescription(parameter.description);
return Object.freeze({
name,
...(description === undefined ? {} : { description }),
required: parameter.required,
});
});
const template = boundedTemplate(prompt.template);
const referenced = new Set<string>();
for (const match of template.matchAll(
/\{\{([A-Za-z][A-Za-z0-9_.-]{0,63})\}\}/g,
)) {
referenced.add(match[1]!);
}
if (
[...referenced].some((name) => !names.has(name)) ||
[...names].some((name) => !referenced.has(name))
) {
return invalid(
'Prompt placeholders and declared parameters must match exactly',
);
}
const description = optionalDescription(prompt.description);
return Object.freeze({
schema: PLUGIN_PACKAGE_PROMPT_RESOURCE_SCHEMA,
id: resourceId(prompt.id, 'Prompt id'),
name: boundedText(prompt.name, 'Prompt name', 255),
...(description === undefined ? {} : { description }),
template,
parameters: Object.freeze(
[...parameters].sort((left, right) =>
left.name.localeCompare(right.name),
),
),
});
}
export function normalizePluginPackageWorkflowResource(
value: unknown,
): Readonly<PluginPackageWorkflowResource> {
return normalizeWorkflowResource(value);
}
export function normalizePluginPackagePromptResource(
value: unknown,
): Readonly<PluginPackagePromptResource> {
return normalizePromptResource(value);
}
function normalizeToolResource(
value: unknown,
packageName: string,
manifest: Readonly<PluginPackageManifest>,
): Readonly<PluginPackageToolResource> {
const tool = dataRecord(value, 'Tool resource');
exactKeys(tool, ['schema', 'definition'], [], 'Tool resource');
if (tool.schema !== PLUGIN_PACKAGE_TOOL_RESOURCE_SCHEMA) {
return invalid('Tool resource schema is unsupported');
}
let definition: Readonly<ToolDefinition>;
try {
definition = normalizeToolDefinition(tool.definition);
} catch {
return invalid('Tool definition is invalid');
}
if (!definition.name.startsWith(`${packageName}.`)) {
return invalid('Tool name must be namespaced by its Package name');
}
const approved = new Set(manifest.spec.permissions.tools);
for (const permission of definition.requiredPermissions) {
const packagePermission =
PROJECT_PERMISSION_TO_PACKAGE_PERMISSION[permission];
if (
packagePermission === undefined ||
!approved.has(packagePermission as never)
) {
return invalid(
'Tool required permission is not present in the approved Package manifest',
);
}
}
return Object.freeze({
schema: PLUGIN_PACKAGE_TOOL_RESOURCE_SCHEMA,
definition,
});
}
function normalizeMaterializedValue(
kind: PluginPackageResourceKind,
value: unknown,
packageName: string,
projectId: string,
manifest: Readonly<PluginPackageManifest>,
registry: TaskSpecSemanticRegistry,
secretBinding: Readonly<PluginPackageSecretBinding> | undefined,
source: boolean,
): PluginPackageMaterializedResourceValue {
switch (kind) {
case 'task':
return normalizeTaskResource(
value,
packageName,
projectId,
manifest,
registry,
secretBinding,
source,
);
case 'workflow':
return normalizeWorkflowResource(value);
case 'prompt':
return normalizePromptResource(value);
case 'tool':
return normalizeToolResource(value, packageName, manifest);
}
}
function validateCrossResourceSemantics(
resources: readonly Readonly<PluginPackageMaterializedResource>[],
): void {
const ids = new Map<PluginPackageResourceKind, Set<string>>([
['task', new Set()],
['workflow', new Set()],
['prompt', new Set()],
['tool', new Set()],
]);
for (const resource of resources) {
const identity =
resource.kind === 'tool'
? (resource.value as PluginPackageToolResource).definition.name
: (
resource.value as
| PluginPackageTaskResource
| PluginPackageWorkflowResource
| PluginPackagePromptResource
).id;
const kindIds = ids.get(resource.kind)!;
if (kindIds.has(identity)) {
invalid(`${resource.kind} resource identity is duplicated`);
}
kindIds.add(identity);
}
const tasks = ids.get('task')!;
for (const resource of resources) {
if (resource.kind !== 'workflow') continue;
const workflow = resource.value as PluginPackageWorkflowResource;
if (workflow.steps.some((step) => !tasks.has(step.task))) {
invalid('Workflow references an unknown package Task');
}
}
if (ids.get('tool')!.size > MAX_TOOL_DEFINITIONS) {
invalid('Package Tool resources exceed the immutable registry budget');
}
}
function normalizeManifestBytes(
value: unknown,
lock: Readonly<PluginPackageLock>,
): Readonly<PluginPackageManifest> {
const material = bytes(
value,
MAX_PLUGIN_PACKAGE_MANIFEST_BYTES,
'manifest bytes',
);
let manifest: Readonly<PluginPackageManifest>;
try {
manifest = normalizePluginPackageManifest(
parseJson(material, 'Package manifest'),
);
} catch (error) {
if (error instanceof InvalidPluginPackageResourceMaterializationError) {
throw error;
}
return invalid('Package manifest semantics are invalid');
}
if (
serializePluginPackageManifest(manifest) !== material.toString('utf8') ||
pluginPackageManifestDigest(manifest) !== lock.manifestDigest ||
manifest.metadata.name !== lock.packageName ||
manifest.metadata.version !== lock.packageVersion
) {
return invalid('Package manifest does not match its immutable lock');
}
return manifest;
}
function normalizeRevisionResources(
values: unknown,
generation: Readonly<PluginPackageResourceGeneration>,
manifest: Readonly<PluginPackageManifest>,
registry: TaskSpecSemanticRegistry,
secretBinding: Readonly<PluginPackageSecretBinding> | undefined,
): readonly Readonly<PluginPackageMaterializedResource>[] {
const entries = boundedDenseArray(
values,
generation.resources.length,
'materialized resources',
);
if (entries.length !== generation.resources.length) {
return invalid('materialized resources are incomplete');
}
let totalBytes = 0;
const resources = entries.map((entryValue, index) => {
const entry = dataRecord(entryValue, 'materialized resource');
exactKeys(
entry,
['kind', 'path', 'sourceBytes', 'sourceDigest', 'value'],
[],
'materialized resource',
);
const reference = generation.resources[index]!;
if (entry.kind !== reference.kind || entry.path !== reference.path) {
return invalid(
'materialized resource order or identity does not match generation',
);
}
const sourceBytes = byteCount(entry.sourceBytes, 'resource source bytes');
totalBytes += sourceBytes;
if (totalBytes > MAX_PLUGIN_PACKAGE_MATERIALIZED_TOTAL_BYTES) {
return invalid('materialized resources exceed the total byte budget');
}
return Object.freeze({
kind: reference.kind,
path: reference.path,
sourceBytes,
sourceDigest: digest(entry.sourceDigest, 'resource source digest'),
value: normalizeMaterializedValue(
reference.kind,
entry.value,
generation.packageName,
generation.projectId,
manifest,
registry,
secretBinding,
false,
),
});
});
validateCrossResourceSemantics(resources);
return Object.freeze(resources);
}
function assertGenerationMatchesLock(
generation: Readonly<PluginPackageResourceGeneration>,
lock: Readonly<PluginPackageLock>,
): void {
const previousLockDigest = lock.previousLockDigest ?? null;
if (
generation.lockDigest !== lock.lockDigest ||
generation.projectId !== lock.projectId ||
generation.packageName !== lock.packageName ||
generation.generation !== lock.targetGeneration ||
generation.previousActiveLockDigest !== previousLockDigest ||
generation.contentDigest !== lock.source.contentDigest ||
JSON.stringify(generation.resources) !== JSON.stringify(lock.resources)
) {
invalid('active generation does not match its immutable lock');
}
}
function assertManifestMatchesGeneration(
manifest: Readonly<PluginPackageManifest>,
generation: Readonly<PluginPackageResourceGeneration>,
): void {
const references = pluginPackageResourceReferencesFromContents(
manifest.spec.contents,
);
if (JSON.stringify(references) !== JSON.stringify(generation.resources)) {
invalid('Package manifest resources do not match active generation');
}
}
export function normalizePluginPackageMaterializedRevision(
value: PluginPackageMaterializedRevision,
taskSpecSemanticRegistry: TaskSpecSemanticRegistry,
): Readonly<PluginPackageMaterializedRevision> {
assertDataTree(value, { nodes: 0 });
const revision = dataRecord(value, 'materialized revision');
exactKeys(
revision,
[
'schema',
'generation',
'lock',
'manifest',
'manifestDigest',
'resources',
'revisionDigest',
],
['secretBinding'],
'materialized revision',
);
if (revision.schema !== PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA) {
return invalid('materialized revision schema is unsupported');
}
const generation = normalizePluginPackageResourceGeneration(
revision.generation as PluginPackageResourceGeneration,
);
const lock = normalizePluginPackageLock(revision.lock as PluginPackageLock);
assertGenerationMatchesLock(generation, lock);
const manifest = normalizePluginPackageManifest(revision.manifest);
const manifestDigest = digest(revision.manifestDigest, 'manifest digest');
if (
pluginPackageManifestDigest(manifest) !== manifestDigest ||
manifestDigest !== lock.manifestDigest ||
manifest.metadata.name !== generation.packageName ||
manifest.metadata.version !== lock.packageVersion
) {
return invalid('materialized manifest identity or digest is invalid');
}
assertManifestMatchesGeneration(manifest, generation);
const secretBinding = normalizeMaterializationSecretBinding(
revision.secretBinding as Readonly<PluginPackageSecretBinding> | undefined,
generation,
manifest,
);
const resources = normalizeRevisionResources(
revision.resources,
generation,
manifest,
taskSpecSemanticRegistry,
secretBinding,
);
const descriptors: PluginPackageContentEntryDescriptor[] = resources
.map((resource) =>
Object.freeze({
path: resource.path,
bytes: resource.sourceBytes,
digest: resource.sourceDigest,
}),
)
.sort((left, right) => left.path.localeCompare(right.path));
if (
pluginPackageContentTreeDigest(descriptors) !== generation.contentDigest
) {
return invalid('materialized resource bytes do not match the content tree');
}
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
generation,
lock,
manifest,
manifestDigest,
...(secretBinding === undefined ? {} : { secretBinding }),
resources,
});
const revisionDigest = digest(revision.revisionDigest, 'revision digest');
if (materializedRevisionDigest(unsigned) !== revisionDigest) {
return invalid('materialized revision digest does not match');
}
return Object.freeze({ ...unsigned, revisionDigest });
}
export function materializePluginPackageResources(
value: MaterializePluginPackageResourcesInput,
): Readonly<PluginPackageMaterializedRevision> {
const input = dataRecord(value, 'materialization input');
exactKeys(
input,
[
'generation',
'lock',
'manifestBytes',
'resources',
'taskSpecSemanticRegistry',
],
['secretBinding'],
'materialization input',
);
const generation = normalizePluginPackageResourceGeneration(value.generation);
const lock = normalizePluginPackageLock(value.lock);
assertGenerationMatchesLock(generation, lock);
const manifest = normalizeManifestBytes(value.manifestBytes, lock);
assertManifestMatchesGeneration(manifest, generation);
const secretBinding = normalizeMaterializationSecretBinding(
value.secretBinding,
generation,
manifest,
);
const entries = boundedDenseArray(
value.resources,
generation.resources.length,
'resource byte entries',
);
if (entries.length !== generation.resources.length) {
return invalid('resource byte entries are incomplete');
}
let totalBytes = 0;
const resources = entries.map((entryValue, index) => {
const entry = dataRecord(entryValue, 'resource byte entry');
exactKeys(entry, ['reference', 'bytes'], [], 'resource byte entry');
const reference = dataRecord(entry.reference, 'resource reference');
exactKeys(reference, ['kind', 'path'], [], 'resource reference');
const expected = generation.resources[index]!;
if (
reference.kind !== expected.kind ||
reference.path !== expected.path ||
!expected.path.endsWith('.json')
) {
return invalid(
'resource byte entry identity, order or JSON extension is invalid',
);
}
const material = bytes(
entry.bytes,
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
'resource bytes',
);
totalBytes += material.byteLength;
if (totalBytes > MAX_PLUGIN_PACKAGE_MATERIALIZED_TOTAL_BYTES) {
return invalid('resource bytes exceed the total materialization budget');
}
return Object.freeze({
kind: expected.kind,
path: expected.path,
sourceBytes: material.byteLength,
sourceDigest: createHash('sha256').update(material).digest('hex'),
value: normalizeMaterializedValue(
expected.kind,
parseJson(material, `${expected.kind} resource`),
generation.packageName,
generation.projectId,
manifest,
value.taskSpecSemanticRegistry,
secretBinding,
true,
),
});
});
validateCrossResourceSemantics(resources);
const descriptors = resources
.map((resource) =>
Object.freeze({
path: resource.path,
bytes: resource.sourceBytes,
digest: resource.sourceDigest,
}),
)
.sort((left, right) => left.path.localeCompare(right.path));
if (
pluginPackageContentTreeDigest(descriptors) !== generation.contentDigest
) {
return invalid('resource bytes do not match the active content tree');
}
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
generation,
lock,
manifest,
manifestDigest: lock.manifestDigest,
...(secretBinding === undefined ? {} : { secretBinding }),
resources: Object.freeze(resources),
});
return normalizePluginPackageMaterializedRevision(
Object.freeze({
...unsigned,
revisionDigest: materializedRevisionDigest(unsigned),
}),
value.taskSpecSemanticRegistry,
);
}
function normalizeMaterializationSecretBinding(
value: Readonly<PluginPackageSecretBinding> | undefined,
generation: Readonly<PluginPackageResourceGeneration>,
manifest: Readonly<PluginPackageManifest>,
): Readonly<PluginPackageSecretBinding> | undefined {
if (manifest.spec.permissions.secrets.length === 0) {
if (value !== undefined) {
invalid('Secret binding is forbidden when Manifest declares no Secrets');
}
return undefined;
}
if (!manifest.spec.permissions.tools.includes('secret.use')) {
return invalid(
'Secret-aware Package requires the approved secret.use permission',
);
}
if (value === undefined) {
return invalid('Secret-aware Package requires an approved binding');
}
try {
return assertPluginPackageSecretBindingMatches(value, generation, manifest);
} catch {
return invalid('Secret binding does not match generation and Manifest');
}
}
function materializationSources(
value: MaterializeActivePluginPackageResourcesOptions,
): void {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!value.generationSource ||
typeof value.generationSource.findActiveResourceGeneration !== 'function' ||
!value.lockSource ||
typeof value.lockSource.findLock !== 'function' ||
!value.byteSource ||
typeof value.byteSource.open !== 'function' ||
(value.secretBindingSource !== undefined &&
(!value.secretBindingSource ||
typeof value.secretBindingSource.find !== 'function')) ||
!(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
) {
invalid('active materialization sources are invalid');
}
if (
typeof value.projectId !== 'string' ||
value.projectId.length < 1 ||
Buffer.byteLength(value.projectId, 'utf8') > 128 ||
typeof value.packageName !== 'string' ||
!PACKAGE_NAME.test(value.packageName)
) {
invalid('active materialization identity is invalid');
}
}
export async function materializeActivePluginPackageResources(
value: MaterializeActivePluginPackageResourcesOptions,
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
materializationSources(value);
try {
const first = await value.generationSource.findActiveResourceGeneration(
value.projectId,
value.packageName,
);
if (first === null) return null;
const generation = normalizePluginPackageResourceGeneration(first);
if (
generation.projectId !== value.projectId ||
generation.packageName !== value.packageName
) {
throw new PluginPackageResourceMaterializationConflictError(
'generation source returned another Package identity',
);
}
const lockValue = await value.lockSource.findLock(generation.lockDigest);
if (lockValue === null) {
throw new PluginPackageResourceMaterializationConflictError(
'active generation lock is missing',
);
}
const lock = normalizePluginPackageLock(lockValue);
const reader = await value.byteSource.open(generation);
if (
!reader ||
typeof reader !== 'object' ||
typeof reader.read !== 'function' ||
typeof reader.close !== 'function'
) {
invalid('resource byte source returned an invalid reader');
}
let manifestBytes: Uint8Array;
const resources: PluginPackageResourceMaterializationEntry[] = [];
try {
manifestBytes = await reader.read(
'package.json',
MAX_PLUGIN_PACKAGE_MANIFEST_BYTES,
);
let totalBytes = 0;
for (const reference of generation.resources) {
const material = await reader.read(
reference.path,
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
);
if (!(material instanceof Uint8Array)) {
invalid('resource byte source returned a non-byte value');
}
totalBytes += material.byteLength;
if (totalBytes > MAX_PLUGIN_PACKAGE_MATERIALIZED_TOTAL_BYTES) {
invalid(
'resource byte source exceeded the total materialization budget',
);
}
resources.push(Object.freeze({ reference, bytes: material }));
}
} finally {
try {
await reader.close();
} catch (error) {
throw new PluginPackageResourceMaterializationUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
}
const activeManifest = normalizeManifestBytes(manifestBytes, lock);
const activeSecretBindingValue =
activeManifest.spec.permissions.secrets.length === 0
? undefined
: await value.secretBindingSource?.find(generation.generationDigest);
const activeSecretBinding = activeSecretBindingValue ?? undefined;
const revision = materializePluginPackageResources({
generation,
lock,
manifestBytes,
...(activeSecretBinding === undefined
? {}
: { secretBinding: activeSecretBinding }),
resources: Object.freeze(resources),
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
});
const secondValue =
await value.generationSource.findActiveResourceGeneration(
value.projectId,
value.packageName,
);
if (secondValue === null) {
throw new PluginPackageResourceMaterializationConflictError(
'active generation disappeared during materialization',
);
}
const second = normalizePluginPackageResourceGeneration(secondValue);
if (second.generationDigest !== generation.generationDigest) {
throw new PluginPackageResourceMaterializationConflictError(
'active generation changed during materialization',
);
}
return revision;
} catch (error) {
if (
error instanceof InvalidPluginPackageResourceMaterializationError ||
error instanceof PluginPackageResourceMaterializationConflictError
) {
throw error;
}
throw new PluginPackageResourceMaterializationUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
}
export function pluginPackageTaskDefinitionDrafts(
revisionValue: PluginPackageMaterializedRevision,
taskSpecSemanticRegistry: TaskSpecSemanticRegistry,
): readonly Readonly<PluginPackageTaskDefinitionDraft>[] {
const revision = normalizePluginPackageMaterializedRevision(
revisionValue,
taskSpecSemanticRegistry,
);
return Object.freeze(
revision.resources
.filter(
(
resource,
): resource is Readonly<PluginPackageMaterializedResource> & {
readonly kind: 'task';
readonly value: Readonly<PluginPackageTaskResource>;
} => resource.kind === 'task',
)
.map((resource) => {
const task = resource.value;
return Object.freeze({
projectId: revision.generation.projectId,
taskId: taskIdentity(revision.generation.packageName, task.id),
name: task.name,
...(task.description === undefined
? {}
: { description: task.description }),
kind: task.kind,
spec: task.spec,
labels: task.labels,
enabled: task.enabled,
});
}),
);
}
export function pluginPackageToolDefinitions(
revisionValue: PluginPackageMaterializedRevision,
taskSpecSemanticRegistry: TaskSpecSemanticRegistry,
): readonly Readonly<ToolDefinition>[] {
const revision = normalizePluginPackageMaterializedRevision(
revisionValue,
taskSpecSemanticRegistry,
);
return Object.freeze(
revision.resources
.filter((resource) => resource.kind === 'tool')
.map(
(resource) =>
(resource.value as Readonly<PluginPackageToolResource>).definition,
),
);
}