diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3bbc87e..68f7dd6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "ennoia", "source": "./plugins/ennoia", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", - "version": "1.4.0" + "version": "1.4.1" } ] } diff --git a/plugins/ennoia/.claude-plugin/plugin.json b/plugins/ennoia/.claude-plugin/plugin.json index 5cf4a14..17ac2d4 100644 --- a/plugins/ennoia/.claude-plugin/plugin.json +++ b/plugins/ennoia/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ennoia", - "version": "1.4.0", + "version": "1.4.1", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", "author": { "name": "Wantedlab", diff --git a/plugins/ennoia/.codex-plugin/plugin.json b/plugins/ennoia/.codex-plugin/plugin.json index 8109a4b..db7185c 100644 --- a/plugins/ennoia/.codex-plugin/plugin.json +++ b/plugins/ennoia/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ennoia", - "version": "1.4.0", + "version": "1.4.1", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", "author": { "name": "Wantedlab", diff --git a/plugins/ennoia/mcp/file-uploader.mjs b/plugins/ennoia/mcp/file-uploader.mjs index 9b24135..71e862f 100644 --- a/plugins/ennoia/mcp/file-uploader.mjs +++ b/plugins/ennoia/mcp/file-uploader.mjs @@ -15,12 +15,48 @@ const UPLOAD_TIMEOUT_MS = 120000; const EXTENSIONS = new Set(['.csv', '.txt', '.md', '.pdf', '.docx', '.pptx', '.xlsx', '.xls', '.zip']); const HOSTS = new Set(['mcp.ennoia.so', 'dev-mcp-server.ennoia.so']); const REQUIRED_HEADERS = new Set(['content-length', 'content-type', 'x-ennoia-upload-token']); -class UploadError extends Error {} +const ERROR_DETAILS = Object.freeze({ + ARGUMENTS_INVALID: ['업로드 인자 형식이 올바르지 않습니다.', 'local_path, upload_url, headers만 전달하세요.'], + UPLOAD_URL_INVALID: ['업로드 URL을 사용할 수 없습니다.', 'prepare_rag_document_upload로 새 upload ticket을 발급하세요.'], + FILE_TYPE_UNSUPPORTED: ['지원하지 않는 파일 형식입니다.', 'get_rag_capabilities에서 지원 확장자를 확인하세요.'], + HEADERS_INVALID: ['업로드 header가 올바르지 않습니다.', 'prepare_rag_document_upload가 반환한 headers를 수정 없이 전달하세요.'], + FILE_NOT_FOUND_ON_UPLOADER_HOST: [ + '업로더가 실행되는 device host에서 파일을 찾을 수 없습니다.', + 'device host에 연결된 로컬 폴더의 경로로 다시 시도하거나 Ennoia 웹 업로드를 사용하세요.', + ], + FILE_ACCESS_DENIED: [ + '업로더가 실행되는 device host에서 파일을 읽을 권한이 없습니다.', + 'device host에서 읽을 수 있는 위치로 파일을 옮기거나 Ennoia 웹 업로드를 사용하세요.', + ], + FILE_NOT_REGULAR: [ + '업로드 대상은 symlink나 디렉터리가 아닌 일반 파일이어야 합니다.', + 'device host에서 읽을 수 있는 일반 파일을 선택하거나 Ennoia 웹 업로드를 사용하세요.', + ], +}); +class UploadError extends Error { + constructor(code) { super(code); this.code = code; } +} const fail = code => { throw new UploadError(code); }; +function errorDetail(error) { + const code = error instanceof UploadError ? error.code : 'UPLOAD_FAILED'; + const [message, next_action] = ERROR_DETAILS[code] ?? [ + '파일 업로드를 완료하지 못했습니다.', + '업로드 정보를 다시 확인하고 계속 실패하면 Ennoia 웹 업로드를 사용하세요.', + ]; + return { code, message, next_action }; +} + +function fileSystemError(error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return 'FILE_NOT_FOUND_ON_UPLOADER_HOST'; + if (error?.code === 'EACCES' || error?.code === 'EPERM') return 'FILE_ACCESS_DENIED'; + if (error?.code === 'ELOOP') return 'FILE_NOT_REGULAR'; + return 'FILE_INVALID'; +} + export const uploadTool = { name: 'upload_ennoia_rag_file', - description: 'prepare_rag_document_upload가 반환한 upload_url과 exact headers로 로컬 파일을 PUT합니다. local_path만 지정하고 파일 byte/base64는 MCP JSON에 넣지 않습니다. 성공 후 collection_code/file_seq로 처리 상태를 확인하세요.', + description: 'prepare_rag_document_upload가 반환한 upload_url과 exact headers로 로컬 파일을 PUT합니다. 이 도구가 실행되는 device host에서 보이는 local_path만 읽을 수 있으며, 채팅 첨부의 cloud path는 device host에서 보이지 않을 수 있습니다. 경로를 읽을 수 없으면 연결된 로컬 폴더의 파일을 사용하거나 Ennoia 웹 업로드 링크를 안내하세요. 파일 byte/base64는 MCP JSON에 넣지 않습니다. 성공 후 collection_code/file_seq로 처리 상태를 확인하세요.', inputSchema: { type: 'object', additionalProperties: false, properties: { @@ -89,14 +125,20 @@ export async function uploadFile(args, request = httpsRequest, { signal, openFil let file; try { checkCancellation(); - const before = await lstat(args.local_path); - if (!before.isFile() || before.isSymbolicLink()) fail('FILE_INVALID'); + let before; + try { before = await lstat(args.local_path); } + catch (error) { fail(fileSystemError(error)); } + if (!before.isFile() || before.isSymbolicLink()) fail('FILE_NOT_REGULAR'); checkCancellation(); // NOFOLLOW와 fd 검증으로 lstat/open 사이의 symlink 교체도 거부한다. - file = await openFile(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); - const stat = await file.stat(); + try { file = await openFile(args.local_path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } + catch (error) { fail(fileSystemError(error)); } + let stat; + try { stat = await file.stat(); } + catch (error) { fail(fileSystemError(error)); } checkCancellation(); - if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) fail('FILE_INVALID'); + if (!stat.isFile()) fail('FILE_NOT_REGULAR'); + if (stat.dev !== before.dev || stat.ino !== before.ino) fail('FILE_INVALID'); if (stat.size < 1 || stat.size > MAX_FILE_SIZE) fail('FILE_SIZE_INVALID'); if (length !== String(stat.size)) fail('CONTENT_LENGTH_MISMATCH'); let req, source, incoming, timer, transfer, rejectResponse; @@ -143,7 +185,7 @@ export async function uploadFile(args, request = httpsRequest, { signal, openFil } catch (error) { checkCancellation(); if (error instanceof UploadError) throw error; - fail('FILE_INVALID'); + fail(fileSystemError(error)); } finally { await file?.close().catch(() => {}); } @@ -157,7 +199,7 @@ async function handleRpc(message, upload = uploadFile) { const reply = result => ({ jsonrpc: '2.0', id, result }); if (message.method === 'initialize') return reply({ protocolVersion: ['2024-11-05', '2025-03-26', '2025-06-18'].includes(message.params?.protocolVersion) ? message.params.protocolVersion : '2025-06-18', - capabilities: { tools: {} }, serverInfo: { name: 'ennoia-file-uploader', version: '1.4.0' }, + capabilities: { tools: {} }, serverInfo: { name: 'ennoia-file-uploader', version: '1.4.1' }, }); if (message.method === 'ping') return reply({}); if (message.method === 'tools/list') return reply({ tools: [uploadTool] }); @@ -167,7 +209,8 @@ async function handleRpc(message, upload = uploadFile) { const result = await upload(message.params.arguments); return reply({ content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result }); } catch (err) { - return reply({ isError: true, content: [{ type: 'text', text: err instanceof UploadError ? err.message : 'UPLOAD_FAILED' }] }); + const detail = errorDetail(err); + return reply({ isError: true, content: [{ type: 'text', text: JSON.stringify(detail) }], structuredContent: detail }); } } diff --git a/plugins/ennoia/plugin.json b/plugins/ennoia/plugin.json index 367e024..cccac3a 100644 --- a/plugins/ennoia/plugin.json +++ b/plugins/ennoia/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ennoia", - "version": "1.4.0", + "version": "1.4.1", "description": "Ennoia에서 에이전트 생성, 문서 지식 연결, App 실행, 운영 진단과 제품 피드백을 수행합니다.", "author": { "name": "Wantedlab", diff --git a/plugins/ennoia/skills/ennoia-knowledge/SKILL.md b/plugins/ennoia/skills/ennoia-knowledge/SKILL.md index b9752a8..e5dbe75 100644 --- a/plugins/ennoia/skills/ennoia-knowledge/SKILL.md +++ b/plugins/ennoia/skills/ennoia-knowledge/SKILL.md @@ -15,8 +15,9 @@ Ennoia MCP의 현재 input schema를 확인하고 `get_current_ennoia_project` 1. 대상 프로젝트와 정확한 `collection_code`를 이미 알면 그 code로 `get_rag_collection`을 우선 조회한다. 표시 이름이나 목록을 다시 찾지 않는다. code를 모를 때만 `list_multi_agent_rag_collections`로 query 검색하고 필요한 단건을 읽는다. 적합한 기존 컬렉션을 사용한다. 신규가 요청됐거나 필요할 때 `create_rag_collection`을 사용한다. 2. `get_rag_capabilities` 응답의 `allowed_extensions`와 `max_file_size_bytes`로 허용 확장자·크기를 확인하고 업로드 경로를 선택한다. 대화에 제공된 텍스트 내용은 `upload_rag_text_document`, 공개 HTTPS 문서는 `import_rag_document_from_url`을 사용한다. 사용자 PC의 파일은 확장자와 관계없이 아래 local upload 순서를 따른다. -3. 로컬 파일은 [업로드 경로](references/uploads.md)를 읽고, 이름·실제 byte 크기·content type만 확인해 `prepare_rag_document_upload(collection_code, file_name, size_bytes, content_type)`를 호출한다. 성공 응답의 `upload_url`과 `headers`를 그대로 `upload_ennoia_rag_file(local_path, upload_url, headers)`에 전달한다. 로컬 tool이 파일을 디스크에서 읽어 PUT한다. 파일 byte·base64·전체 본문을 모델 context 또는 MCP JSON에 넣지 않는다. -4. 원격 tool에는 `collection_code`, 파일 조회에는 `file_seq`를 사용한다. `collection_id`·`file_id`·`file_data`는 이 계약의 인자가 아니다. 동일한 프로젝트에서 등록과 상태 확인을 이어간다. +3. 로컬 파일은 [업로드 경로](references/uploads.md)를 읽는다. `get_current_ennoia_project`의 검증된 `group_code`·`project_code`로 `project_scope={scope:"specified", group_code, project_code}`를 확정하고 이후 모든 원격 호출에 동일하게 사용한다. ASCII 파일명·실제 byte 크기·content type을 확인해 `prepare_rag_document_upload(collection_code, file_name, size_bytes, content_type, project_scope)`를 호출한다. 한글 등 non-ASCII 파일명은 영문·숫자·`-`·`_`로 바꾸고 확장자를 유지한 실제 로컬 파일 경로를 사용하도록 안내한다. +4. prepare 성공 응답이 `method=PUT`인지 확인한 뒤에만 `upload_url`과 `headers`를 그대로 `upload_ennoia_rag_file(local_path, upload_url, headers)`에 전달한다. 다른 method이면 uploader를 호출하지 않는다. 로컬 tool은 자신이 실행되는 device host의 파일을 디스크에서 읽어 PUT한다. 채팅 첨부의 cloud path처럼 device host에서 볼 수 없는 경로를 전달하지 않는다. 파일 byte·base64·전체 본문을 모델 context 또는 MCP JSON에 넣지 않는다. +5. 원격 tool에는 `collection_code`, 파일 조회에는 `file_seq`를 사용한다. `collection_id`·`file_id`·`file_data`는 이 계약의 인자가 아니다. 등록, 목록, 상태 확인에 같은 `project_scope`를 전달한다. ## 처리 완료와 연결 diff --git a/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md b/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md index 42c10e3..b4629db 100644 --- a/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md +++ b/plugins/ennoia/skills/ennoia-knowledge/references/uploads.md @@ -9,14 +9,18 @@ ## 로컬 파일 전송 계약 1. 정확한 `collection_code`를 알면 `get_rag_collection(collection_code)`로 조회한다. code를 모를 때만 컬렉션 목록에서 찾는다. 표시 이름을 code로 가정하지 않는다. -2. `get_rag_capabilities` 응답의 `allowed_extensions`와 `max_file_size_bytes`를 확인한다. 파일 내용은 읽지 않고 파일명, 실제 byte 크기, content type을 확인한다. 로컬 uploader의 허용 형식은 `.csv`, `.txt`, `.md`, `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.xls`, `.zip`이고 크기는 1~104857600 bytes이다. 서버의 `max_file_size_bytes`가 더 작으면 그 제한을 따른다. 일반 파일만 지원하며 symlink는 지원하지 않는다. -3. 현재 원격 schema에 맞춰 `prepare_rag_document_upload`에 `collection_code`, `file_name`, `size_bytes`, `content_type` 및 동일한 `project_scope`를 전달한다. 로컬 경로를 원격 prepare에 전달하지 않는다. -4. prepare가 성공하고 `method=PUT`인 경우 로컬 `upload_ennoia_rag_file`에 정확히 세 인자를 전달한다: `local_path`는 `/tmp/employee-handbook.pdf`, `upload_url`은 prepare 성공 응답의 URL, `headers`는 prepare 성공 응답의 header object 그대로다. `headers`를 문자열로 바꾸지 않는다. OAuth Bearer·Cookie를 추가하거나 `Content-Length`를 바꾸지 않는다. 파일 byte·base64·본문·`file_data`를 MCP JSON이나 모델 context에 넣지 않는다. -5. 로컬 tool은 disk stream으로 1회 PUT하고 redirect를 거부한다. 대상은 HTTPS `mcp.ennoia.so` 또는 `dev-mcp-server.ennoia.so`의 `/rag/uploads/{opaque-id}`만 허용한다. upload URL·token·headers를 로그·보고서·공유 링크로 출력하지 않는다. -6. PUT 결과에 `file_seq`가 있으면 같은 `collection_code`로 `get_rag_file_status`를 조회한다. 없으면 `list_rag_files(collection_code)`로 해당 파일의 `file_seq`를 찾아 조회한다. 원격 식별자는 `collection_code`와 `file_seq`이며 `collection_id`·`file_id`로 바꾸지 않는다. 같은 `project_scope`를 유지한다. -7. `processing_state=ready`와 `ready_for_agent=true`를 함께 확인해야 준비 완료다. 업로드 ticket 발급, HTTP 전송 성공, 인덱싱 완료를 구분한다. timeout이면 먼저 파일 목록·상태를 확인하고 중복 업로드를 피한다. +2. 파일 전송은 `upload_ennoia_rag_file`을 사용하며 일반 HTTP client나 `curl`을 대체 수단으로 사용하지 않는다. +3. `get_rag_capabilities` 응답의 `allowed_extensions`와 `max_file_size_bytes`를 확인한다. 파일 내용은 읽지 않고 파일명, 실제 byte 크기, content type을 확인한다. 로컬 uploader의 허용 형식은 `.csv`, `.txt`, `.md`, `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.xls`, `.zip`이고 크기는 1~104857600 bytes이다. 서버의 `max_file_size_bytes`가 더 작으면 그 제한을 따른다. 일반 파일만 지원하며 symlink는 지원하지 않는다. +4. `file_name`은 ASCII만 사용한다. 한글 등 non-ASCII 이름이면 영문·숫자·`-`·`_`로 이름을 바꾸고 기존 확장자를 유지한 실제 파일을 준비하도록 안내한다. 표시 이름만 바꾸거나 존재하지 않는 경로를 만들어내지 않는다. +5. 현재 원격 schema에 맞춰 `prepare_rag_document_upload`에 `collection_code`, `file_name`, `size_bytes`, `content_type` 및 동일한 `project_scope`를 전달한다. 로컬 경로를 원격 prepare에 전달하지 않는다. +6. prepare가 성공하고 `method=PUT`인 경우 로컬 `upload_ennoia_rag_file`에 정확히 세 인자를 전달한다: `local_path`는 `/tmp/employee-handbook.pdf`, `upload_url`은 prepare 성공 응답의 URL, `headers`는 prepare 성공 응답의 header object 그대로다. `headers`를 문자열로 바꾸지 않는다. OAuth Bearer·Cookie를 추가하거나 `Content-Length`를 바꾸지 않는다. 파일 byte·base64·본문·`file_data`를 MCP JSON이나 모델 context에 넣지 않는다. +7. 로컬 tool은 자신이 실행되는 device host에서 보이는 파일만 읽는다. Cowork 채팅 첨부의 cloud path와 device host의 연결된 로컬 폴더는 서로 다른 filesystem일 수 있다. `FILE_NOT_FOUND_ON_UPLOADER_HOST`, `FILE_ACCESS_DENIED`, `FILE_NOT_REGULAR`이면 같은 ticket을 임의의 다른 경로로 반복하지 않고 아래 웹 업로드 링크를 안내한다. +8. 웹 링크는 확인된 `group_code`, `project_code`, `collection_code`를 각각 percent-encoding하여 만든다. 표시 이름이나 예시 ID를 대신 넣지 않는다. 환경은 현재 Ennoia MCP 연결 endpoint로 판단한다. `https://mcp.ennoia.so/mcp`이면 `[Ennoia에서 파일 업로드](https://ennoia.so/studio/rag/files-detail?group={group_code}&project={project_code}&slug=rag&collection={collection_code})`, `https://dev-mcp-server.ennoia.so/mcp`이면 `[Ennoia dev에서 파일 업로드](https://dev.ennoia.so/studio/rag/files-detail?group={group_code}&project={project_code}&slug=rag&collection={collection_code})` 형식을 사용한다. 결과에는 `{...}` placeholder가 아니라 실제 값을 넣은 클릭 가능한 Markdown 링크를 제공한다. 현재 context에 code가 없으면 `get_current_ennoia_project`로 확인한다. endpoint를 확인할 수 없으면 환경을 추측하거나 잘못된 링크를 만들지 않는다. +9. 로컬 tool은 disk stream으로 1회 PUT하고 redirect를 거부한다. 대상은 HTTPS `mcp.ennoia.so` 또는 `dev-mcp-server.ennoia.so`의 `/rag/uploads/{opaque-id}`만 허용한다. upload URL·token·headers를 로그·보고서·공유 링크로 출력하지 않는다. +10. PUT 결과에 `file_seq`가 있으면 같은 `collection_code`로 `get_rag_file_status`를 조회한다. 없으면 `list_rag_files(collection_code)`로 해당 파일의 `file_seq`를 찾아 조회한다. 원격 식별자는 `collection_code`와 `file_seq`이며 `collection_id`·`file_id`로 바꾸지 않는다. 같은 `project_scope`를 유지한다. +11. `processing_state=ready`와 `ready_for_agent=true`를 함께 확인해야 준비 완료다. 업로드 ticket 발급, HTTP 전송 성공, 인덱싱 완료를 구분한다. timeout이면 먼저 파일 목록·상태를 확인하고 중복 업로드를 피한다. -로컬 uploader가 설치되지 않았거나 host가 Node 실행·파일 접근을 지원하지 않으면 Ennoia 파일 업로드 화면을 안내하고, 완료 후 같은 컬렉션에서 상태 확인을 이어간다. 파일을 base64로 바꾸거나 원격 서버에서 로컬 경로를 읽으려 하지 않는다. +device host가 Node 실행·파일 접근을 지원하지 않으면 위 규칙으로 실제 Ennoia 파일 업로드 화면 링크를 안내하고, 완료 후 같은 컬렉션에서 상태 확인을 이어간다. 파일을 base64로 바꾸거나 원격 서버에서 로컬 경로를 읽으려 하지 않는다. binary 파일을 임의로 텍스트 변환해 `upload_rag_text_document`로 대신 올리지 않는다. URL import에 cookie·Authorization header·signed query를 넣거나 내부/private 주소를 제공하지 않는다. 보호된 문서를 public으로 공개해서 이 경로에 맞추지 않는다. 사용자가 제공한 로컬 파일은 위 local upload 경로를 사용한다. diff --git a/tests/test_file_uploader.mjs b/tests/test_file_uploader.mjs index 6f9fc8d..7d41467 100644 --- a/tests/test_file_uploader.mjs +++ b/tests/test_file_uploader.mjs @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable, Writable } from 'node:stream'; import { spawn } from 'node:child_process'; -import { uploadFile } from '../plugins/ennoia/mcp/file-uploader.mjs'; +import { uploadFile, uploadTool } from '../plugins/ennoia/mcp/file-uploader.mjs'; const server = new URL('../plugins/ennoia/mcp/file-uploader.mjs', import.meta.url); const url = 'https://mcp.ennoia.so/rag/uploads/opaque-id_123'; @@ -66,10 +66,22 @@ test('stdio initializes, ignores notification, lists exact tool and returns safe assert.equal(tool.name, 'upload_ennoia_rag_file'); assert.deepEqual(tool.inputSchema.required, ['local_path', 'upload_url', 'headers']); assert.deepEqual(Object.keys(tool.inputSchema.properties), ['local_path', 'upload_url', 'headers']); - assert.equal(replies.find(reply => reply.id === 3).result.isError, true); + assert.match(tool.description, /device host/); + assert.match(tool.description, /웹 업로드/); + const uploadError = replies.find(reply => reply.id === 3).result; + assert.equal(uploadError.isError, true); + assert.equal(uploadError.structuredContent.code, 'HEADERS_INVALID'); + assert.match(uploadError.structuredContent.next_action, /prepare_rag_document_upload/); + assert.equal(uploadError.content[0].text, JSON.stringify(uploadError.structuredContent)); assert.equal(replies.find(reply => reply.id === 4).error.code, -32602); }); +test('tool description explains uploader host visibility and web fallback', () => { + assert.match(uploadTool.description, /이 도구가 실행되는 device host/); + assert.match(uploadTool.description, /채팅 첨부.*cloud path/); + assert.match(uploadTool.description, /Ennoia 웹 업로드/); +}); + test('streams actual disk chunks by PUT with exact headers and returns safe file identity', async t => { const args = await fixture(t); const network = transport(); @@ -118,10 +130,21 @@ test('rejects symlink and non-regular directory', async t => { const args = await fixture(t); const network = transport(); const link = args.local_path + '.pdf'; await symlink(args.local_path, link); - await assert.rejects(uploadFile({ ...args, local_path: link }, network.request), /FILE_INVALID/); + await assert.rejects(uploadFile({ ...args, local_path: link }, network.request), /FILE_NOT_REGULAR/); const directory = args.local_path + '-dir.pdf'; await mkdir(directory); - await assert.rejects(uploadFile({ ...args, local_path: directory }, network.request), /FILE_INVALID/); + await assert.rejects(uploadFile({ ...args, local_path: directory }, network.request), /FILE_NOT_REGULAR/); + assert.equal(network.calls.length, 0); +}); + +test('distinguishes a path missing from the uploader host', async t => { + const args = await fixture(t); + await rm(args.local_path); + const network = transport(); + await assert.rejects( + uploadFile(args, network.request), + error => error.code === 'FILE_NOT_FOUND_ON_UPLOADER_HOST', + ); assert.equal(network.calls.length, 0); }); diff --git a/tests/test_validation.py b/tests/test_validation.py index 2189049..c7a61a7 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -47,6 +47,35 @@ def test_missing_reference_is_rejected(self): (self.root / "plugins/ennoia/skills/ennoia-knowledge/references/uploads.md").unlink() self.assertTrue(any("reference" in e for e in self.validate(self.root))) + def test_knowledge_skill_has_file_upload_recovery_guidance(self): + skill = (ROOT / "plugins/ennoia/skills/ennoia-knowledge/SKILL.md").read_text() + uploads = (ROOT / "plugins/ennoia/skills/ennoia-knowledge/references/uploads.md").read_text() + combined = skill + uploads + for fragment in ( + "upload_ennoia_rag_file", + "FILE_NOT_FOUND_ON_UPLOADER_HOST", + "https://ennoia.so/studio/rag/files-detail", + "https://dev.ennoia.so/studio/rag/files-detail", + "group_code", + "project_code", + "collection_code", + "percent-encoding", + "ASCII", + "한글", + ): + self.assertIn(fragment, combined) + for fragment in ("project_scope", "method=PUT"): + self.assertIn(fragment, skill) + for fragment in ( + "cloud path", + "웹 업로드", + "현재 Ennoia MCP 연결 endpoint", + "https://mcp.ennoia.so/mcp", + "https://dev-mcp-server.ennoia.so/mcp", + "endpoint를 확인할 수 없으면", + ): + self.assertIn(fragment, uploads) + def test_reference_outside_plugin_cache_is_rejected(self): skill = self.root / "plugins/ennoia/skills/ennoia-run/SKILL.md" skill.write_text(skill.read_text() + "\n[local](../../../../README.md)\n")