Skip to content
Open
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
10 changes: 5 additions & 5 deletions .claude/skills/setup-agent-team/qa-fixtures-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Cloud credentials are stored in `~/.config/spawn/{cloud}.json` (loaded by `sh/sh

For each cloud with a fixture directory, check if its required env vars are set:
- **hetzner**: `HCLOUD_TOKEN`
- **digitalocean**: `DO_API_TOKEN`
- **digitalocean**: `DIGITALOCEAN_ACCESS_TOKEN`
- **aws**: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`

Skip clouds where credentials are missing (log which ones).
Expand All @@ -53,11 +53,11 @@ curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1
curl -s -H "Authorization: Bearer ${HCLOUD_TOKEN}" "https://api.hetzner.cloud/v1/locations"
```

### DigitalOcean (needs DO_API_TOKEN)
### DigitalOcean (needs DIGITALOCEAN_ACCESS_TOKEN)
```bash
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/account/keys"
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/sizes"
curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/regions"
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/account/keys"
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/sizes"
curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" "https://api.digitalocean.com/v2/regions"
```

For any other cloud directories found, read their TypeScript module in `packages/cli/src/{cloud}/` to discover the API base URL and auth pattern, then call equivalent GET-only endpoints.
Expand Down
22 changes: 11 additions & 11 deletions .github/workflows/packer-snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,18 @@ jobs:
- name: Generate variables file
run: |
jq -n \
--arg token "$DO_API_TOKEN" \
--arg token "$DIGITALOCEAN_ACCESS_TOKEN" \
--arg agent "$AGENT_NAME" \
--arg tier "$TIER" \
--argjson install "$INSTALL_COMMANDS" \
'{
do_api_token: $token,
digitalocean_access_token: $token,
agent_name: $agent,
cloud_init_tier: $tier,
install_commands: $install
}' > packer/auto.pkrvars.json
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
AGENT_NAME: ${{ matrix.agent }}
TIER: ${{ steps.config.outputs.tier }}
INSTALL_COMMANDS: ${{ steps.config.outputs.install }}
Expand All @@ -96,7 +96,7 @@ jobs:
if: cancelled()
run: |
# Filter by spawn-packer tag to avoid destroying builder droplets from other workflows
DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
DROPLET_IDS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/droplets?per_page=200&tag_name=spawn-packer" \
| jq -r '.droplets[].id')

Expand All @@ -107,28 +107,28 @@ jobs:

for ID in $DROPLET_IDS; do
echo "Destroying orphaned builder droplet: ${ID}"
curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \
curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/droplets/${ID}" || true
done
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}

- name: Cleanup old snapshots
if: success()
run: |
PREFIX="spawn-${AGENT_NAME}-"
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" \
SNAPSHOTS=$(curl -s -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/images?private=true&per_page=100" \
| jq -r --arg prefix "$PREFIX" \
'[.images[] | select(.name | startswith($prefix))] | sort_by(.created_at) | reverse | .[1:] | .[].id')

for ID in $SNAPSHOTS; do
echo "Deleting old snapshot: ${ID}"
curl -s -X DELETE -H "Authorization: Bearer ${DO_API_TOKEN}" \
curl -s -X DELETE -H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
"https://api.digitalocean.com/v2/images/${ID}" || true
done
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
AGENT_NAME: ${{ matrix.agent }}

- name: Submit to DO Marketplace
Expand Down Expand Up @@ -162,7 +162,7 @@ jobs:
HTTP_CODE=$(curl -s -o /tmp/mp-response.json -w "%{http_code}" \
-X PATCH \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${DO_API_TOKEN}" \
-H "Authorization: Bearer ${DIGITALOCEAN_ACCESS_TOKEN}" \
-d "$(jq -n \
--arg reason "Nightly rebuild — $(date -u '+%Y-%m-%d')" \
--argjson imageId "$IMG_ID" \
Expand All @@ -177,6 +177,6 @@ jobs:
exit 1 ;;
esac
env:
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DO_API_TOKEN }}
AGENT_NAME: ${{ matrix.agent }}
MARKETPLACE_APP_IDS: ${{ secrets.MARKETPLACE_APP_IDS }}
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export OPENROUTER_API_KEY=sk-or-v1-xxxxx
# Cloud-specific credentials (varies by provider)
# Note: Sprite uses `sprite login` for authentication
export HCLOUD_TOKEN=... # For Hetzner
export DO_API_TOKEN=... # For DigitalOcean
export DIGITALOCEAN_ACCESS_TOKEN=... # For DigitalOcean

# Run non-interactively
spawn claude hetzner
Expand Down Expand Up @@ -258,7 +258,7 @@ If spawn fails to install, try these steps:
2. **Set credentials via environment variables** before launching:
```powershell
$env:OPENROUTER_API_KEY = "sk-or-v1-xxxxx"
$env:DO_API_TOKEN = "dop_v1_xxxxx" # For DigitalOcean
$env:DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_xxxxx" # For DigitalOcean
$env:HCLOUD_TOKEN = "xxxxx" # For Hetzner
spawn openclaw digitalocean
```
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@
"description": "Cloud servers (account + payment method required)",
"url": "https://www.digitalocean.com/",
"type": "api",
"auth": "DO_API_TOKEN",
"auth": "DIGITALOCEAN_ACCESS_TOKEN",
"provision_method": "POST /v2/droplets with user_data",
"exec_method": "ssh root@IP",
"interactive_method": "ssh -t root@IP",
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/__tests__/commands-exported-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ describe("parseAuthEnvVars", () => {
});

it("should extract env var starting with letter followed by digits", () => {
expect(parseAuthEnvVars("DO_API_TOKEN")).toEqual([
"DO_API_TOKEN",
expect(parseAuthEnvVars("DIGITALOCEAN_ACCESS_TOKEN")).toEqual([
"DIGITALOCEAN_ACCESS_TOKEN",
]);
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/__tests__/do-cov.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ describe("digitalocean/getServerIp", () => {
);
const { getServerIp } = await import("../digitalocean/digitalocean");
// Need to set the token state
process.env.DO_API_TOKEN = "test-token";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-token";
// getServerIp calls doApi which uses internal state token - need to set via ensureDoToken
// But doApi will use _state.token. Since we can't easily set _state, we test the 404 path
// by mocking fetch to always return 404
Expand Down
32 changes: 28 additions & 4 deletions packages/cli/src/__tests__/do-payment-warning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
let warnSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
// Save and clear DO_API_TOKEN
savedEnv["DO_API_TOKEN"] = process.env.DO_API_TOKEN;
delete process.env.DO_API_TOKEN;
// Save and clear all accepted DigitalOcean token env vars
for (const v of [
"DIGITALOCEAN_ACCESS_TOKEN",
"DIGITALOCEAN_API_TOKEN",
"DO_API_TOKEN",
]) {
savedEnv[v] = process.env[v];
delete process.env[v];
}

// Fail OAuth connectivity check → tryDoOAuth returns null immediately
globalThis.fetch = mock(() => Promise.reject(new Error("Network unreachable")));
Expand Down Expand Up @@ -73,7 +79,25 @@ describe("ensureDoToken — payment method warning for first-time users", () =>
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});

it("does NOT show payment warning when DO_API_TOKEN env var is set", async () => {
it("does NOT show payment warning when DIGITALOCEAN_ACCESS_TOKEN env var is set", async () => {
process.env.DIGITALOCEAN_ACCESS_TOKEN = "dop_v1_invalid_env_token";

await expect(ensureDoToken()).rejects.toThrow();

const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});

it("does NOT show payment warning when DIGITALOCEAN_API_TOKEN env var is set", async () => {
process.env.DIGITALOCEAN_API_TOKEN = "dop_v1_invalid_env_token";

await expect(ensureDoToken()).rejects.toThrow();

const warnMessages = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
expect(warnMessages.some((msg: string) => msg.includes("payment method"))).toBe(false);
});

it("does NOT show payment warning when legacy DO_API_TOKEN env var is set", async () => {
process.env.DO_API_TOKEN = "dop_v1_invalid_env_token";

await expect(ensureDoToken()).rejects.toThrow();
Expand Down
36 changes: 32 additions & 4 deletions packages/cli/src/__tests__/run-path-credential-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function makeManifest(overrides?: Partial<Manifest>): Manifest {
price: "test",
url: "https://digitalocean.com",
type: "api",
auth: "DO_API_TOKEN",
auth: "DIGITALOCEAN_ACCESS_TOKEN",
provision_method: "api",
exec_method: "ssh root@IP",
interactive_method: "ssh -t root@IP",
Expand Down Expand Up @@ -138,6 +138,8 @@ describe("prioritizeCloudsByCredentials", () => {
// Save and clear credential env vars
for (const v of [
"HCLOUD_TOKEN",
"DIGITALOCEAN_ACCESS_TOKEN",
"DIGITALOCEAN_API_TOKEN",
"DO_API_TOKEN",
"UPCLOUD_USERNAME",
"UPCLOUD_PASSWORD",
Expand Down Expand Up @@ -191,7 +193,7 @@ describe("prioritizeCloudsByCredentials", () => {

it("should move multiple credential clouds to front", () => {
process.env.HCLOUD_TOKEN = "test-token";
process.env.DO_API_TOKEN = "test-do-token";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "test-do-token";
const manifest = makeManifest();
const clouds = [
"upcloud",
Expand Down Expand Up @@ -290,7 +292,7 @@ describe("prioritizeCloudsByCredentials", () => {

it("should preserve relative order within each group", () => {
process.env.HCLOUD_TOKEN = "token";
process.env.DO_API_TOKEN = "token";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "token";
const manifest = makeManifest();
// Input order: digitalocean before hetzner (both have creds)
const clouds = [
Expand Down Expand Up @@ -331,7 +333,7 @@ describe("prioritizeCloudsByCredentials", () => {

it("should count all credential clouds correctly with all set", () => {
process.env.HCLOUD_TOKEN = "t1";
process.env.DO_API_TOKEN = "t2";
process.env.DIGITALOCEAN_ACCESS_TOKEN = "t2";
process.env.UPCLOUD_USERNAME = "u";
process.env.UPCLOUD_PASSWORD = "p";
const manifest = makeManifest();
Expand All @@ -350,4 +352,30 @@ describe("prioritizeCloudsByCredentials", () => {
expect(result.sortedClouds.slice(3)).toContain("sprite");
expect(result.sortedClouds.slice(3)).toContain("localcloud");
});

it("should recognize legacy DO_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
process.env.DO_API_TOKEN = "legacy-token";
const manifest = makeManifest();
const clouds = [
"digitalocean",
"hetzner",
];
const result = prioritizeCloudsByCredentials(clouds, manifest);

expect(result.credCount).toBe(1);
expect(result.sortedClouds[0]).toBe("digitalocean");
});

it("should recognize DIGITALOCEAN_API_TOKEN as alias for DIGITALOCEAN_ACCESS_TOKEN", () => {
process.env.DIGITALOCEAN_API_TOKEN = "alt-token";
const manifest = makeManifest();
const clouds = [
"digitalocean",
"hetzner",
];
const result = prioritizeCloudsByCredentials(clouds, manifest);

expect(result.credCount).toBe(1);
expect(result.sortedClouds[0]).toBe("digitalocean");
});
});
12 changes: 6 additions & 6 deletions packages/cli/src/__tests__/script-failure-guidance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,28 +209,28 @@ describe("getScriptFailureGuidance", () => {

it("should show specific env var name and setup hint for default case when authHint is provided", () => {
const savedOR = process.env.OPENROUTER_API_KEY;
const savedDO = process.env.DO_API_TOKEN;
const savedDO = process.env.DIGITALOCEAN_ACCESS_TOKEN;
delete process.env.OPENROUTER_API_KEY;
delete process.env.DO_API_TOKEN;
const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN");
delete process.env.DIGITALOCEAN_ACCESS_TOKEN;
const lines = stripped_getScriptFailureGuidance(42, "digitalocean", "DIGITALOCEAN_ACCESS_TOKEN");
const joined = lines.join("\n");
expect(joined).toContain("DO_API_TOKEN");
expect(joined).toContain("DIGITALOCEAN_ACCESS_TOKEN");
expect(joined).toContain("OPENROUTER_API_KEY");
expect(joined).toContain("spawn digitalocean");
expect(joined).toContain("setup");
if (savedOR !== undefined) {
process.env.OPENROUTER_API_KEY = savedOR;
}
if (savedDO !== undefined) {
process.env.DO_API_TOKEN = savedDO;
process.env.DIGITALOCEAN_ACCESS_TOKEN = savedDO;
}
});

it("should show generic setup hint for default case when no authHint", () => {
const lines = stripped_getScriptFailureGuidance(42, "digitalocean");
const joined = lines.join("\n");
expect(joined).toContain("spawn digitalocean");
expect(joined).not.toContain("DO_API_TOKEN");
expect(joined).not.toContain("DIGITALOCEAN_ACCESS_TOKEN");
});

it("should handle multi-credential auth hint", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {
getImplementedClouds,
hasCloudCli,
hasCloudCredentials,
isAuthEnvVarSet,
isInteractiveTTY,
levenshtein,
loadManifestWithSpinner,
Expand Down
25 changes: 21 additions & 4 deletions packages/cli/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,26 @@ export function parseAuthEnvVars(auth: string): string[] {
.filter((s) => /^[A-Z][A-Z0-9_]{3,}$/.test(s));
}

/** Legacy env var names accepted as aliases for the canonical names in the manifest */
const AUTH_VAR_ALIASES: Record<string, string[]> = {
DIGITALOCEAN_ACCESS_TOKEN: [
"DIGITALOCEAN_API_TOKEN",
"DO_API_TOKEN",
],
};

/** Check if an auth env var (or one of its legacy aliases) is set */
export function isAuthEnvVarSet(varName: string): boolean {
if (process.env[varName]) {
return true;
}
const aliases = AUTH_VAR_ALIASES[varName];
return !!aliases?.some((a) => !!process.env[a]);
}

/** Format an auth env var line showing whether it's already set or needs to be exported */
function formatAuthVarLine(varName: string, urlHint?: string): string {
if (process.env[varName]) {
if (isAuthEnvVarSet(varName)) {
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
}
const hint = urlHint ? ` ${pc.dim(`# ${urlHint}`)}` : "";
Expand All @@ -504,12 +521,12 @@ export function hasCloudCredentials(auth: string): boolean {
if (vars.length === 0) {
return false;
}
return vars.every((v) => !!process.env[v]);
return vars.every((v) => isAuthEnvVarSet(v));
}

/** Format a single credential env var as a status line (green if set, red if missing) */
export function formatCredStatusLine(varName: string, urlHint?: string): string {
if (process.env[varName]) {
if (isAuthEnvVarSet(varName)) {
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
}
const suffix = urlHint ? ` ${pc.dim(urlHint)}` : "";
Expand Down Expand Up @@ -542,7 +559,7 @@ export function collectMissingCredentials(authVars: string[], cloud?: string): s
missing.push("OPENROUTER_API_KEY");
}
for (const v of authVars) {
if (!process.env[v]) {
if (!isAuthEnvVarSet(v)) {
missing.push(v);
}
}
Expand Down
Loading
Loading