From 5c317a7f751182c06b79a7358b5412d259dbd63b Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Thu, 12 Mar 2026 09:07:30 +0100 Subject: [PATCH] feat(cli): support profile-backed spz auth --- cli/skills/spz/SKILL.md | 22 +++- cli/src/index.ts | 14 ++- cli/test/provisioner-create.test.ts | 154 ++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/cli/skills/spz/SKILL.md b/cli/skills/spz/SKILL.md index edcc9b1..925c89b 100644 --- a/cli/skills/spz/SKILL.md +++ b/cli/skills/spz/SKILL.md @@ -45,6 +45,7 @@ Use this for services, bots, and automation. - env: `SPRITZ_BEARER_TOKEN` - flag: `--token` +- active profile field: `bearerToken` This is the preferred mode for external provisioners such as bots. @@ -65,6 +66,25 @@ This mode is not the right fit for external automation. - `SPRITZ_CONFIG_DIR`: config directory for profiles - `SPRITZ_PROFILE`: active profile name +## Zenobot and other preconfigured bot images + +Some bot images ship with `spz` already configured through an active profile. + +When you are inside one of those images: + +- check `spz profile current` +- inspect `spz profile show ` +- prefer the active profile over asking for raw `SPRITZ_*` env vars + +Expected shape: + +- profile name like `zenobot` +- preconfigured API URL +- preconfigured bearer token +- preconfigured namespace when needed + +So if `spz profile current` returns a profile, assume Spritz is already configured unless the command itself fails. + ## Service-principal create flow For external provisioners, the normal command is: @@ -130,7 +150,7 @@ spz terminal openclaw-tide-wind Use profiles: ```bash -spz profile set staging --api-url https://console.example.com/api --namespace spritz +spz profile set staging --api-url https://console.example.com/api --token service-token --namespace spritz spz profile use staging ``` diff --git a/cli/src/index.ts b/cli/src/index.ts index 861628b..3e4293e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -11,6 +11,7 @@ import { terminalHardResetSequence, terminalResetSequence } from './terminal_seq type ProfileConfig = { apiUrl?: string; + bearerToken?: string; userId?: string; userEmail?: string; userTeams?: string; @@ -383,7 +384,7 @@ Usage: spritz profile list spritz profile current spritz profile show [name] - spritz profile set [--api-url ] [--user-id ] [--user-email ] [--user-teams ] [--namespace ] + spritz profile set [--api-url ] [--token ] [--user-id ] [--user-email ] [--user-teams ] [--namespace ] spritz profile use spritz profile delete spritz --skill ... @@ -565,11 +566,11 @@ function isJSend(payload: any): payload is { status: string; data?: any; message } async function authHeaders(): Promise> { - const token = argValue('--token') || process.env.SPRITZ_BEARER_TOKEN; + const { profile } = await resolveProfile({ allowFlag: true }); + const token = argValue('--token') || process.env.SPRITZ_BEARER_TOKEN || profile?.bearerToken; if (token?.trim()) { return { Authorization: `Bearer ${token.trim()}` }; } - const { profile } = await resolveProfile({ allowFlag: true }); const headers: Record = {}; const userId = process.env.SPRITZ_USER_ID || profile?.userId || process.env.USER; const userEmail = process.env.SPRITZ_USER_EMAIL || profile?.userEmail; @@ -988,6 +989,7 @@ async function main() { } console.log(`Profile: ${name}`); console.log(`API URL: ${profile.apiUrl || '(unset)'}`); + console.log(`Bearer Token: ${profile.bearerToken ? '(set)' : '(unset)'}`); console.log(`User ID: ${profile.userId || '(unset)'}`); console.log(`User Email: ${profile.userEmail || '(unset)'}`); console.log(`User Teams: ${profile.userTeams || '(unset)'}`); @@ -998,6 +1000,7 @@ async function main() { if (action === 'set') { const name = normalizeProfileName(rest[1] || ''); const apiUrlInfo = argValueInfo('--api-url'); + const tokenInfo = argValueInfo('--token'); const userIdInfo = argValueInfo('--user-id'); const userEmailInfo = argValueInfo('--user-email'); const userTeamsInfo = argValueInfo('--user-teams'); @@ -1005,6 +1008,7 @@ async function main() { const anyFlag = apiUrlInfo.present || + tokenInfo.present || userIdInfo.present || userEmailInfo.present || userTeamsInfo.present || @@ -1020,6 +1024,10 @@ async function main() { if (!apiUrlInfo.value) throw new Error('--api-url requires a value'); profile.apiUrl = normalizeProfileValue(apiUrlInfo.value); } + if (tokenInfo.present) { + if (!tokenInfo.value) throw new Error('--token requires a value'); + profile.bearerToken = normalizeProfileValue(tokenInfo.value); + } if (userIdInfo.present) { if (!userIdInfo.value) throw new Error('--user-id requires a value'); profile.userId = normalizeProfileValue(userIdInfo.value); diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts index b0c09c6..75e9ebe 100644 --- a/cli/test/provisioner-create.test.ts +++ b/cli/test/provisioner-create.test.ts @@ -212,3 +212,157 @@ test('create allows server-side default preset resolution', async (t) => { assert.equal(payload.presetId, 'openclaw'); assert.equal(payload.ownerId, 'user-123'); }); + +test('create uses active profile api url and bearer token without SPRITZ env vars', async (t) => { + let requestBody: any = null; + let requestHeaders: http.IncomingHttpHeaders | null = null; + + const server = http.createServer((req, res) => { + requestHeaders = req.headers; + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8')); + res.writeHead(201, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'success', + data: { + accessUrl: 'https://console.example.com/w/openclaw-profile-smoke/', + ownerId: 'user-123', + presetId: 'openclaw', + }, + })); + }); + }); + 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 configDir = mkdtempSync(path.join(os.tmpdir(), 'spz-config-')); + const profileChild = spawn( + process.execPath, + [ + '--import', + 'tsx', + cliPath, + 'profile', + 'set', + 'zenobot', + '--api-url', + `http://127.0.0.1:${address.port}/api`, + '--token', + 'profile-token', + '--namespace', + 'spritz-staging', + ], + { + env: { + ...process.env, + SPRITZ_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const profileExitCode = await new Promise((resolve) => profileChild.on('exit', resolve)); + assert.equal(profileExitCode, 0, 'profile set should succeed'); + + const useChild = spawn(process.execPath, ['--import', 'tsx', cliPath, 'profile', 'use', 'zenobot'], { + env: { + ...process.env, + SPRITZ_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const useExitCode = await new Promise((resolve) => useChild.on('exit', resolve)); + assert.equal(useExitCode, 0, 'profile use should succeed'); + + const child = spawn( + process.execPath, + ['--import', 'tsx', cliPath, 'create', '--owner-id', 'user-123', '--preset', 'openclaw', '--idempotency-key', 'profile-request'], + { + env: { + ...process.env, + SPRITZ_CONFIG_DIR: configDir, + }, + 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 exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.equal(exitCode, 0, `spz create should succeed: ${stderr}`); + + assert.equal(requestHeaders?.authorization, 'Bearer profile-token'); + assert.deepEqual(requestBody, { + namespace: 'spritz-staging', + presetId: 'openclaw', + ownerId: 'user-123', + idempotencyKey: 'profile-request', + spec: {}, + }); + + const payload = JSON.parse(stdout); + assert.equal(payload.accessUrl, 'https://console.example.com/w/openclaw-profile-smoke/'); +}); + +test('profile show redacts bearer tokens', async () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), 'spz-config-')); + + const profileChild = spawn( + process.execPath, + [ + '--import', + 'tsx', + cliPath, + 'profile', + 'set', + 'zenobot', + '--api-url', + 'https://staging.spritz.textcortex.com/api', + '--token', + 'super-secret-token', + ], + { + env: { + ...process.env, + SPRITZ_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const profileExitCode = await new Promise((resolve) => profileChild.on('exit', resolve)); + assert.equal(profileExitCode, 0, 'profile set should succeed'); + + const showChild = spawn(process.execPath, ['--import', 'tsx', cliPath, 'profile', 'show', 'zenobot'], { + env: { + ...process.env, + SPRITZ_CONFIG_DIR: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + showChild.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + showChild.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => showChild.on('exit', resolve)); + assert.equal(exitCode, 0, `profile show should succeed: ${stderr}`); + assert.match(stdout, /Bearer Token: \(set\)/); + assert.doesNotMatch(stdout, /super-secret-token/); +});