Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion cli/skills/spz/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <name>`
- 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:
Expand Down Expand Up @@ -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
```

Expand Down
14 changes: 11 additions & 3 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { terminalHardResetSequence, terminalResetSequence } from './terminal_seq

type ProfileConfig = {
apiUrl?: string;
bearerToken?: string;
userId?: string;
userEmail?: string;
userTeams?: string;
Expand Down Expand Up @@ -383,7 +384,7 @@ Usage:
spritz profile list
spritz profile current
spritz profile show [name]
spritz profile set <name> [--api-url <url>] [--user-id <id>] [--user-email <email>] [--user-teams <csv>] [--namespace <ns>]
spritz profile set <name> [--api-url <url>] [--token <token>] [--user-id <id>] [--user-email <email>] [--user-teams <csv>] [--namespace <ns>]
spritz profile use <name>
spritz profile delete <name>
spritz --skill <list|show|export|install> ...
Expand Down Expand Up @@ -565,11 +566,11 @@ function isJSend(payload: any): payload is { status: string; data?: any; message
}

async function authHeaders(): Promise<Record<string, string>> {
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<string, string> = {};
const userId = process.env.SPRITZ_USER_ID || profile?.userId || process.env.USER;
const userEmail = process.env.SPRITZ_USER_EMAIL || profile?.userEmail;
Expand Down Expand Up @@ -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)'}`);
Expand All @@ -998,13 +1000,15 @@ 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');
const namespaceInfo = argValueInfo('--namespace');

const anyFlag =
apiUrlInfo.present ||
tokenInfo.present ||
userIdInfo.present ||
userEmailInfo.present ||
userTeamsInfo.present ||
Expand All @@ -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);
Expand Down
154 changes: 154 additions & 0 deletions cli/test/provisioner-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<number | null>((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<number | null>((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<number | null>((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<number | null>((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<number | null>((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/);
});
Loading