mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@qinglong/local-mcp-server",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 optional authenticated local MCP stdio server",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/application-runtime/mcpServer.js",
|
||||
"types": "dist/application-runtime/mcpServer.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/application-runtime/mcpServer.d.ts",
|
||||
"require": "./dist/application-runtime/mcpServer.js",
|
||||
"default": "./dist/application-runtime/mcpServer.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/production-process/config.d.ts",
|
||||
"require": "./dist/production-process/config.js",
|
||||
"default": "./dist/production-process/config.js"
|
||||
},
|
||||
"./process": {
|
||||
"types": "./dist/production-process/processApplication.d.ts",
|
||||
"require": "./dist/production-process/processApplication.js",
|
||||
"default": "./dist/production-process/processApplication.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"bin": {
|
||||
"ql3-mcp": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0",
|
||||
"@qinglong/local-command-file": "workspace:*",
|
||||
"@qinglong/local-owner-console": "workspace:*",
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
ApprovalRequestDetailSource,
|
||||
ApprovalRequestSource,
|
||||
} from '@qinglong/runtime-core/approval-discovery';
|
||||
import {
|
||||
BUILTIN_APPROVAL_GET_TOOL,
|
||||
BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
|
||||
executeBuiltInApprovalGetTool,
|
||||
} from '../tool-projection/approvalGet';
|
||||
import {
|
||||
BUILTIN_APPROVAL_LIST_TOOL,
|
||||
BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
|
||||
executeBuiltInApprovalListTool,
|
||||
} from '../tool-projection/approvalList';
|
||||
import {
|
||||
BUILTIN_TASK_LIST_TOOL,
|
||||
BUILTIN_TASK_LIST_TOOL_DEFINITION,
|
||||
executeBuiltInTaskListTool,
|
||||
} from '../tool-projection/taskList';
|
||||
import {
|
||||
BUILTIN_TASK_GET_TOOL,
|
||||
BUILTIN_TASK_GET_TOOL_DEFINITION,
|
||||
executeBuiltInTaskGetTool,
|
||||
} from '../tool-projection/taskGet';
|
||||
import {
|
||||
McpServer,
|
||||
fromJsonSchema,
|
||||
type CallToolResult,
|
||||
type JsonSchemaType,
|
||||
} from '@modelcontextprotocol/server';
|
||||
import {
|
||||
BUILTIN_RUN_EVENT_LIST_TOOL,
|
||||
BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
|
||||
executeBuiltInRunEventListTool,
|
||||
} from '../tool-projection/runEventList';
|
||||
import {
|
||||
BUILTIN_RUN_LIST_TOOL,
|
||||
BUILTIN_RUN_LIST_TOOL_DEFINITION,
|
||||
executeBuiltInRunListTool,
|
||||
} from '../tool-projection/runList';
|
||||
import {
|
||||
BUILTIN_RUN_STEP_LIST_TOOL,
|
||||
BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
|
||||
executeBuiltInRunStepListTool,
|
||||
} from '../tool-projection/runStepList';
|
||||
import {
|
||||
BUILTIN_RUN_READ_TOOL,
|
||||
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
executeBuiltInRunReadTool,
|
||||
} from '@qinglong/runtime-core/builtin-run-read-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
||||
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
|
||||
import type { TriggerSource } from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditOutcome,
|
||||
type SecurityAuditSink,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import {
|
||||
InvalidToolJsonValueError,
|
||||
ToolDefinitionRegistry,
|
||||
ToolPolicySnapshotConflictError,
|
||||
ToolPolicyUnavailableError,
|
||||
prepareToolInvocation,
|
||||
type ToolDefinition,
|
||||
type ToolJsonValue,
|
||||
type ToolPolicyAuthorizer,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
import {
|
||||
BUILTIN_TRIGGER_LIST_TOOL,
|
||||
BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
|
||||
executeBuiltInTriggerListTool,
|
||||
} from '../tool-projection/triggerList';
|
||||
|
||||
export const QINGLONG_LOCAL_MCP_SERVER = Object.freeze({
|
||||
name: 'qinglong-local',
|
||||
version: '3.0.0-alpha.0',
|
||||
});
|
||||
|
||||
const MCP_OPERATION_ID = 'mcp.tool.call';
|
||||
|
||||
export interface AuthenticatedLocalMcpRequest {
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
confirm(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface QingLongLocalMcpServerDependencies {
|
||||
readonly projectId: string;
|
||||
readonly authenticate: () => Promise<Readonly<AuthenticatedLocalMcpRequest> | null>;
|
||||
readonly policy: ToolPolicyAuthorizer;
|
||||
readonly audit: SecurityAuditSink;
|
||||
readonly runs: LocalMcpRunReader;
|
||||
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
|
||||
readonly taskDefinitions: LocalMcpTaskReader;
|
||||
readonly triggers: LocalMcpTriggerReader;
|
||||
readonly approvals: LocalMcpApprovalReader;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
|
||||
type LocalMcpRunReader = Pick<
|
||||
RunRepositoryReader,
|
||||
'findRunById' | 'listEvents'
|
||||
> &
|
||||
ProjectRunListReader;
|
||||
|
||||
type LocalMcpTaskReader = Pick<
|
||||
TaskDefinitionSource,
|
||||
'findCurrentTaskDefinition' | 'listTaskDefinitions'
|
||||
>;
|
||||
type LocalMcpTriggerReader = Pick<TriggerSource, 'listTriggers'>;
|
||||
type LocalMcpApprovalReader = Pick<
|
||||
ApprovalRequestSource,
|
||||
'listApprovalRequests'
|
||||
> &
|
||||
Pick<ApprovalRequestDetailSource, 'getApprovalRequestDetail'>;
|
||||
|
||||
interface LocalMcpReadAuthority {
|
||||
readonly runs: LocalMcpRunReader;
|
||||
readonly stepRuns: Pick<StepRunRepository, 'listByRun'>;
|
||||
readonly taskDefinitions: LocalMcpTaskReader;
|
||||
readonly triggers: LocalMcpTriggerReader;
|
||||
readonly approvals: LocalMcpApprovalReader;
|
||||
}
|
||||
|
||||
export class LocalMcpAdmissionError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string) {
|
||||
super('Local MCP Tool admission failed');
|
||||
this.name = 'LocalMcpAdmissionError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalMcpReadToolDescriptor {
|
||||
readonly tool: Readonly<{ name: string; version: string }>;
|
||||
readonly definition: Readonly<ToolDefinition>;
|
||||
readonly title: string;
|
||||
readonly auditReason: string;
|
||||
readonly unavailableCode: string;
|
||||
execute(
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>>;
|
||||
}
|
||||
|
||||
const LOCAL_MCP_READ_TOOLS: readonly LocalMcpReadToolDescriptor[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
tool: BUILTIN_RUN_LIST_TOOL,
|
||||
definition: BUILTIN_RUN_LIST_TOOL_DEFINITION,
|
||||
title: 'QingLong Runs',
|
||||
auditReason: 'tool_qinglong_run_list',
|
||||
unavailableCode: 'run_list_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) => executeBuiltInRunListTool(authority.runs, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_RUN_READ_TOOL,
|
||||
definition: BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
title: 'QingLong Run',
|
||||
auditReason: 'tool_qinglong_run_get',
|
||||
unavailableCode: 'run_query_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) => executeBuiltInRunReadTool(authority.runs, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_RUN_EVENT_LIST_TOOL,
|
||||
definition: BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
|
||||
title: 'QingLong Run Events',
|
||||
auditReason: 'tool_qinglong_run_events_list',
|
||||
unavailableCode: 'run_event_query_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) => executeBuiltInRunEventListTool(authority.runs, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_RUN_STEP_LIST_TOOL,
|
||||
definition: BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
|
||||
title: 'QingLong Run Steps',
|
||||
auditReason: 'tool_qinglong_run_steps_list',
|
||||
unavailableCode: 'run_step_query_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) =>
|
||||
executeBuiltInRunStepListTool(
|
||||
authority.runs,
|
||||
authority.stepRuns,
|
||||
projectId,
|
||||
input,
|
||||
),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_TASK_GET_TOOL,
|
||||
definition: BUILTIN_TASK_GET_TOOL_DEFINITION,
|
||||
title: 'QingLong Task',
|
||||
auditReason: 'tool_qinglong_task_get',
|
||||
unavailableCode: 'task_query_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) =>
|
||||
executeBuiltInTaskGetTool(authority.taskDefinitions, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_TASK_LIST_TOOL,
|
||||
definition: BUILTIN_TASK_LIST_TOOL_DEFINITION,
|
||||
title: 'QingLong Tasks',
|
||||
auditReason: 'tool_qinglong_task_list',
|
||||
unavailableCode: 'task_list_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) =>
|
||||
executeBuiltInTaskListTool(authority.taskDefinitions, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_TRIGGER_LIST_TOOL,
|
||||
definition: BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
|
||||
title: 'QingLong Triggers',
|
||||
auditReason: 'tool_qinglong_trigger_list',
|
||||
unavailableCode: 'trigger_list_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) => executeBuiltInTriggerListTool(authority.triggers, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_APPROVAL_LIST_TOOL,
|
||||
definition: BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
|
||||
title: 'QingLong Approvals',
|
||||
auditReason: 'tool_qinglong_approval_list',
|
||||
unavailableCode: 'approval_list_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) =>
|
||||
executeBuiltInApprovalListTool(authority.approvals, projectId, input),
|
||||
}),
|
||||
Object.freeze({
|
||||
tool: BUILTIN_APPROVAL_GET_TOOL,
|
||||
definition: BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
|
||||
title: 'QingLong Approval',
|
||||
auditReason: 'tool_qinglong_approval_get',
|
||||
unavailableCode: 'approval_query_unavailable',
|
||||
execute: (
|
||||
authority: LocalMcpReadAuthority,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
) => executeBuiltInApprovalGetTool(authority.approvals, projectId, input),
|
||||
}),
|
||||
]);
|
||||
|
||||
function validateDependencies(
|
||||
dependencies: QingLongLocalMcpServerDependencies,
|
||||
): void {
|
||||
if (
|
||||
!dependencies ||
|
||||
typeof dependencies !== 'object' ||
|
||||
Array.isArray(dependencies) ||
|
||||
typeof dependencies.projectId !== 'string' ||
|
||||
typeof dependencies.authenticate !== 'function' ||
|
||||
typeof dependencies.policy?.authorize !== 'function' ||
|
||||
typeof dependencies.audit?.record !== 'function' ||
|
||||
typeof dependencies.runs?.listRunsByProject !== 'function' ||
|
||||
typeof dependencies.runs?.findRunById !== 'function' ||
|
||||
typeof dependencies.runs?.listEvents !== 'function' ||
|
||||
typeof dependencies.stepRuns?.listByRun !== 'function' ||
|
||||
typeof dependencies.taskDefinitions?.findCurrentTaskDefinition !==
|
||||
'function' ||
|
||||
typeof dependencies.taskDefinitions?.listTaskDefinitions !== 'function' ||
|
||||
typeof dependencies.triggers?.listTriggers !== 'function' ||
|
||||
typeof dependencies.approvals?.listApprovalRequests !== 'function' ||
|
||||
typeof dependencies.approvals?.getApprovalRequestDetail !== 'function' ||
|
||||
(dependencies.now !== undefined &&
|
||||
typeof dependencies.now !== 'function') ||
|
||||
(dependencies.randomUuid !== undefined &&
|
||||
typeof dependencies.randomUuid !== 'function')
|
||||
) {
|
||||
throw new TypeError('Local MCP server dependencies are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function toolError(code: string): CallToolResult {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ code }) }],
|
||||
};
|
||||
}
|
||||
|
||||
function timestamp(now: () => number): number {
|
||||
let value: number;
|
||||
try {
|
||||
value = now();
|
||||
} catch {
|
||||
throw new LocalMcpAdmissionError('clock_unavailable');
|
||||
}
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LocalMcpAdmissionError('clock_unavailable');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function recordAudit(
|
||||
dependencies: QingLongLocalMcpServerDependencies,
|
||||
requestId: string,
|
||||
outcome: SecurityAuditOutcome,
|
||||
reasons: readonly string[],
|
||||
principal: Readonly<SecurityPrincipal> | null,
|
||||
fence: Readonly<{
|
||||
projectVersion: number;
|
||||
bindingVersion: number | null;
|
||||
}> | null,
|
||||
now: () => number,
|
||||
uuid: () => string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await dependencies.audit.record(
|
||||
normalizeSecurityAuditRecord({
|
||||
eventId: uuid(),
|
||||
requestId,
|
||||
operationId: MCP_OPERATION_ID,
|
||||
projectId: dependencies.projectId,
|
||||
subject: principal?.subject ?? null,
|
||||
authenticationId: principal?.authenticationId ?? null,
|
||||
outcome,
|
||||
reasons,
|
||||
fence,
|
||||
occurredAtMs: timestamp(now),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new LocalMcpAdmissionError('security_audit_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one read-only MCP endpoint. Every Tool call re-authenticates, passes
|
||||
* the shared Tool Registry and Project Policy, records durable admission,
|
||||
* confirms the credential fence, and only then performs a bounded read.
|
||||
*/
|
||||
export function createQingLongLocalMcpServer(
|
||||
dependencies: QingLongLocalMcpServerDependencies,
|
||||
): McpServer {
|
||||
validateDependencies(dependencies);
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const uuid = dependencies.randomUuid ?? randomUUID;
|
||||
const registry = new ToolDefinitionRegistry(
|
||||
LOCAL_MCP_READ_TOOLS.map(({ definition }) => definition),
|
||||
);
|
||||
const server = new McpServer(QINGLONG_LOCAL_MCP_SERVER, {
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
for (const descriptor of LOCAL_MCP_READ_TOOLS) {
|
||||
const definition = registry.resolve(
|
||||
descriptor.tool.name,
|
||||
descriptor.tool.version,
|
||||
);
|
||||
const inputSchema = fromJsonSchema<Record<string, unknown>>(
|
||||
definition.inputSchema as unknown as JsonSchemaType,
|
||||
);
|
||||
const outputSchema = fromJsonSchema<Record<string, unknown>>(
|
||||
definition.outputSchema as unknown as JsonSchemaType,
|
||||
);
|
||||
server.registerTool(
|
||||
definition.name,
|
||||
{
|
||||
title: descriptor.title,
|
||||
description: definition.description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
},
|
||||
async (argumentsValue): Promise<CallToolResult> => {
|
||||
const requestId = `mcp:${uuid()}`;
|
||||
let authenticated: Readonly<AuthenticatedLocalMcpRequest> | null;
|
||||
try {
|
||||
authenticated = await dependencies.authenticate();
|
||||
} catch {
|
||||
try {
|
||||
await recordAudit(
|
||||
dependencies,
|
||||
requestId,
|
||||
'authentication_unavailable',
|
||||
['authentication_unavailable', descriptor.auditReason],
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
uuid,
|
||||
);
|
||||
} catch (error) {
|
||||
return toolError((error as LocalMcpAdmissionError).code);
|
||||
}
|
||||
return toolError('authentication_unavailable');
|
||||
}
|
||||
if (!authenticated) {
|
||||
try {
|
||||
await recordAudit(
|
||||
dependencies,
|
||||
requestId,
|
||||
'authentication_rejected',
|
||||
['authentication_rejected', descriptor.auditReason],
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
uuid,
|
||||
);
|
||||
} catch (error) {
|
||||
return toolError((error as LocalMcpAdmissionError).code);
|
||||
}
|
||||
return toolError('authentication_required');
|
||||
}
|
||||
|
||||
let plan;
|
||||
try {
|
||||
plan = await prepareToolInvocation(
|
||||
registry,
|
||||
{
|
||||
projectId: dependencies.projectId,
|
||||
principal: authenticated.principal,
|
||||
nowMs: timestamp(now),
|
||||
tool: descriptor.tool,
|
||||
input: argumentsValue,
|
||||
},
|
||||
dependencies.policy,
|
||||
);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof InvalidToolJsonValueError
|
||||
? 'invalid_tool_input'
|
||||
: error instanceof ToolPolicySnapshotConflictError
|
||||
? 'policy_fence_conflict'
|
||||
: error instanceof ToolPolicyUnavailableError
|
||||
? 'authorization_unavailable'
|
||||
: 'authorization_unavailable';
|
||||
try {
|
||||
await recordAudit(
|
||||
dependencies,
|
||||
requestId,
|
||||
'authorization_unavailable',
|
||||
[code, descriptor.auditReason],
|
||||
authenticated.principal,
|
||||
null,
|
||||
now,
|
||||
uuid,
|
||||
);
|
||||
} catch (auditError) {
|
||||
return toolError((auditError as LocalMcpAdmissionError).code);
|
||||
}
|
||||
return toolError(code);
|
||||
}
|
||||
|
||||
if (plan.status === 'denied' || plan.status === 'approval_required') {
|
||||
const approvalRequired = plan.status === 'approval_required';
|
||||
try {
|
||||
await recordAudit(
|
||||
dependencies,
|
||||
requestId,
|
||||
approvalRequired ? 'approval_required' : 'denied',
|
||||
[
|
||||
approvalRequired
|
||||
? 'tool_approval_required'
|
||||
: 'tool_invocation_denied',
|
||||
descriptor.auditReason,
|
||||
],
|
||||
authenticated.principal,
|
||||
plan.status === 'denied' ? null : plan.fence,
|
||||
now,
|
||||
uuid,
|
||||
);
|
||||
} catch (error) {
|
||||
return toolError((error as LocalMcpAdmissionError).code);
|
||||
}
|
||||
return toolError(
|
||||
approvalRequired ? 'approval_required' : 'forbidden',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await recordAudit(
|
||||
dependencies,
|
||||
requestId,
|
||||
'allowed',
|
||||
['tool_invocation_allowed', descriptor.auditReason],
|
||||
authenticated.principal,
|
||||
plan.fence,
|
||||
now,
|
||||
uuid,
|
||||
);
|
||||
await authenticated.confirm();
|
||||
const output = registry.normalizeOutput(
|
||||
definition.name,
|
||||
definition.version,
|
||||
await descriptor.execute(
|
||||
dependencies,
|
||||
dependencies.projectId,
|
||||
plan.input,
|
||||
),
|
||||
);
|
||||
if (!output || typeof output !== 'object' || Array.isArray(output)) {
|
||||
return toolError(descriptor.unavailableCode);
|
||||
}
|
||||
const structuredContent = output as Record<string, ToolJsonValue>;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(structuredContent),
|
||||
},
|
||||
],
|
||||
structuredContent,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof LocalMcpAdmissionError) {
|
||||
return toolError(error.code);
|
||||
}
|
||||
return toolError(descriptor.unavailableCode);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
import { openProductionLocalMcpServer } from './production-process/processApplication';
|
||||
|
||||
const USAGE = 'Usage: ql3-mcp --config /absolute/private-config.json';
|
||||
|
||||
function configFileArgument(argv: readonly string[]): string | null {
|
||||
if (argv.length !== 2 || argv[0] !== '--config' || !argv[1]) return null;
|
||||
return argv[1];
|
||||
}
|
||||
|
||||
function failureFact(error: unknown): Readonly<Record<string, unknown>> {
|
||||
const candidate = error as { readonly name?: unknown; readonly code?: unknown };
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-mcp',
|
||||
level: 'error',
|
||||
event: 'process_failed',
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.length <= 128
|
||||
? candidate.name
|
||||
: 'Error',
|
||||
...(typeof candidate?.code === 'string' && candidate.code.length <= 128
|
||||
? { code: candidate.code }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]): Promise<void> {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
process.stdout.write(`${USAGE}\n`);
|
||||
return;
|
||||
}
|
||||
const configFilePath = configFileArgument(argv);
|
||||
if (configFilePath === null) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
code: 'LOCAL_MCP_SERVER_CLI_USAGE_INVALID',
|
||||
message: USAGE,
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
|
||||
let active: Awaited<ReturnType<typeof openProductionLocalMcpServer>> | undefined;
|
||||
let handle: ReturnType<typeof serveStdio> | undefined;
|
||||
let stopPromise: Promise<void> | undefined;
|
||||
const stop = () => {
|
||||
if (stopPromise) return stopPromise;
|
||||
stopPromise = (async () => {
|
||||
if (handle) await handle.close();
|
||||
if (active) await active.close();
|
||||
})();
|
||||
return stopPromise;
|
||||
};
|
||||
|
||||
try {
|
||||
active = await openProductionLocalMcpServer({ configFilePath });
|
||||
handle = serveStdio(() => active!.createServer(), {
|
||||
onerror: () => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-local-mcp',
|
||||
level: 'error',
|
||||
event: 'transport_error',
|
||||
})}\n`,
|
||||
);
|
||||
},
|
||||
maxSubscriptions: 1,
|
||||
});
|
||||
const shutdown = () => {
|
||||
void stop().catch((error) => {
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
};
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
process.stdin.once('end', shutdown);
|
||||
} catch (error) {
|
||||
try {
|
||||
await stop();
|
||||
} catch {
|
||||
// Preserve the startup failure.
|
||||
}
|
||||
process.stderr.write(`${JSON.stringify(failureFact(error))}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main(process.argv.slice(2));
|
||||
@@ -0,0 +1,173 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
import { assertProjectPolicyProjectId } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
export const LOCAL_MCP_SERVER_CONFIG_SCHEMA =
|
||||
'qinglong/local-mcp-server@v1' as const;
|
||||
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface LocalMcpServerConfig {
|
||||
readonly schema: typeof LOCAL_MCP_SERVER_CONFIG_SCHEMA;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly projectId: string;
|
||||
readonly deploymentRoot: string;
|
||||
readonly databasePath: string;
|
||||
readonly ownerPepperKeyringDirectory: string;
|
||||
readonly credentialFilePath: string;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class LocalMcpServerConfigError extends TypeError {
|
||||
readonly code = 'LOCAL_MCP_SERVER_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local MCP server configuration is invalid: ${message}`, options);
|
||||
this.name = 'LocalMcpServerConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactRecord(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalMcpServerConfigError('configuration must be an object');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const expected = [
|
||||
'credentialFilePath',
|
||||
'databasePath',
|
||||
'deploymentRoot',
|
||||
'ownerPepperKeyringDirectory',
|
||||
'profile',
|
||||
'projectId',
|
||||
'schema',
|
||||
...(Object.hasOwn(record, 'busyTimeoutMs') ? ['busyTimeoutMs'] : []),
|
||||
].sort();
|
||||
const actual = Object.keys(record).sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new LocalMcpServerConfigError('configuration shape is invalid');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
|
||||
value.includes('\0') ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value
|
||||
) {
|
||||
throw new LocalMcpServerConfigError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function descendant(root: string, value: string, label: string): void {
|
||||
const relative = path.relative(root, value);
|
||||
if (
|
||||
relative.length === 0 ||
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new LocalMcpServerConfigError(
|
||||
`${label} must be a descendant of deploymentRoot`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalMcpServerConfig(
|
||||
value: unknown,
|
||||
): Readonly<LocalMcpServerConfig> {
|
||||
const record = exactRecord(value);
|
||||
if (record.schema !== LOCAL_MCP_SERVER_CONFIG_SCHEMA) {
|
||||
throw new LocalMcpServerConfigError('schema is unsupported');
|
||||
}
|
||||
if (record.profile !== 'edge' && record.profile !== 'standalone') {
|
||||
throw new LocalMcpServerConfigError('profile is invalid');
|
||||
}
|
||||
try {
|
||||
assertProjectPolicyProjectId(record.projectId as string);
|
||||
} catch (error) {
|
||||
throw new LocalMcpServerConfigError('projectId is invalid', { cause: error });
|
||||
}
|
||||
const deploymentRoot = absolutePath(
|
||||
record.deploymentRoot,
|
||||
'deploymentRoot',
|
||||
);
|
||||
const databasePath = absolutePath(record.databasePath, 'databasePath');
|
||||
const ownerPepperKeyringDirectory = absolutePath(
|
||||
record.ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
const credentialFilePath = absolutePath(
|
||||
record.credentialFilePath,
|
||||
'credentialFilePath',
|
||||
);
|
||||
descendant(deploymentRoot, databasePath, 'databasePath');
|
||||
descendant(
|
||||
deploymentRoot,
|
||||
ownerPepperKeyringDirectory,
|
||||
'ownerPepperKeyringDirectory',
|
||||
);
|
||||
descendant(deploymentRoot, credentialFilePath, 'credentialFilePath');
|
||||
if (
|
||||
new Set([
|
||||
databasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
]).size !== 3
|
||||
) {
|
||||
throw new LocalMcpServerConfigError('authority paths must be distinct');
|
||||
}
|
||||
const busyTimeoutMs = record.busyTimeoutMs;
|
||||
if (
|
||||
busyTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(busyTimeoutMs) ||
|
||||
(busyTimeoutMs as number) < 100 ||
|
||||
(busyTimeoutMs as number) > 30_000)
|
||||
) {
|
||||
throw new LocalMcpServerConfigError('busyTimeoutMs is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: LOCAL_MCP_SERVER_CONFIG_SCHEMA,
|
||||
profile: record.profile,
|
||||
projectId: record.projectId as string,
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
...(busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: busyTimeoutMs as number }),
|
||||
});
|
||||
}
|
||||
|
||||
export function readLocalMcpServerConfig(
|
||||
configFilePath: string,
|
||||
): Readonly<LocalMcpServerConfig> {
|
||||
try {
|
||||
return normalizeLocalMcpServerConfig(
|
||||
readPrivateLocalCommandFile(configFilePath),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LocalMcpServerConfigError) throw error;
|
||||
throw new LocalMcpServerConfigError('private config cannot be read', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { establishAuthenticatedLocalCommand } from '@qinglong/local-owner-console/authenticated-command';
|
||||
import {
|
||||
openLocalSqliteMcpReadDatabase,
|
||||
type LocalSqliteMcpReadDatabase,
|
||||
} from '@qinglong/local-sqlite/mcp-read-database';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
import {
|
||||
createQingLongLocalMcpServer,
|
||||
type QingLongLocalMcpServerDependencies,
|
||||
} from '../application-runtime/mcpServer';
|
||||
import { readLocalMcpServerConfig, type LocalMcpServerConfig } from './config';
|
||||
|
||||
export interface OpenProductionLocalMcpServerOptions {
|
||||
readonly configFilePath: string;
|
||||
}
|
||||
|
||||
export interface ActiveProductionLocalMcpServer {
|
||||
readonly config: Readonly<LocalMcpServerConfig>;
|
||||
createServer(): ReturnType<typeof createQingLongLocalMcpServer>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProductionLocalMcpServerAdapters {
|
||||
readonly readConfig: typeof readLocalMcpServerConfig;
|
||||
readonly openDatabase: typeof openLocalSqliteMcpReadDatabase;
|
||||
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
||||
}
|
||||
|
||||
function validateOptions(options: OpenProductionLocalMcpServerOptions): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).length !== 1 ||
|
||||
typeof options.configFilePath !== 'string'
|
||||
) {
|
||||
throw new TypeError('Production local MCP server options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function validateAdapters(adapters: ProductionLocalMcpServerAdapters): void {
|
||||
if (
|
||||
!adapters ||
|
||||
typeof adapters !== 'object' ||
|
||||
Array.isArray(adapters) ||
|
||||
typeof adapters.readConfig !== 'function' ||
|
||||
typeof adapters.openDatabase !== 'function' ||
|
||||
typeof adapters.authenticate !== 'function'
|
||||
) {
|
||||
throw new TypeError('Production local MCP server adapters are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens one optional MCP process authority without starting a network listener. */
|
||||
export async function openProductionLocalMcpServer(
|
||||
options: OpenProductionLocalMcpServerOptions,
|
||||
adapters: ProductionLocalMcpServerAdapters = {
|
||||
readConfig: readLocalMcpServerConfig,
|
||||
openDatabase: openLocalSqliteMcpReadDatabase,
|
||||
authenticate: establishAuthenticatedLocalCommand,
|
||||
},
|
||||
): Promise<Readonly<ActiveProductionLocalMcpServer>> {
|
||||
validateOptions(options);
|
||||
validateAdapters(adapters);
|
||||
const config = adapters.readConfig(options.configFilePath);
|
||||
let database: LocalSqliteMcpReadDatabase | undefined;
|
||||
try {
|
||||
database = await adapters.openDatabase({
|
||||
databasePath: config.databasePath,
|
||||
profile: config.profile,
|
||||
...(config.busyTimeoutMs === undefined
|
||||
? {}
|
||||
: { busyTimeoutMs: config.busyTimeoutMs }),
|
||||
});
|
||||
const activeDatabase = database;
|
||||
const policy = new ProjectPolicyEngine(activeDatabase.projectPolicy);
|
||||
const serverDependencies: QingLongLocalMcpServerDependencies = {
|
||||
projectId: config.projectId,
|
||||
authenticate: () =>
|
||||
adapters.authenticate(activeDatabase, {
|
||||
deploymentRoot: config.deploymentRoot,
|
||||
databasePath: config.databasePath,
|
||||
ownerPepperKeyringDirectory: config.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: config.credentialFilePath,
|
||||
authenticationNamespace: 'mcp_read',
|
||||
}),
|
||||
policy,
|
||||
audit: activeDatabase.securityAudit,
|
||||
runs: activeDatabase.runs,
|
||||
stepRuns: activeDatabase.stepRuns,
|
||||
taskDefinitions: activeDatabase.taskDefinitions,
|
||||
triggers: activeDatabase.triggers,
|
||||
approvals: activeDatabase.approvals,
|
||||
};
|
||||
let closePromise: Promise<void> | undefined;
|
||||
return Object.freeze({
|
||||
config,
|
||||
createServer: () => createQingLongLocalMcpServer(serverDependencies),
|
||||
close() {
|
||||
const closing = closePromise ?? activeDatabase.close();
|
||||
closePromise = closing;
|
||||
return closing;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (database) {
|
||||
try {
|
||||
await database.close();
|
||||
} catch {
|
||||
// Preserve the startup failure.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
APPROVAL_DECISION_MODES,
|
||||
APPROVAL_REQUEST_STATES,
|
||||
APPROVAL_RISKS,
|
||||
normalizeApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import type {
|
||||
ApprovalRequestDetailSource,
|
||||
} from '@qinglong/runtime-core/approval-discovery';
|
||||
import {
|
||||
normalizeApprovalDetailPreview,
|
||||
} from '@qinglong/runtime-core/approval-discovery';
|
||||
import { normalizeProjectPermission } from '@qinglong/runtime-core/project-policy';
|
||||
import { SECURITY_SUBJECT_TYPES } from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_APPROVAL_GET_TOOL = Object.freeze({
|
||||
name: 'qinglong.approval.get',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_APPROVAL_GET_TIMEOUT_SECONDS = 5;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const PREVIEW_FIELD_KINDS = ['count', 'identifier', 'redacted', 'text'] as const;
|
||||
|
||||
const PREVIEW_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', minLength: 1, maxLength: 256 },
|
||||
summary: { type: 'string', minLength: 1, maxLength: 2048 },
|
||||
fields: {
|
||||
type: 'array',
|
||||
maxItems: 16,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
enum: [...PREVIEW_FIELD_KINDS],
|
||||
},
|
||||
label: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
value: { type: 'string', minLength: 1, maxLength: 512 },
|
||||
},
|
||||
required: ['kind', 'label'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
warnings: {
|
||||
type: 'array',
|
||||
maxItems: 8,
|
||||
items: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
},
|
||||
},
|
||||
required: ['title', 'summary', 'fields', 'warnings'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const BUILTIN_APPROVAL_GET_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_APPROVAL_GET_TOOL.name,
|
||||
version: BUILTIN_APPROVAL_GET_TOOL.version,
|
||||
description:
|
||||
'Get one Approval and its bounded redacted Tool preview in the authenticated Project',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requestId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['requestId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
found: { type: 'boolean' },
|
||||
approval: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requestId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
version: { type: 'integer', minimum: 1, maximum: MAX_INT },
|
||||
state: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...APPROVAL_REQUEST_STATES],
|
||||
},
|
||||
risk: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...APPROVAL_RISKS],
|
||||
},
|
||||
decisionMode: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...APPROVAL_DECISION_MODES],
|
||||
},
|
||||
permission: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
actionType: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
requestedByType: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...SECURITY_SUBJECT_TYPES],
|
||||
},
|
||||
requestedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
expiresAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
previewAvailable: { type: 'boolean' },
|
||||
preview: PREVIEW_SCHEMA,
|
||||
},
|
||||
required: [
|
||||
'requestId',
|
||||
'version',
|
||||
'state',
|
||||
'risk',
|
||||
'decisionMode',
|
||||
'permission',
|
||||
'actionType',
|
||||
'requestedByType',
|
||||
'requestedAtMs',
|
||||
'expiresAtMs',
|
||||
'previewAvailable',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['found'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['approval.read', 'artifact.read'],
|
||||
timeoutSeconds: BUILTIN_APPROVAL_GET_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInApprovalGetToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_APPROVAL_GET_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Approval get Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInApprovalGetToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInApprovalGetToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_APPROVAL_GET_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Approval get Tool is unavailable');
|
||||
this.name = 'BuiltInApprovalGetToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInApprovalGetToolError(message);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximumBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeBuiltInApprovalGetTool(
|
||||
source: Pick<ApprovalRequestDetailSource, 'getApprovalRequestDetail'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const record =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
if (
|
||||
typeof source?.getApprovalRequestDetail !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!record ||
|
||||
Reflect.ownKeys(record).length !== 1 ||
|
||||
!Object.hasOwn(record, 'requestId') ||
|
||||
!boundedText(record.requestId, 128)
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
let detail;
|
||||
try {
|
||||
detail = await source.getApprovalRequestDetail({
|
||||
projectId,
|
||||
requestId: record.requestId,
|
||||
});
|
||||
} catch {
|
||||
throw new BuiltInApprovalGetToolUnavailableError();
|
||||
}
|
||||
if (!detail) return Object.freeze({ found: false });
|
||||
try {
|
||||
if (
|
||||
typeof detail !== 'object' ||
|
||||
Array.isArray(detail) ||
|
||||
Reflect.ownKeys(detail).length !== 2 ||
|
||||
!Object.hasOwn(detail, 'request') ||
|
||||
!Object.hasOwn(detail, 'preview')
|
||||
) {
|
||||
throw new BuiltInApprovalGetToolUnavailableError();
|
||||
}
|
||||
const request = normalizeApprovalRequestRecord(detail.request);
|
||||
const preview = detail.preview
|
||||
? normalizeApprovalDetailPreview(detail.preview)
|
||||
: null;
|
||||
const permission = normalizeProjectPermission(request.action.permission);
|
||||
if (
|
||||
request.projectId !== projectId ||
|
||||
request.id !== record.requestId ||
|
||||
(preview !== null && request.action.actionType !== 'tool.invoke')
|
||||
) {
|
||||
throw new BuiltInApprovalGetToolUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
found: true,
|
||||
approval: Object.freeze({
|
||||
requestId: request.id,
|
||||
version: request.version,
|
||||
state: request.state,
|
||||
risk: request.risk,
|
||||
decisionMode: request.decisionMode,
|
||||
permission,
|
||||
actionType: request.action.actionType,
|
||||
requestedByType: request.requestedBy.type,
|
||||
requestedAtMs: request.requestedAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
previewAvailable: preview !== null,
|
||||
...(preview === null
|
||||
? {}
|
||||
: {
|
||||
preview: Object.freeze({
|
||||
title: preview.title,
|
||||
summary: preview.summary,
|
||||
fields: Object.freeze(
|
||||
preview.fields.map((field) =>
|
||||
Object.freeze({
|
||||
kind: field.kind,
|
||||
label: field.label,
|
||||
...(field.value === null ? {} : { value: field.value }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
warnings: Object.freeze([...preview.warnings]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BuiltInApprovalGetToolUnavailableError) throw error;
|
||||
throw new BuiltInApprovalGetToolUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
APPROVAL_DECISION_MODES,
|
||||
APPROVAL_REQUEST_STATES,
|
||||
APPROVAL_RISKS,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
approvalRequestUpdatedAtMs,
|
||||
type ApprovalRequestCursor,
|
||||
type ApprovalRequestSource,
|
||||
} from '@qinglong/runtime-core/approval-discovery';
|
||||
import { SECURITY_SUBJECT_TYPES } from '@qinglong/runtime-core/security';
|
||||
import { normalizeProjectPermission } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_APPROVAL_LIST_TOOL = Object.freeze({
|
||||
name: 'qinglong.approval.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_APPROVAL_LIST_TIMEOUT_SECONDS = 5;
|
||||
export const BUILTIN_APPROVAL_LIST_DEFAULT_LIMIT = 32;
|
||||
export const BUILTIN_APPROVAL_LIST_MAX_LIMIT = 64;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
const APPROVAL_LIST_CURSOR_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
updatedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
requestId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['updatedAtMs', 'requestId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const BUILTIN_APPROVAL_LIST_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_APPROVAL_LIST_TOOL.name,
|
||||
version: BUILTIN_APPROVAL_LIST_TOOL.version,
|
||||
description:
|
||||
'List recent low-sensitive Approval requests in the authenticated Project',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
after: APPROVAL_LIST_CURSOR_SCHEMA,
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: BUILTIN_APPROVAL_LIST_MAX_LIMIT,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
approvals: {
|
||||
type: 'array',
|
||||
maxItems: BUILTIN_APPROVAL_LIST_MAX_LIMIT,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requestId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
version: { type: 'integer', minimum: 1, maximum: MAX_INT },
|
||||
state: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...APPROVAL_REQUEST_STATES],
|
||||
},
|
||||
risk: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...APPROVAL_RISKS],
|
||||
},
|
||||
decisionMode: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...APPROVAL_DECISION_MODES],
|
||||
},
|
||||
permission: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
actionType: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
requestedByType: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: [...SECURITY_SUBJECT_TYPES],
|
||||
},
|
||||
requestedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
expiresAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
decidedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
consumedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
updatedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'requestId',
|
||||
'version',
|
||||
'state',
|
||||
'risk',
|
||||
'decisionMode',
|
||||
'permission',
|
||||
'actionType',
|
||||
'requestedByType',
|
||||
'requestedAtMs',
|
||||
'expiresAtMs',
|
||||
'updatedAtMs',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
hasMore: { type: 'boolean' },
|
||||
next: APPROVAL_LIST_CURSOR_SCHEMA,
|
||||
},
|
||||
required: ['approvals', 'hasMore'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['approval.read'],
|
||||
timeoutSeconds: BUILTIN_APPROVAL_LIST_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInApprovalListToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_APPROVAL_LIST_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Approval list Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInApprovalListToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInApprovalListToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_APPROVAL_LIST_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Approval list Tool is unavailable');
|
||||
this.name = 'BuiltInApprovalListToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInApprovalListToolError(message);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximumBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
Number(value) >= minimum &&
|
||||
Number(value) <= maximum
|
||||
);
|
||||
}
|
||||
|
||||
function cursor(value: ToolJsonValue | undefined): ApprovalRequestCursor | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid('cursor is invalid');
|
||||
}
|
||||
const record = value as Readonly<Record<string, ToolJsonValue>>;
|
||||
if (
|
||||
Reflect.ownKeys(record).length !== 2 ||
|
||||
!Object.hasOwn(record, 'updatedAtMs') ||
|
||||
!Object.hasOwn(record, 'requestId') ||
|
||||
!integer(record.updatedAtMs, 0, Number.MAX_SAFE_INTEGER) ||
|
||||
!boundedText(record.requestId, 128)
|
||||
) {
|
||||
return invalid('cursor is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
updatedAtMs: record.updatedAtMs,
|
||||
requestId: record.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
function before(
|
||||
updatedAtMs: number,
|
||||
requestId: string,
|
||||
boundary?: Readonly<ApprovalRequestCursor>,
|
||||
): boolean {
|
||||
return (
|
||||
!boundary ||
|
||||
updatedAtMs < boundary.updatedAtMs ||
|
||||
(updatedAtMs === boundary.updatedAtMs && requestId < boundary.requestId)
|
||||
);
|
||||
}
|
||||
|
||||
function projectApproval(
|
||||
value: Readonly<ApprovalRequestRecord>,
|
||||
projectId: string,
|
||||
boundary?: Readonly<ApprovalRequestCursor>,
|
||||
): Readonly<Record<string, ToolJsonValue>> | null {
|
||||
let request: Readonly<ApprovalRequestRecord>;
|
||||
let permission: string;
|
||||
let updatedAtMs: number;
|
||||
try {
|
||||
request = normalizeApprovalRequestRecord(value);
|
||||
permission = normalizeProjectPermission(request.action.permission);
|
||||
updatedAtMs = approvalRequestUpdatedAtMs(request);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
request.projectId !== projectId ||
|
||||
!boundedText(request.id, 128) ||
|
||||
!before(updatedAtMs, request.id, boundary) ||
|
||||
!integer(request.version, 1, MAX_INT) ||
|
||||
!APPROVAL_REQUEST_STATES.includes(request.state) ||
|
||||
!APPROVAL_RISKS.includes(request.risk) ||
|
||||
!APPROVAL_DECISION_MODES.includes(request.decisionMode) ||
|
||||
!boundedText(permission, 255) ||
|
||||
!boundedText(request.action.actionType, 128) ||
|
||||
!SECURITY_SUBJECT_TYPES.includes(request.requestedBy.type) ||
|
||||
!integer(request.requestedAtMs, 0, Number.MAX_SAFE_INTEGER) ||
|
||||
!integer(request.expiresAtMs, 1, Number.MAX_SAFE_INTEGER) ||
|
||||
(request.decidedAtMs !== null &&
|
||||
!integer(request.decidedAtMs, 0, Number.MAX_SAFE_INTEGER)) ||
|
||||
(request.consumedAtMs !== null &&
|
||||
!integer(request.consumedAtMs, 0, Number.MAX_SAFE_INTEGER))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: request.id,
|
||||
version: request.version,
|
||||
state: request.state,
|
||||
risk: request.risk,
|
||||
decisionMode: request.decisionMode,
|
||||
permission,
|
||||
actionType: request.action.actionType,
|
||||
requestedByType: request.requestedBy.type,
|
||||
requestedAtMs: request.requestedAtMs,
|
||||
expiresAtMs: request.expiresAtMs,
|
||||
...(request.decidedAtMs === null
|
||||
? {}
|
||||
: { decidedAtMs: request.decidedAtMs }),
|
||||
...(request.consumedAtMs === null
|
||||
? {}
|
||||
: { consumedAtMs: request.consumedAtMs }),
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeBuiltInApprovalListTool(
|
||||
source: Pick<ApprovalRequestSource, 'listApprovalRequests'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const record =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
const keys = record ? Reflect.ownKeys(record) : [];
|
||||
if (
|
||||
typeof source?.listApprovalRequests !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!record ||
|
||||
keys.length > 2 ||
|
||||
keys.some((key) => key !== 'after' && key !== 'limit') ||
|
||||
(record.limit !== undefined &&
|
||||
!integer(record.limit, 1, BUILTIN_APPROVAL_LIST_MAX_LIMIT))
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
const after = cursor(record.after);
|
||||
const limit = record.limit ?? BUILTIN_APPROVAL_LIST_DEFAULT_LIMIT;
|
||||
let page;
|
||||
try {
|
||||
page = await source.listApprovalRequests({
|
||||
projectId,
|
||||
limit,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
} catch {
|
||||
throw new BuiltInApprovalListToolUnavailableError();
|
||||
}
|
||||
if (
|
||||
!page ||
|
||||
!Array.isArray(page.requests) ||
|
||||
page.requests.length > limit ||
|
||||
typeof page.truncated !== 'boolean' ||
|
||||
page.truncated !== Boolean(page.next)
|
||||
) {
|
||||
throw new BuiltInApprovalListToolUnavailableError();
|
||||
}
|
||||
const approvals: Readonly<Record<string, ToolJsonValue>>[] = [];
|
||||
let boundary = after;
|
||||
for (const request of page.requests) {
|
||||
const projected = projectApproval(request, projectId, boundary);
|
||||
if (!projected) throw new BuiltInApprovalListToolUnavailableError();
|
||||
approvals.push(projected);
|
||||
boundary = Object.freeze({
|
||||
updatedAtMs: projected.updatedAtMs as number,
|
||||
requestId: projected.requestId as string,
|
||||
});
|
||||
}
|
||||
if (
|
||||
page.truncated &&
|
||||
(!page.next ||
|
||||
Reflect.ownKeys(page.next).length !== 2 ||
|
||||
!boundary ||
|
||||
page.next.updatedAtMs !== boundary.updatedAtMs ||
|
||||
page.next.requestId !== boundary.requestId ||
|
||||
approvals.length === 0)
|
||||
) {
|
||||
throw new BuiltInApprovalListToolUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
approvals: Object.freeze(approvals),
|
||||
hasMore: page.truncated,
|
||||
...(page.truncated ? { next: Object.freeze({ ...boundary! }) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { RUN_EVENT_ACTOR_TYPES } from '@qinglong/runtime-core/run';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
import {
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT,
|
||||
InvalidBoundedRunEventListProjectionError,
|
||||
MAX_BOUNDED_RUN_EVENT_LIST_LIMIT,
|
||||
executeBoundedRunEventListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_RUN_EVENT_LIST_TOOL = Object.freeze({
|
||||
name: 'qinglong.run.events.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_RUN_EVENT_LIST_TIMEOUT_SECONDS = 5;
|
||||
export const BUILTIN_RUN_EVENT_LIST_DEFAULT_LIMIT =
|
||||
DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT;
|
||||
export const BUILTIN_RUN_EVENT_LIST_MAX_LIMIT =
|
||||
MAX_BOUNDED_RUN_EVENT_LIST_LIMIT;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
|
||||
export const BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_RUN_EVENT_LIST_TOOL.name,
|
||||
version: BUILTIN_RUN_EVENT_LIST_TOOL.version,
|
||||
description: 'List bounded low-sensitive events for one Project-scoped Run',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
afterSequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: BUILTIN_RUN_EVENT_LIST_MAX_LIMIT,
|
||||
},
|
||||
},
|
||||
required: ['runId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
found: { type: 'boolean' },
|
||||
events: {
|
||||
type: 'array',
|
||||
maxItems: BUILTIN_RUN_EVENT_LIST_MAX_LIMIT,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
|
||||
type: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
actorType: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: RUN_EVENT_ACTOR_TYPES,
|
||||
},
|
||||
createdAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: ['sequence', 'type', 'actorType', 'createdAtMs'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
hasMore: { type: 'boolean' },
|
||||
nextAfterSequence: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: MAX_INT,
|
||||
},
|
||||
},
|
||||
required: ['found', 'events', 'hasMore', 'nextAfterSequence'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: BUILTIN_RUN_EVENT_LIST_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInRunEventListToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_RUN_EVENT_LIST_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Run event list Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInRunEventListToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInRunEventListToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_RUN_EVENT_LIST_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Run event list Tool is unavailable');
|
||||
this.name = 'BuiltInRunEventListToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInRunEventListToolError(message);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
Number(value) >= minimum &&
|
||||
Number(value) <= maximum
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeBuiltInRunEventListTool(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const inputRecord =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
const inputKeys = inputRecord ? Reflect.ownKeys(inputRecord) : [];
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
typeof runs.listEvents !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!inputRecord ||
|
||||
inputKeys.length < 1 ||
|
||||
inputKeys.length > 3 ||
|
||||
inputKeys.some(
|
||||
(key) => key !== 'runId' && key !== 'afterSequence' && key !== 'limit',
|
||||
) ||
|
||||
!Object.hasOwn(inputRecord, 'runId') ||
|
||||
!boundedText(inputRecord.runId, 128) ||
|
||||
(inputRecord.afterSequence !== undefined &&
|
||||
!integer(inputRecord.afterSequence, 0, MAX_INT)) ||
|
||||
(inputRecord.limit !== undefined &&
|
||||
!integer(inputRecord.limit, 1, BUILTIN_RUN_EVENT_LIST_MAX_LIMIT))
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
const runId = inputRecord.runId;
|
||||
const afterSequence = inputRecord.afterSequence ?? 0;
|
||||
const limit = inputRecord.limit ?? BUILTIN_RUN_EVENT_LIST_DEFAULT_LIMIT;
|
||||
try {
|
||||
const projection = await executeBoundedRunEventListProjection(
|
||||
runs,
|
||||
projectId,
|
||||
runId,
|
||||
{ afterSequence, limit },
|
||||
);
|
||||
return Object.freeze({
|
||||
found: projection.found,
|
||||
events: Object.freeze(
|
||||
projection.events.map((event) =>
|
||||
Object.freeze({
|
||||
sequence: event.sequence,
|
||||
type: event.type,
|
||||
actorType: event.actorType,
|
||||
createdAtMs: event.createdAtMs,
|
||||
}),
|
||||
),
|
||||
),
|
||||
hasMore: projection.hasMore,
|
||||
nextAfterSequence: projection.nextAfterSequence,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidBoundedRunEventListProjectionError) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
if (error instanceof BoundedRunEventListProjectionUnavailableError) {
|
||||
throw new BuiltInRunEventListToolUnavailableError();
|
||||
}
|
||||
throw new BuiltInRunEventListToolUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
EXECUTION_ORIGINS,
|
||||
RUN_STATUSES,
|
||||
} from '@qinglong/runtime-core/run';
|
||||
import {
|
||||
type ProjectRunListCursor,
|
||||
type ProjectRunListReader,
|
||||
} from '@qinglong/runtime-core/project-run-list';
|
||||
import {
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_RUN_LIST_LIMIT,
|
||||
InvalidBoundedRunListProjectionError,
|
||||
MAX_BOUNDED_RUN_LIST_LIMIT,
|
||||
executeBoundedRunListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-list-projection';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_RUN_LIST_TOOL = Object.freeze({
|
||||
name: 'qinglong.run.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_RUN_LIST_TIMEOUT_SECONDS = 5;
|
||||
export const BUILTIN_RUN_LIST_DEFAULT_LIMIT = DEFAULT_BOUNDED_RUN_LIST_LIMIT;
|
||||
export const BUILTIN_RUN_LIST_MAX_LIMIT = MAX_BOUNDED_RUN_LIST_LIMIT;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
const MIN_INT = -2_147_483_648;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
const RUN_LIST_ITEM_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
taskRevision: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
status: { type: 'string', maxLength: 32, enum: RUN_STATUSES },
|
||||
version: { type: 'integer', minimum: 0, maximum: MAX_INT },
|
||||
eventSequence: { type: 'integer', minimum: 0, maximum: MAX_INT },
|
||||
priority: { type: 'integer', minimum: MIN_INT, maximum: MAX_INT },
|
||||
executionOrigin: {
|
||||
type: 'string',
|
||||
maxLength: 32,
|
||||
enum: EXECUTION_ORIGINS,
|
||||
},
|
||||
executionOwner: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
enum: ['legacy', 'runtime'],
|
||||
},
|
||||
createdAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
queuedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
startedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
finishedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'status',
|
||||
'version',
|
||||
'eventSequence',
|
||||
'priority',
|
||||
'executionOrigin',
|
||||
'executionOwner',
|
||||
'createdAtMs',
|
||||
],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
const RUN_LIST_CURSOR_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
createdAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
runId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['createdAtMs', 'runId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const BUILTIN_RUN_LIST_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_RUN_LIST_TOOL.name,
|
||||
version: BUILTIN_RUN_LIST_TOOL.version,
|
||||
description: 'List recent low-sensitive Runs in the authenticated Project',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
after: RUN_LIST_CURSOR_SCHEMA,
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: BUILTIN_RUN_LIST_MAX_LIMIT,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runs: {
|
||||
type: 'array',
|
||||
maxItems: BUILTIN_RUN_LIST_MAX_LIMIT,
|
||||
items: RUN_LIST_ITEM_SCHEMA,
|
||||
},
|
||||
hasMore: { type: 'boolean' },
|
||||
next: RUN_LIST_CURSOR_SCHEMA,
|
||||
},
|
||||
required: ['runs', 'hasMore'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: BUILTIN_RUN_LIST_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInRunListToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_RUN_LIST_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Run list Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInRunListToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInRunListToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_RUN_LIST_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Run list Tool is unavailable');
|
||||
this.name = 'BuiltInRunListToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInRunListToolError(message);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
Number(value) >= minimum &&
|
||||
Number(value) <= maximum
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCursor(
|
||||
value: ToolJsonValue | undefined,
|
||||
): ProjectRunListCursor | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid('cursor is invalid');
|
||||
}
|
||||
const cursor = value as Readonly<Record<string, ToolJsonValue>>;
|
||||
if (
|
||||
Reflect.ownKeys(cursor).length !== 2 ||
|
||||
!Object.hasOwn(cursor, 'createdAtMs') ||
|
||||
!Object.hasOwn(cursor, 'runId') ||
|
||||
!integer(cursor.createdAtMs, 0, Number.MAX_SAFE_INTEGER) ||
|
||||
!boundedText(cursor.runId, 128)
|
||||
) {
|
||||
return invalid('cursor is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
createdAtMs: cursor.createdAtMs,
|
||||
runId: cursor.runId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeBuiltInRunListTool(
|
||||
runs: ProjectRunListReader,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const inputRecord =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
const inputKeys = inputRecord ? Reflect.ownKeys(inputRecord) : [];
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.listRunsByProject !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!inputRecord ||
|
||||
inputKeys.length > 2 ||
|
||||
inputKeys.some((key) => key !== 'after' && key !== 'limit') ||
|
||||
(inputRecord.limit !== undefined &&
|
||||
!integer(inputRecord.limit, 1, BUILTIN_RUN_LIST_MAX_LIMIT))
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
const after = normalizeCursor(inputRecord.after);
|
||||
const limit = inputRecord.limit ?? BUILTIN_RUN_LIST_DEFAULT_LIMIT;
|
||||
let result;
|
||||
try {
|
||||
result = await executeBoundedRunListProjection(runs, projectId, {
|
||||
limit,
|
||||
...(after === undefined ? {} : { after }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidBoundedRunListProjectionError) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
if (!(error instanceof BoundedRunListProjectionUnavailableError)) {
|
||||
throw error;
|
||||
}
|
||||
throw new BuiltInRunListToolUnavailableError();
|
||||
}
|
||||
const projected: readonly ToolJsonValue[] = result.runs.map((run) =>
|
||||
Object.freeze({ ...run }),
|
||||
);
|
||||
return Object.freeze({
|
||||
runs: Object.freeze(projected),
|
||||
hasMore: result.hasMore,
|
||||
...(result.next === undefined
|
||||
? {}
|
||||
: {
|
||||
next: Object.freeze({
|
||||
createdAtMs: result.next.createdAtMs,
|
||||
runId: result.next.runId,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import {
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT,
|
||||
InvalidBoundedRunStepListProjectionError,
|
||||
MAX_BOUNDED_RUN_STEP_LIST_LIMIT,
|
||||
executeBoundedRunStepListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
import {
|
||||
MAX_STEP_RUN_ATTEMPTS,
|
||||
STEP_RUN_KINDS,
|
||||
STEP_RUN_STATUSES,
|
||||
type StepRunRepository,
|
||||
} from '@qinglong/runtime-core/step-run';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_RUN_STEP_LIST_TOOL = Object.freeze({
|
||||
name: 'qinglong.run.steps.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_RUN_STEP_LIST_TIMEOUT_SECONDS = 5;
|
||||
export const BUILTIN_RUN_STEP_LIST_DEFAULT_LIMIT =
|
||||
DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT;
|
||||
export const BUILTIN_RUN_STEP_LIST_MAX_LIMIT = MAX_BOUNDED_RUN_STEP_LIST_LIMIT;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export const BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_RUN_STEP_LIST_TOOL.name,
|
||||
version: BUILTIN_RUN_STEP_LIST_TOOL.version,
|
||||
description: 'List bounded low-sensitive Steps for one Project-scoped Run',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
afterStepKey: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
afterStepRunId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: BUILTIN_RUN_STEP_LIST_MAX_LIMIT,
|
||||
},
|
||||
},
|
||||
required: ['runId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
found: { type: 'boolean' },
|
||||
steps: {
|
||||
type: 'array',
|
||||
maxItems: BUILTIN_RUN_STEP_LIST_MAX_LIMIT,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
parentStepRunId: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
},
|
||||
stepKey: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
kind: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 32,
|
||||
enum: STEP_RUN_KINDS,
|
||||
},
|
||||
required: { type: 'boolean' },
|
||||
status: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 32,
|
||||
enum: STEP_RUN_STATUSES,
|
||||
},
|
||||
version: { type: 'integer', minimum: 1, maximum: MAX_INT },
|
||||
attemptCount: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: MAX_STEP_RUN_ATTEMPTS,
|
||||
},
|
||||
readyAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
startedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
finishedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
resultCode: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
createdAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
updatedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'stepKey',
|
||||
'kind',
|
||||
'required',
|
||||
'status',
|
||||
'version',
|
||||
'attemptCount',
|
||||
'createdAtMs',
|
||||
'updatedAtMs',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
hasMore: { type: 'boolean' },
|
||||
next: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stepKey: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
stepRunId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['stepKey', 'stepRunId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['found', 'steps', 'hasMore'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: BUILTIN_RUN_STEP_LIST_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInRunStepListToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_RUN_STEP_LIST_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Run Step list Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInRunStepListToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInRunStepListToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_RUN_STEP_LIST_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Run Step list Tool is unavailable');
|
||||
this.name = 'BuiltInRunStepListToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInRunStepListToolError(message);
|
||||
}
|
||||
|
||||
export async function executeBuiltInRunStepListTool(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
stepRuns: Pick<StepRunRepository, 'listByRun'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const value =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
const keys = value ? Reflect.ownKeys(value) : [];
|
||||
const afterStepKey = value?.afterStepKey;
|
||||
const afterStepRunId = value?.afterStepRunId;
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
!stepRuns ||
|
||||
typeof stepRuns.listByRun !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!value ||
|
||||
keys.length < 1 ||
|
||||
keys.length > 4 ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
key !== 'runId' &&
|
||||
key !== 'afterStepKey' &&
|
||||
key !== 'afterStepRunId' &&
|
||||
key !== 'limit',
|
||||
) ||
|
||||
!Object.hasOwn(value, 'runId') ||
|
||||
!boundedText(value.runId, 128) ||
|
||||
(afterStepKey === undefined) !== (afterStepRunId === undefined) ||
|
||||
(afterStepKey !== undefined && !boundedText(afterStepKey, 128)) ||
|
||||
(afterStepRunId !== undefined && !boundedText(afterStepRunId, 128)) ||
|
||||
(value.limit !== undefined &&
|
||||
(!Number.isSafeInteger(value.limit) ||
|
||||
Number(value.limit) < 1 ||
|
||||
Number(value.limit) > BUILTIN_RUN_STEP_LIST_MAX_LIMIT))
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
try {
|
||||
const projection = await executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
stepRuns,
|
||||
projectId,
|
||||
value.runId,
|
||||
{
|
||||
limit: Number(value.limit ?? BUILTIN_RUN_STEP_LIST_DEFAULT_LIMIT),
|
||||
...(afterStepKey === undefined || afterStepRunId === undefined
|
||||
? {}
|
||||
: { after: { stepKey: afterStepKey, stepRunId: afterStepRunId } }),
|
||||
},
|
||||
);
|
||||
return Object.freeze({
|
||||
found: projection.found,
|
||||
steps: Object.freeze(
|
||||
projection.steps.map((step) =>
|
||||
Object.freeze({
|
||||
id: step.id,
|
||||
...(step.parentStepRunId === null
|
||||
? {}
|
||||
: { parentStepRunId: step.parentStepRunId }),
|
||||
stepKey: step.stepKey,
|
||||
kind: step.kind,
|
||||
required: step.required,
|
||||
status: step.status,
|
||||
version: step.version,
|
||||
attemptCount: step.attemptCount,
|
||||
...(step.readyAtMs === null ? {} : { readyAtMs: step.readyAtMs }),
|
||||
...(step.startedAtMs === null
|
||||
? {}
|
||||
: { startedAtMs: step.startedAtMs }),
|
||||
...(step.finishedAtMs === null
|
||||
? {}
|
||||
: { finishedAtMs: step.finishedAtMs }),
|
||||
...(step.resultCode === null
|
||||
? {}
|
||||
: { resultCode: step.resultCode }),
|
||||
createdAtMs: step.createdAtMs,
|
||||
updatedAtMs: step.updatedAtMs,
|
||||
}),
|
||||
),
|
||||
),
|
||||
hasMore: projection.hasMore,
|
||||
...(projection.next === null ? {} : { next: projection.next }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidBoundedRunStepListProjectionError) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
if (error instanceof BoundedRunStepListProjectionUnavailableError) {
|
||||
throw new BuiltInRunStepListToolUnavailableError();
|
||||
}
|
||||
throw new BuiltInRunStepListToolUnavailableError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
TASK_DEFINITION_KINDS,
|
||||
type TaskDefinitionSource,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
BoundedTaskReadProjectionUnavailableError,
|
||||
InvalidBoundedTaskReadProjectionError,
|
||||
executeBoundedTaskReadProjection,
|
||||
} from '@qinglong/runtime-core/bounded-task-read-projection';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_TASK_GET_TOOL = Object.freeze({
|
||||
name: 'qinglong.task.get',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_TASK_GET_TIMEOUT_SECONDS = 5;
|
||||
|
||||
export const BUILTIN_TASK_GET_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_TASK_GET_TOOL.name,
|
||||
version: BUILTIN_TASK_GET_TOOL.version,
|
||||
description: 'Read one current low-sensitive Task and its immutable fence',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['taskId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
found: { type: 'boolean' },
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
revision: { type: 'integer', minimum: 1, maximum: 2_147_483_647 },
|
||||
name: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
kind: { type: 'string', maxLength: 16, enum: TASK_DEFINITION_KINDS },
|
||||
specSchema: { type: 'string', minLength: 1, maxLength: 137 },
|
||||
enabled: { type: 'boolean' },
|
||||
contentDigest: { type: 'string', minLength: 64, maxLength: 64 },
|
||||
createdAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
updatedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: ['found'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['task.read'],
|
||||
timeoutSeconds: BUILTIN_TASK_GET_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInTaskGetToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_TASK_GET_TOOL_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Task get Tool input is invalid');
|
||||
this.name = 'InvalidBuiltInTaskGetToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInTaskGetToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_TASK_GET_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Task get Tool is unavailable');
|
||||
this.name = 'BuiltInTaskGetToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeBuiltInTaskGetTool(
|
||||
source: Pick<TaskDefinitionSource, 'findCurrentTaskDefinition'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const record =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
if (
|
||||
!record ||
|
||||
Reflect.ownKeys(record).length !== 1 ||
|
||||
!Object.hasOwn(record, 'taskId') ||
|
||||
typeof record.taskId !== 'string'
|
||||
) {
|
||||
throw new InvalidBuiltInTaskGetToolError();
|
||||
}
|
||||
try {
|
||||
return await executeBoundedTaskReadProjection(
|
||||
source,
|
||||
projectId,
|
||||
record.taskId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidBoundedTaskReadProjectionError) {
|
||||
throw new InvalidBuiltInTaskGetToolError();
|
||||
}
|
||||
if (error instanceof BoundedTaskReadProjectionUnavailableError) {
|
||||
throw new BuiltInTaskGetToolUnavailableError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
TASK_DEFINITION_KINDS,
|
||||
type TaskDefinitionSource,
|
||||
} from '@qinglong/runtime-core/task-definition';
|
||||
import {
|
||||
BoundedTaskListProjectionUnavailableError,
|
||||
DEFAULT_BOUNDED_TASK_LIST_LIMIT,
|
||||
InvalidBoundedTaskListProjectionError,
|
||||
MAX_BOUNDED_TASK_LIST_LIMIT,
|
||||
executeBoundedTaskListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_TASK_LIST_TOOL = Object.freeze({
|
||||
name: 'qinglong.task.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_TASK_LIST_TIMEOUT_SECONDS = 5;
|
||||
export const BUILTIN_TASK_LIST_DEFAULT_LIMIT = DEFAULT_BOUNDED_TASK_LIST_LIMIT;
|
||||
export const BUILTIN_TASK_LIST_MAX_LIMIT = MAX_BOUNDED_TASK_LIST_LIMIT;
|
||||
|
||||
const TASK_LIST_CURSOR_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['taskId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const BUILTIN_TASK_LIST_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_TASK_LIST_TOOL.name,
|
||||
version: BUILTIN_TASK_LIST_TOOL.version,
|
||||
description: 'List current low-sensitive Tasks in the authenticated Project',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
after: TASK_LIST_CURSOR_SCHEMA,
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: BUILTIN_TASK_LIST_MAX_LIMIT,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tasks: {
|
||||
type: 'array',
|
||||
maxItems: BUILTIN_TASK_LIST_MAX_LIMIT,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
revision: { type: 'integer', minimum: 1, maximum: 2_147_483_647 },
|
||||
name: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
kind: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
enum: TASK_DEFINITION_KINDS,
|
||||
},
|
||||
specSchema: { type: 'string', minLength: 1, maxLength: 137 },
|
||||
enabled: { type: 'boolean' },
|
||||
updatedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'taskId',
|
||||
'revision',
|
||||
'name',
|
||||
'kind',
|
||||
'specSchema',
|
||||
'enabled',
|
||||
'updatedAtMs',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
hasMore: { type: 'boolean' },
|
||||
next: TASK_LIST_CURSOR_SCHEMA,
|
||||
},
|
||||
required: ['tasks', 'hasMore'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['task.read'],
|
||||
timeoutSeconds: BUILTIN_TASK_LIST_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInTaskListToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_TASK_LIST_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Task list Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInTaskListToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInTaskListToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_TASK_LIST_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Task list Tool is unavailable');
|
||||
this.name = 'BuiltInTaskListToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInTaskListToolError(message);
|
||||
}
|
||||
|
||||
export async function executeBuiltInTaskListTool(
|
||||
source: Pick<TaskDefinitionSource, 'listTaskDefinitions'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const record =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
if (!record) return invalid('execution context or input is invalid');
|
||||
try {
|
||||
const result = await executeBoundedTaskListProjection(
|
||||
source,
|
||||
projectId,
|
||||
record,
|
||||
);
|
||||
return Object.freeze({
|
||||
tasks: Object.freeze(
|
||||
result.tasks.map((task) => Object.freeze({ ...task })),
|
||||
),
|
||||
hasMore: result.hasMore,
|
||||
...(result.next === undefined
|
||||
? {}
|
||||
: { next: Object.freeze({ ...result.next }) }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidBoundedTaskListProjectionError) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
if (error instanceof BoundedTaskListProjectionUnavailableError) {
|
||||
throw new BuiltInTaskListToolUnavailableError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type {
|
||||
TriggerCursor,
|
||||
TriggerRecord,
|
||||
TriggerSource,
|
||||
} from '@qinglong/runtime-core/trigger';
|
||||
import {
|
||||
normalizeToolDefinition,
|
||||
type ToolJsonValue,
|
||||
} from '@qinglong/runtime-core/tool-registry';
|
||||
|
||||
export const BUILTIN_TRIGGER_LIST_TOOL = Object.freeze({
|
||||
name: 'qinglong.trigger.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
export const BUILTIN_TRIGGER_LIST_TIMEOUT_SECONDS = 5;
|
||||
export const BUILTIN_TRIGGER_LIST_DEFAULT_LIMIT = 32;
|
||||
export const BUILTIN_TRIGGER_LIST_MAX_LIMIT = 64;
|
||||
|
||||
const MAX_INT = 2_147_483_647;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const TRIGGER_SPEC_SCHEMA_PATTERN =
|
||||
/^[a-z][a-z0-9.-]{0,63}\/[a-z][a-z0-9.-]{0,63}@v[1-9][0-9]{0,5}$/;
|
||||
|
||||
const TRIGGER_LIST_CURSOR_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
triggerId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
},
|
||||
required: ['triggerId'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const BUILTIN_TRIGGER_LIST_TOOL_DEFINITION = normalizeToolDefinition({
|
||||
name: BUILTIN_TRIGGER_LIST_TOOL.name,
|
||||
version: BUILTIN_TRIGGER_LIST_TOOL.version,
|
||||
description:
|
||||
'List current low-sensitive Triggers in the authenticated Project',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
after: TRIGGER_LIST_CURSOR_SCHEMA,
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: BUILTIN_TRIGGER_LIST_MAX_LIMIT,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triggers: {
|
||||
type: 'array',
|
||||
maxItems: BUILTIN_TRIGGER_LIST_MAX_LIMIT,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triggerId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
revision: { type: 'integer', minimum: 1, maximum: MAX_INT },
|
||||
taskId: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
taskRevision: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: MAX_INT,
|
||||
},
|
||||
specSchema: { type: 'string', minLength: 1, maxLength: 137 },
|
||||
enabled: { type: 'boolean' },
|
||||
updatedAtMs: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'triggerId',
|
||||
'revision',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'specSchema',
|
||||
'enabled',
|
||||
'updatedAtMs',
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
hasMore: { type: 'boolean' },
|
||||
next: TRIGGER_LIST_CURSOR_SCHEMA,
|
||||
},
|
||||
required: ['triggers', 'hasMore'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['trigger.read'],
|
||||
timeoutSeconds: BUILTIN_TRIGGER_LIST_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export class InvalidBuiltInTriggerListToolError extends TypeError {
|
||||
readonly code = 'BUILTIN_TRIGGER_LIST_TOOL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Built-in Trigger list Tool is invalid: ${message}`);
|
||||
this.name = 'InvalidBuiltInTriggerListToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltInTriggerListToolUnavailableError extends Error {
|
||||
readonly code = 'BUILTIN_TRIGGER_LIST_TOOL_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Built-in Trigger list Tool is unavailable');
|
||||
this.name = 'BuiltInTriggerListToolUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidBuiltInTriggerListToolError(message);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximumBytes: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
|
||||
!CONTROL_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
Number(value) >= minimum &&
|
||||
Number(value) <= maximum
|
||||
);
|
||||
}
|
||||
|
||||
function cursor(value: ToolJsonValue | undefined): TriggerCursor | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid('cursor is invalid');
|
||||
}
|
||||
const record = value as Readonly<Record<string, ToolJsonValue>>;
|
||||
if (
|
||||
Reflect.ownKeys(record).length !== 1 ||
|
||||
!Object.hasOwn(record, 'triggerId') ||
|
||||
!boundedText(record.triggerId, 128)
|
||||
) {
|
||||
return invalid('cursor is invalid');
|
||||
}
|
||||
return Object.freeze({ triggerId: record.triggerId });
|
||||
}
|
||||
|
||||
function projectTrigger(
|
||||
value: TriggerRecord,
|
||||
projectId: string,
|
||||
after?: string,
|
||||
): Readonly<Record<string, ToolJsonValue>> | null {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
value.projectId !== projectId ||
|
||||
(after !== undefined && value.triggerId <= after) ||
|
||||
!boundedText(value.triggerId, 128) ||
|
||||
!integer(value.revision, 1, MAX_INT) ||
|
||||
!boundedText(value.taskId, 128) ||
|
||||
!integer(value.taskRevision, 1, MAX_INT) ||
|
||||
!boundedText(value.spec?.schema, 137) ||
|
||||
!TRIGGER_SPEC_SCHEMA_PATTERN.test(value.spec.schema) ||
|
||||
typeof value.enabled !== 'boolean' ||
|
||||
!integer(value.updatedAtMs, 0, Number.MAX_SAFE_INTEGER)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
triggerId: value.triggerId,
|
||||
revision: value.revision,
|
||||
taskId: value.taskId,
|
||||
taskRevision: value.taskRevision,
|
||||
specSchema: value.spec.schema,
|
||||
enabled: value.enabled,
|
||||
updatedAtMs: value.updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeBuiltInTriggerListTool(
|
||||
source: Pick<TriggerSource, 'listTriggers'>,
|
||||
projectId: string,
|
||||
input: ToolJsonValue,
|
||||
): Promise<Readonly<Record<string, ToolJsonValue>>> {
|
||||
const record =
|
||||
input && typeof input === 'object' && !Array.isArray(input)
|
||||
? (input as Readonly<Record<string, ToolJsonValue>>)
|
||||
: null;
|
||||
const keys = record ? Reflect.ownKeys(record) : [];
|
||||
if (
|
||||
typeof source?.listTriggers !== 'function' ||
|
||||
!boundedText(projectId, 128) ||
|
||||
!record ||
|
||||
keys.length > 2 ||
|
||||
keys.some((key) => key !== 'after' && key !== 'limit') ||
|
||||
(record.limit !== undefined &&
|
||||
!integer(record.limit, 1, BUILTIN_TRIGGER_LIST_MAX_LIMIT))
|
||||
) {
|
||||
return invalid('execution context or input is invalid');
|
||||
}
|
||||
const after = cursor(record.after);
|
||||
const limit = record.limit ?? BUILTIN_TRIGGER_LIST_DEFAULT_LIMIT;
|
||||
let page;
|
||||
try {
|
||||
page = await source.listTriggers({
|
||||
projectId,
|
||||
limit,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
} catch {
|
||||
throw new BuiltInTriggerListToolUnavailableError();
|
||||
}
|
||||
if (
|
||||
!page ||
|
||||
!Array.isArray(page.triggers) ||
|
||||
page.triggers.length > limit ||
|
||||
typeof page.truncated !== 'boolean' ||
|
||||
page.truncated !== Boolean(page.next)
|
||||
) {
|
||||
throw new BuiltInTriggerListToolUnavailableError();
|
||||
}
|
||||
const triggers: Readonly<Record<string, ToolJsonValue>>[] = [];
|
||||
let boundary = after?.triggerId;
|
||||
for (const trigger of page.triggers) {
|
||||
const projected = projectTrigger(trigger, projectId, boundary);
|
||||
if (!projected) throw new BuiltInTriggerListToolUnavailableError();
|
||||
triggers.push(projected);
|
||||
boundary = trigger.triggerId;
|
||||
}
|
||||
if (
|
||||
page.truncated &&
|
||||
(!page.next ||
|
||||
Reflect.ownKeys(page.next).length !== 1 ||
|
||||
!boundedText(page.next.triggerId, 128) ||
|
||||
page.next.triggerId !== boundary ||
|
||||
triggers.length === 0)
|
||||
) {
|
||||
throw new BuiltInTriggerListToolUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
triggers: Object.freeze(triggers),
|
||||
hasMore: page.truncated,
|
||||
...(page.truncated ? { next: Object.freeze({ triggerId: boundary! }) } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createToolInvocationPreviewArtifact,
|
||||
} = require('@qinglong/runtime-core/tool-invocation-artifact');
|
||||
const {
|
||||
BUILTIN_APPROVAL_GET_TOOL,
|
||||
BUILTIN_APPROVAL_GET_TOOL_DEFINITION,
|
||||
BuiltInApprovalGetToolUnavailableError,
|
||||
InvalidBuiltInApprovalGetToolError,
|
||||
executeBuiltInApprovalGetTool,
|
||||
} = require('../dist/tool-projection/approvalGet.js');
|
||||
|
||||
function detail() {
|
||||
const previewArtifact = createToolInvocationPreviewArtifact({
|
||||
artifactId: 'preview-1',
|
||||
projectId: 'default',
|
||||
actionRef: 'tool:approval-1',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
redactionContractDigest: 'c'.repeat(64),
|
||||
sealedAtMs: 1_000,
|
||||
preview: {
|
||||
title: 'Run task',
|
||||
summary: 'Runs the selected task once.',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Task', value: 'task-1' },
|
||||
{ kind: 'redacted', label: 'Token', value: null },
|
||||
],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
});
|
||||
const request = createApprovalRequest({
|
||||
id: 'approval-1',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: previewArtifact.actionRef,
|
||||
actionDigest: previewArtifact.actionDigest,
|
||||
previewDigest: previewArtifact.previewDigest,
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'private-agent' },
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
requestFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
});
|
||||
return Object.freeze({ request, preview: previewArtifact.preview });
|
||||
}
|
||||
|
||||
test('defines an exact dual-authorized read-only Approval detail Tool', () => {
|
||||
assert.deepEqual(BUILTIN_APPROVAL_GET_TOOL, {
|
||||
name: 'qinglong.approval.get',
|
||||
version: '1.0.0',
|
||||
});
|
||||
assert.equal(BUILTIN_APPROVAL_GET_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_APPROVAL_GET_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_APPROVAL_GET_TOOL_DEFINITION.requiredPermissions, [
|
||||
'approval.read',
|
||||
'artifact.read',
|
||||
]);
|
||||
});
|
||||
|
||||
test('projects only Approval metadata and the redacted preview document', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInApprovalGetTool(
|
||||
{
|
||||
async getApprovalRequestDetail(query) {
|
||||
captured = query;
|
||||
return detail();
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ requestId: 'approval-1' },
|
||||
);
|
||||
assert.deepEqual(captured, { projectId: 'default', requestId: 'approval-1' });
|
||||
assert.deepEqual(output, {
|
||||
found: true,
|
||||
approval: {
|
||||
requestId: 'approval-1',
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
requestedByType: 'agent',
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
previewAvailable: true,
|
||||
preview: {
|
||||
title: 'Run task',
|
||||
summary: 'Runs the selected task once.',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Task', value: 'task-1' },
|
||||
{ kind: 'redacted', label: 'Token' },
|
||||
],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
for (const hidden of [
|
||||
'private-agent',
|
||||
'actionRef',
|
||||
'actionDigest',
|
||||
'previewDigest',
|
||||
'artifactDigest',
|
||||
'redactionContractDigest',
|
||||
'requestFence',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('masks an absent request and reports an unavailable preview explicitly', async () => {
|
||||
const missing = await executeBuiltInApprovalGetTool(
|
||||
{ async getApprovalRequestDetail() { return null; } },
|
||||
'default',
|
||||
{ requestId: 'missing' },
|
||||
);
|
||||
assert.deepEqual(missing, { found: false });
|
||||
const withoutPreview = detail();
|
||||
const output = await executeBuiltInApprovalGetTool(
|
||||
{
|
||||
async getApprovalRequestDetail() {
|
||||
return { request: withoutPreview.request, preview: null };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ requestId: 'approval-1' },
|
||||
);
|
||||
assert.equal(output.approval.previewAvailable, false);
|
||||
assert.equal(Object.hasOwn(output.approval, 'preview'), false);
|
||||
});
|
||||
|
||||
test('rejects widened input before reading and fails closed on binding drift', async () => {
|
||||
let reads = 0;
|
||||
const source = {
|
||||
async getApprovalRequestDetail() {
|
||||
reads += 1;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
for (const input of [null, {}, { requestId: '' }, { requestId: 'a', extra: true }]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInApprovalGetTool(source, 'default', input),
|
||||
InvalidBuiltInApprovalGetToolError,
|
||||
);
|
||||
}
|
||||
assert.equal(reads, 0);
|
||||
const value = detail();
|
||||
await assert.rejects(
|
||||
executeBuiltInApprovalGetTool(
|
||||
{ async getApprovalRequestDetail() { return value; } },
|
||||
'other',
|
||||
{ requestId: 'approval-1' },
|
||||
),
|
||||
BuiltInApprovalGetToolUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
executeBuiltInApprovalGetTool(
|
||||
{ async getApprovalRequestDetail() { throw new Error('private'); } },
|
||||
'default',
|
||||
{ requestId: 'approval-1' },
|
||||
),
|
||||
BuiltInApprovalGetToolUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
executeBuiltInApprovalGetTool(
|
||||
{
|
||||
async getApprovalRequestDetail() {
|
||||
return { ...value, extra: true };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ requestId: 'approval-1' },
|
||||
),
|
||||
BuiltInApprovalGetToolUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
BUILTIN_APPROVAL_LIST_DEFAULT_LIMIT,
|
||||
BUILTIN_APPROVAL_LIST_MAX_LIMIT,
|
||||
BUILTIN_APPROVAL_LIST_TOOL,
|
||||
BUILTIN_APPROVAL_LIST_TOOL_DEFINITION,
|
||||
BuiltInApprovalListToolUnavailableError,
|
||||
InvalidBuiltInApprovalListToolError,
|
||||
executeBuiltInApprovalListTool,
|
||||
} = require('../dist/tool-projection/approvalList.js');
|
||||
|
||||
function approval(id, requestedAtMs, overrides = {}) {
|
||||
return createApprovalRequest({
|
||||
id,
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: `private:${id}`,
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'private-agent' },
|
||||
requestedAtMs,
|
||||
expiresAtMs: requestedAtMs + 60_000,
|
||||
requestFence: { projectVersion: 1, bindingVersion: 2 },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('defines one bounded low-risk approval.read Tool', () => {
|
||||
assert.deepEqual(BUILTIN_APPROVAL_LIST_TOOL, {
|
||||
name: 'qinglong.approval.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
assert.equal(BUILTIN_APPROVAL_LIST_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_APPROVAL_LIST_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_APPROVAL_LIST_TOOL_DEFINITION.requiredPermissions, [
|
||||
'approval.read',
|
||||
]);
|
||||
assert.equal(BUILTIN_APPROVAL_LIST_DEFAULT_LIMIT, 32);
|
||||
assert.equal(BUILTIN_APPROVAL_LIST_MAX_LIMIT, 64);
|
||||
});
|
||||
|
||||
test('projects bounded Approval state without authority or sensitive evidence', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInApprovalListTool(
|
||||
{
|
||||
async listApprovalRequests(query) {
|
||||
captured = query;
|
||||
return Object.freeze({
|
||||
requests: Object.freeze([approval('approval-2', 2_000)]),
|
||||
truncated: true,
|
||||
next: Object.freeze({ updatedAtMs: 2_000, requestId: 'approval-2' }),
|
||||
});
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ after: { updatedAtMs: 3_000, requestId: 'approval-3' }, limit: 1 },
|
||||
);
|
||||
assert.deepEqual(captured, {
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: { updatedAtMs: 3_000, requestId: 'approval-3' },
|
||||
});
|
||||
assert.deepEqual(output, {
|
||||
approvals: [
|
||||
{
|
||||
requestId: 'approval-2',
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
requestedByType: 'agent',
|
||||
requestedAtMs: 2_000,
|
||||
expiresAtMs: 62_000,
|
||||
updatedAtMs: 2_000,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { updatedAtMs: 2_000, requestId: 'approval-2' },
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
for (const hidden of [
|
||||
'private',
|
||||
'actionRef',
|
||||
'actionDigest',
|
||||
'previewDigest',
|
||||
'requestFence',
|
||||
'projectId',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults to 32 and returns no cursor for a complete page', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInApprovalListTool(
|
||||
{
|
||||
async listApprovalRequests(query) {
|
||||
captured = query;
|
||||
return { requests: [], truncated: false };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(captured, { projectId: 'default', limit: 32 });
|
||||
assert.deepEqual(output, { approvals: [], hasMore: false });
|
||||
});
|
||||
|
||||
test('rejects invalid input before reading', async () => {
|
||||
let reads = 0;
|
||||
const source = {
|
||||
async listApprovalRequests() {
|
||||
reads += 1;
|
||||
return { requests: [], truncated: false };
|
||||
},
|
||||
};
|
||||
for (const input of [
|
||||
null,
|
||||
{ limit: 65 },
|
||||
{ after: { updatedAtMs: -1, requestId: 'approval-1' } },
|
||||
{ after: { updatedAtMs: 1, requestId: '' } },
|
||||
{ after: { updatedAtMs: 1, requestId: 'approval-1', extra: true } },
|
||||
{ unexpected: true },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInApprovalListTool(source, 'default', input),
|
||||
InvalidBuiltInApprovalListToolError,
|
||||
);
|
||||
}
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('fails closed on cross-Project, unordered, oversized or inconsistent pages', async () => {
|
||||
for (const { page, input = {} } of [
|
||||
{
|
||||
page: {
|
||||
requests: [approval('approval-2', 2_000, { projectId: 'other' })],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
page: {
|
||||
requests: [approval('approval-1', 1_000), approval('approval-2', 2_000)],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
page: {
|
||||
requests: [approval('approval-2', 2_000), approval('approval-1', 1_000)],
|
||||
truncated: false,
|
||||
},
|
||||
input: { limit: 1 },
|
||||
},
|
||||
{
|
||||
page: { requests: [approval('approval-1', 1_000)], truncated: true },
|
||||
},
|
||||
{
|
||||
page: {
|
||||
requests: [approval('approval-1', 1_000)],
|
||||
truncated: true,
|
||||
next: { updatedAtMs: 1_000, requestId: 'approval-other' },
|
||||
},
|
||||
},
|
||||
{
|
||||
page: { requests: [{ projectId: 'default' }], truncated: false },
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInApprovalListTool(
|
||||
{
|
||||
async listApprovalRequests() {
|
||||
return page;
|
||||
},
|
||||
},
|
||||
'default',
|
||||
input,
|
||||
),
|
||||
BuiltInApprovalListToolUnavailableError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_MCP_SERVER_CONFIG_SCHEMA,
|
||||
normalizeLocalMcpServerConfig,
|
||||
readLocalMcpServerConfig,
|
||||
} = require('@qinglong/local-mcp-server/config');
|
||||
|
||||
function candidate(root) {
|
||||
return {
|
||||
schema: LOCAL_MCP_SERVER_CONFIG_SCHEMA,
|
||||
profile: 'edge',
|
||||
projectId: 'default',
|
||||
deploymentRoot: root,
|
||||
databasePath: path.join(root, 'data', 'qinglong3.sqlite'),
|
||||
ownerPepperKeyringDirectory: path.join(root, 'owner-peppers'),
|
||||
credentialFilePath: path.join(root, 'operator', 'credential.json'),
|
||||
busyTimeoutMs: 500,
|
||||
};
|
||||
}
|
||||
|
||||
test('accepts an exact private MCP config with deployment-root descendants', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-mcp-config-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.chmodSync(root, 0o700);
|
||||
const filePath = path.join(root, 'mcp.json');
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(candidate(root))}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
assert.deepEqual(readLocalMcpServerConfig(filePath), candidate(root));
|
||||
});
|
||||
|
||||
test('rejects public config files, extra keys and authority paths outside deployment root', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-mcp-config-bad-'));
|
||||
try {
|
||||
fs.chmodSync(root, 0o700);
|
||||
const filePath = path.join(root, 'mcp.json');
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(candidate(root))}\n`, {
|
||||
mode: 0o644,
|
||||
});
|
||||
assert.throws(() => readLocalMcpServerConfig(filePath), {
|
||||
code: 'LOCAL_MCP_SERVER_CONFIG_INVALID',
|
||||
});
|
||||
assert.throws(
|
||||
() => normalizeLocalMcpServerConfig({ ...candidate(root), extra: true }),
|
||||
{ code: 'LOCAL_MCP_SERVER_CONFIG_INVALID' },
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeLocalMcpServerConfig({
|
||||
...candidate(root),
|
||||
credentialFilePath: path.join(os.tmpdir(), 'credential.json'),
|
||||
}),
|
||||
{ code: 'LOCAL_MCP_SERVER_CONFIG_INVALID' },
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,872 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { InMemoryTransport } = require('@modelcontextprotocol/server');
|
||||
const { createQingLongLocalMcpServer } = require('@qinglong/local-mcp-server');
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createToolInvocationPreviewArtifact,
|
||||
} = require('@qinglong/runtime-core/tool-invocation-artifact');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
|
||||
const NOW = 50_000;
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'mcp-user' }),
|
||||
authenticationId: 'mcp-test:principal',
|
||||
authenticatedAtMs: NOW - 1,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function run(projectId = 'default') {
|
||||
return Object.freeze({
|
||||
id: 'run-1',
|
||||
projectId,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 3,
|
||||
eventSequence: 4,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 10,
|
||||
queuedAtMs: 11,
|
||||
startedAtMs: 12,
|
||||
finishedAtMs: 13,
|
||||
});
|
||||
}
|
||||
|
||||
function runEvent(sequence) {
|
||||
return Object.freeze({
|
||||
id: `event-${sequence}`,
|
||||
runId: 'run-1',
|
||||
sequence,
|
||||
type: `run.event.${sequence}`,
|
||||
actorType: 'system',
|
||||
actorId: 'private-actor',
|
||||
attemptId: 'private-attempt',
|
||||
payload: Object.freeze({ secret: 'must-not-leak' }),
|
||||
createdAtMs: 100 + sequence,
|
||||
});
|
||||
}
|
||||
|
||||
function task(taskId = 'task-1', projectId = 'default') {
|
||||
return createTaskDefinitionRecord(
|
||||
{
|
||||
projectId,
|
||||
taskId,
|
||||
expectedRevision: 1,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174302',
|
||||
name: 'Example Task',
|
||||
description: 'private description',
|
||||
kind: 'script',
|
||||
spec: {
|
||||
schema: 'qinglong/script@v1',
|
||||
config: { command: 'private command' },
|
||||
},
|
||||
labels: { private: 'label' },
|
||||
enabled: true,
|
||||
occurredAtMs: 20,
|
||||
},
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
function trigger(triggerId = 'trigger-1') {
|
||||
return Object.freeze({
|
||||
projectId: 'default',
|
||||
triggerId,
|
||||
revision: 2,
|
||||
mutationId: 'private-mutation',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 2,
|
||||
taskContentDigest: 'private-task-digest',
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: Object.freeze({
|
||||
expression: 'private cron expression',
|
||||
timezone: 'private timezone',
|
||||
}),
|
||||
}),
|
||||
enabled: true,
|
||||
contentDigest: 'private-trigger-digest',
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 30,
|
||||
});
|
||||
}
|
||||
|
||||
function approval(id = 'approval-1', requestedAtMs = 40) {
|
||||
return createApprovalRequest({
|
||||
id,
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: 'private-action-ref',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'private-agent' },
|
||||
requestedAtMs,
|
||||
expiresAtMs: requestedAtMs + 60_000,
|
||||
requestFence: { projectVersion: 2, bindingVersion: 3 },
|
||||
});
|
||||
}
|
||||
|
||||
function approvalWithPreview() {
|
||||
const previewArtifact = createToolInvocationPreviewArtifact({
|
||||
artifactId: 'preview-approval-detail',
|
||||
projectId: 'default',
|
||||
actionRef: 'private-action-ref',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
redactionContractDigest: 'c'.repeat(64),
|
||||
sealedAtMs: 40,
|
||||
preview: {
|
||||
title: 'Start one run',
|
||||
summary: 'Starts the selected task once.',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Task', value: 'task-1' },
|
||||
{ kind: 'redacted', label: 'Secret', value: null },
|
||||
],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
});
|
||||
const request = createApprovalRequest({
|
||||
id: 'approval-detail',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: previewArtifact.actionRef,
|
||||
actionDigest: previewArtifact.actionDigest,
|
||||
previewDigest: previewArtifact.previewDigest,
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'private-agent' },
|
||||
requestedAtMs: 40,
|
||||
expiresAtMs: 60_040,
|
||||
requestFence: { projectVersion: 2, bindingVersion: 3 },
|
||||
});
|
||||
return Object.freeze({ request, preview: previewArtifact.preview });
|
||||
}
|
||||
|
||||
function fixture(options = {}) {
|
||||
const events = [];
|
||||
const permissions = [];
|
||||
const audits = [];
|
||||
let reads = 0;
|
||||
let listReads = 0;
|
||||
let eventReads = 0;
|
||||
let taskListReads = 0;
|
||||
let triggerListReads = 0;
|
||||
let approvalListReads = 0;
|
||||
let approvalDetailReads = 0;
|
||||
let confirmations = 0;
|
||||
const server = createQingLongLocalMcpServer({
|
||||
projectId: 'default',
|
||||
now: () => NOW,
|
||||
randomUuid: randomUUID,
|
||||
authenticate: async () => {
|
||||
events.push('authenticate');
|
||||
if (options.authentication === 'rejected') return null;
|
||||
if (options.authentication === 'unavailable') throw new Error('hidden');
|
||||
return Object.freeze({
|
||||
principal: PRINCIPAL,
|
||||
async confirm() {
|
||||
events.push('confirm');
|
||||
confirmations += 1;
|
||||
},
|
||||
});
|
||||
},
|
||||
policy: {
|
||||
async authorize(_principal, _projectId, permission) {
|
||||
events.push(`policy:${permission}`);
|
||||
permissions.push(permission);
|
||||
return Object.freeze({
|
||||
effect: options.policy ?? 'allow',
|
||||
reasons: Object.freeze(['test_policy']),
|
||||
fence: Object.freeze({ projectVersion: 2, bindingVersion: 3 }),
|
||||
});
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async record(record) {
|
||||
events.push(`audit:${record.outcome}`);
|
||||
if (options.auditUnavailable) throw new Error('hidden');
|
||||
audits.push(record);
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
async listRunsByProject(query) {
|
||||
events.push('read-list');
|
||||
listReads += 1;
|
||||
const values = [{ ...run(), id: 'run-2', createdAtMs: 20 }, run()];
|
||||
return values.slice(0, query.limit);
|
||||
},
|
||||
async findRunById(runId) {
|
||||
events.push('read');
|
||||
reads += 1;
|
||||
return runId === 'run-1' ? run(options.runProjectId) : null;
|
||||
},
|
||||
async listEvents(runId, query) {
|
||||
events.push('read-events');
|
||||
eventReads += 1;
|
||||
if (runId !== 'run-1') return [];
|
||||
const after = query?.afterSequence ?? 0;
|
||||
const limit = query?.limit ?? 100;
|
||||
return [runEvent(1), runEvent(2), runEvent(3)]
|
||||
.filter(({ sequence }) => sequence > after)
|
||||
.slice(0, limit);
|
||||
},
|
||||
},
|
||||
stepRuns: {
|
||||
async listByRun() {
|
||||
return Object.freeze({
|
||||
stepRuns: Object.freeze([]),
|
||||
truncated: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
taskDefinitions: {
|
||||
async findCurrentTaskDefinition(projectId, taskId) {
|
||||
events.push('read-task');
|
||||
taskListReads += 1;
|
||||
return taskId === 'task-1'
|
||||
? task(taskId, options.taskProjectId ?? projectId)
|
||||
: null;
|
||||
},
|
||||
async listTaskDefinitions(query) {
|
||||
events.push('read-tasks');
|
||||
taskListReads += 1;
|
||||
const definitions = [task('task-1'), task('task-2')].slice(
|
||||
0,
|
||||
query.limit,
|
||||
);
|
||||
return Object.freeze({
|
||||
definitions: Object.freeze(definitions),
|
||||
truncated: query.limit < 2,
|
||||
...(query.limit < 2
|
||||
? { next: Object.freeze({ taskId: definitions.at(-1).taskId }) }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers(query) {
|
||||
events.push('read-triggers');
|
||||
triggerListReads += 1;
|
||||
const triggers = [trigger('trigger-1'), trigger('trigger-2')].slice(
|
||||
0,
|
||||
query.limit,
|
||||
);
|
||||
return Object.freeze({
|
||||
triggers: Object.freeze(triggers),
|
||||
truncated: query.limit < 2,
|
||||
...(query.limit < 2
|
||||
? {
|
||||
next: Object.freeze({
|
||||
triggerId: triggers.at(-1).triggerId,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
async listApprovalRequests(query) {
|
||||
events.push('read-approvals');
|
||||
approvalListReads += 1;
|
||||
const requests = [
|
||||
approval('approval-2', 40),
|
||||
approval('approval-1', 30),
|
||||
].slice(0, query.limit);
|
||||
return Object.freeze({
|
||||
requests: Object.freeze(requests),
|
||||
truncated: query.limit < 2,
|
||||
...(query.limit < 2
|
||||
? {
|
||||
next: Object.freeze({
|
||||
updatedAtMs: requests.at(-1).requestedAtMs,
|
||||
requestId: requests.at(-1).id,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
async getApprovalRequestDetail(query) {
|
||||
events.push('read-approval-detail');
|
||||
approvalDetailReads += 1;
|
||||
return options.approvalDetail?.(query) ?? null;
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
server,
|
||||
events,
|
||||
permissions,
|
||||
audits,
|
||||
counters: () => ({
|
||||
reads,
|
||||
listReads,
|
||||
eventReads,
|
||||
taskListReads,
|
||||
triggerListReads,
|
||||
approvalListReads,
|
||||
approvalDetailReads,
|
||||
confirmations,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function client(server, t) {
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const pending = new Map();
|
||||
clientTransport.onmessage = (message) => {
|
||||
const waiter = pending.get(message.id);
|
||||
if (waiter) {
|
||||
pending.delete(message.id);
|
||||
waiter(message);
|
||||
}
|
||||
};
|
||||
await server.connect(serverTransport);
|
||||
await clientTransport.start();
|
||||
t.after(async () => {
|
||||
await clientTransport.close();
|
||||
await server.close();
|
||||
});
|
||||
let nextId = 1;
|
||||
const request = (method, params = undefined) => {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, resolve);
|
||||
clientTransport
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
const initialized = await request('initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ql3-test', version: '1.0.0' },
|
||||
});
|
||||
assert.equal(initialized.result.protocolVersion, '2025-11-25');
|
||||
await clientTransport.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
});
|
||||
return { request };
|
||||
}
|
||||
|
||||
test('advertises bounded read-only Run Tools and executes auth -> Policy -> Audit -> confirm -> read', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const listed = await connected.request('tools/list', {});
|
||||
assert.deepEqual(
|
||||
listed.result.tools.map((tool) => tool.name),
|
||||
[
|
||||
'qinglong.run.list',
|
||||
'qinglong.run.get',
|
||||
'qinglong.run.events.list',
|
||||
'qinglong.run.steps.list',
|
||||
'qinglong.task.get',
|
||||
'qinglong.task.list',
|
||||
'qinglong.trigger.list',
|
||||
'qinglong.approval.list',
|
||||
'qinglong.approval.get',
|
||||
],
|
||||
);
|
||||
for (const tool of listed.result.tools) {
|
||||
assert.deepEqual(tool.annotations, {
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
readOnlyHint: true,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.run.get',
|
||||
arguments: { runId: 'run-1' },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
found: true,
|
||||
id: 'run-1',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 3,
|
||||
eventSequence: 4,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 10,
|
||||
queuedAtMs: 11,
|
||||
startedAtMs: 12,
|
||||
finishedAtMs: 13,
|
||||
});
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.run.get',
|
||||
'run.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.run.get',
|
||||
'policy:run.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_run_get',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 1,
|
||||
listReads: 0,
|
||||
eventReads: 0,
|
||||
taskListReads: 0,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('discovers recent Project Runs through the same fenced admission', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.run.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
runs: [
|
||||
{
|
||||
id: 'run-2',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 3,
|
||||
eventSequence: 4,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 20,
|
||||
queuedAtMs: 11,
|
||||
startedAtMs: 12,
|
||||
finishedAtMs: 13,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { createdAtMs: 20, runId: 'run-2' },
|
||||
});
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.run.list',
|
||||
'run.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.run.list',
|
||||
'policy:run.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read-list',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_run_list',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 0,
|
||||
listReads: 1,
|
||||
eventReads: 0,
|
||||
taskListReads: 0,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('lists a payload-free Run event page through the same fenced admission', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.run.events.list',
|
||||
arguments: { runId: 'run-1', afterSequence: 1, limit: 1 },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
found: true,
|
||||
events: [
|
||||
{
|
||||
sequence: 2,
|
||||
type: 'run.event.2',
|
||||
actorType: 'system',
|
||||
createdAtMs: 102,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAfterSequence: 2,
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('must-not-leak'), false);
|
||||
assert.equal(JSON.stringify(response).includes('private-'), false);
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.run.events.list',
|
||||
'run.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.run.events.list',
|
||||
'policy:run.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read',
|
||||
'read-events',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_run_events_list',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 1,
|
||||
listReads: 0,
|
||||
eventReads: 1,
|
||||
taskListReads: 0,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('discovers low-sensitive Tasks through task.read admission', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.task.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
revision: 2,
|
||||
name: 'Example Task',
|
||||
kind: 'script',
|
||||
specSchema: 'qinglong/script@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { taskId: 'task-1' },
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.task.list',
|
||||
'task.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.task.list',
|
||||
'policy:task.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read-tasks',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_task_list',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 0,
|
||||
listReads: 0,
|
||||
eventReads: 0,
|
||||
taskListReads: 1,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('reads one current Task fence through task.read admission', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.task.get',
|
||||
arguments: { taskId: 'task-1' },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
found: true,
|
||||
taskId: 'task-1',
|
||||
revision: 2,
|
||||
name: 'Example Task',
|
||||
kind: 'script',
|
||||
specSchema: 'qinglong/script@v1',
|
||||
enabled: true,
|
||||
contentDigest: task().contentDigest,
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.task.get',
|
||||
'task.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.task.get',
|
||||
'policy:task.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read-task',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_task_get',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 0,
|
||||
listReads: 0,
|
||||
eventReads: 0,
|
||||
taskListReads: 1,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
|
||||
const maskedValue = fixture({ taskProjectId: 'other' });
|
||||
const maskedClient = await client(maskedValue.server, t);
|
||||
const masked = await maskedClient.request('tools/call', {
|
||||
name: 'qinglong.task.get',
|
||||
arguments: { taskId: 'task-1' },
|
||||
});
|
||||
assert.deepEqual(masked.result.structuredContent, { found: false });
|
||||
});
|
||||
|
||||
test('discovers low-sensitive Triggers through trigger.read admission', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.trigger.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
triggers: [
|
||||
{
|
||||
triggerId: 'trigger-1',
|
||||
revision: 2,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 2,
|
||||
specSchema: 'qinglong/cron@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: 30,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { triggerId: 'trigger-1' },
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.trigger.list',
|
||||
'trigger.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.trigger.list',
|
||||
'policy:trigger.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read-triggers',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_trigger_list',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 0,
|
||||
listReads: 0,
|
||||
eventReads: 0,
|
||||
taskListReads: 0,
|
||||
triggerListReads: 1,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('discovers low-sensitive Approvals through approval.read admission', async (t) => {
|
||||
const value = fixture();
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.approval.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
approvals: [
|
||||
{
|
||||
requestId: 'approval-2',
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
requestedByType: 'agent',
|
||||
requestedAtMs: 40,
|
||||
expiresAtMs: 60_040,
|
||||
updatedAtMs: 40,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { updatedAtMs: 40, requestId: 'approval-2' },
|
||||
});
|
||||
assert.equal(JSON.stringify(response).includes('private'), false);
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.approval.list',
|
||||
'approval.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.approval.list',
|
||||
'policy:approval.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read-approvals',
|
||||
]);
|
||||
assert.deepEqual(value.audits[0].reasons, [
|
||||
'tool_invocation_allowed',
|
||||
'tool_qinglong_approval_list',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 0,
|
||||
listReads: 0,
|
||||
eventReads: 0,
|
||||
taskListReads: 0,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 1,
|
||||
approvalDetailReads: 0,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('reads one redacted Approval preview through approval.read and artifact.read', async (t) => {
|
||||
const detail = approvalWithPreview();
|
||||
const value = fixture({ approvalDetail: () => detail });
|
||||
const connected = await client(value.server, t);
|
||||
const response = await connected.request('tools/call', {
|
||||
name: 'qinglong.approval.get',
|
||||
arguments: { requestId: 'approval-detail' },
|
||||
});
|
||||
assert.equal(response.result.isError, undefined);
|
||||
assert.deepEqual(response.result.structuredContent, {
|
||||
found: true,
|
||||
approval: {
|
||||
requestId: 'approval-detail',
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
requestedByType: 'agent',
|
||||
requestedAtMs: 40,
|
||||
expiresAtMs: 60_040,
|
||||
previewAvailable: true,
|
||||
preview: {
|
||||
title: 'Start one run',
|
||||
summary: 'Starts the selected task once.',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Task', value: 'task-1' },
|
||||
{ kind: 'redacted', label: 'Secret' },
|
||||
],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const serialized = JSON.stringify(response);
|
||||
for (const hidden of [
|
||||
'private-agent',
|
||||
'private-action-ref',
|
||||
'actionDigest',
|
||||
'previewDigest',
|
||||
'artifactId',
|
||||
'redactionContractDigest',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
assert.deepEqual(value.permissions, [
|
||||
'tool.call:qinglong.approval.get',
|
||||
'approval.read',
|
||||
'artifact.read',
|
||||
]);
|
||||
assert.deepEqual(value.events, [
|
||||
'authenticate',
|
||||
'policy:tool.call:qinglong.approval.get',
|
||||
'policy:approval.read',
|
||||
'policy:artifact.read',
|
||||
'audit:allowed',
|
||||
'confirm',
|
||||
'read-approval-detail',
|
||||
]);
|
||||
assert.deepEqual(value.counters(), {
|
||||
reads: 0,
|
||||
listReads: 0,
|
||||
eventReads: 0,
|
||||
taskListReads: 0,
|
||||
triggerListReads: 0,
|
||||
approvalListReads: 0,
|
||||
approvalDetailReads: 1,
|
||||
confirmations: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('masks cross-Project Runs and fails closed before reads on auth, Policy or Audit denial', async (t) => {
|
||||
const crossProject = fixture({ runProjectId: 'other' });
|
||||
const crossClient = await client(crossProject.server, t);
|
||||
const cross = await crossClient.request('tools/call', {
|
||||
name: 'qinglong.run.get',
|
||||
arguments: { runId: 'run-1' },
|
||||
});
|
||||
assert.deepEqual(cross.result.structuredContent, { found: false });
|
||||
|
||||
for (const options of [
|
||||
{ authentication: 'rejected' },
|
||||
{ authentication: 'unavailable' },
|
||||
{ policy: 'deny' },
|
||||
{ auditUnavailable: true },
|
||||
]) {
|
||||
const denied = fixture(options);
|
||||
const deniedClient = await client(denied.server, t);
|
||||
const result = await deniedClient.request('tools/call', {
|
||||
name: 'qinglong.run.get',
|
||||
arguments: { runId: 'run-1' },
|
||||
});
|
||||
assert.equal(result.result.isError, true);
|
||||
assert.equal(denied.counters().reads, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_MCP_SERVER_CONFIG_SCHEMA,
|
||||
} = require('@qinglong/local-mcp-server/config');
|
||||
const {
|
||||
openProductionLocalMcpServer,
|
||||
} = require('@qinglong/local-mcp-server/process');
|
||||
|
||||
test('opens one bounded database authority and reuses production authentication per server call', async () => {
|
||||
const config = Object.freeze({
|
||||
schema: LOCAL_MCP_SERVER_CONFIG_SCHEMA,
|
||||
profile: 'edge',
|
||||
projectId: 'default',
|
||||
deploymentRoot: '/srv/qinglong',
|
||||
databasePath: '/srv/qinglong/data/qinglong3.sqlite',
|
||||
ownerPepperKeyringDirectory: '/srv/qinglong/owner-peppers',
|
||||
credentialFilePath: '/srv/qinglong/operator/credential.json',
|
||||
busyTimeoutMs: 250,
|
||||
});
|
||||
const calls = [];
|
||||
let closes = 0;
|
||||
const database = {
|
||||
projectPolicy: {
|
||||
async resolve() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
securityAudit: { async record() {} },
|
||||
runs: {
|
||||
async listRunsByProject() {
|
||||
return [];
|
||||
},
|
||||
async findRunById() {
|
||||
return null;
|
||||
},
|
||||
async listEvents() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
stepRuns: {
|
||||
async listByRun() {
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
},
|
||||
taskDefinitions: {
|
||||
async findCurrentTaskDefinition() {
|
||||
return null;
|
||||
},
|
||||
async listTaskDefinitions() {
|
||||
return { definitions: [], truncated: false };
|
||||
},
|
||||
},
|
||||
triggers: {
|
||||
async listTriggers() {
|
||||
return { triggers: [], truncated: false };
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
async listApprovalRequests() {
|
||||
return { requests: [], truncated: false };
|
||||
},
|
||||
async getApprovalRequestDetail() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
apiCredentials: {
|
||||
async resolve() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
ownerPepper: {
|
||||
async resolveKey() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
async close() {
|
||||
closes += 1;
|
||||
},
|
||||
};
|
||||
const active = await openProductionLocalMcpServer(
|
||||
{ configFilePath: '/srv/qinglong/mcp.json' },
|
||||
{
|
||||
readConfig(filePath) {
|
||||
calls.push(['config', filePath]);
|
||||
return config;
|
||||
},
|
||||
async openDatabase(options) {
|
||||
calls.push(['database', options]);
|
||||
return database;
|
||||
},
|
||||
async authenticate(_database, options) {
|
||||
calls.push(['authenticate', options]);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(active.createServer().constructor.name, 'McpServer');
|
||||
assert.deepEqual(calls, [
|
||||
['config', '/srv/qinglong/mcp.json'],
|
||||
[
|
||||
'database',
|
||||
{
|
||||
databasePath: '/srv/qinglong/data/qinglong3.sqlite',
|
||||
profile: 'edge',
|
||||
busyTimeoutMs: 250,
|
||||
},
|
||||
],
|
||||
]);
|
||||
await active.close();
|
||||
await active.close();
|
||||
assert.equal(closes, 1);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_RUN_EVENT_LIST_DEFAULT_LIMIT,
|
||||
BUILTIN_RUN_EVENT_LIST_MAX_LIMIT,
|
||||
BUILTIN_RUN_EVENT_LIST_TOOL,
|
||||
BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION,
|
||||
BuiltInRunEventListToolUnavailableError,
|
||||
InvalidBuiltInRunEventListToolError,
|
||||
executeBuiltInRunEventListTool,
|
||||
} = require('../dist/tool-projection/runEventList.js');
|
||||
|
||||
function run(projectId = 'default') {
|
||||
return Object.freeze({ id: 'run-1', projectId });
|
||||
}
|
||||
|
||||
function event(sequence, overrides = {}) {
|
||||
return Object.freeze({
|
||||
id: `event-${sequence}`,
|
||||
runId: 'run-1',
|
||||
sequence,
|
||||
type: `run.event.${sequence}`,
|
||||
dedupeKey: `private-dedupe-${sequence}`,
|
||||
actorType: 'system',
|
||||
actorId: 'private-actor',
|
||||
attemptId: 'private-attempt',
|
||||
stepRunId: 'private-step',
|
||||
payload: Object.freeze({ secret: 'must-not-leak' }),
|
||||
createdAtMs: 1_000 + sequence,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('defines one bounded low-risk Run event list Tool', () => {
|
||||
assert.deepEqual(BUILTIN_RUN_EVENT_LIST_TOOL, {
|
||||
name: 'qinglong.run.events.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
assert.equal(BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(
|
||||
BUILTIN_RUN_EVENT_LIST_TOOL_DEFINITION.requiredPermissions,
|
||||
['run.read'],
|
||||
);
|
||||
assert.equal(BUILTIN_RUN_EVENT_LIST_DEFAULT_LIMIT, 32);
|
||||
assert.equal(BUILTIN_RUN_EVENT_LIST_MAX_LIMIT, 64);
|
||||
});
|
||||
|
||||
test('returns an ordered payload-free page and a stable cursor', async () => {
|
||||
const calls = [];
|
||||
const result = await executeBuiltInRunEventListTool(
|
||||
{
|
||||
async findRunById(runId) {
|
||||
calls.push(['run', runId]);
|
||||
return run();
|
||||
},
|
||||
async listEvents(runId, options) {
|
||||
calls.push(['events', runId, options]);
|
||||
return [event(3), event(4), event(5)];
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ runId: 'run-1', afterSequence: 2, limit: 2 },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
found: true,
|
||||
events: [
|
||||
{
|
||||
sequence: 3,
|
||||
type: 'run.event.3',
|
||||
actorType: 'system',
|
||||
createdAtMs: 1_003,
|
||||
},
|
||||
{
|
||||
sequence: 4,
|
||||
type: 'run.event.4',
|
||||
actorType: 'system',
|
||||
createdAtMs: 1_004,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAfterSequence: 4,
|
||||
});
|
||||
assert.deepEqual(calls, [
|
||||
['run', 'run-1'],
|
||||
['events', 'run-1', { afterSequence: 2, limit: 3 }],
|
||||
]);
|
||||
assert.equal(JSON.stringify(result).includes('must-not-leak'), false);
|
||||
assert.equal(JSON.stringify(result).includes('private-'), false);
|
||||
});
|
||||
|
||||
test('masks absent and cross-Project Runs without reading events', async () => {
|
||||
for (const value of [null, run('other')]) {
|
||||
let eventReads = 0;
|
||||
const result = await executeBuiltInRunEventListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
return value;
|
||||
},
|
||||
async listEvents() {
|
||||
eventReads += 1;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ runId: 'run-1', afterSequence: 7 },
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
found: false,
|
||||
events: [],
|
||||
hasMore: false,
|
||||
nextAfterSequence: 7,
|
||||
});
|
||||
assert.equal(eventReads, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed input and fails closed on storage or event corruption', async () => {
|
||||
const runs = {
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async listEvents() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
for (const input of [
|
||||
null,
|
||||
{},
|
||||
{ runId: '' },
|
||||
{ runId: 'run-1', limit: 65 },
|
||||
{ runId: 'run-1', afterSequence: -1 },
|
||||
{ runId: 'run-1', unexpected: true },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInRunEventListTool(runs, 'default', input),
|
||||
InvalidBuiltInRunEventListToolError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBuiltInRunEventListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
throw new Error('hidden');
|
||||
},
|
||||
async listEvents() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ runId: 'run-1' },
|
||||
),
|
||||
BuiltInRunEventListToolUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
executeBuiltInRunEventListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
async listEvents() {
|
||||
return [event(2), event(1)];
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ runId: 'run-1' },
|
||||
),
|
||||
BuiltInRunEventListToolUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_RUN_LIST_DEFAULT_LIMIT,
|
||||
BUILTIN_RUN_LIST_MAX_LIMIT,
|
||||
BUILTIN_RUN_LIST_TOOL,
|
||||
BUILTIN_RUN_LIST_TOOL_DEFINITION,
|
||||
BuiltInRunListToolUnavailableError,
|
||||
InvalidBuiltInRunListToolError,
|
||||
executeBuiltInRunListTool,
|
||||
} = require('../dist/tool-projection/runList.js');
|
||||
|
||||
function run(id, createdAtMs, overrides = {}) {
|
||||
return Object.freeze({
|
||||
id,
|
||||
projectId: 'default',
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: 'revision-1',
|
||||
taskName: 'private-name',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
triggeredBy: 'private-actor',
|
||||
requestId: 'private-request',
|
||||
status: 'succeeded',
|
||||
version: 2,
|
||||
eventSequence: 3,
|
||||
priority: 0,
|
||||
inputRef: 'private-input',
|
||||
outputRef: 'private-output',
|
||||
createdAtMs,
|
||||
finishedAtMs: createdAtMs + 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('defines one bounded low-risk Project Run discovery Tool', () => {
|
||||
assert.deepEqual(BUILTIN_RUN_LIST_TOOL, {
|
||||
name: 'qinglong.run.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
assert.equal(BUILTIN_RUN_LIST_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_RUN_LIST_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_RUN_LIST_TOOL_DEFINITION.requiredPermissions, [
|
||||
'run.read',
|
||||
]);
|
||||
assert.equal(BUILTIN_RUN_LIST_DEFAULT_LIMIT, 32);
|
||||
assert.equal(BUILTIN_RUN_LIST_MAX_LIMIT, 64);
|
||||
});
|
||||
|
||||
test('returns a descending low-sensitive page and stable continuation', async () => {
|
||||
const calls = [];
|
||||
const result = await executeBuiltInRunListTool(
|
||||
{
|
||||
async listRunsByProject(query) {
|
||||
calls.push(query);
|
||||
return [run('run-c', 30), run('run-b', 20), run('run-a', 10)];
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ limit: 2 },
|
||||
);
|
||||
assert.deepEqual(calls, [{ projectId: 'default', limit: 3 }]);
|
||||
assert.deepEqual(result, {
|
||||
runs: [
|
||||
{
|
||||
id: 'run-c',
|
||||
taskId: 'task-run-c',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 2,
|
||||
eventSequence: 3,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 30,
|
||||
finishedAtMs: 31,
|
||||
},
|
||||
{
|
||||
id: 'run-b',
|
||||
taskId: 'task-run-b',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'succeeded',
|
||||
version: 2,
|
||||
eventSequence: 3,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: 20,
|
||||
finishedAtMs: 21,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { createdAtMs: 20, runId: 'run-b' },
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('private-'), false);
|
||||
});
|
||||
|
||||
test('passes an exact cursor and fails closed on malformed or corrupt pages', async () => {
|
||||
const seen = [];
|
||||
const reader = {
|
||||
async listRunsByProject(query) {
|
||||
seen.push(query);
|
||||
return [];
|
||||
},
|
||||
};
|
||||
assert.deepEqual(
|
||||
await executeBuiltInRunListTool(reader, 'default', {
|
||||
after: { createdAtMs: 20, runId: 'run-b' },
|
||||
}),
|
||||
{ runs: [], hasMore: false },
|
||||
);
|
||||
assert.deepEqual(seen, [
|
||||
{
|
||||
projectId: 'default',
|
||||
limit: BUILTIN_RUN_LIST_DEFAULT_LIMIT + 1,
|
||||
after: { createdAtMs: 20, runId: 'run-b' },
|
||||
},
|
||||
]);
|
||||
|
||||
for (const input of [
|
||||
null,
|
||||
{ limit: 65 },
|
||||
{ unexpected: true },
|
||||
{ after: null },
|
||||
{ after: { createdAtMs: -1, runId: 'run-b' } },
|
||||
{ after: { createdAtMs: 20, runId: 'run-b', extra: true } },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInRunListTool(reader, 'default', input),
|
||||
InvalidBuiltInRunListToolError,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rows of [
|
||||
[run('run-a', 10, { projectId: 'other' })],
|
||||
[run('run-a', 10), run('run-b', 20)],
|
||||
Array.from({ length: 34 }, (_, index) => run(`run-${index}`, 100 - index)),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInRunListTool(
|
||||
{
|
||||
async listRunsByProject() {
|
||||
return rows;
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{},
|
||||
),
|
||||
BuiltInRunListToolUnavailableError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_RUN_STEP_LIST_DEFAULT_LIMIT,
|
||||
BUILTIN_RUN_STEP_LIST_MAX_LIMIT,
|
||||
BUILTIN_RUN_STEP_LIST_TOOL,
|
||||
BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION,
|
||||
BuiltInRunStepListToolUnavailableError,
|
||||
InvalidBuiltInRunStepListToolError,
|
||||
executeBuiltInRunStepListTool,
|
||||
} = require('../dist/tool-projection/runStepList.js');
|
||||
const {
|
||||
createStepRunRecord,
|
||||
} = require('../../ql3-runtime-core/dist/run/stepRun.js');
|
||||
|
||||
function run(projectId = 'default') {
|
||||
return { id: 'run-1', projectId };
|
||||
}
|
||||
|
||||
function step(id, stepKey) {
|
||||
return createStepRunRecord({
|
||||
id,
|
||||
runId: 'run-1',
|
||||
parentStepRunId: 'step-parent',
|
||||
stepKey,
|
||||
kind: 'tool',
|
||||
definitionRef: 'tool:private.internal@1.0.0',
|
||||
definitionDigest: 'a'.repeat(64),
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:private-input',
|
||||
mutationId: `create-${id}`,
|
||||
createdAtMs: 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
test('defines one bounded low-risk Run Step list Tool', () => {
|
||||
assert.deepEqual(BUILTIN_RUN_STEP_LIST_TOOL, {
|
||||
name: 'qinglong.run.steps.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
assert.equal(BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_RUN_STEP_LIST_TOOL_DEFINITION.requiredPermissions, [
|
||||
'run.read',
|
||||
]);
|
||||
assert.equal(BUILTIN_RUN_STEP_LIST_DEFAULT_LIMIT, 32);
|
||||
assert.equal(BUILTIN_RUN_STEP_LIST_MAX_LIMIT, 64);
|
||||
});
|
||||
|
||||
test('returns a low-sensitive page and omits nullable fields', async () => {
|
||||
const calls = [];
|
||||
const first = step('step-1', 'build');
|
||||
const second = step('step-2', 'deploy');
|
||||
const result = await executeBuiltInRunStepListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun(query) {
|
||||
calls.push(query);
|
||||
return {
|
||||
stepRuns: [first, second],
|
||||
truncated: true,
|
||||
next: { stepKey: second.stepKey, id: second.id },
|
||||
};
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{
|
||||
runId: 'run-1',
|
||||
afterStepKey: 'admit',
|
||||
afterStepRunId: 'step-0',
|
||||
limit: 2,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
runId: 'run-1',
|
||||
limit: 2,
|
||||
after: { stepKey: 'admit', id: 'step-0' },
|
||||
},
|
||||
]);
|
||||
assert.equal(result.found, true);
|
||||
assert.equal(result.steps.length, 2);
|
||||
assert.equal(result.steps[0].parentStepRunId, 'step-parent');
|
||||
assert.equal(Object.hasOwn(result.steps[0], 'startedAtMs'), false);
|
||||
assert.deepEqual(result.next, {
|
||||
stepKey: 'deploy',
|
||||
stepRunId: 'step-2',
|
||||
});
|
||||
const serialized = JSON.stringify(result);
|
||||
assert.equal(serialized.includes('private.internal'), false);
|
||||
assert.equal(serialized.includes('private-input'), false);
|
||||
});
|
||||
|
||||
test('masks Project mismatch and rejects malformed or corrupt input', async () => {
|
||||
let reads = 0;
|
||||
assert.deepEqual(
|
||||
await executeBuiltInRunStepListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
return run('other');
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
reads += 1;
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ runId: 'run-1' },
|
||||
),
|
||||
{ found: false, steps: [], hasMore: false },
|
||||
);
|
||||
assert.equal(reads, 0);
|
||||
for (const input of [
|
||||
{},
|
||||
{ runId: 'run-1', afterStepKey: 'build' },
|
||||
{ runId: 'run-1', afterStepRunId: 'step-1' },
|
||||
{ runId: 'run-1', limit: 65 },
|
||||
{ runId: 'run-1', unexpected: true },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInRunStepListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
return { stepRuns: [], truncated: false };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
input,
|
||||
),
|
||||
InvalidBuiltInRunStepListToolError,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executeBuiltInRunStepListTool(
|
||||
{
|
||||
async findRunById() {
|
||||
return run();
|
||||
},
|
||||
},
|
||||
{
|
||||
async listByRun() {
|
||||
return {
|
||||
stepRuns: [step('step-2', 'deploy'), step('step-1', 'build')],
|
||||
truncated: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ runId: 'run-1' },
|
||||
),
|
||||
BuiltInRunStepListToolUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,706 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
provisionLocalOwnerPepperKey,
|
||||
} = require('@qinglong/local-owner-console/pepper-custody');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite/runtime');
|
||||
const {
|
||||
apiCredentialSecretDigest,
|
||||
formatApiCredentialToken,
|
||||
} = require('@qinglong/runtime-core/api-credential-token');
|
||||
const {
|
||||
approvalRequestDigest,
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const { createTriggerRecord } = require('@qinglong/runtime-core/trigger');
|
||||
|
||||
const NOW = Date.now();
|
||||
const PEPPER_KEY_ID = 'mcp-owner-v1';
|
||||
const CREDENTIAL_ID = 'mcp-owner';
|
||||
const PEPPER_BYTES = Buffer.alloc(32, 31);
|
||||
const PEPPER = PEPPER_BYTES.toString('base64url');
|
||||
const SECRET = Buffer.alloc(32, 32).toString('base64url');
|
||||
|
||||
function privateDirectory(parent, name) {
|
||||
const value = path.join(parent, name);
|
||||
fs.mkdirSync(value, { mode: 0o700 });
|
||||
return value;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const deploymentRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-mcp-stdio-'),
|
||||
);
|
||||
fs.chmodSync(deploymentRoot, 0o700);
|
||||
t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true }));
|
||||
const dataDirectory = privateDirectory(deploymentRoot, 'data');
|
||||
const operatorDirectory = privateDirectory(deploymentRoot, 'operator');
|
||||
const ownerPepperKeyringDirectory = privateDirectory(
|
||||
deploymentRoot,
|
||||
'owner-peppers',
|
||||
);
|
||||
const databasePath = path.join(dataDirectory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun({
|
||||
id: 'run-mcp-e2e',
|
||||
projectId: 'default',
|
||||
taskId: 'task-mcp',
|
||||
taskRevision: 'revision-1',
|
||||
taskName: 'MCP test',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
triggeredBy: 'user:mcp-owner',
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 2,
|
||||
priority: 0,
|
||||
createdAtMs: NOW - 2_000,
|
||||
});
|
||||
await transaction.appendEvent({
|
||||
id: 'mcp-e2e-event-1',
|
||||
runId: 'run-mcp-e2e',
|
||||
sequence: 1,
|
||||
type: 'run.created',
|
||||
actorType: 'user',
|
||||
actorId: 'mcp-user',
|
||||
payload: Object.freeze({ secret: 'must-not-leak' }),
|
||||
createdAtMs: NOW - 1_999,
|
||||
});
|
||||
await transaction.appendEvent({
|
||||
id: 'mcp-e2e-event-2',
|
||||
runId: 'run-mcp-e2e',
|
||||
sequence: 2,
|
||||
type: 'run.queued',
|
||||
actorType: 'system',
|
||||
payload: Object.freeze({ private: 'must-not-leak' }),
|
||||
createdAtMs: NOW - 1_998,
|
||||
});
|
||||
});
|
||||
await runtime.close();
|
||||
|
||||
const pepperSummary = provisionLocalOwnerPepperKey({
|
||||
keyringDirectory: ownerPepperKeyringDirectory,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
randomBytes: () => Buffer.from(PEPPER_BYTES),
|
||||
});
|
||||
const database = new DatabaseSync(databasePath);
|
||||
let taskContentDigest;
|
||||
try {
|
||||
database.exec('BEGIN IMMEDIATE');
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
"pepper_key_id", "material_digest", "backup_digest", "state",
|
||||
"version", "register_mutation_id", "activate_mutation_id",
|
||||
"registered_at_ms", "activated_at_ms"
|
||||
) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
PEPPER_KEY_ID,
|
||||
pepperSummary.digest,
|
||||
'b'.repeat(64),
|
||||
'92000000-0000-4000-8000-000000000001',
|
||||
'92000000-0000-4000-8000-000000000002',
|
||||
NOW - 1_900,
|
||||
NOW - 1_800,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
"generation", "mutation_id", "expected_generation",
|
||||
"previous_pepper_key_id", "active_pepper_key_id",
|
||||
"material_digest", "backup_digest", "activated_at_ms"
|
||||
) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'92000000-0000-4000-8000-000000000002',
|
||||
PEPPER_KEY_ID,
|
||||
pepperSummary.digest,
|
||||
'b'.repeat(64),
|
||||
NOW - 1_800,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
"subject_type", "subject_id", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('user', 'mcp-user', 'active', 1, ?, ?)`,
|
||||
)
|
||||
.run(NOW - 1_700, NOW - 1_700);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
"credential_id", "version", "state", "subject_type",
|
||||
"subject_id", "secret_digest", "created_at_ms",
|
||||
"not_before_at_ms", "expires_at_ms"
|
||||
) VALUES (?, 1, 'active', 'user', 'mcp-user', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
CREDENTIAL_ID,
|
||||
apiCredentialSecretDigest(PEPPER, CREDENTIAL_ID, SECRET),
|
||||
NOW - 1_600,
|
||||
NOW - 1_600,
|
||||
NOW + 600_000,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
"credential_id", "credential_version", "pepper_key_id"
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(CREDENTIAL_ID, PEPPER_KEY_ID);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'mcp-user', 1, 'active', 'owner',
|
||||
'mcp-owner-binding', 'user', 'mcp-user', ?
|
||||
)`,
|
||||
)
|
||||
.run(NOW - 1_500);
|
||||
const taskDefinition = createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'default',
|
||||
taskId: 'task-mcp',
|
||||
expectedRevision: null,
|
||||
mutationId: '92000000-0000-4000-8000-000000000003',
|
||||
name: 'MCP Task',
|
||||
kind: 'script',
|
||||
spec: {
|
||||
schema: 'qinglong/script@v1',
|
||||
config: { command: 'private command' },
|
||||
},
|
||||
labels: { private: 'label' },
|
||||
enabled: true,
|
||||
occurredAtMs: NOW - 1_400,
|
||||
},
|
||||
NOW - 1_400,
|
||||
);
|
||||
taskContentDigest = taskDefinition.contentDigest;
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||
"project_id", "task_id", "current_revision",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskDefinition.revision,
|
||||
taskDefinition.createdAtMs,
|
||||
taskDefinition.updatedAtMs,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
"project_id", "task_id", "revision", "mutation_id",
|
||||
"name", "description", "kind", "spec_json", "labels_json",
|
||||
"enabled", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskDefinition.revision,
|
||||
taskDefinition.mutationId,
|
||||
taskDefinition.name,
|
||||
null,
|
||||
taskDefinition.kind,
|
||||
JSON.stringify(taskDefinition.spec),
|
||||
JSON.stringify(taskDefinition.labels),
|
||||
1,
|
||||
taskDefinition.contentDigest,
|
||||
taskDefinition.updatedAtMs,
|
||||
);
|
||||
const trigger = createTriggerRecord(
|
||||
{
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-mcp',
|
||||
expectedRevision: null,
|
||||
mutationId: '92000000-0000-4000-8000-000000000004',
|
||||
taskId: taskDefinition.taskId,
|
||||
taskRevision: taskDefinition.revision,
|
||||
taskContentDigest: taskDefinition.contentDigest,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '*/5 * * * *',
|
||||
timezone: 'Etc/UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: NOW - 1_300,
|
||||
},
|
||||
NOW - 1_300,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Triggers" (
|
||||
"project_id", "trigger_id", "task_id", "current_revision",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
trigger.projectId,
|
||||
trigger.triggerId,
|
||||
trigger.taskId,
|
||||
trigger.revision,
|
||||
trigger.createdAtMs,
|
||||
trigger.updatedAtMs,
|
||||
);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TriggerRevisions" (
|
||||
"project_id", "trigger_id", "revision", "mutation_id",
|
||||
"task_id", "task_revision", "task_content_digest",
|
||||
"spec_json", "enabled", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
trigger.projectId,
|
||||
trigger.triggerId,
|
||||
trigger.revision,
|
||||
trigger.mutationId,
|
||||
trigger.taskId,
|
||||
trigger.taskRevision,
|
||||
trigger.taskContentDigest,
|
||||
JSON.stringify(trigger.spec),
|
||||
1,
|
||||
trigger.contentDigest,
|
||||
trigger.updatedAtMs,
|
||||
);
|
||||
const approval = createApprovalRequest({
|
||||
id: 'approval-mcp',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: 'private-action-ref',
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: { type: 'agent', id: 'private-agent' },
|
||||
requestedAtMs: NOW - 1_200,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
requestFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
});
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApprovalRequests" (
|
||||
"request_id", "project_id", "version", "state", "action_type",
|
||||
"action_ref", "action_digest", "preview_digest",
|
||||
"requested_by_type", "requested_by_id", "decision_id",
|
||||
"consumption_id", "dispatch_id", "expires_at_ms", "request_json",
|
||||
"request_digest", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
approval.id,
|
||||
approval.projectId,
|
||||
approval.version,
|
||||
approval.state,
|
||||
approval.action.actionType,
|
||||
approval.action.actionRef,
|
||||
approval.action.actionDigest,
|
||||
approval.action.previewDigest,
|
||||
approval.requestedBy.type,
|
||||
approval.requestedBy.id,
|
||||
approval.decisionId,
|
||||
approval.consumptionId,
|
||||
approval.dispatchId,
|
||||
approval.expiresAtMs,
|
||||
JSON.stringify(approval),
|
||||
approvalRequestDigest(approval),
|
||||
approval.requestedAtMs,
|
||||
);
|
||||
database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
if (database.isTransaction) database.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
fs.chmodSync(databasePath, 0o600);
|
||||
|
||||
const credentialFilePath = path.join(operatorDirectory, 'credential.json');
|
||||
fs.writeFileSync(
|
||||
credentialFilePath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-local-identity-credential-presentation',
|
||||
token: formatApiCredentialToken(CREDENTIAL_ID, SECRET),
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const configFilePath = path.join(deploymentRoot, 'mcp.json');
|
||||
fs.writeFileSync(
|
||||
configFilePath,
|
||||
`${JSON.stringify({
|
||||
schema: 'qinglong/local-mcp-server@v1',
|
||||
profile: 'edge',
|
||||
projectId: 'default',
|
||||
deploymentRoot,
|
||||
databasePath,
|
||||
ownerPepperKeyringDirectory,
|
||||
credentialFilePath,
|
||||
busyTimeoutMs: 500,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return {
|
||||
configFilePath,
|
||||
databasePath,
|
||||
taskContentDigest,
|
||||
};
|
||||
}
|
||||
|
||||
test('serves the authenticated Run Tool over the real stdio protocol and persists allowed audit', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../dist/cli.js'),
|
||||
'--config',
|
||||
value.configFilePath,
|
||||
],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.stdout.setEncoding('utf8');
|
||||
let buffered = '';
|
||||
const pending = new Map();
|
||||
child.stdout.on('data', (chunk) => {
|
||||
buffered += chunk;
|
||||
for (;;) {
|
||||
const newline = buffered.indexOf('\n');
|
||||
if (newline < 0) break;
|
||||
const line = buffered.slice(0, newline);
|
||||
buffered = buffered.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
const message = JSON.parse(line);
|
||||
const resolve = pending.get(message.id);
|
||||
if (resolve) {
|
||||
pending.delete(message.id);
|
||||
resolve(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
let id = 0;
|
||||
const request = (method, params) => {
|
||||
id += 1;
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
})}\n`,
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, resolve);
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`timeout: ${method}`)),
|
||||
5_000,
|
||||
);
|
||||
timer.unref();
|
||||
});
|
||||
};
|
||||
|
||||
const initialized = await request('initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ql3-e2e', version: '1.0.0' },
|
||||
});
|
||||
assert.equal(initialized.result.protocolVersion, '2025-11-25');
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
})}\n`,
|
||||
);
|
||||
const listed = await request('tools/list', {});
|
||||
assert.deepEqual(
|
||||
listed.result.tools.map((tool) => tool.name),
|
||||
[
|
||||
'qinglong.run.list',
|
||||
'qinglong.run.get',
|
||||
'qinglong.run.events.list',
|
||||
'qinglong.run.steps.list',
|
||||
'qinglong.task.get',
|
||||
'qinglong.task.list',
|
||||
'qinglong.trigger.list',
|
||||
'qinglong.approval.list',
|
||||
'qinglong.approval.get',
|
||||
],
|
||||
);
|
||||
const tasks = await request('tools/call', {
|
||||
name: 'qinglong.task.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(tasks.result.isError, undefined, JSON.stringify(tasks));
|
||||
assert.deepEqual(tasks.result.structuredContent, {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-mcp',
|
||||
revision: 1,
|
||||
name: 'MCP Task',
|
||||
kind: 'script',
|
||||
specSchema: 'qinglong/script@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: NOW - 1_400,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
assert.equal(JSON.stringify(tasks).includes('private'), false);
|
||||
const currentTask = await request('tools/call', {
|
||||
name: 'qinglong.task.get',
|
||||
arguments: { taskId: 'task-mcp' },
|
||||
});
|
||||
assert.equal(
|
||||
currentTask.result.isError,
|
||||
undefined,
|
||||
JSON.stringify(currentTask),
|
||||
);
|
||||
assert.deepEqual(currentTask.result.structuredContent, {
|
||||
found: true,
|
||||
taskId: 'task-mcp',
|
||||
revision: 1,
|
||||
name: 'MCP Task',
|
||||
kind: 'script',
|
||||
specSchema: 'qinglong/script@v1',
|
||||
enabled: true,
|
||||
contentDigest: value.taskContentDigest,
|
||||
createdAtMs: NOW - 1_400,
|
||||
updatedAtMs: NOW - 1_400,
|
||||
});
|
||||
assert.equal(JSON.stringify(currentTask).includes('private'), false);
|
||||
const triggers = await request('tools/call', {
|
||||
name: 'qinglong.trigger.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(triggers.result.isError, undefined, JSON.stringify(triggers));
|
||||
assert.deepEqual(triggers.result.structuredContent, {
|
||||
triggers: [
|
||||
{
|
||||
triggerId: 'trigger-mcp',
|
||||
revision: 1,
|
||||
taskId: 'task-mcp',
|
||||
taskRevision: 1,
|
||||
specSchema: 'qinglong/cron@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: NOW - 1_300,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
assert.equal(JSON.stringify(triggers).includes('*/5'), false);
|
||||
assert.equal(JSON.stringify(triggers).includes('Etc/UTC'), false);
|
||||
const approvals = await request('tools/call', {
|
||||
name: 'qinglong.approval.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(approvals.result.isError, undefined, JSON.stringify(approvals));
|
||||
assert.deepEqual(approvals.result.structuredContent, {
|
||||
approvals: [
|
||||
{
|
||||
requestId: 'approval-mcp',
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
requestedByType: 'agent',
|
||||
requestedAtMs: NOW - 1_200,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
updatedAtMs: NOW - 1_200,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
assert.equal(JSON.stringify(approvals).includes('private'), false);
|
||||
const approvalDetail = await request('tools/call', {
|
||||
name: 'qinglong.approval.get',
|
||||
arguments: { requestId: 'approval-mcp' },
|
||||
});
|
||||
assert.equal(
|
||||
approvalDetail.result.isError,
|
||||
undefined,
|
||||
JSON.stringify(approvalDetail),
|
||||
);
|
||||
assert.deepEqual(approvalDetail.result.structuredContent, {
|
||||
found: true,
|
||||
approval: {
|
||||
requestId: 'approval-mcp',
|
||||
version: 1,
|
||||
state: 'pending',
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
requestedByType: 'agent',
|
||||
requestedAtMs: NOW - 1_200,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
previewAvailable: false,
|
||||
},
|
||||
});
|
||||
assert.equal(JSON.stringify(approvalDetail).includes('private'), false);
|
||||
const discovered = await request('tools/call', {
|
||||
name: 'qinglong.run.list',
|
||||
arguments: { limit: 1 },
|
||||
});
|
||||
assert.equal(
|
||||
discovered.result.isError,
|
||||
undefined,
|
||||
JSON.stringify(discovered),
|
||||
);
|
||||
assert.deepEqual(discovered.result.structuredContent, {
|
||||
runs: [
|
||||
{
|
||||
id: 'run-mcp-e2e',
|
||||
taskId: 'task-mcp',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 2,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: NOW - 2_000,
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
const called = await request('tools/call', {
|
||||
name: 'qinglong.run.get',
|
||||
arguments: { runId: 'run-mcp-e2e' },
|
||||
});
|
||||
assert.equal(called.result.isError, undefined, JSON.stringify(called));
|
||||
assert.deepEqual(called.result.structuredContent, {
|
||||
found: true,
|
||||
id: 'run-mcp-e2e',
|
||||
taskId: 'task-mcp',
|
||||
taskRevision: 'revision-1',
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 2,
|
||||
priority: 0,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
createdAtMs: NOW - 2_000,
|
||||
});
|
||||
const events = await request('tools/call', {
|
||||
name: 'qinglong.run.events.list',
|
||||
arguments: { runId: 'run-mcp-e2e', limit: 1 },
|
||||
});
|
||||
assert.equal(events.result.isError, undefined, JSON.stringify(events));
|
||||
assert.deepEqual(events.result.structuredContent, {
|
||||
found: true,
|
||||
events: [
|
||||
{
|
||||
sequence: 1,
|
||||
type: 'run.created',
|
||||
actorType: 'user',
|
||||
createdAtMs: NOW - 1_999,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAfterSequence: 1,
|
||||
});
|
||||
assert.equal(JSON.stringify(events).includes('must-not-leak'), false);
|
||||
child.stdin.end();
|
||||
const exitCode = await new Promise((resolve) => child.once('exit', resolve));
|
||||
assert.equal(exitCode, 0, stderr);
|
||||
assert.equal(stderr, '');
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const audit = database
|
||||
.prepare(
|
||||
`SELECT operation_id AS "operationId", outcome, subject_id AS "subjectId"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id = 'mcp.tool.call'`,
|
||||
)
|
||||
.all();
|
||||
assert.deepEqual(
|
||||
audit.map((row) => ({ ...row })),
|
||||
[
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
{
|
||||
operationId: 'mcp.tool.call',
|
||||
outcome: 'allowed',
|
||||
subjectId: 'mcp-user',
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
BUILTIN_TASK_GET_TOOL_DEFINITION,
|
||||
BuiltInTaskGetToolUnavailableError,
|
||||
InvalidBuiltInTaskGetToolError,
|
||||
executeBuiltInTaskGetTool,
|
||||
} = require('../dist/tool-projection/taskGet.js');
|
||||
|
||||
function task(overrides = {}) {
|
||||
return createTaskDefinitionRecord(
|
||||
{
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: 1,
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174301',
|
||||
name: 'Example Task',
|
||||
description: 'private description',
|
||||
kind: 'script',
|
||||
spec: {
|
||||
schema: 'qinglong/script@v1',
|
||||
config: { command: 'private command' },
|
||||
},
|
||||
labels: { private: 'label' },
|
||||
enabled: true,
|
||||
occurredAtMs: 20,
|
||||
...overrides,
|
||||
},
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
test('defines an exact low-risk task.read Tool', () => {
|
||||
assert.equal(BUILTIN_TASK_GET_TOOL_DEFINITION.name, 'qinglong.task.get');
|
||||
assert.equal(BUILTIN_TASK_GET_TOOL_DEFINITION.version, '1.0.0');
|
||||
assert.equal(BUILTIN_TASK_GET_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_TASK_GET_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_TASK_GET_TOOL_DEFINITION.requiredPermissions, [
|
||||
'task.read',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reads one current Task and omits private definition fields', async () => {
|
||||
const definition = task({ enabled: false });
|
||||
let captured;
|
||||
const output = await executeBuiltInTaskGetTool(
|
||||
{
|
||||
async findCurrentTaskDefinition(projectId, taskId) {
|
||||
captured = [projectId, taskId];
|
||||
return definition;
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ taskId: 'task-1' },
|
||||
);
|
||||
assert.deepEqual(captured, ['default', 'task-1']);
|
||||
assert.deepEqual(output, {
|
||||
found: true,
|
||||
taskId: 'task-1',
|
||||
revision: 2,
|
||||
name: 'Example Task',
|
||||
kind: 'script',
|
||||
specSchema: 'qinglong/script@v1',
|
||||
enabled: false,
|
||||
contentDigest: definition.contentDigest,
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
for (const hidden of [
|
||||
'private description',
|
||||
'private command',
|
||||
'private',
|
||||
'mutationId',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('masks absence and maps invalid or unavailable reads', async () => {
|
||||
assert.deepEqual(
|
||||
await executeBuiltInTaskGetTool(
|
||||
{ async findCurrentTaskDefinition() { return null; } },
|
||||
'default',
|
||||
{ taskId: 'task-absent' },
|
||||
),
|
||||
{ found: false },
|
||||
);
|
||||
await assert.rejects(
|
||||
executeBuiltInTaskGetTool(
|
||||
{ async findCurrentTaskDefinition() { return null; } },
|
||||
'default',
|
||||
{ taskId: '', extra: true },
|
||||
),
|
||||
InvalidBuiltInTaskGetToolError,
|
||||
);
|
||||
await assert.rejects(
|
||||
executeBuiltInTaskGetTool(
|
||||
{ async findCurrentTaskDefinition() { throw new Error('offline'); } },
|
||||
'default',
|
||||
{ taskId: 'task-1' },
|
||||
),
|
||||
BuiltInTaskGetToolUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_TASK_LIST_TOOL_DEFINITION,
|
||||
BuiltInTaskListToolUnavailableError,
|
||||
InvalidBuiltInTaskListToolError,
|
||||
executeBuiltInTaskListTool,
|
||||
} = require('../dist/tool-projection/taskList.js');
|
||||
|
||||
function definition(taskId, overrides = {}) {
|
||||
return Object.freeze({
|
||||
projectId: 'default',
|
||||
taskId,
|
||||
revision: 2,
|
||||
mutationId: 'private-mutation',
|
||||
name: `Task ${taskId}`,
|
||||
description: 'private description',
|
||||
kind: 'script',
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/script@v1',
|
||||
config: Object.freeze({ command: 'private command' }),
|
||||
}),
|
||||
labels: Object.freeze({ private: 'label' }),
|
||||
enabled: true,
|
||||
contentDigest: 'private-digest',
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('defines an exact low-risk task.read Tool', () => {
|
||||
assert.equal(BUILTIN_TASK_LIST_TOOL_DEFINITION.name, 'qinglong.task.list');
|
||||
assert.equal(BUILTIN_TASK_LIST_TOOL_DEFINITION.version, '1.0.0');
|
||||
assert.equal(BUILTIN_TASK_LIST_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_TASK_LIST_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_TASK_LIST_TOOL_DEFINITION.requiredPermissions, [
|
||||
'task.read',
|
||||
]);
|
||||
});
|
||||
|
||||
test('projects bounded current Tasks without private definition fields', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInTaskListTool(
|
||||
{
|
||||
async listTaskDefinitions(query) {
|
||||
captured = query;
|
||||
return Object.freeze({
|
||||
definitions: Object.freeze([definition('task-b')]),
|
||||
truncated: true,
|
||||
next: Object.freeze({ taskId: 'task-b' }),
|
||||
});
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ after: { taskId: 'task-a' }, limit: 1 },
|
||||
);
|
||||
assert.deepEqual(captured, {
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: { taskId: 'task-a' },
|
||||
});
|
||||
assert.deepEqual(output, {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'task-b',
|
||||
revision: 2,
|
||||
name: 'Task task-b',
|
||||
kind: 'script',
|
||||
specSchema: 'qinglong/script@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { taskId: 'task-b' },
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
for (const hidden of [
|
||||
'private-mutation',
|
||||
'private description',
|
||||
'private command',
|
||||
'private-digest',
|
||||
'label',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults to 32 and returns no cursor for a complete page', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInTaskListTool(
|
||||
{
|
||||
async listTaskDefinitions(query) {
|
||||
captured = query;
|
||||
return { definitions: [], truncated: false };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(captured, { projectId: 'default', limit: 32 });
|
||||
assert.deepEqual(output, { tasks: [], hasMore: false });
|
||||
});
|
||||
|
||||
test('rejects invalid input before reading', async () => {
|
||||
let reads = 0;
|
||||
const source = {
|
||||
async listTaskDefinitions() {
|
||||
reads += 1;
|
||||
return { definitions: [], truncated: false };
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
executeBuiltInTaskListTool(source, 'default', { limit: 65 }),
|
||||
InvalidBuiltInTaskListToolError,
|
||||
);
|
||||
await assert.rejects(
|
||||
executeBuiltInTaskListTool(source, 'default', { after: { taskId: '' } }),
|
||||
InvalidBuiltInTaskListToolError,
|
||||
);
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('fails closed on cross-Project, unordered, oversized or inconsistent pages', async () => {
|
||||
for (const page of [
|
||||
{
|
||||
definitions: [definition('task-a', { projectId: 'other' })],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
definitions: [definition('task-b'), definition('task-a')],
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
definitions: [definition('task-a'), definition('task-b')],
|
||||
truncated: false,
|
||||
},
|
||||
{ definitions: [definition('task-a')], truncated: true },
|
||||
{
|
||||
definitions: [definition('task-a')],
|
||||
truncated: true,
|
||||
next: { taskId: 'other' },
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInTaskListTool(
|
||||
{
|
||||
async listTaskDefinitions() {
|
||||
return page;
|
||||
},
|
||||
},
|
||||
'default',
|
||||
page.definitions.length === 2 && page.definitions[0].taskId === 'task-a'
|
||||
? { limit: 1 }
|
||||
: {},
|
||||
),
|
||||
BuiltInTaskListToolUnavailableError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_TRIGGER_LIST_DEFAULT_LIMIT,
|
||||
BUILTIN_TRIGGER_LIST_MAX_LIMIT,
|
||||
BUILTIN_TRIGGER_LIST_TOOL,
|
||||
BUILTIN_TRIGGER_LIST_TOOL_DEFINITION,
|
||||
BuiltInTriggerListToolUnavailableError,
|
||||
InvalidBuiltInTriggerListToolError,
|
||||
executeBuiltInTriggerListTool,
|
||||
} = require('../dist/tool-projection/triggerList.js');
|
||||
|
||||
function trigger(triggerId, overrides = {}) {
|
||||
return Object.freeze({
|
||||
projectId: 'default',
|
||||
triggerId,
|
||||
revision: 2,
|
||||
mutationId: 'private-mutation',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 3,
|
||||
taskContentDigest: 'private-task-digest',
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: Object.freeze({
|
||||
expression: 'private cron expression',
|
||||
timezone: 'private timezone',
|
||||
misfire: 'private misfire policy',
|
||||
}),
|
||||
}),
|
||||
enabled: true,
|
||||
contentDigest: 'private-trigger-digest',
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 20,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('defines one bounded low-risk trigger.read Tool', () => {
|
||||
assert.deepEqual(BUILTIN_TRIGGER_LIST_TOOL, {
|
||||
name: 'qinglong.trigger.list',
|
||||
version: '1.0.0',
|
||||
});
|
||||
assert.equal(BUILTIN_TRIGGER_LIST_TOOL_DEFINITION.effect, 'read');
|
||||
assert.equal(BUILTIN_TRIGGER_LIST_TOOL_DEFINITION.risk, 'low');
|
||||
assert.deepEqual(BUILTIN_TRIGGER_LIST_TOOL_DEFINITION.requiredPermissions, [
|
||||
'trigger.read',
|
||||
]);
|
||||
assert.equal(BUILTIN_TRIGGER_LIST_DEFAULT_LIMIT, 32);
|
||||
assert.equal(BUILTIN_TRIGGER_LIST_MAX_LIMIT, 64);
|
||||
});
|
||||
|
||||
test('projects bounded current Triggers without schedule configuration', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInTriggerListTool(
|
||||
{
|
||||
async listTriggers(query) {
|
||||
captured = query;
|
||||
return Object.freeze({
|
||||
triggers: Object.freeze([trigger('trigger-b')]),
|
||||
truncated: true,
|
||||
next: Object.freeze({ triggerId: 'trigger-b' }),
|
||||
});
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{ after: { triggerId: 'trigger-a' }, limit: 1 },
|
||||
);
|
||||
assert.deepEqual(captured, {
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: { triggerId: 'trigger-a' },
|
||||
});
|
||||
assert.deepEqual(output, {
|
||||
triggers: [
|
||||
{
|
||||
triggerId: 'trigger-b',
|
||||
revision: 2,
|
||||
taskId: 'task-1',
|
||||
taskRevision: 3,
|
||||
specSchema: 'qinglong/cron@v1',
|
||||
enabled: true,
|
||||
updatedAtMs: 20,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
next: { triggerId: 'trigger-b' },
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
for (const hidden of [
|
||||
'private',
|
||||
'expression',
|
||||
'timezone',
|
||||
'misfire',
|
||||
'contentDigest',
|
||||
'projectId',
|
||||
]) {
|
||||
assert.equal(serialized.includes(hidden), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults to 32 and returns no cursor for a complete page', async () => {
|
||||
let captured;
|
||||
const output = await executeBuiltInTriggerListTool(
|
||||
{
|
||||
async listTriggers(query) {
|
||||
captured = query;
|
||||
return { triggers: [], truncated: false };
|
||||
},
|
||||
},
|
||||
'default',
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(captured, { projectId: 'default', limit: 32 });
|
||||
assert.deepEqual(output, { triggers: [], hasMore: false });
|
||||
});
|
||||
|
||||
test('rejects invalid input before reading', async () => {
|
||||
let reads = 0;
|
||||
const source = {
|
||||
async listTriggers() {
|
||||
reads += 1;
|
||||
return { triggers: [], truncated: false };
|
||||
},
|
||||
};
|
||||
for (const input of [
|
||||
null,
|
||||
{ limit: 65 },
|
||||
{ after: { triggerId: '' } },
|
||||
{ after: { triggerId: 'trigger-a', extra: true } },
|
||||
{ unexpected: true },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInTriggerListTool(source, 'default', input),
|
||||
InvalidBuiltInTriggerListToolError,
|
||||
);
|
||||
}
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('fails closed on cross-Project, unordered, oversized or inconsistent pages', async () => {
|
||||
for (const { page, input = {} } of [
|
||||
{
|
||||
page: {
|
||||
triggers: [trigger('trigger-a', { projectId: 'other' })],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
page: {
|
||||
triggers: [trigger('trigger-b'), trigger('trigger-a')],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
page: {
|
||||
triggers: [trigger('trigger-a'), trigger('trigger-b')],
|
||||
truncated: false,
|
||||
},
|
||||
input: { limit: 1 },
|
||||
},
|
||||
{
|
||||
page: { triggers: [trigger('trigger-a')], truncated: true },
|
||||
},
|
||||
{
|
||||
page: {
|
||||
triggers: [trigger('trigger-a')],
|
||||
truncated: true,
|
||||
next: { triggerId: 'trigger-b' },
|
||||
},
|
||||
},
|
||||
{
|
||||
page: {
|
||||
triggers: [
|
||||
trigger('trigger-a', { spec: { schema: 'invalid', config: {} } }),
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
]) {
|
||||
await assert.rejects(
|
||||
executeBuiltInTriggerListTool(
|
||||
{
|
||||
async listTriggers() {
|
||||
return page;
|
||||
},
|
||||
},
|
||||
'default',
|
||||
input,
|
||||
),
|
||||
BuiltInTriggerListToolUnavailableError,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user