feat(agent-skills): publish Realmroot ZPan skill

This commit is contained in:
jarvis
2026-08-15 12:46:37 -04:00
parent 10e16d14a2
commit bbf4a8e6c0
12 changed files with 409 additions and 23 deletions
+169
View File
@@ -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.
+4
View File
@@ -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."
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+100
View File
@@ -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
}
+60
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"