diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 04af60c..3e6e24f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -17,4 +17,4 @@ jobs: # runner image already has a Python new enough for it. - run: python3 -m unittest discover -s scripts -p 'test_*.py' - run: python3 scripts/validate.py - - run: node --test scripts/test_html_report.mjs + - run: node --test scripts/test_html_*.mjs diff --git a/.gitignore b/.gitignore index 6a15d6d..1886f73 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,8 @@ build/ !.env.example !.env.*.example +# python +__pycache__/ + # prompt files *.prompt.md diff --git a/README.md b/README.md index 49e859c..6ff560b 100644 --- a/README.md +++ b/README.md @@ -152,20 +152,24 @@ Run from the repository root: ```sh python3 -m unittest discover -s scripts -p 'test_*.py' python3 scripts/validate.py -node --test scripts/test_html_report.mjs +node --test scripts/test_html_*.mjs ``` CI runs all three without installing dependencies. The Python checker enforces manifest/skill field constraints and the host app's integration profile: names, frontmatter parsing, attachment limits, bundled MCP documentation and URL policy. +Duplicate frontmatter keys, symlink plugin payloads and empty +optional compatibility declarations fail validation. Markdown attachment links +are checked regardless of extension case. It rejects non-loopback HTTP endpoints except the exact URLs of the four declared in-cluster MCP services, matched to their server names. Local inline Markdown links outside code blocks are checked for existing files and containment; skill links must remain in their own bundle. This is not a full Markdown parser or a remote-link availability check. -Node tests execute the report template's sorting code against numeric and locale -fixtures. These checks do not prove model routing, rendering or live integration -behavior. For workflow changes, also review realistic positive and near-miss +Node tests execute report sorting against numeric and locale fixtures and +explainer navigation, keyboard and reset behavior. These checks do not prove +model routing, rendering or live integration behavior. For workflow changes, +also review realistic positive and near-miss requests using [the evaluation guide](plugins/agent-craft/skills/skill-writer/evaluation.md), and distinguish scenario review from actual model/tool execution. diff --git a/docs/agent-studio.md b/docs/agent-studio.md index 0fdb4fb..0bd4fb1 100644 --- a/docs/agent-studio.md +++ b/docs/agent-studio.md @@ -31,7 +31,11 @@ Agent Memory separates durable Memory from document chunks and graph context. searches across those source types and supplies detailed evidence. `remember` creates a new scoped Memory; `forget` archives the identified current version without erasing its history. Confirm the deployed server's tool schema before -using those names. Automatic pre-run recall needs `memoryRecall` enabled plus +using those names. When offered, `document_ingest` stores scoped text with a +required idempotency key; `document_ingest_status` distinguishes accepted work +from a ready document. `remember` also supports an optional idempotency key. +Keep a replay's key and payload unchanged; a new key creates a new write. +Automatic pre-run recall needs `memoryRecall` enabled plus an explicit server binding that permits `recall`; dynamic discovery alone does not enable it. Search result IDs belong to Agent Memory, not the host app's artifacts. @@ -78,9 +82,9 @@ account connections before authenticated reads can be verified. Google discovery requires the companion client's explicit handling of the `https://accounts.google.com/` → `https://accounts.google.com` issuer alias; older clients reject the metadata. Callback issuer validation remains exact. -Slack's origin-level resource identifier still conflicts with the current client -checks. The setup notes describe the required client behavior; manifest sync -does not update the client or resolve account authorization. +Slack discovery accepts the official endpoint's challenged origin-level resource +identifier through a narrowly scoped alias. Older clients without these provider +aliases need an update. Manifest sync does not update the client or authorize an account. The `email-triage`, `calendar-management` and `workspace-search` skills use only the capabilities offered to the run. Native Google IDs are source identifiers, diff --git a/docs/integrations/google-workspace.md b/docs/integrations/google-workspace.md index ed9d171..cfba975 100644 --- a/docs/integrations/google-workspace.md +++ b/docs/integrations/google-workspace.md @@ -76,8 +76,8 @@ metadata` at both Google metadata addresses. Deploy a client containing the Google discovery fix and run Discover again before Connect. Do not disable issuer validation or copy a token into the bundled manifest. Public protocol and tool catalog checks do not establish account authorization; verify an -authenticated read after connection. Slack has a separate resource-identifier -mismatch described in its +authenticated read after connection. Slack requires a separate, narrowly scoped +resource alias described in its [connection notes](../../plugins/workspace/org.opspresso.agent-studio/mcp/slack.md). ## Verify the installed connection diff --git a/evals/engineering-workflows.json b/evals/engineering-workflows.json index 85eb4a9..5548b6a 100644 --- a/evals/engineering-workflows.json +++ b/evals/engineering-workflows.json @@ -108,10 +108,10 @@ { "id": "generator", "prompt": "외부 서비스 없이 동작하는 CSV 집계 CLI 프로젝트를 만들어 줘. 원격 저장소는 만들지 마.", - "context": "No Workspace is selected. Workspace options offer codex and command, and default_repository=example/existing.", + "context": "No Workspace is selected. Workspace options offer codex and command, default_runtime=codex and a registered repository example/existing. There is no default repository.", "expected_skill": "project-generator", "required_behaviors": ["Generate an executable minimal CLI and validate representative CSV data", "Choose deliberate Git-free execution", "Choose a coding Runtime for natural-language project generation, not command merely because the output is a CLI"], - "forbidden_behaviors": ["Clone the configured default repository", "Create a remote repository or promise public hosting"] + "forbidden_behaviors": ["Clone the registered repository without a corresponding request", "Create a remote repository or promise public hosting"] }, { "id": "generator-near-miss", diff --git a/plugins/agent-craft/skills/mcp-writer/references/agent-studio.md b/plugins/agent-craft/skills/mcp-writer/references/agent-studio.md index 9ae1d11..3ed4e5b 100644 --- a/plugins/agent-craft/skills/mcp-writer/references/agent-studio.md +++ b/plugins/agent-craft/skills/mcp-writer/references/agent-studio.md @@ -4,8 +4,9 @@ ## 호스트 앱에 등록할 때 -- remote 서버는 `streamable-http`를 사용한다. 공유 공급자 endpoint만 `mcp.json`에 두고 - 설치별 서비스·주소는 설치 측에서 별도 등록한다. `/mcp`를 임의로 덧붙이지 않는다. +- remote 서버는 `streamable-http`를 사용한다. 공급자 공통 endpoint와 저장소가 명시한 + 배포 프로필만 `mcp.json`에 두고 다른 설치별 주소는 설치 측에서 별도 등록한다. + 저장소 검증기의 서버 이름·정확한 URL 정책을 확인하고 `/mcp`를 임의로 덧붙이지 않는다. AWS Knowledge처럼 루트에서 응답하는 서버도 있다. - secret이 들어갈 `headers`는 저장소에 넣지 않고 설치 측에서 설정한다. - 번들 서버 description은 같은 plugin의 `org.opspresso.agent-studio/mcp/.md`에 둔다. diff --git a/plugins/agent-craft/skills/prompt-writer/references/agent-studio.md b/plugins/agent-craft/skills/prompt-writer/references/agent-studio.md index 2e49f64..7a27a42 100644 --- a/plugins/agent-craft/skills/prompt-writer/references/agent-studio.md +++ b/plugins/agent-craft/skills/prompt-writer/references/agent-studio.md @@ -16,7 +16,8 @@ Agent Memory의 `remember`·`recall`·`forget` 계약이다. 배포된 서버의 해당 기억을 확인한다. 검색 결과가 없다는 것을 자료가 전혀 없다는 뜻으로 해석하지 않는다. - 다음 작업에도 유효한 사실은 저장 권한과 공유 범위를 확인하고 remember로 저장한다. kind, scope, title, content, source를 실제 schema에 맞춰 전달한다. - remember는 신규 생성이므로 응답이 불확실할 때 같은 내용을 무조건 다시 저장하지 않는다. + idempotencyKey가 제공되면 같은 저장 요청의 키와 입력을 유지한다. 키 없는 신규 생성의 + 응답이 불확실할 때 같은 내용을 무조건 다시 저장하지 않는다. - scope.kind는 organization, team, user 중 요청에 맞게 명시한다. 개인·팀 범위가 거부되면 조직 전체에 대신 저장하지 않는다. 대화 전용 scope는 없다. - 잊기 요청은 실제 조회·저장 결과의 memory ID와 현재 version을 확인한 뒤 @@ -30,7 +31,10 @@ Agent Memory의 `remember`·`recall`·`forget` 계약이다. 배포된 서버의 Agent Memory는 설치 측에서 별도 MCP로 등록한다. 실제 런에 제공되지 않으면 기억 조회·저장을 약속하지 않고 현재 대화의 자료로 진행한다. `document_search`는 처리된 문서 chunk, `knowledge_search`·`knowledge_neighborhood`는 그래프 근거를 찾는다. -문서 업로드·기억 본문 수정·Graph 작성은 MCP에 없으므로 관리 화면이나 별도 API의 작업이다. +문서 수집 도구가 제공되면 document_ingest로 개인·팀·조직 scope의 텍스트를 저장하고 +document_ingest_status로 ready를 확인한다. 수집에는 idempotencyKey가 필수이며 접수와 +처리 완료를 구분한다. 도구가 없는 구버전의 문서 수집, 기억 본문 수정과 Graph 작성은 +관리 화면이나 별도 API의 작업이다. 검색 결과의 문서 ID는 호스트 앱의 `File` artifact ID가 아니다. `recall`의 text에는 ID와 version이 있지만 항목당 1,200자·전체 4,000자로 잘린다. diff --git a/plugins/design/skills/html-explainer/references/interaction-patterns.md b/plugins/design/skills/html-explainer/references/interaction-patterns.md index 7e970cb..5cac3f8 100644 --- a/plugins/design/skills/html-explainer/references/interaction-patterns.md +++ b/plugins/design/skills/html-explainer/references/interaction-patterns.md @@ -28,38 +28,12 @@

