diff --git a/cli/skills/spz/SKILL.md b/cli/skills/spz/SKILL.md index 4141b72..593696b 100644 --- a/cli/skills/spz/SKILL.md +++ b/cli/skills/spz/SKILL.md @@ -110,8 +110,12 @@ Rules: - for Discord, Slack, Teams, and similar platform-triggered creates, pass the external platform user through `--owner-provider` and `--owner-subject` - never pass a Discord, Slack, or Teams user ID through `--owner-id` +- do not ask for or depend on an internal owner ID unless it is already known + from a trusted internal context - use `--owner-id` only when you already have the canonical internal Spritz owner ID and intend a direct internal/admin create +- if provider, subject, preset, or tenant context is unclear, ask for + clarification instead of guessing - the service principal is only the actor - the same `idempotency-key` and same request should replay the same workspace - the same `idempotency-key` with a different request should fail with conflict @@ -131,6 +135,14 @@ Create from a preset for a known internal owner: spz create --preset openclaw --owner-id user-123 --idle-ttl 24h --ttl 168h --idempotency-key req-123 --json ``` +If external owner resolution fails, explain it like this: + +```text +The external account could not be resolved to a Spritz owner. +Ask the user to connect their account in the product or integration that owns +this identity mapping, then retry the create request. +``` + Create from an explicit image: ```bash @@ -174,6 +186,12 @@ spz profile use staging - prefer bearer-token auth for bots - for chat-platform-triggered creates, prefer external owner flags over direct `--owner-id` +- do not assume the caller already knows an internal owner ID +- if the required provider, subject, tenant, or preset is unclear, ask for the + missing detail instead of guessing +- when reporting a successful create back in a messaging app, tag the person + who requested it and include what was created plus the returned URLs for + opening it - treat the create response as the source of truth for the access URL - do not construct workspace URLs yourself - use idempotency keys for any retried or externally triggered create operation diff --git a/cli/src/index.ts b/cli/src/index.ts index da59bb5..d9503c5 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -31,6 +31,15 @@ type TerminalSessionInfo = { }; type SkillflagModule = typeof import('skillflag'); +type Audience = 'human' | 'agent'; +type AudienceGuidance = { + audience: Audience; + usageNote: string; + createOwnershipGuidance: string[]; + reportingGuidance: string[]; + missingOwnerInputGuidance: string[]; + unresolvedExternalOwnerGuidance: (provider: string) => string[]; +}; type TtyContext = { ttyPath: string | null; @@ -59,6 +68,20 @@ const sttyBinary = process.env.SPRITZ_STTY_BINARY || 'stty'; const resetBinary = process.env.SPRITZ_RESET_BINARY || 'reset'; let skillflagModulePromise: Promise | undefined; +class SpritzRequestError extends Error { + statusCode: number; + code?: string; + data?: any; + + constructor(message: string, options: { statusCode: number; code?: string; data?: any }) { + super(message); + this.name = 'SpritzRequestError'; + this.statusCode = options.statusCode; + this.code = options.code; + this.data = options.data; + } +} + function loadSkillflagModule(): Promise { skillflagModulePromise ??= import('skillflag'); return skillflagModulePromise; @@ -370,12 +393,94 @@ function startTtyWatchdog(context: TtyContext): (() => void) | null { }; } -function usage() { +function resolveAudience(value = process.env.AUDIENCE): Audience { + return value?.trim().toLowerCase() === 'agent' ? 'agent' : 'human'; +} + +const audienceGuidanceByAudience: Record = { + human: { + audience: 'human', + usageNote: 'Use `spritz create --help` for detailed owner guidance and examples.', + createOwnershipGuidance: [ + 'Use --owner-provider and --owner-subject when you only know a platform-native user ID such as a Discord, Slack, or Teams user.', + 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', + 'If provider, subject, preset, or tenant context is unclear, clarify it before running the create command.', + ], + reportingGuidance: [ + 'After create succeeds, use the returned workspace name and URLs as the source of truth when reporting the result back to the caller.', + ], + missingOwnerInputGuidance: [ + 'owner input is required.', + 'Use --owner-provider and --owner-subject when you only know a messaging-platform user ID.', + 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', + ], + unresolvedExternalOwnerGuidance: (provider: string) => [ + `The ${provider} account could not be resolved to a Spritz owner.`, + 'Ask the user to connect their account in the product or integration that owns this identity mapping, then retry the create request.', + ], + }, + agent: { + audience: 'agent', + usageNote: 'If a request originated from a messaging app, prefer --owner-provider and --owner-subject with the platform-native user ID.', + createOwnershipGuidance: [ + 'If the request came from Discord, Slack, Teams, or another messaging platform, use the platform-native user ID with --owner-provider and --owner-subject.', + 'Never pass a messaging-platform user ID through --owner-id.', + 'If provider, subject, preset, or tenant context is unclear, ask for clarification instead of guessing.', + 'If external owner resolution fails, tell the caller the user needs to connect their account, then retry with the same platform user ID.', + ], + reportingGuidance: [ + 'When replying in a messaging app, tag the person who requested the workspace.', + 'Say what was created and how to open it, using the returned workspace name and URLs from the create response.', + 'Do not construct workspace URLs yourself.', + ], + missingOwnerInputGuidance: [ + 'owner input is required.', + 'If this request came from a messaging app, use the platform-native user ID with --owner-provider and --owner-subject.', + 'Do not ask for or pass a messaging-platform user ID as --owner-id.', + 'If the provider, subject, preset, or tenant is unclear, ask for clarification before retrying.', + ], + unresolvedExternalOwnerGuidance: (provider: string) => [ + `The ${provider} account could not be resolved to a Spritz owner.`, + 'Ask the user to connect their account in the product or integration that owns this identity mapping, then retry the create request.', + 'Keep using the platform-native user ID with --owner-provider and --owner-subject.', + ], + }, +}; + +function guidanceForAudience(value = process.env.AUDIENCE): AudienceGuidance { + return audienceGuidanceByAudience[resolveAudience(value)]; +} + +function renderBullets(lines: string[]): string { + return lines.map((line) => ` - ${line}`).join('\n'); +} + +function createUsage(guidance = guidanceForAudience()) { + const ownerNotes = `Ownership guidance:\n${renderBullets(guidance.createOwnershipGuidance)}\n`; + const reportingNotes = `Reporting guidance:\n${renderBullets(guidance.reportingGuidance)}\n`; + + console.log(`Spritz create + +Usage: + spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-provider --owner-subject [--owner-tenant ] | --owner-id ] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] + +Environment: + AUDIENCE (current: ${guidance.audience}) + +Examples: + spritz create --preset claude-code --owner-provider discord --owner-subject 123456789012345678 --source discord --request-id discord-123 --idempotency-key discord-123 --json + spritz create --preset openclaw --owner-id user-123 --idempotency-key req-123 --json + +${ownerNotes} +${reportingNotes}`); +} + +function usage(guidance = guidanceForAudience()) { console.log(`Spritz CLI Usage: spritz list [--namespace ] - spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-id | --owner-provider --owner-subject [--owner-tenant ]] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] + spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-provider --owner-subject [--owner-tenant ] | --owner-id ] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] spritz suggest-name [--preset ] [--image ] [--name-prefix ] [--namespace ] spritz delete [--namespace ] spritz open [--namespace ] @@ -399,12 +504,23 @@ Environment: SPRITZ_API_HEADER_ID, SPRITZ_API_HEADER_EMAIL, SPRITZ_API_HEADER_TEAMS SPRITZ_TERMINAL_TRANSPORT (default: ${terminalTransportDefault}) SPRITZ_PROFILE, SPRITZ_CONFIG_DIR + AUDIENCE (default: human, current: ${guidance.audience}) Notes: + ${guidance.usageNote} When ZMX sessions are enabled, detach with Ctrl+\\ and reconnect later. `); } +function missingOwnerInputMessage(guidance = guidanceForAudience()): string { + return guidance.missingOwnerInputGuidance.join(' '); +} + +function unresolvedExternalOwnerMessage(error: SpritzRequestError, guidance = guidanceForAudience()): string { + const provider = typeof error.data?.identity?.provider === 'string' ? error.data.identity.provider : 'external'; + return guidance.unresolvedExternalOwnerGuidance(provider).join('\n'); +} + function argValue(flag: string): string | undefined { const idx = rest.indexOf(flag); if (idx === -1) return undefined; @@ -617,12 +733,16 @@ async function request(path: string, init?: RequestInit) { } const jsend = isJSend(data) ? data : null; if (!res.ok || (res.ok && jsend && jsend.status !== 'success')) { + const errorCode = + (jsend && typeof jsend.data?.error === 'string' ? jsend.data.error : undefined) || + undefined; + const errorData = jsend?.data; const message = (jsend && (jsend.message || jsend.data?.message || jsend.data?.error)) || text || res.statusText || 'Request failed'; - throw new Error(message); + throw new SpritzRequestError(message, { statusCode: res.status, code: errorCode, data: errorData }); } if (res.status === 204) return null; if (jsend) return jsend.data ?? null; @@ -938,6 +1058,7 @@ async function resolveNamespace(): Promise { } async function main() { + const guidance = guidanceForAudience(); if (shouldMaybeHandleSkillflag(process.argv)) { const { findSkillsRoot, maybeHandleSkillflag } = await loadSkillflagModule(); await maybeHandleSkillflag(process.argv, { @@ -948,7 +1069,16 @@ async function main() { } if (!command || command === 'help' || command === '--help') { - usage(); + if (command === 'help' && rest[0] === 'create') { + createUsage(guidance); + return; + } + usage(guidance); + return; + } + + if (command === 'create' && hasFlag('--help')) { + createUsage(guidance); return; } @@ -1132,6 +1262,9 @@ async function main() { const ownerId = usingExternalOwner ? undefined : explicitOwnerId || (token?.trim() ? process.env.SPRITZ_OWNER_ID : await resolveDefaultOwnerId()); + if (!usingExternalOwner && !ownerId) { + throw new Error(missingOwnerInputMessage(guidance)); + } const idleTtl = argValue('--idle-ttl'); const ttl = argValue('--ttl'); const idempotencyKey = argValue('--idempotency-key'); @@ -1167,11 +1300,19 @@ async function main() { if (branch) body.spec.repo.branch = branch; } - const data = await request('/spritzes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + let data: any; + try { + data = await request('/spritzes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (error) { + if (error instanceof SpritzRequestError && error.code === 'external_identity_unresolved') { + throw new Error(unresolvedExternalOwnerMessage(error, guidance)); + } + throw error; + } console.log(JSON.stringify(data, null, 2)); return; diff --git a/cli/test/help.test.ts b/cli/test/help.test.ts new file mode 100644 index 0000000..f31e78f --- /dev/null +++ b/cli/test/help.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, '..', 'src', 'index.ts'); + +async function runCli(args: string[], env: NodeJS.ProcessEnv = process.env) { + const child = spawn(process.execPath, ['--import', 'tsx', cliPath, ...args], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const code = await new Promise((resolve) => child.on('exit', resolve)); + return { code, stdout, stderr }; +} + +test('create help defaults to human audience', async () => { + const result = await runCli(['create', '--help'], { ...process.env, AUDIENCE: '' }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /AUDIENCE \(current: human\)/); + assert.match(result.stdout, /Use --owner-provider and --owner-subject when you only know a platform-native\s+user ID/i); +}); + +test('create help for agent audience prefers external owner guidance', async () => { + const result = await runCli(['create', '--help'], { ...process.env, AUDIENCE: 'agent' }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /AUDIENCE \(current: agent\)/); + assert.match(result.stdout, /use the platform-native user ID with --owner-provider and --owner-subject/i); + assert.match(result.stdout, /Never pass a messaging-platform user ID through --owner-id/i); + assert.match(result.stdout, /connect their account/i); + assert.match(result.stdout, /ask for\s+clarification instead of guessing/i); + assert.match(result.stdout, /tag the person who requested the workspace/i); + assert.match(result.stdout, /what was created and how to open it/i); +}); diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts index 695c7f0..3afdb93 100644 --- a/cli/test/provisioner-create.test.ts +++ b/cli/test/provisioner-create.test.ts @@ -214,6 +214,98 @@ test('create rejects mixed owner-id and external owner flags', async () => { assert.match(stderr, /mutually exclusive/); }); +test('create explains unresolved external owners with connect-account guidance', async (t) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + res.writeHead(409, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'fail', + data: { + message: 'external identity is unresolved', + error: 'external_identity_unresolved', + identity: { + provider: 'discord', + subject: '123456789012345678', + }, + }, + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + cliPath, + 'create', + '--preset', + 'openclaw', + '--owner-provider', + 'discord', + '--owner-subject', + '123456789012345678', + ], + { + env: { + ...process.env, + SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`, + SPRITZ_BEARER_TOKEN: 'service-token', + SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')), + AUDIENCE: 'agent', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.notEqual(exitCode, 0, 'spz create should fail for unresolved external owner'); + assert.match(stderr, /could not be resolved to a Spritz owner/i); + assert.match(stderr, /connect their account/i); + assert.match(stderr, /--owner-provider and --owner-subject/i); +}); + +test('create without owner input guides agent callers toward external owner flags', async () => { + const child = spawn( + process.execPath, + ['--import', 'tsx', cliPath, 'create', '--preset', 'openclaw'], + { + env: { + ...process.env, + SPRITZ_API_URL: 'http://127.0.0.1:9/api', + SPRITZ_BEARER_TOKEN: 'service-token', + SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')), + AUDIENCE: 'agent', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.notEqual(exitCode, 0, 'spz create should fail when no owner input is provided'); + assert.match(stderr, /owner input is required/i); + assert.match(stderr, /platform-native user ID with --owner-provider and --owner-subject/i); + assert.match(stderr, /ask for clarification/i); +}); + test('create falls back to local owner identity without bearer auth', async (t) => { let requestBody: any = null; let requestHeaders: http.IncomingHttpHeaders | null = null; diff --git a/cli/test/skillflag.test.ts b/cli/test/skillflag.test.ts index 4d16741..0aa64a4 100644 --- a/cli/test/skillflag.test.ts +++ b/cli/test/skillflag.test.ts @@ -41,4 +41,8 @@ test('skillflag show returns the bundled spz skill body', async () => { assert.match(result.stdout, /service-principal create flow/i); assert.match(result.stdout, /--owner-provider discord/i); assert.match(result.stdout, /never pass a Discord, Slack, or Teams user ID through `--owner-id`/i); + assert.match(result.stdout, /connect their account/i); + assert.match(result.stdout, /ask for\s+clarification instead of guessing/i); + assert.match(result.stdout, /tag the person\s+who requested it/i); + assert.match(result.stdout, /include what was created plus the returned URLs\s+for\s+opening it/i); }); diff --git a/docs/2026-03-13-spz-audience-and-external-owner-guidance.md b/docs/2026-03-13-spz-audience-and-external-owner-guidance.md new file mode 100644 index 0000000..3fd04ad --- /dev/null +++ b/docs/2026-03-13-spz-audience-and-external-owner-guidance.md @@ -0,0 +1,170 @@ +--- +date: 2026-03-13 +author: Onur Solmaz +title: spz Audience and External Owner Guidance +tags: [spritz, spz, cli, audience, external-identity] +--- + +## Overview + +This document defines the `AUDIENCE` environment variable contract for `spz` +and the default guidance for direct-owner versus external-owner create flows. + +The goal is to keep the CLI behavior stable while making the human-readable +guidance safer for bots, messaging agents, and service-principal automation. + +## `AUDIENCE` Contract + +- `AUDIENCE` is a human-readable guidance switch for `spz`. +- Allowed values: + - `human` + - `agent` +- Default: `human` + +`AUDIENCE` affects: + +- `spz --help` +- subcommand help such as `spz create --help` +- human-readable examples and remediation hints + +`AUDIENCE` must not change: + +- API requests +- flag semantics +- exit codes +- JSON response shape + +## Ownership Guidance Defaults + +The default guidance for `spz create` must not assume the caller already knows +an internal owner ID. + +Rules: + +- direct `--owner-id` is a direct-owner path for internal, admin, or manual + callers that already know the canonical internal owner ID +- external-owner flags are the default guidance whenever the caller only knows + a platform-native user identity +- documentation and skill examples must not present `--owner-id` as the normal + baseline for messaging-platform-driven creates + +## Agent Audience Rules + +When `AUDIENCE=agent`, the CLI help and printed remediation must guide the +caller to use external-owner resolution by default. + +Agent guidance must state: + +- if the request comes from a messaging platform such as Discord, Slack, + Microsoft Teams, Mattermost, Google Chat, or similar, always use the stable + user ID from that platform as the external subject +- for those flows, use: + - `--owner-provider ` + - `--owner-subject ` +- never pass a messaging-platform user ID through `--owner-id` +- do not ask the end user for an internal owner ID by default + +Examples: + +```bash +spz create \ + --preset claude-code \ + --owner-provider discord \ + --owner-subject 123456789012345678 \ + --source discord \ + --request-id discord-123 \ + --idempotency-key discord-123 \ + --json +``` + +```bash +spz create \ + --preset openclaw \ + --owner-provider msteams \ + --owner-subject 6f0f9d4f-9b0e-4d52-8c3a-ef0fd64b9b9f \ + --owner-tenant 11111111-2222-3333-4444-555555555555 \ + --source msteams \ + --request-id teams-123 \ + --idempotency-key teams-123 \ + --json +``` + +## Human Audience Rules + +When `AUDIENCE=human`, the CLI may present both direct-owner and external-owner +paths, but it must still avoid implying that internal owner IDs are generally +required. + +Human guidance should: + +- show external-owner examples for messaging-platform integrations +- keep `--owner-id` documented as an explicit direct-owner mode +- explain the tradeoff in plain language when both paths are shown + +## Unresolved External Identity Behavior + +When the create flow is using an external owner and resolution fails because the +external identity is not linked to a Spritz owner, the human-readable error +guidance should tell the caller that the user needs to connect their account. + +This guidance should stay generic and deployment-agnostic. + +Preferred wording shape: + +```text +The external account could not be resolved to a Spritz owner. +Ask the user to connect their account in the product or integration that owns +this identity mapping, then retry the create request. +``` + +This must not tell the caller to ask the user for an internal owner ID as the +default fallback. + +## Clarification Behavior + +When the required create inputs are not clear, the CLI guidance should tell the +caller to ask for clarification instead of guessing. + +Examples of unclear input: + +- the requested preset is ambiguous +- the platform is unclear +- the external subject is unavailable from message metadata +- tenant-scoped providers are missing tenant context + +Preferred behavior: + +- ask for the missing detail explicitly +- do not guess an internal owner ID +- do not silently switch from external-owner mode to direct-owner mode + +## Skill and Help Requirements + +The bundled `spz` skill must stay generic and correct: + +- present external-owner create flows first for platform integrations +- explain unresolved-owner remediation as “connect the account” +- tell callers to ask for clarification when provider, subject, or preset is + unclear + +The CLI help and printed remediation must be audience-aware: + +- `AUDIENCE=human` prints balanced direct-owner and external-owner guidance +- `AUDIENCE=agent` prints messaging-platform-first ownership guidance +- the modality switch must live in code, not in duplicated static skill text + +## Validation + +Validation for this contract should include: + +- a bundled-skill test that asserts messaging-platform flows use + `--owner-provider` and `--owner-subject` +- a bundled-skill test that asserts unresolved external owners are explained as + “connect the account” +- CLI help tests for `AUDIENCE=human` and `AUDIENCE=agent` + +## References + +- `docs/2026-03-11-external-provisioner-and-service-principal-architecture.md` +- `docs/2026-03-12-external-identity-resolution-api-architecture.md` +- `cli/skills/spz/SKILL.md`