From 1bac3ca409c51ed69017b02e5865f80c6d4bb05f Mon Sep 17 00:00:00 2001 From: Carl Rannaberg Date: Wed, 21 Oct 2026 17:25:53 +0300 Subject: [PATCH] Extract conditional skill procedures and preserve standalone delivery --- .github/workflows/ci.yml | 6 +- .github/workflows/release-skill-archive.yml | 15 ++- CONTRIBUTING.md | 33 +++++- bin/build-candidate.mjs | 8 +- bin/build-standalone.mjs | 46 ++++++++ codex/display-dev/skills/display-dev/SKILL.md | 101 +----------------- .../display-dev/references/email-domains.md | 60 +++++++++++ .../display-dev/references/staged-upload.md | 43 ++++++++ display-dev/SKILL.md | 101 +----------------- display-dev/references/email-domains.md | 60 +++++++++++ display-dev/references/staged-upload.md | 43 ++++++++ hermes/productivity/display.dev/SKILL.md | 101 +----------------- .../display.dev/references/email-domains.md | 60 +++++++++++ .../display.dev/references/staged-upload.md | 43 ++++++++ pi/agent/skills/display-dev/SKILL.md | 101 +----------------- .../display-dev/references/email-domains.md | 60 +++++++++++ .../display-dev/references/staged-upload.md | 43 ++++++++ skills/display-dev/SKILL.md | 101 +----------------- .../display-dev/references/email-domains.md | 60 +++++++++++ .../display-dev/references/staged-upload.md | 43 ++++++++ tests/archive-candidate.test.mjs | 32 +++++- tests/run.sh | 33 +++--- tests/standalone.test.mjs | 65 +++++++++++ 23 files changed, 748 insertions(+), 510 deletions(-) create mode 100644 bin/build-standalone.mjs create mode 100644 codex/display-dev/skills/display-dev/references/email-domains.md create mode 100644 codex/display-dev/skills/display-dev/references/staged-upload.md create mode 100644 display-dev/references/email-domains.md create mode 100644 display-dev/references/staged-upload.md create mode 100644 hermes/productivity/display.dev/references/email-domains.md create mode 100644 hermes/productivity/display.dev/references/staged-upload.md create mode 100644 pi/agent/skills/display-dev/references/email-domains.md create mode 100644 pi/agent/skills/display-dev/references/staged-upload.md create mode 100644 skills/display-dev/references/email-domains.md create mode 100644 skills/display-dev/references/staged-upload.md create mode 100644 tests/standalone.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd3e1dd..ba180be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Skill scripts stay executable (canonical + generated mounts) # Proves the committed install payload is runnable. Canonical scripts # are checked as committed (not chmod'd first), so a script committed @@ -106,7 +110,7 @@ jobs: run: bash tests/claude-hooks.sh - name: Candidate archive contract - run: node --test tests/archive-candidate.test.mjs + run: node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs - name: ShellCheck # Pinned to v2.0.0 by full commit SHA — public-repo supply-chain diff --git a/.github/workflows/release-skill-archive.yml b/.github/workflows/release-skill-archive.yml index 1301ded..b734580 100644 --- a/.github/workflows/release-skill-archive.yml +++ b/.github/workflows/release-skill-archive.yml @@ -44,9 +44,18 @@ jobs: with: fetch-depth: 1 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Sanity-check skill mirrors are in sync run: bin/sync-mounts.sh --check + - name: Validate packaging and standalone instructions + run: | + node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs + bash tests/run.sh + - name: Compute identifiers id: ids run: | @@ -77,7 +86,7 @@ jobs: --exclude='.DS_Store' \ -C skills/display-dev -cf - . | gzip -n > display-dev.tar.gz - cp skills/display-dev/SKILL.md SKILL.md + node bin/build-standalone.mjs skills/display-dev SKILL.md ARCHIVE_SHA="$(sha256sum display-dev.tar.gz | awk '{print $1}')" ARCHIVE_SIZE="$(stat -c %s display-dev.tar.gz)" @@ -148,7 +157,9 @@ jobs: echo "non-deterministic build: $SHA1 != $SHA2" exit 1 fi - rm -f display-dev.tar.gz.2 + node bin/build-standalone.mjs skills/display-dev SKILL.md.2 + cmp SKILL.md SKILL.md.2 + rm -f display-dev.tar.gz.2 SKILL.md.2 - name: Write checksums.json run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98f3e6a..a12fd98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## What you edit, and what is generated -- **Canonical skill content** lives under `display-dev/` (`SKILL.md`, `scripts/`, `bin/`). Edit it here only. +- **Canonical skill content** lives under `display-dev/` (`SKILL.md`, `references/`, `scripts/`, `bin/`). Edit it here only. - **Hand-authored metadata/config/docs** outside `display-dev/` is edited directly: the plugin manifests (`.cursor-plugin/`, `.claude-plugin/`, `codex/display-dev/.codex-plugin/`), the Codex MCP config (`codex/display-dev/.mcp.json`), the marketplaces (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`), `README.md`, and this file. - **Claude Code plugin components** live under `claude/` and are edited directly: the `/display-dev:feedback` command (`claude/commands/`), the hooks (`claude/hooks/`), and their tests (`tests/claude-hooks.sh`). `.claude-plugin/plugin.json` points at them, so other hosts do not load them. - **Bundled Visualize is never hand-edited.** `claude/skills/visualize/` is a copy of one `display-dev/visualize` release. Change Visualize in its own repo, release it, set the new tag in `claude/visualize.lock`, then run `bin/sync-visualize.sh`. CI runs `bin/sync-visualize.sh --check`. @@ -21,6 +21,27 @@ provider-substitution machinery, but current mounts receive the same canonical bytes and modes. Reintroducing provider placeholders would change this release invariant and requires an explicit design change plus matching CI updates. +## Conditional references and standalone delivery + +Keep common decisions and authority boundaries in `display-dev/SKILL.md`. +The email-domain and staged-upload procedures live in `display-dev/references/`; +read them only for the routed task. All generated mounts include those files. + +The archive contains the lean entrypoint and sibling references. The standalone +release asset is generated from the same sources, with a notice after frontmatter +and the references appended in sorted path order. Do not hand-edit release output: + +```sh +node bin/build-standalone.mjs display-dev /tmp/display-dev-SKILL.md +node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs +``` + +Every local reference link in the entrypoint must resolve to a regular file in +`references/`; every reference must be routed. Generation fails on missing, +unrouted, linked, or unsafe paths. With no references, it preserves the original +entrypoint bytes. PR CI and the release workflow run the same generator tests; +release computes the standalone checksum from generated bytes. + ## Validation layers ### Automated trust-boundary suite @@ -57,15 +78,21 @@ Before release, also run the suite under Linux with the repository mounted read-only and container networking disabled: ```sh +standalone="$(mktemp)" +node bin/build-standalone.mjs display-dev "$standalone" docker run --rm --network none \ --mount "type=bind,src=$PWD,dst=/repo,readonly" \ + --mount "type=bind,src=$standalone,dst=/standalone-SKILL.md,readonly" \ --workdir /repo \ ubuntu:24.04 \ - bash tests/run.sh + bash tests/run.sh /standalone-SKILL.md +rm "$standalone" ``` Docker may need network access to pull `ubuntu:24.04` before this command can -run; `--network none` applies to the test container. This command runs the Bash +run; `--network none` applies to the test container. The standalone file is generated +on the host because this image has no Node.js. +The suite writes only to its temporary directory. This command runs the Bash suite, not an agent. It proves Linux portability and shell-level boundaries only. diff --git a/bin/build-candidate.mjs b/bin/build-candidate.mjs index 90da9e1..220d74b 100644 --- a/bin/build-candidate.mjs +++ b/bin/build-candidate.mjs @@ -89,7 +89,7 @@ async function collectDirectory(sourceRoot, directory) { for (const basename of names) { if (basename === '.DS_Store') continue; const name = `${directory}/${basename}`; - if (!/^(scripts|bin)\/[^/]+$/.test(name)) fail(`invalid candidate path: ${name}`); + if (!/^(scripts|bin|references)\/[^/]+$/.test(name)) fail(`invalid candidate path: ${name}`); const absoluteFile = path.join(absolute, basename); const stat = await lstat(absoluteFile); if (!stat.isFile() || stat.isSymbolicLink()) fail(`candidate entry must be a regular file: ${name}`); @@ -113,7 +113,11 @@ export async function buildCandidate(sourcePath, outputPath) { { name: 'SKILL.md', mode: 0o644, bytes: await readFile(skillPath) }, ...(await collectDirectory(sourceRoot, 'scripts')), ...(await collectDirectory(sourceRoot, 'bin')), - ].sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name))); + ]; + if ((await readdir(sourceRoot)).includes('references')) { + entries.push(...await collectDirectory(sourceRoot, 'references')); + } + entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name))); const unpackedSize = entries.reduce((sum, entry) => sum + entry.bytes.length, 0); if (unpackedSize > MAX_UNPACKED_BYTES) fail(`candidate exceeds ${MAX_UNPACKED_BYTES} unpacked bytes`); diff --git a/bin/build-standalone.mjs b/bin/build-standalone.mjs new file mode 100644 index 0000000..1d5e8d2 --- /dev/null +++ b/bin/build-standalone.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export async function buildStandalone(sourceRoot) { + const original = await readFile(path.join(sourceRoot, 'SKILL.md')); + const skill = original.toString('utf8'); + const routes = [...new Set([...skill.matchAll(/\]\((references\/[^)]+)\)/g)].map((match) => match[1]))].sort(); + const referenceRoot = path.join(sourceRoot, 'references'); + let names = []; + try { + const info = await lstat(referenceRoot); + if (!info.isDirectory()) throw new Error('references must be a real directory'); + names = (await readdir(referenceRoot)).filter((name) => name !== '.DS_Store').sort(); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + for (const route of routes) { + if (!/^references\/[a-z0-9-]+\.md$/.test(route)) throw new Error(`invalid reference path: ${route}`); + } + if (JSON.stringify(names.map((name) => `references/${name}`)) !== JSON.stringify(routes)) { + throw new Error('reference files must match the paths routed from SKILL.md'); + } + if (!routes.length) return original; + + const frontmatter = skill.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/); + if (!frontmatter) throw new Error('SKILL.md is missing YAML frontmatter'); + const notice = '\nThis standalone file includes reference documents in appended sections labeled\n'+ + 'with their exact paths. When an instruction asks you to read a reference, use\n'+ + 'that section instead of opening a sibling file.\n'; + let output = frontmatter[0] + notice + skill.slice(frontmatter[0].length); + for (const route of routes) { + const file = path.join(sourceRoot, route); + if (!(await lstat(file)).isFile()) throw new Error(`reference must be a regular file: ${route}`); + output += `\n\n## ${route}\n\n${await readFile(file, 'utf8')}`; + } + return Buffer.from(output); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [source, output, ...extra] = process.argv.slice(2); + if (!source || !output || extra.length) throw new Error('usage: build-standalone.mjs '); + await writeFile(output, await buildStandalone(source)); +} diff --git a/codex/display-dev/skills/display-dev/SKILL.md b/codex/display-dev/skills/display-dev/SKILL.md index 6387220..47431a1 100644 --- a/codex/display-dev/skills/display-dev/SKILL.md +++ b/codex/display-dev/skills/display-dev/SKILL.md @@ -181,52 +181,14 @@ true: approaches the safe inline tool-call ceiling; and - the code-execution environment can send HTTPS requests to `api.display.dev`. -Then perform these steps: - -1. Measure the raw file byte length without reading its contents into the - conversation. Call `create_upload` with the basename and exact `size_bytes`. -2. In code execution, send the raw file bytes with `PUT` to the returned - first-party `upload_url`. Use every exact entry in `required_headers`, - including `Authorization`, `Content-Type`, and `Content-Length`. Do not - encode the file as JSON, base64, or multipart form data. It is acceptable - for the initiating execution trace to show the URL and bearer. -3. Call `publish` once with the returned `upload_id` plus the user-approved - name, visibility, sharing, or `short_id` / `base_version` update fields. For - an ordinary authenticated publish, omit visibility; pass `company` only - when the user asked to share with the organization. Do not also pass `content` or `format`. -4. Report the canonical artifact URL and relevant publish result. Do not repeat - the upload bearer or source. Discard the upload ID. - -The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new -`create_upload` without inferring or revealing whether expiry, ticket validity, -organization binding, or a missing object caused it. If it returns -`upload_size_mismatch`, report that exact mismatch, measure the raw file again, -and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on -Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes -between staging and publishing. +When these prerequisites hold, read [references/staged-upload.md](references/staged-upload.md) +before calling `create_upload`. If the tools are absent or `api.display.dev` is unreachable, use `dsp publish` where the file exists, ask the user to publish through the dashboard, or use inline `content` only when the source is small enough. Never install a runtime or move credentials to work around the missing capability. -For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow -`api.display.dev` for code execution, then the user must start a new task. MCP -connector traffic uses a separate path and does not grant shell egress. -Personal Claude Pro and Max currently provide no custom code-execution domain -allowlist, so large staged publishing is unavailable there; use one of the -fallbacks above. - -Example: - -```text -User: Publish the 20MB report generated in code execution for my organization. -Agent: Confirms the authorized remote tools, measures the file, calls -create_upload, transfers raw bytes through api.display.dev with every returned -header, calls publish(upload_id=...), and reports the artifact URL without the -temporary bearer or source. -``` - ## Create or sign in to a display.dev account When the host bundles the display.dev remote MCP server, sign-in uses that @@ -263,26 +225,8 @@ provisioning result. ## Add or transfer a company email domain -Email-domain management is Owner-only. Prefer the authorized MCP tools when -they are registered: - -- `list_email_domains` lists verified, pending, dormant, and transfer states. -- `add_email_domain` adds a domain and returns the DNS TXT record. A domain - already connected elsewhere creates an inert transfer request. -- `verify_email_domain` checks the fresh TXT record for an ordinary claim or - transfer request. It also rechecks a support-required transfer after support - confirms the blocker is resolved. -- `remove_email_domain` removes an ordinary row or cancels a non-terminal - transfer request. - -Installed-CLI equivalents are: - -```bash -dsp email-domains list -dsp email-domains add -dsp email-domains verify -dsp email-domains remove -``` +Email-domain management is Owner-only. For listing, adding, verifying, transferring, +or removing a domain, first read [references/email-domains.md](references/email-domains.md). The paid email domains add-on is human-operated. Neither MCP nor the direct CLI can enable or disable it. If the user asks to change it, direct a current Owner @@ -290,49 +234,12 @@ to **Settings → Email Domains**. Do not call the REST endpoint as a fallback o claim the add-on changed. After the Owner completes the change, continue with the registered domain-management tools when needed. -For a foreign-domain collision, surface the request-specific TXT record and -state that DNS verification does not move the domain by itself. After -verification, report `transfer_ready` or `transfer_support_required` without -inferring the source organization or its contents. Source-owner approval is -not required. - -Handle each returned transfer state explicitly: - -- `transfer_pending_dns`: publish the request-specific TXT record, then verify. - The Owner may cancel it with `remove_email_domain` or - `dsp email-domains remove `. -- `transfer_ready`: send a current target Owner to **Settings → Email Domains** - for final review and confirmation. The Owner may still cancel instead. -- `transfer_support_required`: do not guess at hidden source details. Tell the - user to contact support. After support confirms the issue is resolved, run - `verify_email_domain` or `dsp email-domains verify ` again on the - same unexpired request. The existing DNS proof is retained. If the retry - remains support-required, report that result without exposing or guessing - source details. The Owner may cancel instead. -- `transfer_expired`: the request is terminal and cannot be cancelled. Run - `add_email_domain` or `dsp email-domains add ` again to create a new - request and fresh TXT record. - -Completed transfers appear as ordinary `verified` rows. Cancelled requests are -terminal and no longer actionable; add the domain again only when the user -intends to start a fresh request. - Final confirmation is dashboard-only. When a transfer is ready, direct a current target Owner to **Settings → Email Domains** to review the people who will move, accept session revocation and source retirement, and confirm. Neither an MCP tool nor the CLI can complete the transfer. Never claim completion after DNS verification. -Example: - -```text -User: Transfer example.com to this organization. -Agent: add_email_domain returns a fresh TXT record because example.com is -already connected elsewhere. The user publishes it, then the agent runs -verify_email_domain. If the result is transfer_ready, the agent sends a target -Owner to Settings → Email Domains for the final review and confirmation. -``` - ## Share an artifact Use only the audience the user requested: diff --git a/codex/display-dev/skills/display-dev/references/email-domains.md b/codex/display-dev/skills/display-dev/references/email-domains.md new file mode 100644 index 0000000..5ab12ee --- /dev/null +++ b/codex/display-dev/skills/display-dev/references/email-domains.md @@ -0,0 +1,60 @@ +# Company email domains + +Prefer the authorized MCP tools when +they are registered: + +- `list_email_domains` lists verified, pending, dormant, and transfer states. +- `add_email_domain` adds a domain and returns the DNS TXT record. A domain + already connected elsewhere creates an inert transfer request. +- `verify_email_domain` checks the fresh TXT record for an ordinary claim or + transfer request. It also rechecks a support-required transfer after support + confirms the blocker is resolved. +- `remove_email_domain` removes an ordinary row or cancels a non-terminal + transfer request. + +Installed-CLI equivalents are: + +```bash +dsp email-domains list +dsp email-domains add +dsp email-domains verify +dsp email-domains remove +``` + +For a foreign-domain collision, surface the request-specific TXT record and +state that DNS verification does not move the domain by itself. After +verification, report `transfer_ready` or `transfer_support_required` without +inferring the source organization or its contents. Source-owner approval is +not required. + +Handle each returned transfer state explicitly: + +- `transfer_pending_dns`: publish the request-specific TXT record, then verify. + The Owner may cancel it with `remove_email_domain` or + `dsp email-domains remove `. +- `transfer_ready`: send a current target Owner to **Settings → Email Domains** + for final review and confirmation. The Owner may still cancel instead. +- `transfer_support_required`: do not guess at hidden source details. Tell the + user to contact support. After support confirms the issue is resolved, run + `verify_email_domain` or `dsp email-domains verify ` again on the + same unexpired request. The existing DNS proof is retained. If the retry + remains support-required, report that result without exposing or guessing + source details. The Owner may cancel instead. +- `transfer_expired`: the request is terminal and cannot be cancelled. Run + `add_email_domain` or `dsp email-domains add ` again to create a new + request and fresh TXT record. + +Completed transfers appear as ordinary `verified` rows. Cancelled requests are +terminal and no longer actionable; add the domain again only when the user +intends to start a fresh request. + +Example: + +```text +User: Transfer example.com to this organization. +Agent: add_email_domain returns a fresh TXT record because example.com is +already connected elsewhere. The user publishes it, then the agent runs +verify_email_domain. If the result is transfer_ready, the agent sends a target +Owner to Settings → Email Domains for the final review and confirmation. +``` + diff --git a/codex/display-dev/skills/display-dev/references/staged-upload.md b/codex/display-dev/skills/display-dev/references/staged-upload.md new file mode 100644 index 0000000..92a7aa9 --- /dev/null +++ b/codex/display-dev/skills/display-dev/references/staged-upload.md @@ -0,0 +1,43 @@ +# Staged remote MCP upload + +Then perform these steps: + +1. Measure the raw file byte length without reading its contents into the + conversation. Call `create_upload` with the basename and exact `size_bytes`. +2. In code execution, send the raw file bytes with `PUT` to the returned + first-party `upload_url`. Use every exact entry in `required_headers`, + including `Authorization`, `Content-Type`, and `Content-Length`. Do not + encode the file as JSON, base64, or multipart form data. It is acceptable + for the initiating execution trace to show the URL and bearer. +3. Call `publish` once with the returned `upload_id` plus the user-approved + name, visibility, sharing, or `short_id` / `base_version` update fields. For + an ordinary authenticated publish, omit visibility; pass `company` only + when the user asked to share with the organization. Do not also pass `content` or `format`. +4. Report the canonical artifact URL and relevant publish result. Do not repeat + the upload bearer or source. Discard the upload ID. + +The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new +`create_upload` without inferring or revealing whether expiry, ticket validity, +organization binding, or a missing object caused it. If it returns +`upload_size_mismatch`, report that exact mismatch, measure the raw file again, +and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on +Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes +between staging and publishing. + +For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow +`api.display.dev` for code execution, then the user must start a new task. MCP +connector traffic uses a separate path and does not grant shell egress. +Personal Claude Pro and Max currently provide no custom code-execution domain +allowlist, so large staged publishing is unavailable there; use one of the +fallbacks in `SKILL.md`. + +Example: + +```text +User: Publish the 20MB report generated in code execution for my organization. +Agent: Confirms the authorized remote tools, measures the file, calls +create_upload, transfers raw bytes through api.display.dev with every returned +header, calls publish(upload_id=...), and reports the artifact URL without the +temporary bearer or source. +``` + diff --git a/display-dev/SKILL.md b/display-dev/SKILL.md index 6387220..47431a1 100644 --- a/display-dev/SKILL.md +++ b/display-dev/SKILL.md @@ -181,52 +181,14 @@ true: approaches the safe inline tool-call ceiling; and - the code-execution environment can send HTTPS requests to `api.display.dev`. -Then perform these steps: - -1. Measure the raw file byte length without reading its contents into the - conversation. Call `create_upload` with the basename and exact `size_bytes`. -2. In code execution, send the raw file bytes with `PUT` to the returned - first-party `upload_url`. Use every exact entry in `required_headers`, - including `Authorization`, `Content-Type`, and `Content-Length`. Do not - encode the file as JSON, base64, or multipart form data. It is acceptable - for the initiating execution trace to show the URL and bearer. -3. Call `publish` once with the returned `upload_id` plus the user-approved - name, visibility, sharing, or `short_id` / `base_version` update fields. For - an ordinary authenticated publish, omit visibility; pass `company` only - when the user asked to share with the organization. Do not also pass `content` or `format`. -4. Report the canonical artifact URL and relevant publish result. Do not repeat - the upload bearer or source. Discard the upload ID. - -The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new -`create_upload` without inferring or revealing whether expiry, ticket validity, -organization binding, or a missing object caused it. If it returns -`upload_size_mismatch`, report that exact mismatch, measure the raw file again, -and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on -Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes -between staging and publishing. +When these prerequisites hold, read [references/staged-upload.md](references/staged-upload.md) +before calling `create_upload`. If the tools are absent or `api.display.dev` is unreachable, use `dsp publish` where the file exists, ask the user to publish through the dashboard, or use inline `content` only when the source is small enough. Never install a runtime or move credentials to work around the missing capability. -For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow -`api.display.dev` for code execution, then the user must start a new task. MCP -connector traffic uses a separate path and does not grant shell egress. -Personal Claude Pro and Max currently provide no custom code-execution domain -allowlist, so large staged publishing is unavailable there; use one of the -fallbacks above. - -Example: - -```text -User: Publish the 20MB report generated in code execution for my organization. -Agent: Confirms the authorized remote tools, measures the file, calls -create_upload, transfers raw bytes through api.display.dev with every returned -header, calls publish(upload_id=...), and reports the artifact URL without the -temporary bearer or source. -``` - ## Create or sign in to a display.dev account When the host bundles the display.dev remote MCP server, sign-in uses that @@ -263,26 +225,8 @@ provisioning result. ## Add or transfer a company email domain -Email-domain management is Owner-only. Prefer the authorized MCP tools when -they are registered: - -- `list_email_domains` lists verified, pending, dormant, and transfer states. -- `add_email_domain` adds a domain and returns the DNS TXT record. A domain - already connected elsewhere creates an inert transfer request. -- `verify_email_domain` checks the fresh TXT record for an ordinary claim or - transfer request. It also rechecks a support-required transfer after support - confirms the blocker is resolved. -- `remove_email_domain` removes an ordinary row or cancels a non-terminal - transfer request. - -Installed-CLI equivalents are: - -```bash -dsp email-domains list -dsp email-domains add -dsp email-domains verify -dsp email-domains remove -``` +Email-domain management is Owner-only. For listing, adding, verifying, transferring, +or removing a domain, first read [references/email-domains.md](references/email-domains.md). The paid email domains add-on is human-operated. Neither MCP nor the direct CLI can enable or disable it. If the user asks to change it, direct a current Owner @@ -290,49 +234,12 @@ to **Settings → Email Domains**. Do not call the REST endpoint as a fallback o claim the add-on changed. After the Owner completes the change, continue with the registered domain-management tools when needed. -For a foreign-domain collision, surface the request-specific TXT record and -state that DNS verification does not move the domain by itself. After -verification, report `transfer_ready` or `transfer_support_required` without -inferring the source organization or its contents. Source-owner approval is -not required. - -Handle each returned transfer state explicitly: - -- `transfer_pending_dns`: publish the request-specific TXT record, then verify. - The Owner may cancel it with `remove_email_domain` or - `dsp email-domains remove `. -- `transfer_ready`: send a current target Owner to **Settings → Email Domains** - for final review and confirmation. The Owner may still cancel instead. -- `transfer_support_required`: do not guess at hidden source details. Tell the - user to contact support. After support confirms the issue is resolved, run - `verify_email_domain` or `dsp email-domains verify ` again on the - same unexpired request. The existing DNS proof is retained. If the retry - remains support-required, report that result without exposing or guessing - source details. The Owner may cancel instead. -- `transfer_expired`: the request is terminal and cannot be cancelled. Run - `add_email_domain` or `dsp email-domains add ` again to create a new - request and fresh TXT record. - -Completed transfers appear as ordinary `verified` rows. Cancelled requests are -terminal and no longer actionable; add the domain again only when the user -intends to start a fresh request. - Final confirmation is dashboard-only. When a transfer is ready, direct a current target Owner to **Settings → Email Domains** to review the people who will move, accept session revocation and source retirement, and confirm. Neither an MCP tool nor the CLI can complete the transfer. Never claim completion after DNS verification. -Example: - -```text -User: Transfer example.com to this organization. -Agent: add_email_domain returns a fresh TXT record because example.com is -already connected elsewhere. The user publishes it, then the agent runs -verify_email_domain. If the result is transfer_ready, the agent sends a target -Owner to Settings → Email Domains for the final review and confirmation. -``` - ## Share an artifact Use only the audience the user requested: diff --git a/display-dev/references/email-domains.md b/display-dev/references/email-domains.md new file mode 100644 index 0000000..5ab12ee --- /dev/null +++ b/display-dev/references/email-domains.md @@ -0,0 +1,60 @@ +# Company email domains + +Prefer the authorized MCP tools when +they are registered: + +- `list_email_domains` lists verified, pending, dormant, and transfer states. +- `add_email_domain` adds a domain and returns the DNS TXT record. A domain + already connected elsewhere creates an inert transfer request. +- `verify_email_domain` checks the fresh TXT record for an ordinary claim or + transfer request. It also rechecks a support-required transfer after support + confirms the blocker is resolved. +- `remove_email_domain` removes an ordinary row or cancels a non-terminal + transfer request. + +Installed-CLI equivalents are: + +```bash +dsp email-domains list +dsp email-domains add +dsp email-domains verify +dsp email-domains remove +``` + +For a foreign-domain collision, surface the request-specific TXT record and +state that DNS verification does not move the domain by itself. After +verification, report `transfer_ready` or `transfer_support_required` without +inferring the source organization or its contents. Source-owner approval is +not required. + +Handle each returned transfer state explicitly: + +- `transfer_pending_dns`: publish the request-specific TXT record, then verify. + The Owner may cancel it with `remove_email_domain` or + `dsp email-domains remove `. +- `transfer_ready`: send a current target Owner to **Settings → Email Domains** + for final review and confirmation. The Owner may still cancel instead. +- `transfer_support_required`: do not guess at hidden source details. Tell the + user to contact support. After support confirms the issue is resolved, run + `verify_email_domain` or `dsp email-domains verify ` again on the + same unexpired request. The existing DNS proof is retained. If the retry + remains support-required, report that result without exposing or guessing + source details. The Owner may cancel instead. +- `transfer_expired`: the request is terminal and cannot be cancelled. Run + `add_email_domain` or `dsp email-domains add ` again to create a new + request and fresh TXT record. + +Completed transfers appear as ordinary `verified` rows. Cancelled requests are +terminal and no longer actionable; add the domain again only when the user +intends to start a fresh request. + +Example: + +```text +User: Transfer example.com to this organization. +Agent: add_email_domain returns a fresh TXT record because example.com is +already connected elsewhere. The user publishes it, then the agent runs +verify_email_domain. If the result is transfer_ready, the agent sends a target +Owner to Settings → Email Domains for the final review and confirmation. +``` + diff --git a/display-dev/references/staged-upload.md b/display-dev/references/staged-upload.md new file mode 100644 index 0000000..92a7aa9 --- /dev/null +++ b/display-dev/references/staged-upload.md @@ -0,0 +1,43 @@ +# Staged remote MCP upload + +Then perform these steps: + +1. Measure the raw file byte length without reading its contents into the + conversation. Call `create_upload` with the basename and exact `size_bytes`. +2. In code execution, send the raw file bytes with `PUT` to the returned + first-party `upload_url`. Use every exact entry in `required_headers`, + including `Authorization`, `Content-Type`, and `Content-Length`. Do not + encode the file as JSON, base64, or multipart form data. It is acceptable + for the initiating execution trace to show the URL and bearer. +3. Call `publish` once with the returned `upload_id` plus the user-approved + name, visibility, sharing, or `short_id` / `base_version` update fields. For + an ordinary authenticated publish, omit visibility; pass `company` only + when the user asked to share with the organization. Do not also pass `content` or `format`. +4. Report the canonical artifact URL and relevant publish result. Do not repeat + the upload bearer or source. Discard the upload ID. + +The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new +`create_upload` without inferring or revealing whether expiry, ticket validity, +organization binding, or a missing object caused it. If it returns +`upload_size_mismatch`, report that exact mismatch, measure the raw file again, +and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on +Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes +between staging and publishing. + +For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow +`api.display.dev` for code execution, then the user must start a new task. MCP +connector traffic uses a separate path and does not grant shell egress. +Personal Claude Pro and Max currently provide no custom code-execution domain +allowlist, so large staged publishing is unavailable there; use one of the +fallbacks in `SKILL.md`. + +Example: + +```text +User: Publish the 20MB report generated in code execution for my organization. +Agent: Confirms the authorized remote tools, measures the file, calls +create_upload, transfers raw bytes through api.display.dev with every returned +header, calls publish(upload_id=...), and reports the artifact URL without the +temporary bearer or source. +``` + diff --git a/hermes/productivity/display.dev/SKILL.md b/hermes/productivity/display.dev/SKILL.md index 6387220..47431a1 100644 --- a/hermes/productivity/display.dev/SKILL.md +++ b/hermes/productivity/display.dev/SKILL.md @@ -181,52 +181,14 @@ true: approaches the safe inline tool-call ceiling; and - the code-execution environment can send HTTPS requests to `api.display.dev`. -Then perform these steps: - -1. Measure the raw file byte length without reading its contents into the - conversation. Call `create_upload` with the basename and exact `size_bytes`. -2. In code execution, send the raw file bytes with `PUT` to the returned - first-party `upload_url`. Use every exact entry in `required_headers`, - including `Authorization`, `Content-Type`, and `Content-Length`. Do not - encode the file as JSON, base64, or multipart form data. It is acceptable - for the initiating execution trace to show the URL and bearer. -3. Call `publish` once with the returned `upload_id` plus the user-approved - name, visibility, sharing, or `short_id` / `base_version` update fields. For - an ordinary authenticated publish, omit visibility; pass `company` only - when the user asked to share with the organization. Do not also pass `content` or `format`. -4. Report the canonical artifact URL and relevant publish result. Do not repeat - the upload bearer or source. Discard the upload ID. - -The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new -`create_upload` without inferring or revealing whether expiry, ticket validity, -organization binding, or a missing object caused it. If it returns -`upload_size_mismatch`, report that exact mismatch, measure the raw file again, -and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on -Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes -between staging and publishing. +When these prerequisites hold, read [references/staged-upload.md](references/staged-upload.md) +before calling `create_upload`. If the tools are absent or `api.display.dev` is unreachable, use `dsp publish` where the file exists, ask the user to publish through the dashboard, or use inline `content` only when the source is small enough. Never install a runtime or move credentials to work around the missing capability. -For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow -`api.display.dev` for code execution, then the user must start a new task. MCP -connector traffic uses a separate path and does not grant shell egress. -Personal Claude Pro and Max currently provide no custom code-execution domain -allowlist, so large staged publishing is unavailable there; use one of the -fallbacks above. - -Example: - -```text -User: Publish the 20MB report generated in code execution for my organization. -Agent: Confirms the authorized remote tools, measures the file, calls -create_upload, transfers raw bytes through api.display.dev with every returned -header, calls publish(upload_id=...), and reports the artifact URL without the -temporary bearer or source. -``` - ## Create or sign in to a display.dev account When the host bundles the display.dev remote MCP server, sign-in uses that @@ -263,26 +225,8 @@ provisioning result. ## Add or transfer a company email domain -Email-domain management is Owner-only. Prefer the authorized MCP tools when -they are registered: - -- `list_email_domains` lists verified, pending, dormant, and transfer states. -- `add_email_domain` adds a domain and returns the DNS TXT record. A domain - already connected elsewhere creates an inert transfer request. -- `verify_email_domain` checks the fresh TXT record for an ordinary claim or - transfer request. It also rechecks a support-required transfer after support - confirms the blocker is resolved. -- `remove_email_domain` removes an ordinary row or cancels a non-terminal - transfer request. - -Installed-CLI equivalents are: - -```bash -dsp email-domains list -dsp email-domains add -dsp email-domains verify -dsp email-domains remove -``` +Email-domain management is Owner-only. For listing, adding, verifying, transferring, +or removing a domain, first read [references/email-domains.md](references/email-domains.md). The paid email domains add-on is human-operated. Neither MCP nor the direct CLI can enable or disable it. If the user asks to change it, direct a current Owner @@ -290,49 +234,12 @@ to **Settings → Email Domains**. Do not call the REST endpoint as a fallback o claim the add-on changed. After the Owner completes the change, continue with the registered domain-management tools when needed. -For a foreign-domain collision, surface the request-specific TXT record and -state that DNS verification does not move the domain by itself. After -verification, report `transfer_ready` or `transfer_support_required` without -inferring the source organization or its contents. Source-owner approval is -not required. - -Handle each returned transfer state explicitly: - -- `transfer_pending_dns`: publish the request-specific TXT record, then verify. - The Owner may cancel it with `remove_email_domain` or - `dsp email-domains remove `. -- `transfer_ready`: send a current target Owner to **Settings → Email Domains** - for final review and confirmation. The Owner may still cancel instead. -- `transfer_support_required`: do not guess at hidden source details. Tell the - user to contact support. After support confirms the issue is resolved, run - `verify_email_domain` or `dsp email-domains verify ` again on the - same unexpired request. The existing DNS proof is retained. If the retry - remains support-required, report that result without exposing or guessing - source details. The Owner may cancel instead. -- `transfer_expired`: the request is terminal and cannot be cancelled. Run - `add_email_domain` or `dsp email-domains add ` again to create a new - request and fresh TXT record. - -Completed transfers appear as ordinary `verified` rows. Cancelled requests are -terminal and no longer actionable; add the domain again only when the user -intends to start a fresh request. - Final confirmation is dashboard-only. When a transfer is ready, direct a current target Owner to **Settings → Email Domains** to review the people who will move, accept session revocation and source retirement, and confirm. Neither an MCP tool nor the CLI can complete the transfer. Never claim completion after DNS verification. -Example: - -```text -User: Transfer example.com to this organization. -Agent: add_email_domain returns a fresh TXT record because example.com is -already connected elsewhere. The user publishes it, then the agent runs -verify_email_domain. If the result is transfer_ready, the agent sends a target -Owner to Settings → Email Domains for the final review and confirmation. -``` - ## Share an artifact Use only the audience the user requested: diff --git a/hermes/productivity/display.dev/references/email-domains.md b/hermes/productivity/display.dev/references/email-domains.md new file mode 100644 index 0000000..5ab12ee --- /dev/null +++ b/hermes/productivity/display.dev/references/email-domains.md @@ -0,0 +1,60 @@ +# Company email domains + +Prefer the authorized MCP tools when +they are registered: + +- `list_email_domains` lists verified, pending, dormant, and transfer states. +- `add_email_domain` adds a domain and returns the DNS TXT record. A domain + already connected elsewhere creates an inert transfer request. +- `verify_email_domain` checks the fresh TXT record for an ordinary claim or + transfer request. It also rechecks a support-required transfer after support + confirms the blocker is resolved. +- `remove_email_domain` removes an ordinary row or cancels a non-terminal + transfer request. + +Installed-CLI equivalents are: + +```bash +dsp email-domains list +dsp email-domains add +dsp email-domains verify +dsp email-domains remove +``` + +For a foreign-domain collision, surface the request-specific TXT record and +state that DNS verification does not move the domain by itself. After +verification, report `transfer_ready` or `transfer_support_required` without +inferring the source organization or its contents. Source-owner approval is +not required. + +Handle each returned transfer state explicitly: + +- `transfer_pending_dns`: publish the request-specific TXT record, then verify. + The Owner may cancel it with `remove_email_domain` or + `dsp email-domains remove `. +- `transfer_ready`: send a current target Owner to **Settings → Email Domains** + for final review and confirmation. The Owner may still cancel instead. +- `transfer_support_required`: do not guess at hidden source details. Tell the + user to contact support. After support confirms the issue is resolved, run + `verify_email_domain` or `dsp email-domains verify ` again on the + same unexpired request. The existing DNS proof is retained. If the retry + remains support-required, report that result without exposing or guessing + source details. The Owner may cancel instead. +- `transfer_expired`: the request is terminal and cannot be cancelled. Run + `add_email_domain` or `dsp email-domains add ` again to create a new + request and fresh TXT record. + +Completed transfers appear as ordinary `verified` rows. Cancelled requests are +terminal and no longer actionable; add the domain again only when the user +intends to start a fresh request. + +Example: + +```text +User: Transfer example.com to this organization. +Agent: add_email_domain returns a fresh TXT record because example.com is +already connected elsewhere. The user publishes it, then the agent runs +verify_email_domain. If the result is transfer_ready, the agent sends a target +Owner to Settings → Email Domains for the final review and confirmation. +``` + diff --git a/hermes/productivity/display.dev/references/staged-upload.md b/hermes/productivity/display.dev/references/staged-upload.md new file mode 100644 index 0000000..92a7aa9 --- /dev/null +++ b/hermes/productivity/display.dev/references/staged-upload.md @@ -0,0 +1,43 @@ +# Staged remote MCP upload + +Then perform these steps: + +1. Measure the raw file byte length without reading its contents into the + conversation. Call `create_upload` with the basename and exact `size_bytes`. +2. In code execution, send the raw file bytes with `PUT` to the returned + first-party `upload_url`. Use every exact entry in `required_headers`, + including `Authorization`, `Content-Type`, and `Content-Length`. Do not + encode the file as JSON, base64, or multipart form data. It is acceptable + for the initiating execution trace to show the URL and bearer. +3. Call `publish` once with the returned `upload_id` plus the user-approved + name, visibility, sharing, or `short_id` / `base_version` update fields. For + an ordinary authenticated publish, omit visibility; pass `company` only + when the user asked to share with the organization. Do not also pass `content` or `format`. +4. Report the canonical artifact URL and relevant publish result. Do not repeat + the upload bearer or source. Discard the upload ID. + +The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new +`create_upload` without inferring or revealing whether expiry, ticket validity, +organization binding, or a missing object caused it. If it returns +`upload_size_mismatch`, report that exact mismatch, measure the raw file again, +and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on +Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes +between staging and publishing. + +For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow +`api.display.dev` for code execution, then the user must start a new task. MCP +connector traffic uses a separate path and does not grant shell egress. +Personal Claude Pro and Max currently provide no custom code-execution domain +allowlist, so large staged publishing is unavailable there; use one of the +fallbacks in `SKILL.md`. + +Example: + +```text +User: Publish the 20MB report generated in code execution for my organization. +Agent: Confirms the authorized remote tools, measures the file, calls +create_upload, transfers raw bytes through api.display.dev with every returned +header, calls publish(upload_id=...), and reports the artifact URL without the +temporary bearer or source. +``` + diff --git a/pi/agent/skills/display-dev/SKILL.md b/pi/agent/skills/display-dev/SKILL.md index 6387220..47431a1 100644 --- a/pi/agent/skills/display-dev/SKILL.md +++ b/pi/agent/skills/display-dev/SKILL.md @@ -181,52 +181,14 @@ true: approaches the safe inline tool-call ceiling; and - the code-execution environment can send HTTPS requests to `api.display.dev`. -Then perform these steps: - -1. Measure the raw file byte length without reading its contents into the - conversation. Call `create_upload` with the basename and exact `size_bytes`. -2. In code execution, send the raw file bytes with `PUT` to the returned - first-party `upload_url`. Use every exact entry in `required_headers`, - including `Authorization`, `Content-Type`, and `Content-Length`. Do not - encode the file as JSON, base64, or multipart form data. It is acceptable - for the initiating execution trace to show the URL and bearer. -3. Call `publish` once with the returned `upload_id` plus the user-approved - name, visibility, sharing, or `short_id` / `base_version` update fields. For - an ordinary authenticated publish, omit visibility; pass `company` only - when the user asked to share with the organization. Do not also pass `content` or `format`. -4. Report the canonical artifact URL and relevant publish result. Do not repeat - the upload bearer or source. Discard the upload ID. - -The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new -`create_upload` without inferring or revealing whether expiry, ticket validity, -organization binding, or a missing object caused it. If it returns -`upload_size_mismatch`, report that exact mismatch, measure the raw file again, -and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on -Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes -between staging and publishing. +When these prerequisites hold, read [references/staged-upload.md](references/staged-upload.md) +before calling `create_upload`. If the tools are absent or `api.display.dev` is unreachable, use `dsp publish` where the file exists, ask the user to publish through the dashboard, or use inline `content` only when the source is small enough. Never install a runtime or move credentials to work around the missing capability. -For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow -`api.display.dev` for code execution, then the user must start a new task. MCP -connector traffic uses a separate path and does not grant shell egress. -Personal Claude Pro and Max currently provide no custom code-execution domain -allowlist, so large staged publishing is unavailable there; use one of the -fallbacks above. - -Example: - -```text -User: Publish the 20MB report generated in code execution for my organization. -Agent: Confirms the authorized remote tools, measures the file, calls -create_upload, transfers raw bytes through api.display.dev with every returned -header, calls publish(upload_id=...), and reports the artifact URL without the -temporary bearer or source. -``` - ## Create or sign in to a display.dev account When the host bundles the display.dev remote MCP server, sign-in uses that @@ -263,26 +225,8 @@ provisioning result. ## Add or transfer a company email domain -Email-domain management is Owner-only. Prefer the authorized MCP tools when -they are registered: - -- `list_email_domains` lists verified, pending, dormant, and transfer states. -- `add_email_domain` adds a domain and returns the DNS TXT record. A domain - already connected elsewhere creates an inert transfer request. -- `verify_email_domain` checks the fresh TXT record for an ordinary claim or - transfer request. It also rechecks a support-required transfer after support - confirms the blocker is resolved. -- `remove_email_domain` removes an ordinary row or cancels a non-terminal - transfer request. - -Installed-CLI equivalents are: - -```bash -dsp email-domains list -dsp email-domains add -dsp email-domains verify -dsp email-domains remove -``` +Email-domain management is Owner-only. For listing, adding, verifying, transferring, +or removing a domain, first read [references/email-domains.md](references/email-domains.md). The paid email domains add-on is human-operated. Neither MCP nor the direct CLI can enable or disable it. If the user asks to change it, direct a current Owner @@ -290,49 +234,12 @@ to **Settings → Email Domains**. Do not call the REST endpoint as a fallback o claim the add-on changed. After the Owner completes the change, continue with the registered domain-management tools when needed. -For a foreign-domain collision, surface the request-specific TXT record and -state that DNS verification does not move the domain by itself. After -verification, report `transfer_ready` or `transfer_support_required` without -inferring the source organization or its contents. Source-owner approval is -not required. - -Handle each returned transfer state explicitly: - -- `transfer_pending_dns`: publish the request-specific TXT record, then verify. - The Owner may cancel it with `remove_email_domain` or - `dsp email-domains remove `. -- `transfer_ready`: send a current target Owner to **Settings → Email Domains** - for final review and confirmation. The Owner may still cancel instead. -- `transfer_support_required`: do not guess at hidden source details. Tell the - user to contact support. After support confirms the issue is resolved, run - `verify_email_domain` or `dsp email-domains verify ` again on the - same unexpired request. The existing DNS proof is retained. If the retry - remains support-required, report that result without exposing or guessing - source details. The Owner may cancel instead. -- `transfer_expired`: the request is terminal and cannot be cancelled. Run - `add_email_domain` or `dsp email-domains add ` again to create a new - request and fresh TXT record. - -Completed transfers appear as ordinary `verified` rows. Cancelled requests are -terminal and no longer actionable; add the domain again only when the user -intends to start a fresh request. - Final confirmation is dashboard-only. When a transfer is ready, direct a current target Owner to **Settings → Email Domains** to review the people who will move, accept session revocation and source retirement, and confirm. Neither an MCP tool nor the CLI can complete the transfer. Never claim completion after DNS verification. -Example: - -```text -User: Transfer example.com to this organization. -Agent: add_email_domain returns a fresh TXT record because example.com is -already connected elsewhere. The user publishes it, then the agent runs -verify_email_domain. If the result is transfer_ready, the agent sends a target -Owner to Settings → Email Domains for the final review and confirmation. -``` - ## Share an artifact Use only the audience the user requested: diff --git a/pi/agent/skills/display-dev/references/email-domains.md b/pi/agent/skills/display-dev/references/email-domains.md new file mode 100644 index 0000000..5ab12ee --- /dev/null +++ b/pi/agent/skills/display-dev/references/email-domains.md @@ -0,0 +1,60 @@ +# Company email domains + +Prefer the authorized MCP tools when +they are registered: + +- `list_email_domains` lists verified, pending, dormant, and transfer states. +- `add_email_domain` adds a domain and returns the DNS TXT record. A domain + already connected elsewhere creates an inert transfer request. +- `verify_email_domain` checks the fresh TXT record for an ordinary claim or + transfer request. It also rechecks a support-required transfer after support + confirms the blocker is resolved. +- `remove_email_domain` removes an ordinary row or cancels a non-terminal + transfer request. + +Installed-CLI equivalents are: + +```bash +dsp email-domains list +dsp email-domains add +dsp email-domains verify +dsp email-domains remove +``` + +For a foreign-domain collision, surface the request-specific TXT record and +state that DNS verification does not move the domain by itself. After +verification, report `transfer_ready` or `transfer_support_required` without +inferring the source organization or its contents. Source-owner approval is +not required. + +Handle each returned transfer state explicitly: + +- `transfer_pending_dns`: publish the request-specific TXT record, then verify. + The Owner may cancel it with `remove_email_domain` or + `dsp email-domains remove `. +- `transfer_ready`: send a current target Owner to **Settings → Email Domains** + for final review and confirmation. The Owner may still cancel instead. +- `transfer_support_required`: do not guess at hidden source details. Tell the + user to contact support. After support confirms the issue is resolved, run + `verify_email_domain` or `dsp email-domains verify ` again on the + same unexpired request. The existing DNS proof is retained. If the retry + remains support-required, report that result without exposing or guessing + source details. The Owner may cancel instead. +- `transfer_expired`: the request is terminal and cannot be cancelled. Run + `add_email_domain` or `dsp email-domains add ` again to create a new + request and fresh TXT record. + +Completed transfers appear as ordinary `verified` rows. Cancelled requests are +terminal and no longer actionable; add the domain again only when the user +intends to start a fresh request. + +Example: + +```text +User: Transfer example.com to this organization. +Agent: add_email_domain returns a fresh TXT record because example.com is +already connected elsewhere. The user publishes it, then the agent runs +verify_email_domain. If the result is transfer_ready, the agent sends a target +Owner to Settings → Email Domains for the final review and confirmation. +``` + diff --git a/pi/agent/skills/display-dev/references/staged-upload.md b/pi/agent/skills/display-dev/references/staged-upload.md new file mode 100644 index 0000000..92a7aa9 --- /dev/null +++ b/pi/agent/skills/display-dev/references/staged-upload.md @@ -0,0 +1,43 @@ +# Staged remote MCP upload + +Then perform these steps: + +1. Measure the raw file byte length without reading its contents into the + conversation. Call `create_upload` with the basename and exact `size_bytes`. +2. In code execution, send the raw file bytes with `PUT` to the returned + first-party `upload_url`. Use every exact entry in `required_headers`, + including `Authorization`, `Content-Type`, and `Content-Length`. Do not + encode the file as JSON, base64, or multipart form data. It is acceptable + for the initiating execution trace to show the URL and bearer. +3. Call `publish` once with the returned `upload_id` plus the user-approved + name, visibility, sharing, or `short_id` / `base_version` update fields. For + an ordinary authenticated publish, omit visibility; pass `company` only + when the user asked to share with the organization. Do not also pass `content` or `format`. +4. Report the canonical artifact URL and relevant publish result. Do not repeat + the upload bearer or source. Discard the upload ID. + +The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new +`create_upload` without inferring or revealing whether expiry, ticket validity, +organization binding, or a missing object caused it. If it returns +`upload_size_mismatch`, report that exact mismatch, measure the raw file again, +and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on +Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes +between staging and publishing. + +For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow +`api.display.dev` for code execution, then the user must start a new task. MCP +connector traffic uses a separate path and does not grant shell egress. +Personal Claude Pro and Max currently provide no custom code-execution domain +allowlist, so large staged publishing is unavailable there; use one of the +fallbacks in `SKILL.md`. + +Example: + +```text +User: Publish the 20MB report generated in code execution for my organization. +Agent: Confirms the authorized remote tools, measures the file, calls +create_upload, transfers raw bytes through api.display.dev with every returned +header, calls publish(upload_id=...), and reports the artifact URL without the +temporary bearer or source. +``` + diff --git a/skills/display-dev/SKILL.md b/skills/display-dev/SKILL.md index 6387220..47431a1 100644 --- a/skills/display-dev/SKILL.md +++ b/skills/display-dev/SKILL.md @@ -181,52 +181,14 @@ true: approaches the safe inline tool-call ceiling; and - the code-execution environment can send HTTPS requests to `api.display.dev`. -Then perform these steps: - -1. Measure the raw file byte length without reading its contents into the - conversation. Call `create_upload` with the basename and exact `size_bytes`. -2. In code execution, send the raw file bytes with `PUT` to the returned - first-party `upload_url`. Use every exact entry in `required_headers`, - including `Authorization`, `Content-Type`, and `Content-Length`. Do not - encode the file as JSON, base64, or multipart form data. It is acceptable - for the initiating execution trace to show the URL and bearer. -3. Call `publish` once with the returned `upload_id` plus the user-approved - name, visibility, sharing, or `short_id` / `base_version` update fields. For - an ordinary authenticated publish, omit visibility; pass `company` only - when the user asked to share with the organization. Do not also pass `content` or `format`. -4. Report the canonical artifact URL and relevant publish result. Do not repeat - the upload bearer or source. Discard the upload ID. - -The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new -`create_upload` without inferring or revealing whether expiry, ticket validity, -organization binding, or a missing object caused it. If it returns -`upload_size_mismatch`, report that exact mismatch, measure the raw file again, -and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on -Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes -between staging and publishing. +When these prerequisites hold, read [references/staged-upload.md](references/staged-upload.md) +before calling `create_upload`. If the tools are absent or `api.display.dev` is unreachable, use `dsp publish` where the file exists, ask the user to publish through the dashboard, or use inline `content` only when the source is small enough. Never install a runtime or move credentials to work around the missing capability. -For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow -`api.display.dev` for code execution, then the user must start a new task. MCP -connector traffic uses a separate path and does not grant shell egress. -Personal Claude Pro and Max currently provide no custom code-execution domain -allowlist, so large staged publishing is unavailable there; use one of the -fallbacks above. - -Example: - -```text -User: Publish the 20MB report generated in code execution for my organization. -Agent: Confirms the authorized remote tools, measures the file, calls -create_upload, transfers raw bytes through api.display.dev with every returned -header, calls publish(upload_id=...), and reports the artifact URL without the -temporary bearer or source. -``` - ## Create or sign in to a display.dev account When the host bundles the display.dev remote MCP server, sign-in uses that @@ -263,26 +225,8 @@ provisioning result. ## Add or transfer a company email domain -Email-domain management is Owner-only. Prefer the authorized MCP tools when -they are registered: - -- `list_email_domains` lists verified, pending, dormant, and transfer states. -- `add_email_domain` adds a domain and returns the DNS TXT record. A domain - already connected elsewhere creates an inert transfer request. -- `verify_email_domain` checks the fresh TXT record for an ordinary claim or - transfer request. It also rechecks a support-required transfer after support - confirms the blocker is resolved. -- `remove_email_domain` removes an ordinary row or cancels a non-terminal - transfer request. - -Installed-CLI equivalents are: - -```bash -dsp email-domains list -dsp email-domains add -dsp email-domains verify -dsp email-domains remove -``` +Email-domain management is Owner-only. For listing, adding, verifying, transferring, +or removing a domain, first read [references/email-domains.md](references/email-domains.md). The paid email domains add-on is human-operated. Neither MCP nor the direct CLI can enable or disable it. If the user asks to change it, direct a current Owner @@ -290,49 +234,12 @@ to **Settings → Email Domains**. Do not call the REST endpoint as a fallback o claim the add-on changed. After the Owner completes the change, continue with the registered domain-management tools when needed. -For a foreign-domain collision, surface the request-specific TXT record and -state that DNS verification does not move the domain by itself. After -verification, report `transfer_ready` or `transfer_support_required` without -inferring the source organization or its contents. Source-owner approval is -not required. - -Handle each returned transfer state explicitly: - -- `transfer_pending_dns`: publish the request-specific TXT record, then verify. - The Owner may cancel it with `remove_email_domain` or - `dsp email-domains remove `. -- `transfer_ready`: send a current target Owner to **Settings → Email Domains** - for final review and confirmation. The Owner may still cancel instead. -- `transfer_support_required`: do not guess at hidden source details. Tell the - user to contact support. After support confirms the issue is resolved, run - `verify_email_domain` or `dsp email-domains verify ` again on the - same unexpired request. The existing DNS proof is retained. If the retry - remains support-required, report that result without exposing or guessing - source details. The Owner may cancel instead. -- `transfer_expired`: the request is terminal and cannot be cancelled. Run - `add_email_domain` or `dsp email-domains add ` again to create a new - request and fresh TXT record. - -Completed transfers appear as ordinary `verified` rows. Cancelled requests are -terminal and no longer actionable; add the domain again only when the user -intends to start a fresh request. - Final confirmation is dashboard-only. When a transfer is ready, direct a current target Owner to **Settings → Email Domains** to review the people who will move, accept session revocation and source retirement, and confirm. Neither an MCP tool nor the CLI can complete the transfer. Never claim completion after DNS verification. -Example: - -```text -User: Transfer example.com to this organization. -Agent: add_email_domain returns a fresh TXT record because example.com is -already connected elsewhere. The user publishes it, then the agent runs -verify_email_domain. If the result is transfer_ready, the agent sends a target -Owner to Settings → Email Domains for the final review and confirmation. -``` - ## Share an artifact Use only the audience the user requested: diff --git a/skills/display-dev/references/email-domains.md b/skills/display-dev/references/email-domains.md new file mode 100644 index 0000000..5ab12ee --- /dev/null +++ b/skills/display-dev/references/email-domains.md @@ -0,0 +1,60 @@ +# Company email domains + +Prefer the authorized MCP tools when +they are registered: + +- `list_email_domains` lists verified, pending, dormant, and transfer states. +- `add_email_domain` adds a domain and returns the DNS TXT record. A domain + already connected elsewhere creates an inert transfer request. +- `verify_email_domain` checks the fresh TXT record for an ordinary claim or + transfer request. It also rechecks a support-required transfer after support + confirms the blocker is resolved. +- `remove_email_domain` removes an ordinary row or cancels a non-terminal + transfer request. + +Installed-CLI equivalents are: + +```bash +dsp email-domains list +dsp email-domains add +dsp email-domains verify +dsp email-domains remove +``` + +For a foreign-domain collision, surface the request-specific TXT record and +state that DNS verification does not move the domain by itself. After +verification, report `transfer_ready` or `transfer_support_required` without +inferring the source organization or its contents. Source-owner approval is +not required. + +Handle each returned transfer state explicitly: + +- `transfer_pending_dns`: publish the request-specific TXT record, then verify. + The Owner may cancel it with `remove_email_domain` or + `dsp email-domains remove `. +- `transfer_ready`: send a current target Owner to **Settings → Email Domains** + for final review and confirmation. The Owner may still cancel instead. +- `transfer_support_required`: do not guess at hidden source details. Tell the + user to contact support. After support confirms the issue is resolved, run + `verify_email_domain` or `dsp email-domains verify ` again on the + same unexpired request. The existing DNS proof is retained. If the retry + remains support-required, report that result without exposing or guessing + source details. The Owner may cancel instead. +- `transfer_expired`: the request is terminal and cannot be cancelled. Run + `add_email_domain` or `dsp email-domains add ` again to create a new + request and fresh TXT record. + +Completed transfers appear as ordinary `verified` rows. Cancelled requests are +terminal and no longer actionable; add the domain again only when the user +intends to start a fresh request. + +Example: + +```text +User: Transfer example.com to this organization. +Agent: add_email_domain returns a fresh TXT record because example.com is +already connected elsewhere. The user publishes it, then the agent runs +verify_email_domain. If the result is transfer_ready, the agent sends a target +Owner to Settings → Email Domains for the final review and confirmation. +``` + diff --git a/skills/display-dev/references/staged-upload.md b/skills/display-dev/references/staged-upload.md new file mode 100644 index 0000000..92a7aa9 --- /dev/null +++ b/skills/display-dev/references/staged-upload.md @@ -0,0 +1,43 @@ +# Staged remote MCP upload + +Then perform these steps: + +1. Measure the raw file byte length without reading its contents into the + conversation. Call `create_upload` with the basename and exact `size_bytes`. +2. In code execution, send the raw file bytes with `PUT` to the returned + first-party `upload_url`. Use every exact entry in `required_headers`, + including `Authorization`, `Content-Type`, and `Content-Length`. Do not + encode the file as JSON, base64, or multipart form data. It is acceptable + for the initiating execution trace to show the URL and bearer. +3. Call `publish` once with the returned `upload_id` plus the user-approved + name, visibility, sharing, or `short_id` / `base_version` update fields. For + an ordinary authenticated publish, omit visibility; pass `company` only + when the user asked to share with the organization. Do not also pass `content` or `format`. +4. Report the canonical artifact URL and relevant publish result. Do not repeat + the upload bearer or source. Discard the upload ID. + +The capability expires after 15 minutes. Do not finalize the same staged create concurrently. If `publish` returns `upload_unavailable`, start again with a new +`create_upload` without inferring or revealing whether expiry, ticket validity, +organization binding, or a missing object caused it. If it returns +`upload_size_mismatch`, report that exact mismatch, measure the raw file again, +and start with a new upload. Never reuse the old upload ID. Current per-artifact limits still apply: 10MB on +Free and 50MB on Solo, Pro, and Enterprise, including when the plan changes +between staging and publishing. + +For Claude Cowork Team and Enterprise, an Owner or Primary Owner must allow +`api.display.dev` for code execution, then the user must start a new task. MCP +connector traffic uses a separate path and does not grant shell egress. +Personal Claude Pro and Max currently provide no custom code-execution domain +allowlist, so large staged publishing is unavailable there; use one of the +fallbacks in `SKILL.md`. + +Example: + +```text +User: Publish the 20MB report generated in code execution for my organization. +Agent: Confirms the authorized remote tools, measures the file, calls +create_upload, transfers raw bytes through api.display.dev with every returned +header, calls publish(upload_id=...), and reports the artifact URL without the +temporary bearer or source. +``` + diff --git a/tests/archive-candidate.test.mjs b/tests/archive-candidate.test.mjs index 608828e..6e542be 100644 --- a/tests/archive-candidate.test.mjs +++ b/tests/archive-candidate.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { chmod, cp, mkdtemp, mkdir, readFile, readdir, symlink, writeFile } from 'node:fs/promises'; +import { chmod, cp, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -38,14 +38,14 @@ function parseTar(bytes) { async function expectedEntries(root) { const names = ['SKILL.md']; - for (const directory of ['scripts', 'bin']) { + for (const directory of ['scripts', 'bin', 'references']) { for (const basename of (await readdir(path.join(root, directory))).sort()) { if (basename !== '.DS_Store') names.push(`${directory}/${basename}`); } } return Promise.all(names.sort().map(async (name) => { const filePath = path.join(root, name); - const mode = name === 'SKILL.md' || name.endsWith('.LICENSE') ? 0o644 : 0o755; + const mode = name === 'SKILL.md' || name.endsWith('.LICENSE') || name.startsWith('references/') ? 0o644 : 0o755; return { name, mode, bytes: await readFile(filePath) }; })); } @@ -64,7 +64,7 @@ test('builds a deterministic complete archive with exact bytes, modes, and diges assert.deepEqual(parsed.map(({ name, mode, type }) => ({ name, mode, type })), expected.map(({ name, mode }) => ({ name, mode, type: '0' }))); for (const entry of parsed) { assert.deepEqual(entry.bytes, expected.find(({ name }) => name === entry.name).bytes); - assert.match(entry.name, /^(SKILL\.md|scripts\/[^/]+|bin\/[^/]+)$/); + assert.match(entry.name, /^(SKILL\.md|scripts\/[^/]+|bin\/[^/]+|references\/[^/]+)$/); } assert.ok(parsed.reduce((sum, entry) => sum + entry.bytes.length, 0) <= 10 * 1024 * 1024); @@ -73,6 +73,30 @@ test('builds a deterministic complete archive with exact bytes, modes, and diges assert.equal(digest.archive.sha256, createHash('sha256').update(one.archive).digest('hex')); }); +test('still builds a skill without references', async (t) => { + const temp = await mkdtemp(path.join(os.tmpdir(), 'display-dev-candidate-legacy-')); + t.after(() => rm(temp, { recursive: true, force: true })); + const source = path.join(temp, 'source'); + await cp(canonicalRoot, source, { recursive: true }); + await rm(path.join(source, 'references'), { recursive: true }); + await writeFile(path.join(source, 'SKILL.md'), '---\nname: display-dev\ndescription: Publish artifacts.\n---\n\nUse the packaged helpers.\n'); + const result = await buildCandidate(source, path.join(temp, 'output')); + assert.ok(parseTar(gunzipSync(result.archive)).some(({ name }) => name === 'SKILL.md')); + assert.ok(result.entries.every(({ name }) => !name.startsWith('references/'))); +}); + +test('rejects linked or nested references', async (t) => { + const temp = await mkdtemp(path.join(os.tmpdir(), 'display-dev-candidate-references-')); + t.after(() => rm(temp, { recursive: true, force: true })); + const source = path.join(temp, 'source'); + await cp(canonicalRoot, source, { recursive: true }); + await symlink('email-domains.md', path.join(source, 'references', 'linked.md')); + await assert.rejects(buildCandidate(source, path.join(temp, 'linked')), /regular file/); + await rm(path.join(source, 'references', 'linked.md')); + await mkdir(path.join(source, 'references', 'nested')); + await assert.rejects(buildCandidate(source, path.join(temp, 'nested')), /regular file/); +}); + test('rejects links and nested entries instead of archiving unsafe entry types or paths', async () => { const temp = await mkdtemp(path.join(os.tmpdir(), 'display-dev-candidate-invalid-')); const source = path.join(temp, 'source'); diff --git a/tests/run.sh b/tests/run.sh index f4dcc20..74ca48e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -5,6 +5,13 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/display-dev-skill-tests.XXXXXX")" trap 'rm -rf "$TMP_DIR"' EXIT INT TERM +STANDALONE="$TMP_DIR/SKILL.md" +if [[ $# -eq 1 ]]; then + cp "$1" "$STANDALONE" +else + node "$ROOT/bin/build-standalone.mjs" "$ROOT/display-dev" "$STANDALONE" +fi + SYSTEM_PATH="/usr/bin:/bin" WITH_DSP="$ROOT/tests/fixtures/with-dsp" NO_DSP="$ROOT/tests/fixtures/no-dsp" @@ -183,15 +190,15 @@ DESCRIPTION="$(awk ' [[ ${#DESCRIPTION} -le 1024 ]] \ || fail "frontmatter description exceeds 1024 characters (${#DESCRIPTION})" -grep -F 'https://api.display.dev/v1/public/artifacts' "$ROOT/display-dev/SKILL.md" >/dev/null \ +grep -F 'https://api.display.dev/v1/public/artifacts' "$STANDALONE" >/dev/null \ || fail 'standalone skill omits the fixed anonymous endpoint' -grep -F '[[ $# -ne 1 ]]' "$ROOT/display-dev/SKILL.md" >/dev/null \ +grep -F '[[ $# -ne 1 ]]' "$STANDALONE" >/dev/null \ || fail 'standalone recipe omits the exactly-one-file check' -grep -F '[[ "$file" == "-" || ! -r "$file" ]]' "$ROOT/display-dev/SKILL.md" >/dev/null \ +grep -F '[[ "$file" == "-" || ! -r "$file" ]]' "$STANDALONE" >/dev/null \ || fail 'standalone recipe omits readable-file or exact-dash checks' -grep -F '[[ "$file" == *'"'"'"'"'"'* || "$file" == *'"'"';'"'"'* || "$file" == *'"'"','"'"'* || "$file" =~ [[:cntrl:]] ]]' "$ROOT/display-dev/SKILL.md" >/dev/null \ +grep -F '[[ "$file" == *'"'"'"'"'"'* || "$file" == *'"'"';'"'"'* || "$file" == *'"'"','"'"'* || "$file" =~ [[:cntrl:]] ]]' "$STANDALONE" >/dev/null \ || fail 'standalone recipe omits filename character checks' -grep -F 'curl -sS -X POST' "$ROOT/display-dev/SKILL.md" >/dev/null \ +grep -F 'curl -sS -X POST' "$STANDALONE" >/dev/null \ || fail 'standalone anonymous publishing still requires packaged scripts' REQUIRED_ARTIFACT_WORKFLOW=( @@ -224,10 +231,10 @@ REQUIRED_ARTIFACT_WORKFLOW=( 'use `dsp publish`' ) for instruction in "${REQUIRED_ARTIFACT_WORKFLOW[@]}"; do - grep -F "$instruction" "$ROOT/display-dev/SKILL.md" >/dev/null \ + grep -F "$instruction" "$STANDALONE" >/dev/null \ || fail "standalone skill omits artifact workflow instruction: $instruction" done -if grep -F 'fetch/export, reconcile' "$ROOT/display-dev/SKILL.md" >/dev/null; then +if grep -F 'fetch/export, reconcile' "$STANDALONE" >/dev/null; then fail 'standalone skill still teaches the old export-first recovery workflow' fi pass 'artifact read, search, metadata, exact-edit, and copy workflow' @@ -241,7 +248,7 @@ REQUIRED_ACCESS_POLICY_WORKFLOW=( 'neither interface' ) for instruction in "${REQUIRED_ACCESS_POLICY_WORKFLOW[@]}"; do - grep -F "$instruction" "$ROOT/display-dev/SKILL.md" >/dev/null \ + grep -F "$instruction" "$STANDALONE" >/dev/null \ || fail "standalone skill omits organization access-policy instruction: $instruction" done pass 'organization access-policy denials keep audience decisions with the user' @@ -253,7 +260,7 @@ REQUIRED_EMAIL_DOMAIN_WORKFLOW=( 'Do not call the REST endpoint as a fallback' ) for instruction in "${REQUIRED_EMAIL_DOMAIN_WORKFLOW[@]}"; do - grep -F "$instruction" "$ROOT/display-dev/SKILL.md" >/dev/null \ + grep -F "$instruction" "$STANDALONE" >/dev/null \ || fail "standalone skill omits email-domain billing instruction: $instruction" done FORBIDDEN_EMAIL_DOMAIN_BILLING=( @@ -263,7 +270,7 @@ FORBIDDEN_EMAIL_DOMAIN_BILLING=( '/v1/org/email-domains/addon/' ) for instruction in "${FORBIDDEN_EMAIL_DOMAIN_BILLING[@]}"; do - if grep -F "$instruction" "$ROOT/display-dev/SKILL.md" >/dev/null; then + if grep -R -n -F -e "$instruction" "$ROOT/display-dev/SKILL.md" "$ROOT/display-dev/references" "$STANDALONE"; then fail "standalone skill advertises forbidden email-domain billing surface: $instruction" fi done @@ -276,7 +283,7 @@ FORBIDDEN_SIGN_IN_RELAY=( 'login.sh --email' ) for instruction in "${FORBIDDEN_SIGN_IN_RELAY[@]}"; do - if grep -R -n -F -e "$instruction" "$ROOT/display-dev/SKILL.md" "$ROOT/display-dev/scripts"; then + if grep -R -n -F -e "$instruction" "$ROOT/display-dev/SKILL.md" "$ROOT/display-dev/references" "$STANDALONE" "$ROOT/display-dev/scripts"; then fail "standalone skill asks the agent to relay a sign-in code: $instruction" fi done @@ -285,12 +292,12 @@ REQUIRED_SIGN_IN_RULES=( 'Never run `dsp login` or `./scripts/login.sh` yourself, in any shell' ) for instruction in "${REQUIRED_SIGN_IN_RULES[@]}"; do - grep -F "$instruction" "$ROOT/display-dev/SKILL.md" >/dev/null \ + grep -F "$instruction" "$STANDALONE" >/dev/null \ || fail "standalone skill omits sign-in rule: $instruction" done pass 'sign-in codes stay with the user' -SKILL_SOURCE_VERSIONS="$(grep -oE 'display-dev-skill@[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/display-dev/SKILL.md" | sort -u)" +SKILL_SOURCE_VERSIONS="$(grep -oE 'display-dev-skill@[0-9]+\.[0-9]+\.[0-9]+' "$STANDALONE" | sort -u)" [[ "$SKILL_SOURCE_VERSIONS" == "display-dev-skill@$WANT_VERSION" ]] \ || fail "standalone skill attribution version is '$SKILL_SOURCE_VERSIONS' (want display-dev-skill@$WANT_VERSION)" pass 'static trust-boundary invariants' diff --git a/tests/standalone.test.mjs b/tests/standalone.test.mjs new file mode 100644 index 0000000..1d071e9 --- /dev/null +++ b/tests/standalone.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { cp, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { buildStandalone } from '../bin/build-standalone.mjs'; + +const canonical = path.resolve(import.meta.dirname, '../display-dev'); + +async function fixture(t) { + const temp = await mkdtemp(path.join(os.tmpdir(), 'display-dev-standalone-')); + t.after(() => rm(temp, { recursive: true, force: true })); + const source = path.join(temp, 'source'); + await cp(canonical, source, { recursive: true }); + return source; +} + +test('standalone preserves frontmatter and includes exact reference bytes in stable path order', async () => { + const original = await readFile(path.join(canonical, 'SKILL.md'), 'utf8'); + const built = await buildStandalone(canonical); + assert.deepEqual(built, await buildStandalone(canonical)); + const text = built.toString(); + const frontmatter = original.match(/^---\n[\s\S]*?\n---\n/)[0]; + assert.ok(text.startsWith(frontmatter)); + assert.ok(text.includes(original.slice(frontmatter.length))); + let previous = -1; + for (const file of ['email-domains.md', 'staged-upload.md']) { + const content = await readFile(path.join(canonical, 'references', file), 'utf8'); + const start = text.indexOf(`\n\n## references/${file}\n\n${content}`); + assert.ok(start > previous); + previous = start; + } +}); + +test('no-reference standalone remains byte-identical', async (t) => { + const source = await fixture(t); + await rm(path.join(source, 'references'), { recursive: true }); + const original = Buffer.from('---\r\nname: example\r\ndescription: Example\r\n---\r\n\r\nUnchanged.\r\n'); + await writeFile(path.join(source, 'SKILL.md'), original); + assert.deepEqual(await buildStandalone(source), original); +}); + +test('missing, unrouted, and unsafe reference paths fail generation', async (t) => { + const source = await fixture(t); + await rm(path.join(source, 'references', 'staged-upload.md')); + await assert.rejects(buildStandalone(source), /must match/); + await writeFile(path.join(source, 'references', 'staged-upload.md'), 'restored'); + await writeFile(path.join(source, 'references', 'unrouted.md'), 'would be omitted'); + await assert.rejects(buildStandalone(source), /must match/); + await rm(path.join(source, 'references', 'unrouted.md')); + const skill = await readFile(path.join(source, 'SKILL.md'), 'utf8'); + await writeFile(path.join(source, 'SKILL.md'), `${skill}\n[bad](references/../private.md)\n`); + await assert.rejects(buildStandalone(source), /invalid reference path/); +}); + +test('linked references are rejected rather than read outside the skill', async (t) => { + const source = await fixture(t); + const file = path.join(source, 'references', 'staged-upload.md'); + await rm(file); + await symlink('../SKILL.md', file); + await assert.rejects(buildStandalone(source), /regular file/); + await rm(path.join(source, 'references'), { recursive: true }); + await symlink('scripts', path.join(source, 'references')); + await assert.rejects(buildStandalone(source), /real directory/); +});