mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat(agent-skills): publish Realmroot ZPan skill
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: use-zpan
|
||||
description: Use ZPan through Realmroot to browse private workspaces; list, inspect, upload, download, rename, move, copy, delete, restore, or purge files and folders; create or revoke shares; inspect quota and storage usage; and handle workspace-scoped access or upload-capacity workflows. Use whenever an Agent needs to read or operate a user's private ZPan storage with controller-approved least-privilege access.
|
||||
---
|
||||
|
||||
# Use ZPan
|
||||
|
||||
Treat ZPan as private workspace-scoped file storage. Operate it through the
|
||||
stable Agent identity and authority supplied by `$realmroot`. Do not borrow the
|
||||
user's browser session, cookies, OAuth tokens, API keys, or WebDAV credentials.
|
||||
|
||||
## Discover The Live Contract
|
||||
|
||||
Require `$realmroot` to be installed. Reuse a known healthy ZPan Resource
|
||||
Server, Context, operation, and authority. Discover or refresh only when the
|
||||
target, Context, operation, or required scope is unknown, or after a connection,
|
||||
authorization, or contract failure:
|
||||
|
||||
```bash
|
||||
realmroot toolbox
|
||||
realmroot toolbox zpan
|
||||
realmroot toolbox zpan --search "<capability>"
|
||||
realmroot toolbox zpan <group> <operation> --help
|
||||
```
|
||||
|
||||
Select only the Resource Server named `zpan` unless the user explicitly asks
|
||||
for another discovered deployment such as staging. Require its protected
|
||||
resource URL to match the intended deployment. Treat Toolbox output and the
|
||||
operation's live help as authoritative for operation names, arguments, scopes,
|
||||
and response shapes; examples in this Skill are not a substitute for discovery.
|
||||
|
||||
## Select The Workspace Context
|
||||
|
||||
Inspect Contexts when no default is selected, multiple workspaces could match,
|
||||
or the user names a workspace:
|
||||
|
||||
```bash
|
||||
realmroot toolbox zpan context
|
||||
realmroot toolbox zpan context show "<workspace>"
|
||||
```
|
||||
|
||||
Use `--context "<workspace>"` for one workflow. Change the default with
|
||||
`context use` only when the user asks to change it. A ZPan target credential is
|
||||
bound to exactly one workspace; never add a custom workspace header or assume
|
||||
that an object ID selects the correct workspace.
|
||||
|
||||
If the Context exists but is not connected or authorized, use `$realmroot` to
|
||||
request the connection or authority and wait for controller approval. Do not
|
||||
switch to the user's identity. When two workspaces are involved, finish and
|
||||
verify operations in one Context before switching to the other.
|
||||
|
||||
## Request Least-Privilege Authority
|
||||
|
||||
Inspect every operation needed for the current workflow, then request their
|
||||
scope union in one controller approval. Omit unrelated scopes. Typical mappings
|
||||
are:
|
||||
|
||||
- list, inspect, and download objects or inspect trash: `objects:read`;
|
||||
- create folders and upload or save objects: `objects:create`;
|
||||
- rename, move, copy, restore, or transfer objects: `objects:update`;
|
||||
- move objects to trash: `objects:delete`;
|
||||
- permanently remove trashed objects: `objects:purge`;
|
||||
- list, create, or revoke shares: `shares:read`, `shares:create`, or
|
||||
`shares:delete`;
|
||||
- inspect quota or storage usage: `quota:read` or `storage-usage:read`.
|
||||
|
||||
Request all required scopes together and bind the request to the selected
|
||||
Context:
|
||||
|
||||
```bash
|
||||
realmroot agent request \
|
||||
--resource-server zpan \
|
||||
--scope <scope> \
|
||||
--context "<workspace>" \
|
||||
--reason "Manage the requested ZPan files"
|
||||
```
|
||||
|
||||
Repeat `--scope` for each required scope. Existing broader durable authority
|
||||
may satisfy a narrower request, but each issued target credential must contain
|
||||
only the scopes needed now.
|
||||
|
||||
## Resolve Objects Before Acting
|
||||
|
||||
List or search within the selected Context and use IDs returned by ZPan:
|
||||
|
||||
```bash
|
||||
realmroot toolbox zpan objects list-objects --parent "<path>" --json
|
||||
realmroot toolbox zpan objects list-objects --search "<name>" --json
|
||||
realmroot toolbox zpan objects get-object "<object-id>" --json
|
||||
```
|
||||
|
||||
Follow `nextPageToken` until the desired object is found or results are
|
||||
exhausted. Do not guess IDs, treat a same-named object in another Context as the
|
||||
target, or use a stale ID from another deployment. If multiple objects match
|
||||
and the user's intent does not distinguish them, present the material choices.
|
||||
|
||||
## Upload Files Directly
|
||||
|
||||
Inspect `create-object` help, determine the local file's exact byte size and
|
||||
media type, and create a draft with `objects:create`. Preserve the exact create
|
||||
body when retrying after an uncertain result or capacity purchase.
|
||||
|
||||
Use the returned `upload` descriptor as the authoritative workflow:
|
||||
|
||||
1. For every `upload.parts[]` entry, read exactly `offset` through
|
||||
`offset + length` from the local file.
|
||||
2. Send that byte range directly to the part's presigned `url` with its exact
|
||||
method and `headers`. Do not route file bytes through Realmroot or ZPan.
|
||||
3. Require a successful storage response and retain the response `ETag` with
|
||||
its `partNumber`.
|
||||
4. Call the descriptor's `workflow.complete.operationId` with every
|
||||
`{partNumber, etag}` pair.
|
||||
5. Read the completed object back and confirm its name, size, status, and
|
||||
selected workspace.
|
||||
|
||||
Use the advertised re-presign operation only for missing or expired part URLs.
|
||||
Abort the advertised upload session when the workflow cannot be completed and
|
||||
the user does not want the draft retained. Never log, display, or persist
|
||||
presigned URLs; they are short-lived bearer capabilities.
|
||||
|
||||
For folders, create an object with the live folder representation advertised by
|
||||
`create-object`; no storage upload follows. For a directory tree, create parent
|
||||
folders before children and preserve relative paths.
|
||||
|
||||
## Download And Verify Files
|
||||
|
||||
Call `get-object` in the selected Context and use its current `downloadUrl`
|
||||
immediately. Download directly from that presigned URL without sending it
|
||||
through Realmroot. Do not persist or reveal the URL.
|
||||
|
||||
When the user asks to copy or verify a file, compare byte length and a local
|
||||
cryptographic digest when practical. A successful HTTP status alone does not
|
||||
prove that the intended bytes were received.
|
||||
|
||||
## Mutate, Share, And Remove Objects
|
||||
|
||||
Discover the relevant live operation first. After rename, move, copy, restore,
|
||||
transfer, or share changes, read the object or share back when possible and
|
||||
verify the requested state.
|
||||
|
||||
Treat `delete-object` as a soft delete into trash. Use permanent purge only
|
||||
when the user explicitly requests irreversible removal, resolve the current
|
||||
trash object first, and verify its absence afterward. Revoke shares before
|
||||
cleanup when the workflow created them. Share tokens, passwords, direct URLs,
|
||||
and recipient details are sensitive; return them only as needed for the user's
|
||||
request.
|
||||
|
||||
## Handle Capacity And Failures
|
||||
|
||||
- On `402 CAPACITY_REQUIRED`, preserve the original create body and request
|
||||
hash. Follow only the purchase operation and offer advertised by the live
|
||||
response. Use `$realmroot` for any discovered payer and controller-approved
|
||||
budget, then retry the unchanged create request after confirmed delivery.
|
||||
- On `401`, stop and use `$realmroot` to refresh the connection or target
|
||||
credential. Never retry with copied credentials.
|
||||
- On `403`, re-read operation help and request only the missing task scope; also
|
||||
respect the workspace role reported by ZPan.
|
||||
- On `404`, verify the deployment, Context, and current object ID. Do not assume
|
||||
that cross-workspace invisibility means deletion.
|
||||
- On `409`, honor the user's conflict intent and use only a conflict strategy
|
||||
advertised by the live schema.
|
||||
- On expired upload or download URLs, request fresh descriptors instead of
|
||||
reconstructing URLs.
|
||||
- On an uncertain mutation result, read current state before retrying to avoid
|
||||
duplicate folders, drafts, copies, or shares.
|
||||
|
||||
Do not stop after discovery or approval. Complete the requested ZPan operation,
|
||||
verify the resulting state, and report the selected workspace plus the relevant
|
||||
object or share result without exposing credentials or presigned URLs.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Use ZPan"
|
||||
short_description: "Manage private ZPan files through Realmroot"
|
||||
default_prompt: "Use $use-zpan to manage my private ZPan files through Realmroot."
|
||||
@@ -29,6 +29,8 @@ Starting from `https://zpan.example/api`, clients discover:
|
||||
| API discovery | `/api` |
|
||||
| OpenAPI | `/api/openapi.json` |
|
||||
| Upload workflows | `/api/workflows.arazzo.json` |
|
||||
| Agent Skills Discovery | `/.well-known/agent-skills/index.json` |
|
||||
| Realmroot-oriented ZPan Skill | `/.well-known/agent-skills/use-zpan.tar.gz` |
|
||||
| Protected resource metadata | `/.well-known/oauth-protected-resource/api` |
|
||||
| Authorization server metadata | `/.well-known/oauth-authorization-server/api/auth` |
|
||||
| Dynamic client registration | `/api/auth/oauth2/register` |
|
||||
@@ -74,6 +76,14 @@ an automatic consequence of installing or updating a plugin. ZPan separately
|
||||
publishes its dynamic-registration and RFC 7592 configuration operations
|
||||
implemented at the auth boundary.
|
||||
|
||||
The optional `use-zpan` Agent Skill teaches Realmroot Toolbox orchestration,
|
||||
workspace Context selection, least-privilege scope requests, and safe direct
|
||||
upload/download handling. It treats live Toolbox help, OpenAPI, Arazzo, and
|
||||
response-provided descriptors as authoritative; it does not embed credentials,
|
||||
deployment URLs, or a parallel API contract. Generic controllers remain able to
|
||||
operate ZPan without installing the Skill. Discovery and the archive are
|
||||
generated into the static asset tree at build time; they are not Worker routes.
|
||||
|
||||
## Workspace Authorization Details
|
||||
|
||||
ZPan defines this RFC 9396 authorization detail type:
|
||||
|
||||
+25
-7
@@ -17,7 +17,9 @@ ZPan owns:
|
||||
- JWT bearer actor authentication and OAuth token exchange;
|
||||
- DPoP-bound resource tokens and revocation;
|
||||
- scope-aware file APIs, Arazzo workflows, and structured direct-upload
|
||||
instructions.
|
||||
instructions;
|
||||
- Agent Skills Discovery and an optional Realmroot-oriented workflow Skill
|
||||
that consumes the live self-describing contract.
|
||||
|
||||
The external controller owns Agent identity, approval of application access, delegated
|
||||
credential injection, and tool orchestration. ZPan does not ship a fixed OAuth
|
||||
@@ -89,11 +91,23 @@ draft with part numbers and ETags. Re-presigning and completion reauthorize
|
||||
against ZPan. Presigned URLs and file bytes never need to transit the external
|
||||
controller.
|
||||
|
||||
### Optional Agent Skill
|
||||
|
||||
- Publish Agent Skills Discovery v0.2.0 at the Resource Server origin.
|
||||
- Publish `use-zpan` as a digest-pinned archive that requires `$realmroot` and
|
||||
teaches workspace Context selection, least-privilege access, file lifecycle,
|
||||
and safe direct transfer handling.
|
||||
- Keep live Toolbox operation help, OpenAPI, Arazzo, and response-provided
|
||||
descriptors authoritative; do not duplicate deployment URLs, credentials, or
|
||||
the complete API schema in the Skill.
|
||||
- Preserve the protocol goal: generic controllers can operate ZPan without the
|
||||
Skill.
|
||||
|
||||
### Authorization
|
||||
|
||||
The grantable resource scopes cover object read/create/update/delete, share
|
||||
read/create/delete, quota read, storage-usage read, and task read. Purge,
|
||||
administration, billing, credential management, WebDAV configuration, and
|
||||
The grantable resource scopes cover object read/create/update/delete/purge,
|
||||
share read/create/delete, quota read, storage-usage read, and task read.
|
||||
Administration, billing, credential management, WebDAV configuration, and
|
||||
downloader registration remain excluded.
|
||||
|
||||
OAuth resolves to the same protocol-neutral principal and route policies used
|
||||
@@ -105,7 +119,8 @@ controller or client ID.
|
||||
- fixed, system-managed OAuth client;
|
||||
- OpenAPI `x-cli-config` profiles;
|
||||
- `restish-zpan` command plugin and release artifact;
|
||||
- repository-hosted ZPan Agent skill.
|
||||
- the legacy credential-coupled ZPan Agent skill that duplicated target routes
|
||||
and profiles.
|
||||
|
||||
The existing `zpan-cli` device authorization remains only for its legacy,
|
||||
single-use downloader-registration bootstrap.
|
||||
@@ -119,8 +134,9 @@ single-use downloader-registration bootstrap.
|
||||
5. Tool-neutral OpenAPI authorization metadata.
|
||||
6. Discoverable Arazzo upload workflows.
|
||||
7. Self-describing single/multipart upload responses.
|
||||
8. Remove fixed-client, profile, plugin, and skill surfaces.
|
||||
9. Complete local gates and real FlareAuth acceptance.
|
||||
8. Remove fixed-client, profile, plugin, and credential-coupled skill surfaces.
|
||||
9. Publish the optional discovery-based `use-zpan` Skill.
|
||||
10. Complete local gates and real FlareAuth acceptance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -132,6 +148,8 @@ single-use downloader-registration bootstrap.
|
||||
- After approval, the controller obtains a DPoP resource token for `/api`.
|
||||
- A generic Agent creates an upload, sends all returned byte ranges, captures
|
||||
ETags, and completes it without ZPan-specific code.
|
||||
- Realmroot discovers the optional `use-zpan` Skill, verifies its digest, and
|
||||
presents an installation command.
|
||||
- The same connection lists, reads, and renames the uploaded file.
|
||||
- Grant or JWT revocation stops subsequent resource access.
|
||||
- Lint, type checking, Node tests, Cloudflare tests, and applicable end-to-end
|
||||
|
||||
+9
-7
@@ -8,12 +8,14 @@
|
||||
"scripts": {
|
||||
"dev": "CLOUDFLARE_ENV=staging vite dev",
|
||||
"dev:node": "node --env-file=.dev.vars node_modules/vite/bin/vite.js dev --mode node",
|
||||
"build": "[ \"$WORKERS_CI\" = \"1\" ] && [ \"$WORKERS_CI_BRANCH\" != \"main\" ] && export CLOUDFLARE_ENV=staging; vite build",
|
||||
"build:node": "vite build --mode node && tsup server/entry-node.ts --config server/tsup.config.mjs --format esm --outDir dist-server --external better-sqlite3 --external @libsql/client",
|
||||
"build:lambda": "tsup server/entry-lambda.ts --config server/tsup.config.mjs --format cjs --outDir dist-lambda --external @libsql/client",
|
||||
"build:vercel": "vite build --mode node && tsup server/entry-vercel.ts --config server/tsup.config.mjs --format esm --outDir api --external @libsql/client",
|
||||
"build:netlify": "tsup server/entry-netlify.ts --config server/tsup.config.mjs --format esm --outDir netlify/functions --external @libsql/client",
|
||||
"build:azure": "vite build && tsup server/entry-azure.ts --config server/tsup.config.mjs --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist",
|
||||
"agent-skills:build": "node scripts/build-agent-skills.mjs",
|
||||
"agent-skills:check": "node scripts/build-agent-skills.mjs --check",
|
||||
"build": "pnpm agent-skills:build && (if [ \"$WORKERS_CI\" = \"1\" ] && [ \"$WORKERS_CI_BRANCH\" != \"main\" ]; then export CLOUDFLARE_ENV=staging; fi; vite build)",
|
||||
"build:node": "pnpm agent-skills:build && vite build --mode node && tsup server/entry-node.ts --config server/tsup.config.mjs --format esm --outDir dist-server --external better-sqlite3 --external @libsql/client",
|
||||
"build:lambda": "pnpm agent-skills:build && tsup server/entry-lambda.ts --config server/tsup.config.mjs --format cjs --outDir dist-lambda --external @libsql/client",
|
||||
"build:vercel": "pnpm agent-skills:build && vite build --mode node && tsup server/entry-vercel.ts --config server/tsup.config.mjs --format esm --outDir api --external @libsql/client",
|
||||
"build:netlify": "pnpm agent-skills:build && tsup server/entry-netlify.ts --config server/tsup.config.mjs --format esm --outDir netlify/functions --external @libsql/client",
|
||||
"build:azure": "pnpm agent-skills:build && vite build && tsup server/entry-azure.ts --config server/tsup.config.mjs --format esm --outDir azure-functions --external @azure/functions --external @libsql/client && cp server/azure-host.json azure-functions/host.json && cp -r dist azure-functions/dist",
|
||||
"deploy": "pnpm build && pnpm db:migrate:d1:prod && wrangler deploy",
|
||||
"release": "node scripts/release.mjs",
|
||||
"release:check-version": "node scripts/check-release-version.mjs",
|
||||
@@ -36,7 +38,7 @@
|
||||
"test:cf": "vitest run --project cloudflare-contract --project cloudflare-isolated",
|
||||
"test:libsql": "vitest run --project libsql",
|
||||
"test:watch": "vitest --project backend-unit --project frontend-unit --project backend-integration-http --project backend-integration-data",
|
||||
"lint": "biome check . && pnpm lint:ids && pnpm lint:tests",
|
||||
"lint": "biome check . && pnpm agent-skills:check && pnpm lint:ids && pnpm lint:tests",
|
||||
"lint:ids": "node scripts/lint-id-generation.mjs",
|
||||
"lint:tests": "node scripts/lint-test-boundaries.mjs",
|
||||
"lint:fix": "biome check --write .",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "use-zpan",
|
||||
"type": "archive",
|
||||
"description": "Use ZPan through Realmroot to browse private workspaces; list, inspect, upload, download, rename, move, copy, delete, restore, or purge files and folders; create or revoke shares; inspect quota and storage usage; and handle workspace-scoped access or upload-capacity workflows. Use whenever an Agent needs to read or operate a user's private ZPan storage with controller-approved least-privilege access.",
|
||||
"url": "/.well-known/agent-skills/use-zpan.tar.gz",
|
||||
"digest": "sha256:0c3a46eaa73ff9488fa6de137b0d0082ae8e43d39253c676def0aba41d5fce01"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,100 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const skillDirectory = join(root, 'agent-skills', 'use-zpan')
|
||||
const outputDirectory = join(root, 'public', '.well-known', 'agent-skills')
|
||||
const archivePath = join(outputDirectory, 'use-zpan.tar.gz')
|
||||
const indexPath = join(outputDirectory, 'index.json')
|
||||
const files = ['SKILL.md', 'agents/openai.yaml'].map((name) => ({
|
||||
name,
|
||||
content: readFileSync(join(skillDirectory, name)),
|
||||
}))
|
||||
|
||||
const frontmatter = files[0].content.toString('utf8').match(/^---\n([\s\S]*?)\n---/)
|
||||
if (!frontmatter) throw new Error('use-zpan SKILL.md has no YAML frontmatter')
|
||||
|
||||
const name = frontmatter[1].match(/^name:\s*(.+)$/m)?.[1]?.trim()
|
||||
const description = frontmatter[1].match(/^description:\s*(.+)$/m)?.[1]?.trim()
|
||||
if (name !== 'use-zpan' || !description) throw new Error('use-zpan SKILL.md has invalid metadata')
|
||||
|
||||
const archive = gzipSync(createTar(files), { level: 9, mtime: 0 })
|
||||
archive[9] = 255
|
||||
const digest = `sha256:${createHash('sha256').update(archive).digest('hex')}`
|
||||
const index = `${JSON.stringify(
|
||||
{
|
||||
$schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json',
|
||||
skills: [
|
||||
{
|
||||
name,
|
||||
type: 'archive',
|
||||
description,
|
||||
url: '/.well-known/agent-skills/use-zpan.tar.gz',
|
||||
digest,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
if (process.argv.includes('--check')) {
|
||||
if (
|
||||
!existsSync(indexPath) ||
|
||||
!existsSync(archivePath) ||
|
||||
readFileSync(indexPath, 'utf8') !== index ||
|
||||
!readFileSync(archivePath).equals(archive)
|
||||
) {
|
||||
throw new Error('Generated use-zpan Skill artifact is stale; run pnpm agent-skills:build')
|
||||
}
|
||||
} else {
|
||||
writeFileSync(indexPath, index)
|
||||
writeFileSync(archivePath, archive)
|
||||
}
|
||||
|
||||
function createTar(entries) {
|
||||
const blocks = []
|
||||
for (const entry of entries) {
|
||||
const header = Buffer.alloc(512)
|
||||
writeString(header, entry.name, 0, 100)
|
||||
writeOctal(header, 0o644, 100, 8)
|
||||
writeOctal(header, 0, 108, 8)
|
||||
writeOctal(header, 0, 116, 8)
|
||||
writeOctal(header, entry.content.length, 124, 12)
|
||||
writeOctal(header, 0, 136, 12)
|
||||
header.fill(0x20, 148, 156)
|
||||
header[156] = 0x30
|
||||
writeString(header, 'ustar', 257, 6)
|
||||
writeString(header, '00', 263, 2)
|
||||
writeString(header, 'zpan', 265, 32)
|
||||
writeString(header, 'zpan', 297, 32)
|
||||
writeOctal(header, checksum(header), 148, 8)
|
||||
blocks.push(header, entry.content)
|
||||
|
||||
const padding = (512 - (entry.content.length % 512)) % 512
|
||||
if (padding > 0) blocks.push(Buffer.alloc(padding))
|
||||
}
|
||||
blocks.push(Buffer.alloc(1024))
|
||||
return Buffer.concat(blocks)
|
||||
}
|
||||
|
||||
function writeString(buffer, value, offset, length) {
|
||||
const bytes = Buffer.from(value)
|
||||
if (bytes.length > length) throw new Error(`Tar field is too long: ${value}`)
|
||||
bytes.copy(buffer, offset)
|
||||
}
|
||||
|
||||
function writeOctal(buffer, value, offset, length) {
|
||||
const encoded = value.toString(8).padStart(length - 1, '0')
|
||||
writeString(buffer, `${encoded}\0`, offset, length)
|
||||
}
|
||||
|
||||
function checksum(buffer) {
|
||||
let total = 0
|
||||
for (const byte of buffer) total += byte
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const skillDirectory = new URL('../agent-skills/use-zpan/', import.meta.url)
|
||||
const outputDirectory = new URL('../public/.well-known/agent-skills/', import.meta.url)
|
||||
const wranglerConfig = readFileSync(new URL('../wrangler.toml', import.meta.url), 'utf8')
|
||||
|
||||
describe('Agent Skill artifacts', () => {
|
||||
it('publishes a digest-pinned archive containing the canonical use-zpan Skill', () => {
|
||||
const index = JSON.parse(readFileSync(new URL('index.json', outputDirectory), 'utf8'))
|
||||
const archive = readFileSync(new URL('use-zpan.tar.gz', outputDirectory))
|
||||
|
||||
expect(archive[9]).toBe(255)
|
||||
expect(index).toEqual({
|
||||
$schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json',
|
||||
skills: [
|
||||
{
|
||||
name: 'use-zpan',
|
||||
type: 'archive',
|
||||
description: expect.stringContaining('through Realmroot'),
|
||||
url: '/.well-known/agent-skills/use-zpan.tar.gz',
|
||||
digest: `sha256:${createHash('sha256').update(archive).digest('hex')}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const files = readTarFiles(gunzipSync(archive))
|
||||
expect([...files.keys()]).toEqual(['SKILL.md', 'agents/openai.yaml'])
|
||||
expect(files.get('SKILL.md')).toEqual(readFileSync(new URL('SKILL.md', skillDirectory)))
|
||||
expect(files.get('agents/openai.yaml')).toEqual(
|
||||
readFileSync(new URL('agents/openai.yaml', skillDirectory)),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Agent Skill discovery on static assets', () => {
|
||||
expect(wranglerConfig).toContain('"/.well-known/oauth-authorization-server/*"')
|
||||
expect(wranglerConfig).toContain('"/.well-known/openid-configuration/*"')
|
||||
expect(wranglerConfig).toContain('"/.well-known/oauth-protected-resource/*"')
|
||||
expect(wranglerConfig).toContain('"/.well-known/zpan-domain-verification/*"')
|
||||
expect(wranglerConfig).not.toContain('"/.well-known/*"')
|
||||
expect(wranglerConfig).not.toContain('"/.well-known/agent-skills')
|
||||
expect(wranglerConfig).not.toMatch(/"!\/\.well-known\//)
|
||||
})
|
||||
})
|
||||
|
||||
function readTarFiles(archive) {
|
||||
const files = new Map()
|
||||
for (let offset = 0; offset + 512 <= archive.length; ) {
|
||||
const header = archive.subarray(offset, offset + 512)
|
||||
if (header.every((byte) => byte === 0)) break
|
||||
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
||||
const size = Number.parseInt(header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim(), 8)
|
||||
offset += 512
|
||||
files.set(name, archive.subarray(offset, offset + size))
|
||||
offset += Math.ceil(size / 512) * 512
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.gz': 'application/gzip',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
|
||||
+6
-8
@@ -8,6 +8,7 @@ import { resolveAppCommit, resolveAppVersion } from './scripts/app-version.mjs'
|
||||
|
||||
const appPort = Number(process.env.E2E_APP_PORT ?? 5185)
|
||||
const apiPort = Number(process.env.E2E_API_PORT ?? 8222)
|
||||
const nodeApiProxy = { target: `http://localhost:${apiPort}`, changeOrigin: false }
|
||||
const appVersion = resolveAppVersion()
|
||||
const appCommit = resolveAppCommit()
|
||||
const configuredDevHosts = (process.env.ZPAN_DEV_ALLOWED_HOSTS ?? '')
|
||||
@@ -91,14 +92,11 @@ export default defineConfig(({ mode }) => ({
|
||||
...(mode === 'node'
|
||||
? {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://localhost:${apiPort}`,
|
||||
changeOrigin: false,
|
||||
},
|
||||
'/.well-known': {
|
||||
target: `http://localhost:${apiPort}`,
|
||||
changeOrigin: false,
|
||||
},
|
||||
'/api': nodeApiProxy,
|
||||
'^/\\.well-known/oauth-authorization-server/': nodeApiProxy,
|
||||
'^/\\.well-known/openid-configuration/': nodeApiProxy,
|
||||
'^/\\.well-known/oauth-protected-resource/': nodeApiProxy,
|
||||
'^/\\.well-known/zpan-domain-verification/': nodeApiProxy,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
+13
-1
@@ -6,7 +6,19 @@ compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"]
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = ["/api", "/api/*", "/.well-known/*", "/dav", "/dav/*", "/ih/*", "/r/*", "/s/*"]
|
||||
run_worker_first = [
|
||||
"/api",
|
||||
"/api/*",
|
||||
"/.well-known/oauth-authorization-server/*",
|
||||
"/.well-known/openid-configuration/*",
|
||||
"/.well-known/oauth-protected-resource/*",
|
||||
"/.well-known/zpan-domain-verification/*",
|
||||
"/dav",
|
||||
"/dav/*",
|
||||
"/ih/*",
|
||||
"/r/*",
|
||||
"/s/*",
|
||||
]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
|
||||
Reference in New Issue
Block a user