docs: publish ZPan Agent Skill (#543)

* docs: publish zpan agent skill

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* test: cover openapi auth route metadata

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* fix: align zpan skill restish commands

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* docs: clarify zpan skill release boundaries

* docs: align zpan upload profile examples

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

* fix: select upload plugin profile via environment

* fix: return zpan upload plugin help

Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133

---------

Co-authored-by: Noah Reed <noah-reed@mails.agent-kanban.dev>
Co-authored-by: saltbo <saltbo@foxmail.com>
This commit is contained in:
agent-kanban[bot]
2026-07-29 20:19:16 -04:00
committed by GitHub
parent 360237d069
commit e50c19051a
19 changed files with 753 additions and 16 deletions
+1
View File
@@ -164,6 +164,7 @@ After startup:
## Documentation
- [v2 Launch Offers](docs/v2-launch-offers.md) — earn ZPan Pro for free
- [ZPan Agent Skill](docs/agent-skill.md) — agent workflows for Restish setup, least-privilege profiles, uploads, CI, and MCP
- [Restish ZPan upload plugin](docs/restish-zpan.md) — install `restish-zpan` and upload local files through Restish profiles
- [Roadmap](V2_ROADMAP.md)
- [Contributing](CONTRIBUTING.md)
+20
View File
@@ -40,6 +40,17 @@ type fileIdentity struct {
}
func Run(startupArgs, args []string, h host) error {
if wantsHelp(args) {
return h.Response(200, nil, map[string]any{
"usage": "restish zpan-upload [flags] SOURCE [DESTINATION]",
"examples": []string{
"RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager ./photo.jpg",
"RSH_PROFILE=ci restish zpan-upload --api zpan --profile ci --parent folder-id ./photo.jpg report.jpg",
"RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./large.bin",
"RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./large.bin",
},
})
}
opts, err := parseOptions(args)
if err != nil {
return err
@@ -50,6 +61,15 @@ func Run(startupArgs, args []string, h host) error {
return runWithStorage(ctx, opts, h, newHTTPStorageClient())
}
func wantsHelp(args []string) bool {
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return true
}
}
return false
}
func parseOptions(args []string) (uploadOptions, error) {
opts := uploadOptions{API: "zpan", Conflict: "fail", Concurrency: 4}
fs := flag.NewFlagSet("zpan-upload", flag.ContinueOnError)
+18
View File
@@ -1036,6 +1036,24 @@ func TestRunRejectsInvalidArgs(t *testing.T) {
}
}
func TestRunReturnsHelp(t *testing.T) {
host := &fakeHost{}
if err := Run(nil, []string{"--help"}, host); err != nil {
t.Fatal(err)
}
body, ok := host.body.(map[string]any)
if !ok {
t.Fatalf("unexpected help body: %#v", host.body)
}
examples := strings.Join(body["examples"].([]string), "\n")
if !strings.Contains(examples, "RSH_PROFILE=file-manager") || !strings.Contains(examples, "--profile file-manager") {
t.Fatalf("help examples do not select profiles: %q", examples)
}
if len(host.requests) != 0 {
t.Fatalf("help should not make delegated requests: %#v", host.requests)
}
}
func TestRunWithStorageRejectsMissingSource(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing.bin")
err := runWithStorage(context.Background(), uploadOptions{API: "zpan", Source: missing, Name: "missing.bin", Conflict: "fail", Concurrency: 1}, &fakeHost{}, &fakeStorage{})
+4 -4
View File
@@ -32,10 +32,10 @@ func commands() []plugin.CommandDecl {
Short: "Upload a local file to ZPan",
Long: "Upload a local file to ZPan using Restish-managed API auth and direct presigned storage PUTs.\n\n" +
"Examples:\n" +
" restish zpan-upload ./photo.jpg\n" +
" restish --rsh-profile ci zpan-upload --parent folder-id ./photo.jpg report.jpg\n" +
" restish zpan-upload --resume ./large.bin\n" +
" restish zpan-upload --abort ./large.bin",
" RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager ./photo.jpg\n" +
" RSH_PROFILE=ci restish zpan-upload --api zpan --profile ci --parent folder-id ./photo.jpg report.jpg\n" +
" RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./large.bin\n" +
" RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./large.bin",
},
}
}
+5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/rest-sh/restish/v2/plugin"
@@ -36,6 +37,10 @@ func TestCommandDiscoveryContract(t *testing.T) {
if len(discovery.Commands) != 1 || discovery.Commands[0].Name != "zpan-upload" {
t.Fatalf("unexpected commands: %#v", discovery.Commands)
}
help := discovery.Commands[0].Long
if !strings.Contains(help, "RSH_PROFILE=file-manager") || strings.Contains(help, "--rsh-profile") {
t.Fatalf("upload help must use the delegated HTTP profile environment: %q", help)
}
}
func TestRunCommandRejectsUnknownCommand(t *testing.T) {
+51
View File
@@ -0,0 +1,51 @@
# ZPan Agent Skill
ZPan v2.9 publishes a versioned Agent Skill in [skills/zpan](../skills/zpan).
The Skill teaches coding agents to use ZPan through Restish and the
`restish-zpan` upload plugin.
## Install and Connect
Install Restish v2.3 or later, confirm the ZPan origin, then connect the single
unified OpenAPI document:
```sh
restish api connect zpan https://files.example.com/api/openapi.json --replace --yes
restish api sync zpan
```
Interactive agents use browser OAuth authorization code + PKCE through Restish.
CI and unattended jobs use the `ci` profile with `ZPAN_AGENT_API_KEY` from the
environment.
## Upload Plugin
Before installing the plugin, tell the user that Restish plugins are trusted
local executable code and ask them to approve the source:
```sh
restish plugin install saltbo/zpan zpan
```
Every local upload goes through:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent root ./file.bin
```
The Skill does not implement upload transport logic. The plugin owns local file
streaming, storage response capture, retry, resume, abort, and checkpoint
cleanup.
## Profiles
- `reader`: read objects, shares, quota, and storage usage.
- `file-manager`: reader plus create, upload, move, copy, rename, and soft
delete objects.
- `publisher`: reader plus public share creation and revocation.
- `ci`: environment-backed Agent API key for unattended file-management jobs.
The profile names are shortcuts for explicit scopes. They are not server-side
roles and routes do not authorize by preset name.
Interactive Restish login uses browser OAuth authorization code + PKCE.
+8 -1
View File
@@ -103,6 +103,11 @@ routes must authorize scopes rather than require a concrete `kind`.
Credential-specific fields remain available for diagnostics and revocation;
they do not select business behavior.
The versioned ZPan Agent Skill is published under `skills/zpan` and summarized
in [ZPan Agent Skill](../agent-skill.md). It consumes this authorization model
through Restish profiles instead of adding a second credential or upload
protocol.
This boundary deliberately avoids two migration traps:
- File routes must not treat an OAuth bearer as an unrestricted browser user.
@@ -381,7 +386,9 @@ restish plugin install saltbo/zpan zpan
The plugin uses Restish delegated HTTP for ZPan draft, part re-sign, complete,
and abort operations, preserving the selected profile, OAuth/API-key
authentication, TLS, and normalized output. It streams local file sections
authentication, TLS, and normalized output. With Restish v2.3 command plugins,
the host profile is selected through `RSH_PROFILE` while the plugin's matching
`--profile` selects spec validation and checkpoint identity. It streams local file sections
directly to presigned S3 URLs with bounded concurrency, retry, ETag capture,
resume checkpoints, and idempotent completion.
+16 -7
View File
@@ -4,8 +4,16 @@
profiles for ZPan API calls and streams file bytes directly from disk to
presigned storage URLs.
The companion [ZPan Agent Skill](agent-skill.md) selects when to use generated
Restish commands and when to invoke this plugin. The Skill does not implement
multipart upload behavior itself.
## Install
Restish plugins are trusted local executable code. Agents must explain that
trust boundary and get explicit user approval for the `saltbo/zpan` source
before installing:
```bash
restish plugin install saltbo/zpan zpan
```
@@ -13,10 +21,10 @@ restish plugin install saltbo/zpan zpan
## Usage
```bash
restish zpan-upload ./photo.jpg
restish --rsh-profile file-manager zpan-upload --api zpan --parent albums ./photo.jpg cover.jpg
restish zpan-upload --resume ./large.bin
restish zpan-upload --abort ./large.bin
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager ./photo.jpg
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent albums ./photo.jpg cover.jpg
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./large.bin
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./large.bin
```
The plugin validates the connected ZPan OpenAPI operations before uploading:
@@ -33,6 +41,7 @@ They contain API/profile identity, source file identity, destination identity,
the ZPan object/session IDs, part size/count, and completed part ETags. They do
not contain credentials, cookies, presigned URLs, or file bytes.
Restish v2.3 delegated HTTP uses the host's active profile. Select profiles with
Restish's global profile flag or `RSH_PROFILE`; the plugin's `--profile` value is
used for spec validation and checkpoint identity.
Restish v2.3 command plugins receive the delegated HTTP profile through
`RSH_PROFILE`; use it for the host credential selection. The plugin's
`--profile` value is separately used for spec validation and checkpoint
identity.
+16 -4
View File
@@ -26,6 +26,8 @@ redesigning the OpenAPI operations, Skill, upload plugin, or file use cases.
The detailed model is in
[Agent Authentication and Authorization](../design/agent-authentication.md).
The published workflow package is [ZPan Agent Skill](../agent-skill.md), with
the installable Skill source under `skills/zpan`.
## Product Boundary
@@ -156,6 +158,10 @@ workflows refer to a selected ZPan Restish profile rather than assuming a
specific environment variable or credential type, so a future profile may use
an external Agent Auth signer without changing the file-operation instructions.
The repository ships the Skill as `skills/zpan` with routed references for
setup, file operations, uploads, CI, MCP, and acceptance evidence. The
user-facing setup guide is [docs/agent-skill.md](../agent-skill.md).
### Restish Upload Plugin
Ship `restish-zpan` from this repository and install it with:
@@ -166,7 +172,10 @@ restish plugin install saltbo/zpan zpan
It contributes `restish zpan-upload`. The plugin uses Restish delegated HTTP
for ZPan draft, re-sign, complete, and abort operations, preserving the selected
profile, OAuth/API-key authentication, TLS, and output behavior. It streams
profile, OAuth/API-key authentication, TLS, and output behavior. With Restish
v2.3 command plugins, the host profile is selected through `RSH_PROFILE` while
the plugin's matching `--profile` selects spec validation and checkpoint
identity. It streams
local file parts directly to presigned S3 URLs with bounded concurrency, retry,
ETag capture, resume checkpoints, and idempotent completion. File bytes and
presigned URLs never pass through the Agent context or Restish's plugin CBOR
@@ -179,14 +188,17 @@ the plugin; it does not implement the upload state machine itself.
The official `restish-mcp` plugin is an optional transport for ordinary API
operations, not the upload implementation. Its default read-only mode is useful
for browsing. Enable write tools only with an explicit operation allowlist:
for browsing. Keep the default recipe read-only:
```sh
restish plugin install rest-sh/restish mcp
restish mcp serve zpan --allow-write-tools \
--operations listObjects,getObject,createObject,completeObjectUpload
restish mcp serve zpan --operations listObjects,getObject,listShares,getUserQuota,getStorageUsage
```
Enable write tools only with an explicit reviewed operation allowlist. Do not
allow upload draft, part signing, completion, or abort operation IDs through
MCP.
Keep MCP results bounded; the plugin's default result limit is 16 KiB. Object
contents continue to move through presigned URLs, never through an MCP result.
+1
View File
@@ -39,6 +39,7 @@
"lint:arch": "depcruise server/ shared/ --config .dependency-cruiser.cjs",
"lint:http": "tsx scripts/lint-http-boundary.ts",
"lint:spec": "node scripts/lint-spec.mjs",
"lint:zpan-skill": "node scripts/lint-zpan-skill.mjs",
"prepare": "husky",
"format": "biome format --write .",
"e2e": "playwright test",
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
const ROOT = process.cwd()
const SKILL_DIR = join(ROOT, 'skills', 'zpan')
function walkMarkdown(dir) {
const files = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const stat = statSync(full)
if (stat.isDirectory()) files.push(...walkMarkdown(full))
else if (name.endsWith('.md')) files.push(full)
}
return files.sort()
}
const files = walkMarkdown(SKILL_DIR)
const documents = files.map((file) => ({
file,
rel: relative(ROOT, file),
text: readFileSync(file, 'utf8'),
}))
const corpus = documents.map((doc) => doc.text).join('\n\n')
const normalizedCorpus = corpus.toLowerCase()
const failures = []
function requireMatch(label, pattern) {
if (!pattern.test(corpus)) failures.push(`missing required contract: ${label}`)
}
function requireText(label, text) {
if (!normalizedCorpus.includes(text.toLowerCase())) {
failures.push(`missing required contract: ${label}`)
}
}
function forbidMatch(label, pattern) {
for (const doc of documents) {
for (const match of doc.text.matchAll(pattern)) {
const line = doc.text.slice(0, match.index).split('\n').length
failures.push(`forbidden contract text: ${label} (${doc.rel}:${line})`)
}
}
}
function forbidUnsafeLine(label, pattern) {
const safePrefix = /\b(do not|don't|never|must not|not|no)\b/i
for (const doc of documents) {
const lines = doc.text.split('\n')
lines.forEach((lineText, index) => {
if (pattern.test(lineText) && !safePrefix.test(lineText)) {
failures.push(`unsafe contract guidance: ${label} (${doc.rel}:${index + 1})`)
}
})
}
}
function commandLines() {
return corpus
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('restish ') || /^RSH_PROFILE=\S+\s+restish\b/.test(line))
}
function requireCommandLine(label, pattern) {
if (!commandLines().some((line) => pattern.test(line))) {
failures.push(`missing executable command example: ${label}`)
}
}
function forbidCommandLine(label, pattern) {
for (const doc of documents) {
const lines = doc.text.split('\n')
lines.forEach((lineText, index) => {
const line = lineText.trim()
if (/^(?:RSH_PROFILE=\S+\s+)?restish /.test(line) && pattern.test(line)) {
failures.push(`forbidden executable command: ${label} (${doc.rel}:${index + 1})`)
}
})
}
}
function validateSkillFrontmatter() {
const skill = documents.find((doc) => doc.rel === 'skills/zpan/SKILL.md')
if (!skill) {
failures.push('missing skills/zpan/SKILL.md')
return
}
const match = skill.text.match(/^---\n([\s\S]*?)\n---\n/)
if (!match) {
failures.push('missing Skill YAML frontmatter')
return
}
const keys = [...match[1].matchAll(/^([A-Za-z0-9_-]+):/gm)].map((entry) => entry[1])
const extras = keys.filter((key) => key !== 'name' && key !== 'description')
if (extras.length > 0) {
failures.push(`unsupported Skill frontmatter key(s): ${extras.join(', ')}`)
}
}
validateSkillFrontmatter()
requireMatch('Restish v2.3 or later', /Restish v2\.3(?:\+| or later)/i)
requireText('connect exactly /api/openapi.json', '/api/openapi.json')
requireText('plugin install command', 'restish plugin install saltbo/zpan zpan')
requireText('upload command surface', 'restish zpan-upload')
for (const command of [
'list-objects',
'get-object',
'create-object',
'update-object',
'copy-object',
'transfer-object',
'delete-object',
'list-shares',
'create-share',
'revoke-share',
'get-user-quota',
'get-storage-usage',
'list-download-tasks',
'get-download-task',
'list-download-task-events',
]) {
requireCommandLine(`restish zpan ${command}`, new RegExp(`\\brestish\\s+(?:--rsh-profile\\s+\\S+\\s+)?zpan\\s+${command}\\b`))
}
for (const operationId of ['createObject', 'presignObjectUploadParts', 'completeObjectUpload', 'abortObjectUpload']) {
requireText(`upload plugin validates ${operationId}`, operationId)
}
requireCommandLine('list pagination uses --page-size', /\bzpan\s+list-objects\b.*\s--page-size\s+\d+/)
requireCommandLine('share pagination uses --page-size', /\bzpan\s+list-shares\b.*\s--page-size\s+\d+/)
requireCommandLine('task pagination uses --page-size', /\bzpan\s+list-download-tasks\b.*\s--page-size\s+\d+/)
requireCommandLine('create-object uses positional body input', /\bzpan\s+create-object\s+'[^']*\bname:/)
requireCommandLine('update-object uses positional body input', /\bzpan\s+update-object\s+\S+\s+'[^']*\bname:/)
requireCommandLine('copy-object uses positional body input', /\bzpan\s+copy-object\s+\S+\s+'[^']*\bparent:/)
requireCommandLine('transfer-object uses positional body input', /\bzpan\s+transfer-object\s+\S+\s+'[^']*\btargetOrgId:/)
requireCommandLine('create-share uses positional body input', /\bzpan\s+create-share\s+'[^']*\bmatterId:/)
requireCommandLine('revoke-share uses positional body input', /\bzpan\s+revoke-share\s+\S+\s+'[^']*\bstatus:\s*revoked/)
requireCommandLine('upload passes Restish and plugin profiles', /\bRSH_PROFILE=(\S+)\s+restish\s+zpan-upload\b.*\s--api\s+zpan\b.*\s--profile\s+\1\b/)
requireText('reader profile', '`reader`')
requireText('file-manager profile', '`file-manager`')
requireText('publisher profile', '`publisher`')
requireText('ci profile', '`ci`')
requireMatch('least-privilege profile selection', /(least-privilege|narrowest) profile/i)
requireText('objects read scope', 'objects:read')
requireText('objects write scopes', 'objects:create')
requireText('share publishing scopes', 'shares:create')
requireText('environment-backed Agent API key', 'Environment-backed')
requireMatch('OAuth authorization code with PKCE', /OAuth authorization code \+ PKCE|authorization code\s*\+\s*PKCE/i)
requireMatch('CI Agent API key guidance', /CI[\s\S]{0,240}Agent API key|Agent API key[\s\S]{0,240}CI/i)
requireMatch('confirm target workspace', /confirm[\s\S]{0,120}workspace/i)
requireMatch('confirm conflict policy', /confirm[\s\S]{0,160}(conflict|overwrite|replace)/i)
requireMatch('confirm destructive delete', /confirm[\s\S]{0,160}(destructive|soft delete|delet)/i)
requireMatch('confirm permanent purge', /confirm[\s\S]{0,160}(purge|permanent)/i)
requireMatch('confirm public sharing', /confirm[\s\S]{0,160}public share/i)
requireMatch(
'confirm plugin executable trust',
/(?:confirm|ask)[\s\S]{0,200}(trusted local executable|executable Restish plugin|plugin trust)/i,
)
forbidMatch('agent OpenAPI document', /\/api\/openapi\.agent\.json/gi)
forbidMatch('standalone zpan file CLI', /standalone\s+`?zpan`?\s+file CLI/gi)
const openApiDocs = [...corpus.matchAll(/\/api\/openapi(?:\.[a-z0-9-]+)?\.json/gi)].map((match) => match[0])
for (const doc of openApiDocs) {
if (doc !== '/api/openapi.json') {
failures.push(`OpenAPI document must be exactly /api/openapi.json, found ${doc}`)
}
}
forbidUnsafeLine('bearer-token paste flow', /\b(paste|copy\/paste|copy paste)\b.*\bbearer token\b/i)
forbidUnsafeLine('Agent device login as v2.9 flow', /\b(device authorization|device login|device flow)\b.*\bv2\.9\b/i)
forbidUnsafeLine('Skill-handled multipart orchestration', /\b(Skill|agent)\b.*\b(orchestrate|handle|implement)\b.*\bmultipart\b/i)
forbidUnsafeLine('Skill-handled ETag retry loop', /\b(Skill|agent)\b.*\b(ETag|ETags)\b.*\b(retry|retries|loop|loops)\b/i)
forbidUnsafeLine('presigned URL exposure', /\b(expose|return|print|show)\b.*\bpresigned URLs?\b/i)
forbidMatch('silent plugin install approval', /restish\s+plugin\s+install\s+saltbo\/zpan\s+zpan[^\n]*--yes/gi)
forbidMatch('old Restish list limit flag', /\brestish\s+(?:--rsh-profile\s+\S+\s+)?zpan\s+(?:list-objects|list-shares|list-download-tasks)\b[^\n]*\s--limit\b/gi)
forbidMatch('camelCase Restish command example', /\brestish\s+(?:--rsh-profile\s+\S+\s+)?zpan\s+(?:listObjects|getObject|createObject|updateObject|copyObject|transferObject|deleteObject|purgeTrashObject|listShares|createShare|revokeShare|getUserQuota|getStorageUsage|listDownloadTasks|getDownloadTask|listDownloadTaskEvents)\b/gi)
forbidMatch('profile template purge command', /\brestish\s+--rsh-profile\s+(?:reader|file-manager|publisher|ci)\s+zpan\s+purge-trash-object\b/gi)
forbidMatch('invented operator profile', /\brestish\s+--rsh-profile\s+operator\b/gi)
forbidCommandLine('upload without plugin profile', /\b(?:RSH_PROFILE=\S+\s+)?restish\s+(?:--rsh-profile\s+\S+\s+)?zpan-upload\b(?!.*\s--profile\s+\S+)/i)
forbidMatch('upload with ineffective host profile flag', /\brestish\s+--rsh-profile\s+\S+\s+zpan-upload\b/gi)
forbidCommandLine('upload without delegated profile environment', /^restish\s+zpan-upload\b/i)
forbidMatch(
'MCP upload control-plane allowlist',
/restish\s+mcp\s+serve[\s\S]*?--operations[^\n]*(createObject|create-object|presignObjectUploadParts|presign-object-upload-parts|completeObjectUpload|complete-object-upload|abortObjectUpload|abort-object-upload)/gi,
)
if (failures.length > 0) {
console.error(`ZPan Skill static contract failed with ${failures.length} finding(s):`)
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log(`ZPan Skill static contract passed (${documents.length} markdown files checked)`)
+34
View File
@@ -325,6 +325,40 @@ describe('global OpenAPI document', () => {
])
})
it('emits CLI metadata for non-agent authorization policies', () => {
const protectedWithoutScopes = authRoute(
{ access: 'protected' },
{
operationId: 'genericProtectedProbe',
method: 'get',
path: '/probe',
responses: { 200: { description: 'OK' } },
},
) as { security?: unknown; 'x-zpan-auth'?: unknown }
const internalRoute = authRoute(
{ access: 'internal' },
{
operationId: 'internalProbe',
method: 'post',
path: '/internal-probe',
responses: { 204: { description: 'No Content' } },
},
) as { security?: unknown; 'x-zpan-auth'?: unknown; 'x-cli-ignore'?: boolean; 'x-mcp-ignore'?: boolean }
expect(protectedWithoutScopes.security).toEqual([{ bearerAuth: [] }, { cookieAuth: [] }])
expect(protectedWithoutScopes['x-zpan-auth']).toEqual({
access: 'protected',
scopes: [],
minTeamRole: null,
allowDownloader: false,
auditDenied: true,
})
expect(internalRoute.security).toEqual([])
expect(internalRoute['x-zpan-auth']).toEqual({ access: 'internal' })
expect(internalRoute['x-cli-ignore']).toBe(true)
expect(internalRoute['x-mcp-ignore']).toBe(true)
})
it('detects OpenAPI operations missing explicit authorization declarations without an allowlist', () => {
expect(
findOperationsMissingAuthContract({
+69
View File
@@ -0,0 +1,69 @@
---
name: zpan
description: Manage ZPan files through Restish v2.3+ and the trusted restish-zpan upload plugin.
---
# ZPan Agent Skill
This Skill targets the ZPan v2.9 Restish integration.
Use this Skill when an agent needs to browse, inspect, move, copy, delete,
upload, download, share, revoke shares, check quota, or inspect background
tasks on a ZPan instance.
## Operating Boundary
ZPan file management uses two surfaces:
- Generated Restish OpenAPI commands for ordinary API operations.
- `restish zpan-upload` from the `restish-zpan` plugin for every local file
upload.
Do not read local file bytes, orchestrate upload parts, handle storage response
tags, loop part retries, or expose storage upload URLs. The upload plugin owns
local file streaming, upload state, storage response capture, retry, resume,
abort, and checkpoint cleanup.
## Start Here
1. Confirm the ZPan origin with the user before connecting or mutating data.
2. Confirm the Restish API name. Use `zpan` unless the user already has a
different local API name.
3. Require Restish v2.3 or later.
4. Connect exactly one OpenAPI document: `<origin>/api/openapi.json`.
5. Select the least-privilege profile that fits the task:
`reader`, `file-manager`, `publisher`, or `ci`.
6. Sync the Restish API before use when it was connected previously.
Use [references/setup.md](references/setup.md) for install, connect, sync, and
profile selection.
## Workflow Routing
- Browsing, inspecting, folders, move/copy/rename, delete, download links,
shares, quota, and tasks: use [references/file-workflows.md](references/file-workflows.md).
- Local uploads: use [references/uploads.md](references/uploads.md).
- CI or unattended automation with an Agent API key:
use [references/ci.md](references/ci.md).
- Optional MCP transport for reviewed ordinary operations:
use [references/mcp.md](references/mcp.md).
- Release or preview acceptance evidence:
use [references/acceptance.md](references/acceptance.md).
## Safety Rules
Confirm before:
- choosing a target workspace;
- overwriting, replacing, or retrying conflict handling;
- soft deleting files or folders;
- permanently purging trash;
- creating public shares;
- installing executable Restish plugins.
Never ask the user to paste a bearer token. Interactive use goes through
browser OAuth authorization code + PKCE. CI use relies on an environment-backed
Agent API key profile.
Keep results bounded. Prefer compact object IDs, names, paths, URLs, quota
effects, task state, and upload state over full raw responses.
+48
View File
@@ -0,0 +1,48 @@
# Acceptance Evidence
For release or preview verification, record the exact origin, Restish version,
plugin source, profile, and commands used. Do not record credentials.
## Fresh-Machine Interactive Flow
Verify:
1. Install Restish v2.3 or later.
2. Confirm the ZPan origin and local API name.
3. Connect `/api/openapi.json`.
4. Sync the API.
5. Approve installing `restish-zpan` from `saltbo/zpan`.
6. Run a safe reader operation and complete browser OAuth authorization code +
PKCE consent.
7. List objects, upload a local file with `restish zpan-upload`, interrupt and
resume one upload when practical, inspect the uploaded object, create a
public share, revoke the share, and check quota.
For pre-release PR or preview acceptance, a v2.9 GitHub release asset does not
exist yet. Build the already-reviewed `cmd/restish-zpan` source at the exact PR
head, install that trusted local executable for the acceptance run, and
separately verify that the release workflow produces the asset names expected
by Restish. Record that this was a source-build acceptance.
After v2.9 is released, repeat the install step through the user-facing release
path:
```sh
restish plugin install saltbo/zpan zpan
```
## CI Flow
Verify:
1. Create a workspace-scoped Agent API key in ZPan settings.
2. Store it in `ZPAN_AGENT_API_KEY`.
3. Use the `ci` Restish profile without token copy/paste.
4. Run list and upload operations.
5. Attempt an operation outside the key scope and confirm `403`.
## Static Contract
Run the repository Skill contract check before release. It verifies the required
Restish setup, upload plugin, profile, safety, CI, and MCP guidance while
guarding against removed or unsafe v2.9 workflows.
+22
View File
@@ -0,0 +1,22 @@
# CI and Unattended Automation
Use the `ci` Restish profile for unattended jobs. The profile reads the Agent
API key from the environment and does not require token copy/paste:
```sh
export ZPAN_AGENT_API_KEY="$ZPAN_AGENT_API_KEY"
restish --rsh-profile ci zpan list-objects --parent root --page-size 50
RSH_PROFILE=ci restish zpan-upload --api zpan --profile ci --parent releases ./dist/app.tar.gz
```
The key must be created by a user in ZPan Agent Access settings, scoped to one
workspace, named for the environment, given explicit permissions, and stored in
the CI secret store. The plaintext key is shown once by ZPan and should never be
posted into chat, logs, issue comments, or PR output.
Use separate keys for separate environments. Expired or revoked keys are
terminal; create a new key when the job needs a different lifetime.
When a job receives `403`, report the missing operation and expected scope. Do
not broaden the requested scopes automatically. The user should decide whether
to issue a new key or approve a broader scope set.
+75
View File
@@ -0,0 +1,75 @@
# Ordinary File Workflows
Use generated Restish OpenAPI commands for ordinary ZPan operations. Run
`restish api sync zpan` before relying on operation names from an older local
connection.
## List and Inspect
Use a reader-capable profile for browse and inspect operations:
```sh
restish --rsh-profile reader zpan list-objects --parent root --page-size 50
restish --rsh-profile reader zpan get-object obj_123
restish --rsh-profile reader zpan get-user-quota user_123
restish --rsh-profile reader zpan get-storage-usage
```
Keep list limits explicit and summarize IDs, names, paths, sizes, and relevant
URLs. Do not dump unbounded trees.
## Create Folders, Move, Copy, and Rename
Use `file-manager` for object mutations:
```sh
restish --rsh-profile file-manager zpan create-object 'name: releases, parent: root, type: folder, dirtype: 1'
restish --rsh-profile file-manager zpan update-object obj_123 'name: release.zip, onConflict: fail'
restish --rsh-profile file-manager zpan transfer-object obj_123 'mode: move, targetOrgId: org_123, targetParent: folder_456'
restish --rsh-profile file-manager zpan copy-object obj_123 'parent: folder_456, onConflict: fail'
```
Before writes, confirm the workspace and target folder. Before overwrite or
replace behavior, confirm the conflict policy.
## Delete and Purge
Soft delete requires `objects:delete`:
```sh
restish --rsh-profile file-manager zpan delete-object obj_123
```
Confirm destructive intent before soft delete. Permanent trash purge is more
destructive, must be confirmed separately, and is outside the v2.9 Agent
OAuth/API-key profile templates because it requires `objects:purge` on an
authorized human/operator surface. Do not attempt purge through this Skill or
invent an `operator` Restish profile. Ask the user to complete permanent purge
in an authorized operator surface instead.
Return soft-deleted object IDs and any quota effect reported by the API.
## Public Sharing
Use `publisher` for public shares:
```sh
restish --rsh-profile publisher zpan create-share 'matterId: obj_123, kind: landing, private: false'
restish --rsh-profile publisher zpan list-shares --page-size 50
restish --rsh-profile publisher zpan revoke-share share_token_123 'status: revoked'
```
Confirm before creating public shares. Summaries may include share IDs, public
URLs, expiry, and revocation state, but should not include credentials.
## Tasks
Use generated task operations for status checks:
```sh
restish --rsh-profile file-manager zpan list-download-tasks --page-size 25
restish --rsh-profile file-manager zpan get-download-task task_123
restish --rsh-profile file-manager zpan list-download-task-events task_123
```
Summarize state, progress, and errors. Keep event output bounded.
+20
View File
@@ -0,0 +1,20 @@
# Optional Restish MCP
Restish MCP is optional and only for reviewed ordinary OpenAPI operations. It is
not the upload transport. Local file uploads still use `restish zpan-upload`.
Default to read-only MCP:
```sh
restish plugin install rest-sh/restish mcp
restish mcp serve zpan --operations list-objects,get-object,list-shares,get-user-quota,get-storage-usage
```
Enable write tools only after reviewing the exact operation allowlist. Do not
allow upload control-plane operation IDs through MCP.
Keep results bounded. Do not route file bytes, storage upload URLs, bearer
tokens, cookies, API keys, or checkpoint contents through MCP results.
Do not expose authentication, administration, billing, entitlement, membership,
or credential-management operations through MCP.
+77
View File
@@ -0,0 +1,77 @@
# ZPan Restish Setup
## Confirm Origin and API Name
Before connecting, ask the user to confirm:
- the ZPan origin, for example `https://files.example.com`;
- the local Restish API name, normally `zpan`;
- the intended workspace if the next operation reads or changes workspace data.
Use one OpenAPI document only:
```sh
restish api connect zpan https://files.example.com/api/openapi.json --replace --yes
```
The `--yes` here approves replacing the Restish API connection after the user
has confirmed the origin. It does not approve plugin installation.
For an existing connection, sync before use:
```sh
restish api sync zpan
```
## Restish Version
Require Restish v2.3 or later:
```sh
restish --version
```
Stop and ask the user to upgrade if the version is older than v2.3.
## Profiles and Scopes
Reader, File manager, and Publisher are Restish convenience profiles that
expand to explicit scopes. They are not server-side roles or route names.
| Profile | Use for | Scope set |
| --- | --- | --- |
| `reader` | Browse, inspect, download links, quota | `objects:read`, `shares:read`, `quota:read`, `storage-usage:read` |
| `file-manager` | Reader plus create folders, upload, move, copy, rename, soft delete | Reader scopes plus `objects:create`, `objects:update`, `objects:delete` |
| `publisher` | Reader plus create and revoke public shares | Reader scopes plus `shares:create`, `shares:delete` |
| `ci` | Unattended file-management automation | Environment-backed `agentApiKey` with file-manager scopes |
Prefer the narrowest profile:
```sh
restish --rsh-profile reader zpan list-objects --page-size 50
restish --rsh-profile file-manager zpan list-objects --page-size 50
restish --rsh-profile publisher zpan list-shares --page-size 50
```
The first safe OAuth-backed command may open the browser for authorization code
+ PKCE consent. Restish owns token storage, refresh, logout, and redacted auth
diagnostics.
Use `--rsh-no-browser` only when the authorization-code callback can still be
completed manually.
## Upload Plugin Trust Gate
Install `restish-zpan` only after telling the user that Restish plugins are
trusted local executable code and asking them to approve this source:
```sh
restish plugin install saltbo/zpan zpan
```
This shorthand is the post-v2.9-release user path. For pre-release preview
acceptance, follow [acceptance.md](acceptance.md) and label the trusted local
source build explicitly.
Do not add a silent approval flag to plugin installation. After installation,
confirm that `restish zpan-upload` is available before using upload workflows.
+65
View File
@@ -0,0 +1,65 @@
# Upload Workflows
Every local file upload must use the Restish command plugin:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent root ./artifact.zip
```
The Skill must not implement upload chunking or upload orchestration.
`restish-zpan` validates `createObject`, `presignObjectUploadParts`,
`completeObjectUpload`, and `abortObjectUpload`, then creates or resumes ZPan
upload sessions through Restish delegated HTTP, streams file parts from disk to
storage, records storage responses, retries parts, and removes safe checkpoints
after completion.
## Before Uploading
Confirm:
- the target workspace;
- the target folder or parent object ID;
- whether a same-name destination should fail, rename, or replace;
- plugin trust if `restish zpan-upload` is not installed yet.
Install only after explicit source approval:
```sh
restish plugin install saltbo/zpan zpan
```
## Upload
Use the selected Restish host profile, plugin profile, and API name explicitly.
For plugin delegated HTTP on Restish v2.3, set `RSH_PROFILE` to the same value
as the plugin `--profile` flag. The environment selects the host credential;
the flag separately selects spec validation and checkpoint identity.
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent folder_456 ./release.tar.gz
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --parent folder_456 ./release.tar.gz release-linux.tar.gz
```
If the plugin supports a conflict flag in the installed version, pass only the
user-approved policy.
## Resume and Abort
Resume interrupted local uploads through the plugin:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --resume ./release.tar.gz
```
Abort an upload only after confirmation:
```sh
RSH_PROFILE=file-manager restish zpan-upload --api zpan --profile file-manager --abort ./release.tar.gz
```
## Output
Return a compact summary with object ID, object URL or share URL when relevant,
parent ID, upload mode, part count, bytes uploaded, task state, and quota effect
when reported. Do not expose storage upload URLs, bearer tokens, API keys,
cookies, or checkpoint contents.