diff --git a/README.md b/README.md index 09eed008..49ffde1c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/internal/restishzpan/upload.go b/cmd/internal/restishzpan/upload.go index 391a3fbc..8d934ce4 100644 --- a/cmd/internal/restishzpan/upload.go +++ b/cmd/internal/restishzpan/upload.go @@ -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) diff --git a/cmd/internal/restishzpan/upload_test.go b/cmd/internal/restishzpan/upload_test.go index 0e7753b7..dbd574a9 100644 --- a/cmd/internal/restishzpan/upload_test.go +++ b/cmd/internal/restishzpan/upload_test.go @@ -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{}) diff --git a/cmd/restish-zpan/main.go b/cmd/restish-zpan/main.go index ef4f3036..89e32969 100644 --- a/cmd/restish-zpan/main.go +++ b/cmd/restish-zpan/main.go @@ -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", }, } } diff --git a/cmd/restish-zpan/main_test.go b/cmd/restish-zpan/main_test.go index 41b856bc..9700970e 100644 --- a/cmd/restish-zpan/main_test.go +++ b/cmd/restish-zpan/main_test.go @@ -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) { diff --git a/docs/agent-skill.md b/docs/agent-skill.md new file mode 100644 index 00000000..c9578825 --- /dev/null +++ b/docs/agent-skill.md @@ -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. diff --git a/docs/design/agent-authentication.md b/docs/design/agent-authentication.md index 933565de..aec06aa7 100644 --- a/docs/design/agent-authentication.md +++ b/docs/design/agent-authentication.md @@ -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. diff --git a/docs/restish-zpan.md b/docs/restish-zpan.md index 1a288e8c..5e1748d5 100644 --- a/docs/restish-zpan.md +++ b/docs/restish-zpan.md @@ -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. diff --git a/docs/roadmap/v2.9.md b/docs/roadmap/v2.9.md index cd30851a..726cc72e 100644 --- a/docs/roadmap/v2.9.md +++ b/docs/roadmap/v2.9.md @@ -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. diff --git a/package.json b/package.json index 4f97dc52..7a0d16bf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/lint-zpan-skill.mjs b/scripts/lint-zpan-skill.mjs new file mode 100644 index 00000000..ea86f01e --- /dev/null +++ b/scripts/lint-zpan-skill.mjs @@ -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)`) diff --git a/server/openapi.test.ts b/server/openapi.test.ts index 25b24e34..5b8b3f7c 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -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({ diff --git a/skills/zpan/SKILL.md b/skills/zpan/SKILL.md new file mode 100644 index 00000000..86feecfa --- /dev/null +++ b/skills/zpan/SKILL.md @@ -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: `/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. diff --git a/skills/zpan/references/acceptance.md b/skills/zpan/references/acceptance.md new file mode 100644 index 00000000..ab88356f --- /dev/null +++ b/skills/zpan/references/acceptance.md @@ -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. diff --git a/skills/zpan/references/ci.md b/skills/zpan/references/ci.md new file mode 100644 index 00000000..5273b7eb --- /dev/null +++ b/skills/zpan/references/ci.md @@ -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. diff --git a/skills/zpan/references/file-workflows.md b/skills/zpan/references/file-workflows.md new file mode 100644 index 00000000..3f61b66b --- /dev/null +++ b/skills/zpan/references/file-workflows.md @@ -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. diff --git a/skills/zpan/references/mcp.md b/skills/zpan/references/mcp.md new file mode 100644 index 00000000..11839a89 --- /dev/null +++ b/skills/zpan/references/mcp.md @@ -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. diff --git a/skills/zpan/references/setup.md b/skills/zpan/references/setup.md new file mode 100644 index 00000000..6b48059a --- /dev/null +++ b/skills/zpan/references/setup.md @@ -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. diff --git a/skills/zpan/references/uploads.md b/skills/zpan/references/uploads.md new file mode 100644 index 00000000..126af78c --- /dev/null +++ b/skills/zpan/references/uploads.md @@ -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.