d1a657b5d2
保持401与Bearer挑战并提供机器可读的Key配置步骤 统一缺失格式错误和无效Key响应以避免凭据枚举 保留请求元信息并同步OpenAPI集成清单与架构文档 补充鉴权脱敏契约测试并完成本地dev栈验证
181 lines
6.1 KiB
Rust
181 lines
6.1 KiB
Rust
use std::io::{Cursor, Write};
|
|
|
|
use axum::{
|
|
Json,
|
|
body::Body,
|
|
http::{
|
|
HeaderValue, StatusCode,
|
|
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
|
},
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
use zip::{ZipWriter, write::SimpleFileOptions};
|
|
|
|
use crate::http_error::AppError;
|
|
|
|
const SKILL_ROOT: &str = "genarrative-external-editor-api";
|
|
const SKILL_FILES: [(&str, &str); 7] = [
|
|
(
|
|
"SKILL.md",
|
|
include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"),
|
|
),
|
|
(
|
|
"references/capability-routing.md",
|
|
include_str!(
|
|
"../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md"
|
|
),
|
|
),
|
|
(
|
|
"references/api-operations.md",
|
|
include_str!(
|
|
"../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md"
|
|
),
|
|
),
|
|
(
|
|
"references/authentication-and-safety.md",
|
|
include_str!(
|
|
"../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md"
|
|
),
|
|
),
|
|
(
|
|
"references/requests-and-outputs.md",
|
|
include_str!(
|
|
"../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md"
|
|
),
|
|
),
|
|
(
|
|
"scripts/genarrative_external_api.py",
|
|
include_str!(
|
|
"../../../../.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py"
|
|
),
|
|
),
|
|
(
|
|
"agents/openai.yaml",
|
|
include_str!(
|
|
"../../../../.codex/skills/genarrative-external-editor-api/agents/openai.yaml"
|
|
),
|
|
),
|
|
];
|
|
|
|
pub async fn get_external_skill_entry() -> Response {
|
|
let mut response = Body::from(SKILL_FILES[0].1).into_response();
|
|
response.headers_mut().insert(
|
|
CONTENT_TYPE,
|
|
HeaderValue::from_static("text/markdown; charset=utf-8"),
|
|
);
|
|
response
|
|
}
|
|
|
|
pub async fn download_external_skill_archive() -> Result<Response, AppError> {
|
|
let bytes = build_external_skill_archive()?;
|
|
let mut response = Body::from(bytes).into_response();
|
|
response
|
|
.headers_mut()
|
|
.insert(CONTENT_TYPE, HeaderValue::from_static("application/zip"));
|
|
response.headers_mut().insert(
|
|
CONTENT_DISPOSITION,
|
|
HeaderValue::from_static(
|
|
"attachment; filename=\"genarrative-external-editor-api.skill.zip\"",
|
|
),
|
|
);
|
|
Ok(response)
|
|
}
|
|
|
|
pub async fn get_external_agent_integration_manifest() -> Result<Json<Value>, AppError> {
|
|
let archive = build_external_skill_archive()?;
|
|
let sha256 = format!("{:x}", Sha256::digest(&archive));
|
|
Ok(Json(json!({
|
|
"name": SKILL_ROOT,
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"mcp": {
|
|
"transport": "streamable-http",
|
|
"url": "/api/external/v1/mcp",
|
|
"authentication": "bearer-api-key",
|
|
"credentialSetup": {
|
|
"action": "CONFIGURE_BEARER_API_KEY",
|
|
"authorizationValueFormat": "Bearer <tnr_sk_...>",
|
|
"navigationLabel": "开发者 API Key",
|
|
"guide": "/api/external/v1/skill/SKILL.md"
|
|
}
|
|
},
|
|
"openapi": "/api/external/v1/openapi.json",
|
|
"skill": {
|
|
"entry": "/api/external/v1/skill/SKILL.md",
|
|
"archive": "/api/external/v1/skill.zip",
|
|
"archiveSha256": sha256,
|
|
"files": SKILL_FILES.map(|(path, _)| format!("{SKILL_ROOT}/{path}")),
|
|
}
|
|
})))
|
|
}
|
|
|
|
fn build_external_skill_archive() -> Result<Vec<u8>, AppError> {
|
|
let cursor = Cursor::new(Vec::new());
|
|
let mut archive = ZipWriter::new(cursor);
|
|
let options = SimpleFileOptions::default().unix_permissions(0o644);
|
|
for (path, contents) in SKILL_FILES {
|
|
archive
|
|
.start_file(format!("{SKILL_ROOT}/{path}"), options)
|
|
.map_err(skill_archive_error)?;
|
|
archive
|
|
.write_all(contents.as_bytes())
|
|
.map_err(skill_archive_error)?;
|
|
}
|
|
archive
|
|
.finish()
|
|
.map(Cursor::into_inner)
|
|
.map_err(skill_archive_error)
|
|
}
|
|
|
|
fn skill_archive_error(error: impl std::fmt::Display) -> AppError {
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
"provider": "external-skill-archive",
|
|
"message": format!("构建外部 Skill 包失败:{error}"),
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn archive_contains_complete_skill_bundle() {
|
|
let bytes = build_external_skill_archive().expect("skill archive should build");
|
|
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("archive should parse");
|
|
assert_eq!(archive.len(), SKILL_FILES.len());
|
|
for (path, contents) in SKILL_FILES {
|
|
let name = format!("{SKILL_ROOT}/{path}");
|
|
let mut file = archive.by_name(&name).expect("skill file should exist");
|
|
let mut actual = String::new();
|
|
std::io::Read::read_to_string(&mut file, &mut actual).expect("skill file should read");
|
|
assert_eq!(actual, contents);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn integration_manifest_matches_complete_skill_archive() {
|
|
let bytes = build_external_skill_archive().expect("skill archive should build");
|
|
let Json(manifest) = get_external_agent_integration_manifest()
|
|
.await
|
|
.expect("integration manifest should build");
|
|
let expected_files = SKILL_FILES
|
|
.map(|(path, _)| format!("{SKILL_ROOT}/{path}"))
|
|
.to_vec();
|
|
assert_eq!(manifest["skill"]["files"], json!(expected_files));
|
|
assert_eq!(
|
|
manifest["mcp"]["credentialSetup"],
|
|
json!({
|
|
"action": "CONFIGURE_BEARER_API_KEY",
|
|
"authorizationValueFormat": "Bearer <tnr_sk_...>",
|
|
"navigationLabel": "开发者 API Key",
|
|
"guide": "/api/external/v1/skill/SKILL.md"
|
|
})
|
|
);
|
|
assert_eq!(
|
|
manifest["skill"]["archiveSha256"],
|
|
json!(format!("{:x}", Sha256::digest(&bytes)))
|
|
);
|
|
}
|
|
}
|