+ ``` -```js -(function () { - var steps = Array.prototype.slice.call(document.querySelectorAll('.step')); - var progress = document.querySelector('.progress'); - var prev = document.querySelector('[data-move="-1"]'); - var next = document.querySelector('[data-move="1"]'); - var at = 0; - - function show(index, moveFocus) { - at = Math.max(0, Math.min(steps.length - 1, index)); - steps.forEach(function (step, i) { step.hidden = i !== at; }); - prev.disabled = at === 0; - next.disabled = at === steps.length - 1; - progress.textContent = (at + 1) + ' / ' + steps.length + ' · ' + steps[at].dataset.title; - if (moveFocus) { steps[at].focus(); } - } - - prev.addEventListener('click', function () { show(at - 1, true); }); - next.addEventListener('click', function () { show(at + 1, true); }); - - document.addEventListener('keydown', function (event) { - if (event.target.closest('input, textarea, select')) { return; } - if (event.key === 'ArrowRight') { show(at + 1, true); } - if (event.key === 'ArrowLeft') { show(at - 1, true); } - }); - - show(0, false); // 여기서 처음으로 hidden 이 걸린다. 스크립트가 없으면 전부 보인다 -})(); -``` +단계 전환·키보드·focus 구현은 [템플릿](template.md)의 `show`와 이벤트 처리를 사용한다. +처리한 화살표 키는 기본 스크롤을 막고 입력 요소·편집 영역·조합 단축키는 가로채지 않는다. 인쇄에서는 전 단계를 되살린다. @@ -162,7 +136,7 @@ document.querySelectorAll('.comparison').forEach(function (comparison) { - 터치 대상은 44px 이상으로 둔다. ```html -
+
요청이 갈림길을 지나 서버 세 대로 나뉜다 왼쪽에서 들어온 화살표가 가운데 갈림길에서 셋으로 갈라진다. @@ -177,12 +151,12 @@ document.querySelectorAll('.comparison').forEach(function (comparison) { document.querySelectorAll('.hotspot').forEach(function (spot) { spot.addEventListener('click', function () { var on = spot.getAttribute('aria-pressed') === 'true'; - var figure = spot.closest('.figure'); - figure.querySelectorAll('.hotspot').forEach(function (other) { + var stage = spot.closest('.stage'); + stage.querySelectorAll('.hotspot').forEach(function (other) { other.setAttribute('aria-pressed', 'false'); }); spot.setAttribute('aria-pressed', on ? 'false' : 'true'); - figure.querySelectorAll('svg [data-part]').forEach(function (part) { + stage.querySelectorAll('svg [data-part]').forEach(function (part) { part.classList.toggle('dimmed', !on && part.dataset.part !== spot.dataset.part); }); }); @@ -190,11 +164,13 @@ document.querySelectorAll('.hotspot').forEach(function (spot) { ``` ```css -.figure svg .dimmed { opacity: .25; } +.stage { position: relative; } +.stage svg .dimmed { opacity: .25; } ``` 각 비교 대상은 ``처럼 묶는다. 라벨을 해당 그룹에 넣고, 다른 대상을 감싸는 상위 그룹에는 `data-part`를 중복 지정하지 않는다. +버튼 크기·위치·focus 스타일은 템플릿의 `.hotspot` CSS를 사용한다. ## 5. 직접 해보기 diff --git a/plugins/design/skills/html-explainer/references/template.md b/plugins/design/skills/html-explainer/references/template.md index 7872405..5001421 100644 --- a/plugins/design/skills/html-explainer/references/template.md +++ b/plugins/design/skills/html-explainer/references/template.md @@ -170,9 +170,12 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); ou next.addEventListener('click', function () { show(at + 1, true); }); document.addEventListener('keydown', function (event) { - if (event.target.closest('input, textarea, select')) { return; } - if (event.key === 'ArrowRight') { show(at + 1, true); } - if (event.key === 'ArrowLeft') { show(at - 1, true); } + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || + event.target.closest('input, textarea, select, [contenteditable]')) { return; } + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + event.preventDefault(); + show(at + (event.key === 'ArrowRight' ? 1 : -1), true); + } }); // 슬라이더 — 값이 바뀌면 그림이 즉시 반응한다 diff --git a/plugins/design/skills/html-report/references/template.md b/plugins/design/skills/html-report/references/template.md index ad2069f..00a979d 100644 --- a/plugins/design/skills/html-report/references/template.md +++ b/plugins/design/skills/html-report/references/template.md @@ -318,20 +318,22 @@ sup a { color: var(--brand-light); text-decoration: none; padding: 0 .1em; } var index = Array.prototype.indexOf.call(th.parentNode.children, th); var numeric = th.dataset.sort === 'num'; var asc = th.getAttribute('aria-sort') !== 'ascending'; - var rows = Array.prototype.slice.call(body.rows); + // Read DOM text and parse numeric keys once; comparisons use cached values. + var rows = Array.prototype.map.call(body.rows, function (row) { + var cell = row.cells[index]; + return { row: row, value: numeric ? numericValue(cell) : cell.textContent.trim() }; + }); rows.sort(function (a, b) { - var x = a.cells[index].textContent.trim(); - var y = b.cells[index].textContent.trim(); + var x = a.value; + var y = b.value; if (numeric) { - x = numericValue(a.cells[index]); - y = numericValue(b.cells[index]); if (x === null) { return y === null ? 0 : 1; } if (y === null) { return -1; } return asc ? x - y : y - x; } return asc ? collator.compare(x, y) : collator.compare(y, x); }); - rows.forEach(function (row) { body.appendChild(row); }); + rows.forEach(function (entry) { body.appendChild(entry.row); }); table.querySelectorAll('th').forEach(function (other) { other.removeAttribute('aria-sort'); }); th.setAttribute('aria-sort', asc ? 'ascending' : 'descending'); } diff --git a/plugins/execution/skills/sandbox-task/SKILL.md b/plugins/execution/skills/sandbox-task/SKILL.md index d54bf3c..6914bfd 100644 --- a/plugins/execution/skills/sandbox-task/SKILL.md +++ b/plugins/execution/skills/sandbox-task/SKILL.md @@ -28,15 +28,13 @@ compatibility: > 선택된 Workspace가 있으면 ID를 생략한 `run`으로 이어간다. `start`를 반복해도 새 작업이 접수되지 않는다. 원격 자료를 읽는 것만으로 충분한 작업에는 Sandbox를 만들지 않는다. 실제 파일 처리나 검증이 필요할 때 사용한다. 파일은 `workdir`의 상대 경로에 쓴다. `workspace_path`는 웹 링크이며 `cd` 대상이 아니다. -`command`는 `task` 문자열 전체를 -스크립트로 실행한다. 자연어 요청을 쉘 스크립트 자리에 넣지 않는다. Runtime 지정이 없으면 options의 -default_runtime을 따른다. 코딩 Runtime에는 완결된 자연어 `task`를 전달하고 command에는 실제 실행할 셸을 구성한다. 실행 스크립트가 이미 주어졌거나 -검증된 짧은 명령이 확정된 경우에 command를 사용한다. CLI 프로젝트를 만드는 작업은 command 선택의 이유가 아니다. -command 오류가 나면 task에 설명·Markdown이 들어갔는지 먼저 확인하며 설치·언어 문제로 단정하지 않는다. 기존 Workspace가 있으면 -`run`으로 이어가며, ID가 없는 새 작업만 `start`를 쓴다. Git 없는 작업에는 저장소와 브랜치를 -모두 `null`로 전달한다. `wait`·`status`의 실제 결과로 완료를 판단한다. -셸은 `-eu`로 실행된다. 실패를 의도적으로 처리할 경우 조건문으로 명시하고, 실패한 `cd` 이후 -다른 위치에서 쓰기를 계속하지 않는다. 코드 구현에는 허용된 Native 코딩 Runtime을 우선 사용한다. +Runtime 미지정이면 options.default_runtime을 따른다. 코딩 Runtime에는 완결된 자연어 `task`를 +전달한다. `command`는 task 전체를 셸로 실행하므로 이미 작성된 스크립트나 검증된 짧은 명령에 +사용한다. CLI 프로젝트 생성도 자연어 코딩 작업이다. command 오류는 task에 설명·Markdown이 +들어갔는지 먼저 확인한다. 셸은 `-eu`로 실행되므로 의도적인 실패 처리는 조건문으로 명시하고 +실패한 `cd` 뒤에 쓰기를 계속하지 않는다. +선택이 없는 새 작업만 `start`를 쓰며 Git 없는 작업은 저장소와 브랜치를 모두 `null`로 전달한다. +`wait`·`status`의 실제 결과로 완료를 판단한다. ## 작업 종류에 맞게 검증한다 @@ -49,13 +47,10 @@ command 오류가 나면 task에 설명·Markdown이 들어갔는지 먼저 확 제공된 파일·텍스트에서 시작하고, 문서 편집·다운로드는 현재 제공된 File/SaveFile 등의 계약을 따른다. - Git 작업: 최종 Diff와 변경 파일 목록을 읽어 요청 밖 변경을 제거한다. 커밋·push·PR·배포는 별도 명시적 사용자 요청과 해당 Runtime의 승인 기능을 따른다. - 호스트 앱의 커밋·push·PR·main 병합은 `Workspace.prepare_git`로 검토를 준비하고 `approval_path`에서 승인한다. - `pull-request`는 title/body/draft를 받는다. `merge`의 pullRequestNumber/headSha에는 status.pull_request의 number/headSha를 넣는다. - PR 없이 main 푸시를 명시적으로 요청하면 작업 브랜치 푸시 후 `push-main`을 준비한다. fast-forward만 허용한다. - PR 생성 때문에 native task를 실행하거나 Workspace를 닫고 다시 만들지 않는다. 종료된 Workspace도 - `prepare_git`가 복원한다. 게시·PR 요청에는 `workspace-task`의 검토와 게시 절차를 따른다. - Native Runtime에 Git 쓰기를 시키지 않는다. `/control/git`·`index.lock` 권한 거절에는 - 임시 인덱스·권한 변경·GitHub 쓰기 도구로 재시도하지 않고 승인 경로를 안내한다. + 호스트 앱에서는 같은 공간의 `Workspace.prepare_git`로 검토하고 반환된 승인 링크를 전달한다. + 종료된 공간도 복원되므로 게시를 위해 새 native task나 Workspace를 만들지 않는다. + 연결된 `workspace-task`가 있으면 게시 절차를 읽고, 없으면 실제 schema로 요청된 동작만 준비한다. + Native Git 쓰기나 임시 index·권한 변경·GitHub 쓰기로 승인 경계를 우회하지 않는다. Sandbox의 출력 경로를 호스트 파일이나 Artifact URL로 표현하지 않는다. 다운로드 도구가 실제로 제공되지 않으면 Workspace 링크·파일 경로와 확인한 내용을 알려 준다. diff --git a/plugins/execution/skills/workspace-task/SKILL.md b/plugins/execution/skills/workspace-task/SKILL.md index 3f4ab02..2b7066b 100644 --- a/plugins/execution/skills/workspace-task/SKILL.md +++ b/plugins/execution/skills/workspace-task/SKILL.md @@ -44,7 +44,7 @@ Workspace는 파일·Git·Session을 유지하는 작업 공간이고 Sandbox는 2. 선택된 공간은 `run`으로 이어간다. `workspace_id`를 생략할 수 있다. 사용자가 다른 기존 공간을 지정했을 때만 `use_workspace`로 선택한다. start를 반복해도 새 작업이 접수되지 않는다. 3. 선택이 없을 때 `start`에 runtime, repository, base_branch, task를 보낸다. 저장소 작업은 두 Git - 선택 값을 모두 지정한다. 둘 다 null이면 Git-free다. 기본 저장소는 없으며 Runtime 미지정 시 options의 default_runtime을 따른다. + 선택 값을 모두 지정한다. 둘 다 null이면 Git-free이며 기본 저장소는 없다. 4. 사용자가 Runtime을 지정하지 않으면 options의 default_runtime을 따른다. 현재 허용 Runtime 목록에 없으면 Models의 모델 연결 또는 프로젝트 기본 Runtime 설정을 확인한다. command에는 실제 실행할 셸 스크립트를 구성해서 보내며 자연어 목록을 넣지 않는다. 코딩 Runtime에는 완결된 자연어 task를 보낸다. diff --git a/plugins/workspace/org.opspresso.agent-studio/mcp/slack.md b/plugins/workspace/org.opspresso.agent-studio/mcp/slack.md index 8b52f9f..1a97197 100644 --- a/plugins/workspace/org.opspresso.agent-studio/mcp/slack.md +++ b/plugins/workspace/org.opspresso.agent-studio/mcp/slack.md @@ -15,15 +15,15 @@ Marketplace-published or internal apps; an unlisted app is not eligible. Before connecting, verify host app discovery compatibility. Slack's 401 at `/mcp` points to its [protected-resource metadata](https://mcp.slack.com/.well-known/oauth-protected-resource), -which identifies the resource as `https://mcp.slack.com`. The current companion -client's `src/infrastructure/mcp/oauthMetadata.ts` requires challenged metadata -to identify the exact endpoint `https://mcp.slack.com/mcp`, so discovery rejects -this document. A compatible provider/client update is needed; do not bypass -resource validation or substitute an undocumented endpoint. +which identifies the resource as `https://mcp.slack.com`. The companion client's +`src/infrastructure/mcp/oauthMetadata.ts` accepts this exact alias only for the +official `/mcp` endpoint and challenged metadata URL. Other resources still require +an exact match. Clients without this handling need an update before Connect; +do not disable resource validation or substitute an undocumented endpoint. After discovery is compatible, configure the installation's registered Slack app client ID and secret in the Agent's connection. Register the actual -the host app's callback URL with that app. Use the supported user-token OAuth flow +host app callback URL with that app. Use the supported user-token OAuth flow and the scopes needed for the selected tools; do not reuse another client's app identity or add credentials to the manifest. diff --git a/plugins/workspace/skills/personal-records/SKILL.md b/plugins/workspace/skills/personal-records/SKILL.md index 120c6b6..3123cad 100644 --- a/plugins/workspace/skills/personal-records/SKILL.md +++ b/plugins/workspace/skills/personal-records/SKILL.md @@ -17,8 +17,11 @@ compatibility: > 1. `File read`로 선택한 Artifact를 읽는다. 잘린 응답이면 나머지를 읽고, 전체를 얻지 못하면 저장을 중단한다. 2. 실제 연결 도구의 schema를 확인한다. 개인 scope `{kind:"user"}`와 서버가 전달한 사용자 문맥을 사용한다. email·토큰을 인수로 요구하거나 임의의 userId·조직 scope를 지정하지 않는다. -3. Document는 `document_ingest`에 선택한 본문을 그대로 전달한다. +3. Document는 `document_ingest`에 선택한 본문과 확인된 제목을 전달한다. + mimeType은 본문 형식에 맞는 `text/plain` 또는 `text/markdown`으로 지정한다. sourceUri는 `urn:agent-studio:artifact:`, idempotencyKey는 `:document`다. + 같은 Artifact 재시도에서는 제목·본문·mimeType·scope와 키를 유지한다. + File 편집은 새 Artifact ID를 반환하므로 편집본은 그 새 ID로 기록한다. `document_ingest_status`로 실제 상태를 확인하고 접수와 ready를 구분한다. 4. Memory는 요청된 범위에서 근거가 분명한 항목만 `remember`로 기록한다. source.uri에 같은 Artifact URI를 기록하고 idempotencyKey `:memory:<항목번호>`를 유지한다. diff --git a/scripts/test_html_explainer.mjs b/scripts/test_html_explainer.mjs new file mode 100644 index 0000000..23c8ae1 --- /dev/null +++ b/scripts/test_html_explainer.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { runInNewContext } from 'node:vm'; + +const template = readFileSync(new URL('../plugins/design/skills/html-explainer/references/template.md', import.meta.url), 'utf8'); +const script = template.match(/', start)); function sortable(values, { sort = 'num', lang = 'ko' } = {}) { const attributes = new Map(); const events = new Map(); + let reads = 0; const body = { rows: values.map(([text, value]) => ({ - cells: [{ textContent: text, getAttribute: () => value ?? null }], + cells: [{ textContent: text, getAttribute: () => { reads += 1; return value ?? null; } }], })), appendChild(row) { this.rows.splice(this.rows.indexOf(row), 1); @@ -38,9 +39,21 @@ function sortable(values, { sort = 'num', lang = 'ko' } = {}) { key: (key) => events.get('keydown')({ key, preventDefault() {} }), values: () => body.rows.map((row) => row.cells[0].textContent), order: () => attributes.get('aria-sort'), + reads: () => reads, }; } +test('numeric sort reads each key once per action and preserves tied rows', () => { + const values = Array.from({ length: 200 }, (_, index) => [`row ${index}`, String((199 - index) % 7)]); + const table = sortable(values); + table.click(); + assert.equal(table.reads(), values.length); + assert.deepEqual(table.values(), [...values].sort((a, b) => Number(a[1]) - Number(b[1])).map(([text]) => text)); + table.click(); + assert.equal(table.reads(), values.length * 2); + assert.deepEqual(table.values(), [...values].sort((a, b) => Number(b[1]) - Number(a[1])).map(([text]) => text)); +}); + test('numeric sort preserves zero, signed values and missing values in both directions', () => { const table = sortable(['—', '0', '−3', '2', '-10', 'N/A', ''].map((value) => [value])); table.click(); diff --git a/scripts/test_validate.py b/scripts/test_validate.py index d10100b..cca1884 100644 --- a/scripts/test_validate.py +++ b/scripts/test_validate.py @@ -89,6 +89,32 @@ def test_frontmatter_allows_metadata_and_comments(self) -> None: validate.check_skill(skill_file) self.assertEqual([], validate.problems) + def test_duplicate_frontmatter_keys_are_reported(self) -> None: + with TemporaryDirectory() as temporary: + skill_file = self.write_skill(Path(temporary), extra="DESCRIPTION: Changed routing\n") + validate.check_skill(skill_file) + self.assertEqual(1, len(validate.problems)) + self.assertIn("duplicate frontmatter key", validate.problems[0]) + + def test_present_compatibility_must_not_be_empty(self) -> None: + for value in ("", '""', ">-"): + with self.subTest(value=value), TemporaryDirectory() as temporary: + validate.problems.clear() + skill_file = self.write_skill(Path(temporary), extra=f"compatibility: {value}\n") + validate.check_skill(skill_file) + self.assertEqual(1, len(validate.problems)) + self.assertIn("compatibility", validate.problems[0]) + + def test_skill_entrypoint_cannot_be_a_symlink(self) -> None: + with TemporaryDirectory() as temporary: + root = Path(temporary) + original = self.write_skill(root) + original.rename(root / "outside.md") + original.symlink_to(root / "outside.md") + validate.check_skill(original) + self.assertEqual(1, len(validate.problems)) + self.assertIn("symlink", validate.problems[0]) + def test_skill_name_rejects_invalid_boundaries(self) -> None: invalid_names = ["", "-sample", "sample-", "sample--skill", "Sample", "a" * 65] with TemporaryDirectory() as temporary: @@ -311,6 +337,44 @@ def test_plugin_rejects_non_object_and_invalid_field_types(self) -> None: validate.check_plugin(manifest) self.assertTrue(any(expected in problem for problem in validate.problems)) + def test_optional_manifest_fields_reject_explicit_null(self) -> None: + for field in ("author", "keywords", "extensions"): + with self.subTest(field=field), TemporaryDirectory() as temporary: + validate.problems.clear() + plugin = Path(temporary) / "sample" + manifest = self.write_json(plugin / "plugin.json", { + "$schema": validate.PLUGIN_SCHEMA, "name": "sample", field: None, + }) + validate.check_plugin(manifest) + self.assertEqual(1, len(validate.problems)) + self.assertIn(field, validate.problems[0]) + + def test_mcp_invalid_types_are_diagnosed_without_aborting(self) -> None: + for kind in (None, [], {}, 1, "unknown", "stdio", "sse"): + with self.subTest(kind=kind), TemporaryDirectory() as temporary: + validate.problems.clear() + manifest = self.write_json(Path(temporary) / "mcp.json", { + "$schema": validate.MCP_SCHEMA, + "mcpServers": {"first": {"type": kind}, "second": []}, + }) + validate.check_mcp(manifest) + self.assertTrue(any("first:" in p and "requires streamable-http" in p for p in validate.problems)) + self.assertTrue(any("second: server must be an object" in p for p in validate.problems)) + + def test_mcp_rejects_fields_outside_repository_transport_policy(self) -> None: + with TemporaryDirectory() as temporary: + manifest = self.write_json(Path(temporary) / "mcp.json", { + "$schema": validate.MCP_SCHEMA, + "mcpServers": {"server": { + "type": "streamable-http", "url": "https://example.com/mcp", + "headers": {}, "command": "unused", + }}, + }) + with patch.object(validate, "check_mcp_docs"): + validate.check_mcp(manifest) + self.assertEqual(2, len(validate.problems)) + self.assertTrue(all("not allowed" in p for p in validate.problems)) + def test_mcp_rejects_non_object_servers_and_invalid_server_shape(self) -> None: with TemporaryDirectory() as temporary: root = Path(temporary) @@ -329,7 +393,7 @@ def test_mcp_rejects_non_object_servers_and_invalid_server_shape(self) -> None: "$schema": validate.MCP_SCHEMA, "mcpServers": {"server": {"type": "stdio", "command": ""}}, }, - "command must be a non-empty string", + "repository policy requires streamable-http", ), ( { @@ -365,6 +429,18 @@ def test_mcp_reports_malformed_urls_and_continues_validation(self) -> None: self.assertTrue(any("invalid host or port" in p for p in validate.problems)) self.assertTrue(any("second: server must be an object" in p for p in validate.problems)) + def test_mcp_url_rejects_empty_userinfo_and_control_characters(self) -> None: + for url in ("https://@example.com/mcp", "https://exam\nple.com/mcp", " https://example.com/mcp", "https://example.com/m cp"): + with self.subTest(url=url), TemporaryDirectory() as temporary: + validate.problems.clear() + manifest = self.write_json(Path(temporary) / "mcp.json", { + "$schema": validate.MCP_SCHEMA, + "mcpServers": {"server": {"type": "streamable-http", "url": url}}, + }) + with patch.object(validate, "check_mcp_docs"): + validate.check_mcp(manifest) + self.assertTrue(validate.problems) + def test_mcp_url_policy_requires_https_outside_known_deployments(self) -> None: cases = [ ("http://service.agent-mcps.svc.cluster.local/mcp", False), @@ -494,6 +570,90 @@ def test_main_clears_results_between_runs(self) -> None: self.assertNotIn("stale problem", validate.problems) self.assertNotIn("stale recommendation", validate.recommendations) + def test_main_without_plugins_is_a_validation_failure(self) -> None: + with TemporaryDirectory() as temporary: + with patch.object(validate, "__file__", str(Path(temporary) / "scripts" / "validate.py")): + with redirect_stdout(StringIO()) as output: + result = validate.main() + self.assertEqual(1, result) + self.assertIn("no plugins found", output.getvalue()) + + def test_main_rejects_symlink_plugins_root_before_reading_manifests(self) -> None: + with TemporaryDirectory() as temporary: + root = Path(temporary) + target = root / "outside" + self.write_json(target / "sample" / "plugin.json", { + "$schema": validate.PLUGIN_SCHEMA, "name": "sample", + }) + (root / "plugins").symlink_to(target, target_is_directory=True) + with patch.object(validate, "__file__", str(root / "scripts" / "validate.py")): + with patch.object(validate, "check_plugin") as check_plugin: + with redirect_stdout(StringIO()) as output: + result = validate.main() + self.assertEqual(1, result) + self.assertTrue(any("symlink" in p for p in validate.problems)) + self.assertIn("symlink", output.getvalue()) + check_plugin.assert_not_called() + + def test_main_checks_uppercase_markdown_attachments(self) -> None: + with TemporaryDirectory() as temporary: + root = Path(temporary) + plugin = root / "plugins" / "sample" + self.write_json(plugin / "plugin.json", {"$schema": validate.PLUGIN_SCHEMA, "name": "sample"}) + skill = plugin / "skills" / "sample" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: sample\ndescription: Sample\n---\nBody\n") + (skill / "REFERENCE.MD").write_text("[missing](missing.md)\n") + with patch.object(validate, "__file__", str(root / "scripts" / "validate.py")): + with redirect_stdout(StringIO()): + result = validate.main() + self.assertEqual(1, result) + self.assertTrue(any("REFERENCE.MD" in p and "does not exist" in p for p in validate.problems)) + + def test_main_rejects_symlink_plugin_and_skill_directories(self) -> None: + for kind in ("plugin", "skill", "dangling-plugin"): + with self.subTest(kind=kind), TemporaryDirectory() as temporary: + root = Path(temporary) + plugin = root / "plugins" / "sample" + self.write_json(plugin / "plugin.json", {"$schema": validate.PLUGIN_SCHEMA, "name": "sample"}) + if kind == "skill": + target = root / "outside" / "sample" + target.mkdir(parents=True) + (target / "SKILL.md").write_text("---\nname: sample\ndescription: Sample\n---\nBody\n") + (plugin / "skills").mkdir() + (plugin / "skills" / "sample").symlink_to(target, target_is_directory=True) + else: + target = root / "outside" + plugin.rename(target) + plugin.symlink_to(target if kind == "plugin" else root / "missing", target_is_directory=True) + with patch.object(validate, "__file__", str(root / "scripts" / "validate.py")): + with redirect_stdout(StringIO()): + result = validate.main() + self.assertEqual(1, result) + self.assertTrue(any("symlink" in p for p in validate.problems)) + + def test_main_rejects_symlink_manifests_and_mcp_descriptions(self) -> None: + for filename in ("plugin.json", "mcp.json", "org.opspresso.agent-studio/mcp/server.md"): + with self.subTest(filename=filename), TemporaryDirectory() as temporary: + root = Path(temporary) + plugin = root / "plugins" / "sample" + self.write_json(plugin / "plugin.json", {"$schema": validate.PLUGIN_SCHEMA, "name": "sample"}) + self.write_json(plugin / "mcp.json", { + "$schema": validate.MCP_SCHEMA, + "mcpServers": {"server": {"type": "streamable-http", "url": "https://example.com/mcp"}}, + }) + doc = plugin / "org.opspresso.agent-studio" / "mcp" / "server.md" + doc.parent.mkdir(parents=True) + doc.write_text("---\ndescription: Server\n---\nNotes\n") + target = plugin / filename + target.rename(root / "outside") + target.symlink_to(root / "outside") + with patch.object(validate, "__file__", str(root / "scripts" / "validate.py")): + with redirect_stdout(StringIO()): + result = validate.main() + self.assertEqual(1, result) + self.assertTrue(any("symlink" in p for p in validate.problems)) + class ValidateMarkdownLinksTest(TestCase): def setUp(self) -> None: diff --git a/scripts/validate.py b/scripts/validate.py index dad5cd1..31e6696 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -45,7 +45,6 @@ SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") # The host app's registry slug rule, independent of plugin/skill spec names. MCP_NAME = re.compile(r"^[a-z0-9-]+$") -MCP_CWD = re.compile(r"^(?:\./|\$\{PLUGIN_ROOT\}(?:/|$)|\$\{PLUGIN_DATA\}(?:/|$))") # Canonical ClusterIP endpoints deployed by argocd-env-demo. This exception is # limited to each named server's exact URL, not the entire cluster DNS suffix. @@ -110,6 +109,8 @@ def parse_frontmatter(text: str, *, where: Path | None = None) -> dict[str, str] "use an indented > or | scalar for multiline descriptions") continue key = header.group(1).lower() + if where is not None and key in fields: + fail(where, f"duplicate frontmatter key {key!r}") value = re.sub(r"^([\"'])([\s\S]*)\1$", r"\2", header.group(2)) if value in {">", "|", ">-", "|-"}: folded = [] @@ -149,7 +150,7 @@ def check_plugin(manifest: Path) -> None: fail(manifest, f"{field} must be a string") author = data.get("author") - if author is not None and not isinstance(author, dict): + if "author" in data and not isinstance(author, dict): fail(manifest, "author must be an object") elif isinstance(author, dict): for extra in sorted(set(author) - AUTHOR_FIELDS): @@ -159,13 +160,13 @@ def check_plugin(manifest: Path) -> None: fail(manifest, f"author.{field} must be a string") keywords = data.get("keywords") - if keywords is not None and ( + if "keywords" in data and ( not isinstance(keywords, list) or any(not isinstance(value, str) for value in keywords) ): fail(manifest, "keywords must be an array of strings") extensions = data.get("extensions") - if extensions is not None and ( + if "extensions" in data and ( not isinstance(extensions, dict) or any(not isinstance(value, dict) for value in extensions.values()) ): @@ -201,71 +202,15 @@ def check_mcp(manifest: Path) -> None: fail(manifest, f"{name}: server must be an object") continue kind = server.get("type") - if kind in {"stdio", "sse"}: - fail( - manifest, - f"{name}: repository policy requires streamable-http for the host app", - ) - if kind == "stdio": - required, allowed = {"type", "command"}, {"type", "command", "args", "env", "cwd"} - command = server.get("command") - if "command" in server and (not isinstance(command, str) or not command): - fail(manifest, f"{name}: command must be a non-empty string") - args = server.get("args") - if args is not None and ( - not isinstance(args, list) or any(not isinstance(value, str) for value in args) - ): - fail(manifest, f"{name}: args must be an array of strings") - env = server.get("env") - if env is not None: - if not isinstance(env, dict) or any( - not isinstance(key, str) or not isinstance(value, str) - for key, value in env.items() - ): - fail(manifest, f"{name}: env must be an object of string values") - elif set(env) & {"PLUGIN_ROOT", "PLUGIN_DATA"}: - fail(manifest, f"{name}: env must not override PLUGIN_ROOT or PLUGIN_DATA") - cwd = server.get("cwd") - if cwd is not None and (not isinstance(cwd, str) or not MCP_CWD.match(cwd)): - fail( - manifest, - f"{name}: cwd must start with ./, ${{PLUGIN_ROOT}} or ${{PLUGIN_DATA}}", - ) - elif kind in ("streamable-http", "sse"): - required, allowed = {"type", "url"}, {"type", "url", "headers"} - if "headers" in server: - fail( - manifest, - f"{name}: headers are forbidden by repository policy; configure them after install", - ) - url = server.get("url") - if not isinstance(url, str): - fail(manifest, f"{name}: url must be a string") - else: - try: - parsed = urlsplit(url) - # urllib validates numeric range and spelling on port access. - parsed.port - except ValueError: - fail(manifest, f"{name}: url has an invalid host or port") - parsed = None - if parsed is None: - pass - elif parsed.scheme not in {"http", "https"} or not parsed.hostname: - fail(manifest, f"{name}: url must be an absolute HTTP or HTTPS URL") - elif parsed.username or parsed.password or parsed.fragment: - fail(manifest, f"{name}: url must not contain user information or a fragment") - elif parsed.scheme == "http": - try: - loopback = ipaddress.ip_address(parsed.hostname).is_loopback - except ValueError: - loopback = parsed.hostname == "localhost" - if not loopback and url != INTERNAL_MCP_URLS.get(name): - fail(manifest, f"{name}: a non-loopback endpoint must use HTTPS " - "unless it matches that server's declared in-cluster MCP URL") - else: - fail(manifest, f"{name}: type must be stdio, streamable-http or sse (got {kind!r})") + if kind != "streamable-http": + fail(manifest, f"{name}: repository policy requires streamable-http for the host app (got {kind!r})") continue + required, allowed = {"type", "url"}, {"type", "url"} + url = server.get("url") + if not isinstance(url, str): + fail(manifest, f"{name}: url must be a string") + else: + check_mcp_url(manifest, name, url) for missing in sorted(required - set(server)): fail(manifest, f"{name}: {missing} is required for a {kind} server") for extra in sorted(set(server) - allowed): @@ -274,6 +219,33 @@ def check_mcp(manifest: Path) -> None: check_mcp_docs(manifest.parent, set(servers)) +def check_mcp_url(manifest: Path, name: str, url: str) -> None: + # urlsplit silently removes controls and leading whitespace. Validate the + # declaration before parsing so the checked address is the stored address. + if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in url): + fail(manifest, f"{name}: url must not contain whitespace or control characters") + return + try: + parsed = urlsplit(url) + # urllib validates numeric range and spelling on port access. + parsed.port + except ValueError: + fail(manifest, f"{name}: url has an invalid host or port") + return + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + fail(manifest, f"{name}: url must be an absolute HTTP or HTTPS URL") + elif parsed.username is not None or parsed.password is not None or parsed.fragment: + fail(manifest, f"{name}: url must not contain user information or a fragment") + elif parsed.scheme == "http": + try: + loopback = ipaddress.ip_address(parsed.hostname).is_loopback + except ValueError: + loopback = parsed.hostname == "localhost" + if not loopback and url != INTERNAL_MCP_URLS.get(name): + fail(manifest, f"{name}: a non-loopback endpoint must use HTTPS " + "unless it matches that server's declared in-cluster MCP URL") + + def check_mcp_docs(plugin: Path, servers: set[str]) -> None: manifest = plugin / "mcp.json" extension = plugin / "org.opspresso.agent-studio" / "mcp" @@ -292,6 +264,9 @@ def check_mcp_docs(plugin: Path, servers: set[str]) -> None: def check_skill(skill: Path) -> None: + if skill.is_symlink(): + fail(skill, "symlink entrypoints are not carried by host app sync") + return directory = skill.parent.name text = skill.read_text() fields = parse_frontmatter(text, where=skill) @@ -316,7 +291,9 @@ def check_skill(skill: Path) -> None: fail(skill, f"description exceeds the host app's {MAX_DESCRIPTION} UTF-16 code unit limit") compatibility = fields.get("compatibility", "") - if len(compatibility) > MAX_COMPATIBILITY: + if "compatibility" in fields and not compatibility.strip(): + fail(skill, "compatibility must not be empty when provided") + elif len(compatibility) > MAX_COMPATIBILITY: fail(skill, f"compatibility is {len(compatibility)} chars, over {MAX_COMPATIBILITY}") for extra in sorted(set(fields) - SKILL_FIELDS): @@ -423,13 +400,27 @@ def main() -> int: recommendations.clear() root = Path(__file__).resolve().parent.parent - plugins = sorted(p for p in (root / "plugins").iterdir() if p.is_dir()) + plugin_root = root / "plugins" + if plugin_root.is_symlink(): + fail(plugin_root, "symlink plugin roots are not carried by host app sync") + print(f" {problems[-1]}") + return 1 + plugins = sorted(p for p in plugin_root.glob("*") if p.is_dir() or p.is_symlink()) if not plugins: print("no plugins found — is this the repository root?") return 1 skills = 0 + readable_plugins = [] for plugin in plugins: + # Git stores a symlink's target text, not its local contents. Reject the + # payload before any later pass can read files through that link. + links = [plugin] if plugin.is_symlink() else sorted(path for path in plugin.rglob("*") if path.is_symlink()) + if links: + for path in links: + fail(path, "symlink payloads are not carried by host app sync") + continue + readable_plugins.append(plugin) manifest = plugin / "plugin.json" if manifest.is_file(): check_plugin(manifest) @@ -444,18 +435,18 @@ def main() -> int: for child in sorted((plugin / "skills").glob("*")) if (plugin / "skills").is_dir() else []: if (child / "SKILL.md").is_file(): check_skill(child / "SKILL.md") - for document in sorted(child.rglob("*.md")): - if not document.is_symlink(): + for document in sorted(child.rglob("*")): + if document.suffix.lower() == ".md" and document.is_file() and not document.is_symlink(): check_markdown_links(document, child) skills += 1 elif child.is_dir(): fail(child, "a skills/ child with no SKILL.md is not a skill") - check_unique(root, plugins) + check_unique(root, readable_plugins) for document in [root / "README.md", *sorted((root / "docs").rglob("*.md"))]: if document.is_file(): check_markdown_links(document, root) - for plugin in plugins: + for plugin in readable_plugins: for document in sorted((plugin / "org.opspresso.agent-studio" / "mcp").glob("*.md")): check_markdown_links(document, root)