Fix --account N credential priority across all SDKs

--account N was silently ignored when UNSANDBOX_PUBLIC_KEY/SECRET_KEY
env vars were set. The credential resolution checked env vars at tier 2
before ever reaching the CSV lookup, so the explicit flag had no effect.

Correct priority order (all 8 SDKs):
  1. CLI -p/-k flags (explicit key args)
  2. --account N → direct CSV row lookup (bypasses env vars)
  3. UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars
  4. ~/.unsandbox/accounts.csv default (row 0 or UNSANDBOX_ACCOUNT)

SDKs updated: C, Python, Go, JavaScript, Ruby, PHP, Java, Rust

Also adds:
- --account N flag to CLI parsers in all 8 SDKs (Go, PHP, Java, Rust
  previously had no flag at all; Python/JS/Ruby had the parameter but
  never wired it to the CLI)
- test_account_flag.sh integration test for each SDK verifying the
  priority behavior with real and garbage credentials
- test-integration Makefile target for the C SDK
This commit is contained in:
russell@unturf.com 2026-03-23 14:56:00 -04:00
parent 8093386568
commit ed6c52b001
20 changed files with 1243 additions and 174 deletions

View file

@ -108,6 +108,14 @@ test: build $(TEST_DIR)/test_library
test-library: test test-library: test
test-integration: build
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo "INTEGRATION: Testing --account flag priority"
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@echo ""
@bash $(TEST_DIR)/test_account_flag.sh
test-functional: build $(TEST_DIR)/test_functional test-functional: build $(TEST_DIR)/test_functional
@echo "" @echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

View file

@ -396,7 +396,14 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli
return creds; return creds;
} }
// Priority 2: Environment variables (keys) // Priority 2: --account N flag → explicit CSV lookup
// When the user explicitly selects an account, go straight to accounts.csv.
// Env vars are intentionally bypassed — an explicit flag must win over ambient env.
if (account_index >= 0) {
return load_credentials_from_csv(account_index);
}
// Priority 3: Environment variables (keys)
const char *env_pk = getenv("UNSANDBOX_PUBLIC_KEY"); const char *env_pk = getenv("UNSANDBOX_PUBLIC_KEY");
const char *env_sk = getenv("UNSANDBOX_SECRET_KEY"); const char *env_sk = getenv("UNSANDBOX_SECRET_KEY");
@ -416,16 +423,12 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli
return creds; return creds;
} }
// Priority 3: Config file (~/.unsandbox/accounts.csv) // Priority 4: Config file (~/.unsandbox/accounts.csv)
// Use account_index from --account flag, or UNSANDBOX_ACCOUNT env var, or default to 0 // Use UNSANDBOX_ACCOUNT env var, or default to account 0
int csv_index = account_index; int csv_index = 0;
if (csv_index < 0) { const char *env_account = getenv("UNSANDBOX_ACCOUNT");
const char *env_account = getenv("UNSANDBOX_ACCOUNT"); if (env_account && strlen(env_account) > 0) {
if (env_account && strlen(env_account) > 0) { csv_index = atoi(env_account);
csv_index = atoi(env_account);
} else {
csv_index = 0;
}
} }
return load_credentials_from_csv(csv_index); return load_credentials_from_csv(csv_index);
} }

View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Integration test: --account N flag must take priority over env vars
#
# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials)
# Run: make test-integration OR bash tests/test_account_flag.sh
#
# The defect this guards against: get_credentials() checked env vars before
# account_index, so --account N was silently ignored when env vars existed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UN_BIN="$SCRIPT_DIR/../un"
RED='\033[31m'
GREEN='\033[32m'
NC='\033[0m'
pass=0
fail=0
check() {
local desc="$1" result="$2"
if [ "$result" = "pass" ]; then
printf " ${GREEN}${NC} %s\n" "$desc"
pass=$((pass + 1))
else
printf " ${RED}${NC} %s\n" "$desc"
fail=$((fail + 1))
fi
}
# Require real credentials to be available
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set"
exit 0
fi
if [ ! -x "$UN_BIN" ]; then
echo "FAIL: UN binary not found at $UN_BIN — run make first"
exit 1
fi
REAL_PK="$UNSANDBOX_PUBLIC_KEY"
REAL_SK="$UNSANDBOX_SECRET_KEY"
# Temporary HOME with accounts.csv:
# index 0: garbage credentials (will always 401)
# index 1: real credentials (will succeed)
TMPHOME="$(mktemp -d)"
mkdir -p "$TMPHOME/.unsandbox"
trap 'rm -rf "$TMPHOME"' EXIT
cat > "$TMPHOME/.unsandbox/accounts.csv" <<CSV
unsb-pk-fake-0000-0000-0000,unsb-sk-fake0-00000-00000-00000
${REAL_PK},${REAL_SK}
CSV
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "INTEGRATION: --account flag priority test"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# --- Test 1: --account 1 should use CSV row 1 (real creds), ignoring env vars ---
# Set env vars to GARBAGE so the test fails if env vars win.
# Auth success = no 401/unauthorized (429 rate-limit means creds passed auth, just hit concurrency cap).
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY=unsb-pk-fake-0000-0000-0000 \
UNSANDBOX_SECRET_KEY=unsb-sk-fake0-00000-00000-00000 \
"$UN_BIN" --account 1 key 2>&1 || true)
if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then
check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass"
else
check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail"
echo " output: $OUT"
fi
# --- Test 2: --account 0 should use CSV row 0 (garbage creds) → 401 ---
# Even though real env vars are set, explicit --account 0 should pick garbage creds
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
"$UN_BIN" --account 0 key 2>&1 || true)
if echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|invalid key"; then
check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass"
else
check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail"
echo " output: $OUT"
fi
# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 ---
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
"$UN_BIN" key 2>&1 || true)
if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then
check "No --account flag: env vars used, succeeds" "pass"
else
check "No --account flag: env vars used, succeeds" "fail"
echo " output: $OUT"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[ "$fail" -eq 0 ]

View file

@ -20,7 +20,7 @@ func NewClient(publicKey, secretKey string) *Client {
// NewClientFromEnv resolves credentials from the 4-tier priority system. // NewClientFromEnv resolves credentials from the 4-tier priority system.
func NewClientFromEnv() (*Client, error) { func NewClientFromEnv() (*Client, error) {
creds, err := ResolveCredentials("", "") creds, err := ResolveCredentials("", "", -1)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -161,10 +161,12 @@ func loadCredentialsFromCsv(csvPath string, accountIndex int) *Credentials {
// //
// Priority: // Priority:
// 1. Function arguments (publicKey, secretKey non-empty) // 1. Function arguments (publicKey, secretKey non-empty)
// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) // 2. accountIndex >= 0 → load from accounts.csv row N (before env vars)
// 3. ~/.unsandbox/accounts.csv // 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
// 4. ./accounts.csv // 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env)
func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) { //
// Pass accountIndex = -1 to mean "not specified".
func ResolveCredentials(publicKey, secretKey string, accountIndex int) (*Credentials, error) {
// Tier 1: Function arguments // Tier 1: Function arguments
if publicKey != "" && secretKey != "" { if publicKey != "" && secretKey != "" {
return &Credentials{ return &Credentials{
@ -173,7 +175,23 @@ func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) {
}, nil }, nil
} }
// Tier 2: Environment variables // Tier 2: Explicit account index → load from CSV before checking env vars
if accountIndex >= 0 {
unsandboxDir, err := getUnsandboxDir()
if err == nil {
if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil {
return creds, nil
}
}
if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil {
return creds, nil
}
return nil, &CredentialsError{
Message: fmt.Sprintf("No credentials found at account index %d in accounts.csv", accountIndex),
}
}
// Tier 3: Environment variables
envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY") envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY")
envSk := os.Getenv("UNSANDBOX_SECRET_KEY") envSk := os.Getenv("UNSANDBOX_SECRET_KEY")
if envPk != "" && envSk != "" { if envPk != "" && envSk != "" {
@ -183,35 +201,36 @@ func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) {
}, nil }, nil
} }
// Determine account index // Determine default account index from env
accountIndex := 0 defaultIndex := 0
if envAccount := os.Getenv("UNSANDBOX_ACCOUNT"); envAccount != "" { if envAccount := os.Getenv("UNSANDBOX_ACCOUNT"); envAccount != "" {
var err error var err error
accountIndex, err = strconv.Atoi(envAccount) defaultIndex, err = strconv.Atoi(envAccount)
if err != nil { if err != nil {
accountIndex = 0 defaultIndex = 0
} }
} }
// Tier 3: ~/.unsandbox/accounts.csv // Tier 4: ~/.unsandbox/accounts.csv
unsandboxDir, err := getUnsandboxDir() unsandboxDir, err := getUnsandboxDir()
if err == nil { if err == nil {
if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil { if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), defaultIndex); creds != nil {
return creds, nil return creds, nil
} }
} }
// Tier 4: ./accounts.csv // Tier 5: ./accounts.csv
if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil { if creds := loadCredentialsFromCsv("accounts.csv", defaultIndex); creds != nil {
return creds, nil return creds, nil
} }
return nil, &CredentialsError{ return nil, &CredentialsError{
Message: "No credentials found. Please provide via:\n" + Message: "No credentials found. Please provide via:\n" +
" 1. Function arguments (publicKey, secretKey)\n" + " 1. Function arguments (publicKey, secretKey)\n" +
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + " 2. --account N flag (CSV row N)\n" +
" 3. ~/.unsandbox/accounts.csv\n" + " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" +
" 4. ./accounts.csv", " 4. ~/.unsandbox/accounts.csv\n" +
" 5. ./accounts.csv",
} }
} }
@ -1647,18 +1666,19 @@ const (
// CLIOptions holds parsed CLI arguments // CLIOptions holds parsed CLI arguments
type CLIOptions struct { type CLIOptions struct {
// Global options // Global options
Shell string Shell string
Env []string Env []string
Files []string Files []string
FilePaths []string FilePaths []string
Artifacts bool Artifacts bool
OutputDir string OutputDir string
PublicKey string PublicKey string
SecretKey string SecretKey string
Network string Network string
VCPU int VCPU int
Yes bool Yes bool
Help bool Help bool
AccountIndex int // -1 means not specified
// Command // Command
Command string Command string
@ -3153,7 +3173,7 @@ func runLanguages(creds *Credentials, args []string) int {
// parseGlobalFlags parses global CLI options // parseGlobalFlags parses global CLI options
func parseGlobalFlags(args []string) (*CLIOptions, []string) { func parseGlobalFlags(args []string) (*CLIOptions, []string) {
opts := &CLIOptions{} opts := &CLIOptions{AccountIndex: -1}
remaining := []string{} remaining := []string{}
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
@ -3196,6 +3216,13 @@ func parseGlobalFlags(args []string) (*CLIOptions, []string) {
opts.SecretKey = args[i+1] opts.SecretKey = args[i+1]
i++ i++
} }
case arg == "--account":
if i+1 < len(args) {
if v, err := strconv.Atoi(args[i+1]); err == nil {
opts.AccountIndex = v
}
i++
}
case arg == "-n" || arg == "--network": case arg == "-n" || arg == "--network":
if i+1 < len(args) { if i+1 < len(args) {
opts.Network = args[i+1] opts.Network = args[i+1]
@ -3249,7 +3276,7 @@ func CliMain() {
} }
// Resolve credentials // Resolve credentials
creds, err := ResolveCredentials(opts.PublicKey, opts.SecretKey) creds, err := ResolveCredentials(opts.PublicKey, opts.SecretKey, opts.AccountIndex)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err) fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(ExitAuthError) os.Exit(ExitAuthError)

View file

@ -173,7 +173,7 @@ func TestResolveCredentialsFromEnv(t *testing.T) {
os.Setenv("UNSANDBOX_SECRET_KEY", testSK) os.Setenv("UNSANDBOX_SECRET_KEY", testSK)
// Test // Test
creds, err := ResolveCredentials("", "") creds, err := ResolveCredentials("", "", -1)
if err != nil { if err != nil {
t.Fatalf("ResolveCredentials failed: %v", err) t.Fatalf("ResolveCredentials failed: %v", err)
} }
@ -201,7 +201,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) {
testPK := "unsb-pk-arg1-arg2-arg3-arg4" testPK := "unsb-pk-arg1-arg2-arg3-arg4"
testSK := "unsb-sk-arg11-arg22-arg33-arg44" testSK := "unsb-sk-arg11-arg22-arg33-arg44"
creds, err := ResolveCredentials(testPK, testSK) creds, err := ResolveCredentials(testPK, testSK, -1)
if err != nil { if err != nil {
t.Fatalf("ResolveCredentials failed: %v", err) t.Fatalf("ResolveCredentials failed: %v", err)
} }
@ -218,7 +218,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) {
// ============================================================================ // ============================================================================
func getTestCredentials(t *testing.T) *Credentials { func getTestCredentials(t *testing.T) *Credentials {
creds, err := ResolveCredentials("", "") creds, err := ResolveCredentials("", "", -1)
if err != nil { if err != nil {
t.Skip("No credentials available for functional tests") t.Skip("No credentials available for functional tests")
} }

View file

@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Integration test: --account N flag must take priority over env vars
#
# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials)
# Run: make test-integration OR bash tests/test_account_flag.sh
#
# The defect this guards against: ResolveCredentials() checked env vars before
# account_index, so --account N was silently ignored when env vars existed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="$SCRIPT_DIR/../src"
UN_BIN="$SCRIPT_DIR/../un"
RED='\033[31m'
GREEN='\033[32m'
NC='\033[0m'
pass=0
fail=0
check() {
local desc="$1" result="$2"
if [ "$result" = "pass" ]; then
printf " ${GREEN}${NC} %s\n" "$desc"
pass=$((pass + 1))
else
printf " ${RED}${NC} %s\n" "$desc"
fail=$((fail + 1))
fi
}
# Require real credentials to be available
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set"
exit 0
fi
# Build the binary if it doesn't exist or source is newer
if [ ! -x "$UN_BIN" ] || [ "$SRC_DIR/un.go" -nt "$UN_BIN" ]; then
echo "Building Go binary..."
(cd "$SRC_DIR" && go build -o "$UN_BIN" .) || {
echo "FAIL: go build failed"
exit 1
}
fi
if [ ! -x "$UN_BIN" ]; then
echo "FAIL: UN binary not found at $UN_BIN — run go build first"
exit 1
fi
REAL_PK="$UNSANDBOX_PUBLIC_KEY"
REAL_SK="$UNSANDBOX_SECRET_KEY"
# Temporary HOME with accounts.csv:
# index 0: garbage credentials (will always 401)
# index 1: real credentials (will succeed)
TMPHOME="$(mktemp -d)"
mkdir -p "$TMPHOME/.unsandbox"
trap 'rm -rf "$TMPHOME"' EXIT
cat > "$TMPHOME/.unsandbox/accounts.csv" <<CSV
unsb-pk-fake-0000-0000-0000,unsb-sk-fake0-00000-00000-00000
${REAL_PK},${REAL_SK}
CSV
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "INTEGRATION: --account flag priority test"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# --- Test 1: --account 1 should use CSV row 1 (real creds), ignoring env vars ---
# Set env vars to GARBAGE so the test fails if env vars win
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY=unsb-pk-fake-0000-0000-0000 \
UNSANDBOX_SECRET_KEY=unsb-sk-fake0-00000-00000-00000 \
"$UN_BIN" --account 1 key 2>&1 || true)
if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then
check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass"
else
check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail"
echo " output: $OUT"
fi
# --- Test 2: --account 0 should use CSV row 0 (garbage creds) → 401 ---
# Even though real env vars are set, explicit --account 0 should pick garbage creds
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
"$UN_BIN" --account 0 key 2>&1 || true)
if echo "$OUT" | grep -qi "401\|unauthorized\|invalid\|error"; then
check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass"
else
check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail"
echo " output: $OUT"
fi
# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 ---
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
"$UN_BIN" key 2>&1 || true)
if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials"; then
check "No --account flag: env vars used, succeeds" "pass"
else
check "No --account flag: env vars used, succeeds" "fail"
echo " output: $OUT"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[ "$fail" -eq 0 ]

View file

@ -173,7 +173,7 @@ func TestResolveCredentialsFromEnv(t *testing.T) {
os.Setenv("UNSANDBOX_SECRET_KEY", testSK) os.Setenv("UNSANDBOX_SECRET_KEY", testSK)
// Test // Test
creds, err := ResolveCredentials("", "") creds, err := ResolveCredentials("", "", -1)
if err != nil { if err != nil {
t.Fatalf("ResolveCredentials failed: %v", err) t.Fatalf("ResolveCredentials failed: %v", err)
} }
@ -201,7 +201,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) {
testPK := "unsb-pk-arg1-arg2-arg3-arg4" testPK := "unsb-pk-arg1-arg2-arg3-arg4"
testSK := "unsb-sk-arg11-arg22-arg33-arg44" testSK := "unsb-sk-arg11-arg22-arg33-arg44"
creds, err := ResolveCredentials(testPK, testSK) creds, err := ResolveCredentials(testPK, testSK, -1)
if err != nil { if err != nil {
t.Fatalf("ResolveCredentials failed: %v", err) t.Fatalf("ResolveCredentials failed: %v", err)
} }
@ -218,7 +218,7 @@ func TestResolveCredentialsFromArgs(t *testing.T) {
// ============================================================================ // ============================================================================
func getTestCredentials(t *testing.T) *Credentials { func getTestCredentials(t *testing.T) *Credentials {
creds, err := ResolveCredentials("", "") creds, err := ResolveCredentials("", "", -1)
if err != nil { if err != nil {
t.Skip("No credentials available for functional tests") t.Skip("No credentials available for functional tests")
} }

View file

@ -25,11 +25,12 @@
* // Snapshot operations * // Snapshot operations
* String snapshotId = Un.sessionSnapshot(sessionId, publicKey, secretKey, "my-snapshot", false); * String snapshotId = Un.sessionSnapshot(sessionId, publicKey, secretKey, "my-snapshot", false);
* *
* Authentication Priority (4-tier): * Authentication Priority (5-tier):
* 1. Method arguments (publicKey, secretKey) * 1. Method arguments (publicKey, secretKey)
* 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) * 2. --account N flag / accountIndex >= 0 (load row N from accounts.csv)
* 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) * 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
* 4. Local directory (./accounts.csv, line 0 by default) * 4. Config file (~/.unsandbox/accounts.csv, line 0 by default)
* 5. Local directory (./accounts.csv, line 0 by default)
* *
* Request Authentication (HMAC-SHA256): * Request Authentication (HMAC-SHA256):
* Authorization: Bearer <public_key> * Authorization: Bearer <public_key>
@ -175,38 +176,58 @@ public class Un {
} }
private static String[] resolveCredentials(String publicKey, String secretKey) { private static String[] resolveCredentials(String publicKey, String secretKey) {
return resolveCredentials(publicKey, secretKey, -1);
}
private static String[] resolveCredentials(String publicKey, String secretKey, int accountIndex) {
// Tier 1: Method arguments // Tier 1: Method arguments
if (publicKey != null && !publicKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) { if (publicKey != null && !publicKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) {
return new String[]{publicKey, secretKey}; return new String[]{publicKey, secretKey};
} }
// Tier 2: Environment variables // Tier 2: Explicit account index (e.g. --account N from CLI)
if (accountIndex >= 0) {
Path unsandboxDir = getUnsandboxDir();
String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex);
if (creds != null) {
return creds;
}
creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex);
if (creds != null) {
return creds;
}
throw new CredentialsException(
"No credentials found at account index " + accountIndex + " in accounts.csv"
);
}
// Tier 3: Environment variables
String envPk = System.getenv("UNSANDBOX_PUBLIC_KEY"); String envPk = System.getenv("UNSANDBOX_PUBLIC_KEY");
String envSk = System.getenv("UNSANDBOX_SECRET_KEY"); String envSk = System.getenv("UNSANDBOX_SECRET_KEY");
if (envPk != null && !envPk.isEmpty() && envSk != null && !envSk.isEmpty()) { if (envPk != null && !envPk.isEmpty() && envSk != null && !envSk.isEmpty()) {
return new String[]{envPk, envSk}; return new String[]{envPk, envSk};
} }
// Determine account index // Determine account index from env (default 0)
int accountIndex = 0; int csvIndex = 0;
String accountEnv = System.getenv("UNSANDBOX_ACCOUNT"); String accountEnv = System.getenv("UNSANDBOX_ACCOUNT");
if (accountEnv != null && !accountEnv.isEmpty()) { if (accountEnv != null && !accountEnv.isEmpty()) {
try { try {
accountIndex = Integer.parseInt(accountEnv); csvIndex = Integer.parseInt(accountEnv);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
// Use default // Use default
} }
} }
// Tier 3: ~/.unsandbox/accounts.csv // Tier 4: ~/.unsandbox/accounts.csv
Path unsandboxDir = getUnsandboxDir(); Path unsandboxDir = getUnsandboxDir();
String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex); String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), csvIndex);
if (creds != null) { if (creds != null) {
return creds; return creds;
} }
// Tier 4: ./accounts.csv // Tier 5: ./accounts.csv
creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex); creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), csvIndex);
if (creds != null) { if (creds != null) {
return creds; return creds;
} }
@ -214,9 +235,10 @@ public class Un {
throw new CredentialsException( throw new CredentialsException(
"No credentials found. Please provide via:\n" + "No credentials found. Please provide via:\n" +
" 1. Method arguments (publicKey, secretKey)\n" + " 1. Method arguments (publicKey, secretKey)\n" +
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" + " 2. --account N flag (load row N from accounts.csv)\n" +
" 3. ~/.unsandbox/accounts.csv\n" + " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" +
" 4. ./accounts.csv" " 4. ~/.unsandbox/accounts.csv\n" +
" 5. ./accounts.csv"
); );
} }
@ -2860,6 +2882,7 @@ public class Un {
String language = null; String language = null;
String networkMode = "zerotrust"; String networkMode = "zerotrust";
int vcpu = 1; int vcpu = 1;
int accountIndex = -1;
List<String> envVars = new ArrayList<>(); List<String> envVars = new ArrayList<>();
List<String> files = new ArrayList<>(); List<String> files = new ArrayList<>();
List<String> positionalArgs = new ArrayList<>(); List<String> positionalArgs = new ArrayList<>();
@ -2871,6 +2894,18 @@ public class Un {
if (arg.equals("-h") || arg.equals("--help")) { if (arg.equals("-h") || arg.equals("--help")) {
showHelp = true; showHelp = true;
i++; i++;
} else if (arg.equals("--account")) {
if (i + 1 >= args.length) {
System.err.println("Error: --account requires an argument");
System.exit(2);
}
try {
accountIndex = Integer.parseInt(args[++i]);
} catch (NumberFormatException e) {
System.err.println("Error: --account requires an integer argument");
System.exit(2);
}
i++;
} else if (arg.equals("-s") || arg.equals("--shell")) { } else if (arg.equals("-s") || arg.equals("--shell")) {
if (i + 1 >= args.length) { if (i + 1 >= args.length) {
System.err.println("Error: -s/--shell requires an argument"); System.err.println("Error: -s/--shell requires an argument");
@ -2936,6 +2971,14 @@ public class Un {
String command = positionalArgs.get(0); String command = positionalArgs.get(0);
// Pre-resolve credentials so --account N is honoured by all subcommands.
// Only resolve if explicit keys were not supplied via -p/-k flags.
if (publicKey == null || publicKey.isEmpty() || secretKey == null || secretKey.isEmpty()) {
String[] creds = resolveCredentials(publicKey, secretKey, accountIndex);
publicKey = creds[0];
secretKey = creds[1];
}
// Route to subcommand handlers // Route to subcommand handlers
switch (command) { switch (command) {
case "session": case "session":
@ -2982,6 +3025,7 @@ public class Un {
System.out.println(" -f, --file FILE Add input file to /tmp/"); System.out.println(" -f, --file FILE Add input file to /tmp/");
System.out.println(" -p, --public-key KEY API public key"); System.out.println(" -p, --public-key KEY API public key");
System.out.println(" -k, --secret-key KEY API secret key"); System.out.println(" -k, --secret-key KEY API secret key");
System.out.println(" --account N Use row N from accounts.csv (overrides env vars)");
System.out.println(" -n, --network MODE Network: zerotrust or semitrusted"); System.out.println(" -n, --network MODE Network: zerotrust or semitrusted");
System.out.println(" -v, --vcpu N vCPU count (1-8)"); System.out.println(" -v, --vcpu N vCPU count (1-8)");
System.out.println(" -h, --help Show help"); System.out.println(" -h, --help Show help");

View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Integration test: --account N credential selection in Java SDK CLI
#
# Tests that --account N loads row N from accounts.csv and takes priority
# over environment variables UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY.
#
# Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY set in the environment.
# SKIP if not set.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="$(cd "$SCRIPT_DIR/../src" && pwd)"
PASS=0
FAIL=0
SKIP=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); }
# ---- Prerequisites ----
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set"
echo ""
echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
exit 0
fi
# Compile Un.java if needed
CLASS_FILE="$SRC_DIR/Un.class"
if [ ! -f "$CLASS_FILE" ] || [ "$SRC_DIR/Un.java" -nt "$CLASS_FILE" ]; then
echo "Compiling Un.java..."
if ! javac -cp "$SRC_DIR" "$SRC_DIR/Un.java" 2>&1; then
fail "javac compilation failed"
echo ""
echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
exit 1
fi
fi
# ---- Temp home setup ----
TMPHOME="$(mktemp -d)"
trap 'rm -rf "$TMPHOME"' EXIT
mkdir -p "$TMPHOME/.unsandbox"
# Row 0: garbage credentials
# Row 1: real credentials from environment
printf 'garbage-pk,garbage-sk\n%s,%s\n' \
"$UNSANDBOX_PUBLIC_KEY" "$UNSANDBOX_SECRET_KEY" \
> "$TMPHOME/.unsandbox/accounts.csv"
# ---- Test 1: --account 1 loads real creds, env vars set to garbage ----
# With HOME=TMPHOME, env set to garbage, --account 1 should pick real creds
# and the 'key' subcommand should succeed (validateKeys returns 200).
RESULT=$(
HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="garbage-pk-env" \
UNSANDBOX_SECRET_KEY="garbage-sk-env" \
java -cp "$SRC_DIR" Un --account 1 key 2>&1
) && RC=$? || RC=$?
if [ $RC -eq 0 ]; then
pass "--account 1 loads row 1 from accounts.csv (real creds), ignores garbage env vars"
else
fail "--account 1 should have succeeded but exited $RC: $RESULT"
fi
# ---- Test 2: --account 0 loads garbage creds, should get 401/error ----
# With env vars set to real creds, --account 0 should use garbage row 0
# and the API call should fail (unauthorized).
RESULT=$(
HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$UNSANDBOX_PUBLIC_KEY" \
UNSANDBOX_SECRET_KEY="$UNSANDBOX_SECRET_KEY" \
java -cp "$SRC_DIR" Un --account 0 key 2>&1
) && RC=$? || RC=$?
if [ $RC -ne 0 ]; then
pass "--account 0 loads garbage creds from row 0, API rejects them (env vars ignored)"
else
fail "--account 0 should have failed (garbage creds) but succeeded: $RESULT"
fi
# ---- Test 3: no --account flag, env vars set to real creds → success ----
RESULT=$(
HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$UNSANDBOX_PUBLIC_KEY" \
UNSANDBOX_SECRET_KEY="$UNSANDBOX_SECRET_KEY" \
java -cp "$SRC_DIR" Un key 2>&1
) && RC=$? || RC=$?
if [ $RC -eq 0 ]; then
pass "no --account flag uses env vars (real creds), succeeds"
else
fail "no --account flag should succeed with real env var creds but exited $RC: $RESULT"
fi
# ---- Summary ----
echo ""
echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
if [ $FAIL -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -331,10 +331,11 @@ function loadCredentialsFromStorage() {
* *
* Priority: * Priority:
* 1. Function arguments * 1. Function arguments
* 2. Environment variables (Node.js) * 2. accountIndex >= 0 load from accounts.csv row N
* 3. localStorage (Browser) * 3. Environment variables (Node.js)
* 4. ~/.unsandbox/accounts.csv (Node.js) * 4. localStorage (Browser)
* 5. ./accounts.csv (Node.js) * 5. ~/.unsandbox/accounts.csv (default row, Node.js)
* 6. ./accounts.csv (default row, Node.js)
*/ */
function resolveCredentials(publicKey, secretKey, accountIndex) { function resolveCredentials(publicKey, secretKey, accountIndex) {
// Tier 1: Function arguments // Tier 1: Function arguments
@ -342,7 +343,26 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
return [publicKey, secretKey]; return [publicKey, secretKey];
} }
// Tier 2: Environment variables (Node.js only) // Tier 2: Explicit accountIndex → load from accounts.csv row N (Node.js only)
if (IS_NODE && fs && path && accountIndex !== undefined && accountIndex >= 0) {
// ~/.unsandbox/accounts.csv first
try {
const unsandboxDir = getUnsandboxDir();
const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex);
if (creds) {
return creds;
}
} catch (e) {
// Continue to next location
}
// ./accounts.csv fallback
const creds = loadCredentialsFromCsv('accounts.csv', accountIndex);
if (creds) {
return creds;
}
}
// Tier 3: Environment variables (Node.js only)
if (IS_NODE) { if (IS_NODE) {
const envPk = process.env.UNSANDBOX_PUBLIC_KEY; const envPk = process.env.UNSANDBOX_PUBLIC_KEY;
const envSk = process.env.UNSANDBOX_SECRET_KEY; const envSk = process.env.UNSANDBOX_SECRET_KEY;
@ -351,7 +371,7 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
} }
} }
// Tier 3: localStorage (Browser only) // Tier 4: localStorage (Browser only)
if (IS_BROWSER) { if (IS_BROWSER) {
const storageCreds = loadCredentialsFromStorage(); const storageCreds = loadCredentialsFromStorage();
if (storageCreds) { if (storageCreds) {
@ -359,17 +379,14 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
} }
} }
// Tier 4 & 5: File-based credentials (Node.js only) // Tier 5 & 6: File-based credentials with default index (Node.js only)
if (IS_NODE && fs && path) { if (IS_NODE && fs && path) {
// Determine account index const defaultIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
if (accountIndex === undefined) {
accountIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
}
// Tier 4: ~/.unsandbox/accounts.csv // Tier 5: ~/.unsandbox/accounts.csv
try { try {
const unsandboxDir = getUnsandboxDir(); const unsandboxDir = getUnsandboxDir();
const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex); const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), defaultIndex);
if (creds) { if (creds) {
return creds; return creds;
} }
@ -377,8 +394,8 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
// Continue to next tier // Continue to next tier
} }
// Tier 5: ./accounts.csv // Tier 6: ./accounts.csv
const creds = loadCredentialsFromCsv('accounts.csv', accountIndex); const creds = loadCredentialsFromCsv('accounts.csv', defaultIndex);
if (creds) { if (creds) {
return creds; return creds;
} }
@ -2193,6 +2210,7 @@ function parseArgs(args) {
output: null, output: null,
publicKey: null, publicKey: null,
secretKey: null, secretKey: null,
accountIndex: undefined,
network: 'zerotrust', network: 'zerotrust',
vcpu: 1, vcpu: 1,
yes: false, yes: false,
@ -2322,6 +2340,9 @@ function parseArgs(args) {
} else if (arg === '-k' || arg === '--secret-key') { } else if (arg === '-k' || arg === '--secret-key') {
result.secretKey = args[++i]; result.secretKey = args[++i];
i++; i++;
} else if (arg === '--account') {
result.accountIndex = parseInt(args[++i], 10);
i++;
} else if (arg === '-n' || arg === '--network') { } else if (arg === '-n' || arg === '--network') {
result.network = args[++i]; result.network = args[++i];
i++; i++;
@ -2509,8 +2530,7 @@ function formatTable(items, columns) {
* Handle session commands. * Handle session commands.
*/ */
async function handleSession(opts) { async function handleSession(opts) {
const pk = opts.publicKey; let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
const sk = opts.secretKey;
// List sessions // List sessions
if (opts.list) { if (opts.list) {
@ -2593,8 +2613,7 @@ async function handleSession(opts) {
* Handle service commands. * Handle service commands.
*/ */
async function handleService(opts) { async function handleService(opts) {
const pk = opts.publicKey; let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
const sk = opts.secretKey;
// Handle env subcommand // Handle env subcommand
if (opts.subcommand === 'env') { if (opts.subcommand === 'env') {
@ -2829,8 +2848,7 @@ async function handleService(opts) {
* Handle snapshot commands. * Handle snapshot commands.
*/ */
async function handleSnapshot(opts) { async function handleSnapshot(opts) {
const pk = opts.publicKey; let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
const sk = opts.secretKey;
// List snapshots // List snapshots
if (opts.list) { if (opts.list) {
@ -2898,8 +2916,7 @@ async function handleSnapshot(opts) {
* Handle image command. * Handle image command.
*/ */
async function handleImage(opts) { async function handleImage(opts) {
const pk = opts.publicKey; let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
const sk = opts.secretKey;
// List images // List images
if (opts.list) { if (opts.list) {
@ -3002,13 +3019,13 @@ async function handleImage(opts) {
* Handle key command. * Handle key command.
*/ */
async function handleKey(opts) { async function handleKey(opts) {
const [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
try { try {
const result = await validateKeys(opts.publicKey, opts.secretKey); const result = await validateKeys(pk, sk);
console.log('API Key Status:'); console.log('API Key Status:');
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} catch (err) { } catch (err) {
// If validate endpoint doesn't exist, just show that credentials were resolved // If validate endpoint doesn't exist, just show that credentials were resolved
const [pk] = resolveCredentials(opts.publicKey, opts.secretKey);
console.log(`Public Key: ${pk}`); console.log(`Public Key: ${pk}`);
console.log('Key validation endpoint returned error - key may still be valid.'); console.log('Key validation endpoint returned error - key may still be valid.');
} }
@ -3018,7 +3035,8 @@ async function handleKey(opts) {
* Handle languages command. * Handle languages command.
*/ */
async function handleLanguages(opts) { async function handleLanguages(opts) {
const languages = await getLanguages(opts.publicKey, opts.secretKey); const [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
const languages = await getLanguages(pk, sk);
if (opts.json) { if (opts.json) {
// Output as JSON array // Output as JSON array
@ -3035,8 +3053,7 @@ async function handleLanguages(opts) {
* Handle execute command (default). * Handle execute command (default).
*/ */
async function handleExecute(opts) { async function handleExecute(opts) {
const pk = opts.publicKey; let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
const sk = opts.secretKey;
let code; let code;
let language; let language;

View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Integration test for --account N credential selection in un.js.
#
# Tests that --account N selects the correct row from accounts.csv,
# taking priority over environment variables.
#
# Requires UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY to be set.
# Skips if credentials are not available.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UN_JS="$SCRIPT_DIR/../src/un.js"
PASS=0
FAIL=0
SKIP=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); }
# Require real credentials from environment
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set"
echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
exit 0
fi
REAL_PK="$UNSANDBOX_PUBLIC_KEY"
REAL_SK="$UNSANDBOX_SECRET_KEY"
GARBAGE_PK="unsb-pk-0000-0000-0000-garbage"
GARBAGE_SK="unsb-sk-00000-00000-00000-garbage"
# Build a temporary HOME with accounts.csv: row 0 = garbage, row 1 = real creds
TMPHOME="$(mktemp -d)"
mkdir -p "$TMPHOME/.unsandbox"
# Header + row 0 (garbage) + row 1 (real)
printf 'public_key,secret_key\n%s,%s\n%s,%s\n' \
"$GARBAGE_PK" "$GARBAGE_SK" \
"$REAL_PK" "$REAL_SK" \
> "$TMPHOME/.unsandbox/accounts.csv"
cleanup() {
rm -rf "$TMPHOME"
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Test 1: HOME=TMPHOME, env vars = garbage, --account 1 => row 1 = real creds
# Expect: key command succeeds (no auth error)
# ---------------------------------------------------------------------------
if HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$GARBAGE_PK" \
UNSANDBOX_SECRET_KEY="$GARBAGE_SK" \
node "$UN_JS" --account 1 key 2>&1 | grep -qiE 'Public Key|key_id|account|status'; then
pass "Test 1: --account 1 selects row 1 (real creds) over env garbage"
else
# Also accept a successful JSON response (key validates)
output="$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$GARBAGE_PK" \
UNSANDBOX_SECRET_KEY="$GARBAGE_SK" \
node "$UN_JS" --account 1 key 2>&1 || true)"
if echo "$output" | grep -qiE '401|invalid|unauthorized|authentication'; then
fail "Test 1: --account 1 selected row 1 but auth failed (real creds may be invalid)"
else
pass "Test 1: --account 1 selects row 1 (real creds) over env garbage"
fi
fi
# ---------------------------------------------------------------------------
# Test 2: HOME=TMPHOME, env vars = real, --account 0 => row 0 = garbage creds
# Expect: 401 / auth error (garbage creds used despite real env vars)
# ---------------------------------------------------------------------------
output2="$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
node "$UN_JS" --account 0 key 2>&1 || true)"
if echo "$output2" | grep -qiE '401|invalid|unauthorized|authentication|error'; then
pass "Test 2: --account 0 selects row 0 (garbage) over env real creds (expected auth failure)"
else
fail "Test 2: --account 0 should use garbage creds and fail auth, but got: $output2"
fi
# ---------------------------------------------------------------------------
# Test 3: HOME=TMPHOME, env vars = real, no --account => env vars win (real creds)
# Expect: key command succeeds
# ---------------------------------------------------------------------------
output3="$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
node "$UN_JS" key 2>&1 || true)"
if echo "$output3" | grep -qiE '401|invalid|unauthorized|authentication failed'; then
fail "Test 3: without --account, env real creds should succeed but got auth error: $output3"
else
pass "Test 3: without --account, env vars (real creds) are used"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Results: PASS=$PASS FAIL=$FAIL SKIP=$SKIP"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -138,6 +138,7 @@ class Unsandbox {
private ?string $defaultPublicKey = null; private ?string $defaultPublicKey = null;
private ?string $defaultSecretKey = null; private ?string $defaultSecretKey = null;
private int $accountIndex = 0; private int $accountIndex = 0;
private bool $accountIndexExplicit = false;
/** /**
* Create a new Unsandbox client. * Create a new Unsandbox client.
@ -1693,17 +1694,18 @@ class Unsandbox {
* Resolve credentials from 4-tier priority system. * Resolve credentials from 4-tier priority system.
* *
* Priority: * Priority:
* 1. Method arguments * 1. Method arguments / constructor defaults
* 2. Environment variables * 2. $accountIndex >= 0 load from accounts.csv row N
* 3. ~/.unsandbox/accounts.csv * 3. Environment variables (UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY)
* 4. ./accounts.csv * 4. Default CSV lookup (account 0)
* *
* @param string|null $publicKey Public key from method argument * @param string|null $publicKey Public key from method argument
* @param string|null $secretKey Secret key from method argument * @param string|null $secretKey Secret key from method argument
* @param int|null $accountIndex Explicit account index (overrides env and default)
* @return array [publicKey, secretKey] * @return array [publicKey, secretKey]
* @throws CredentialsException If no credentials found * @throws CredentialsException If no credentials found
*/ */
private function resolveCredentials(?string $publicKey, ?string $secretKey): array { private function resolveCredentials(?string $publicKey, ?string $secretKey, ?int $accountIndex = null): array {
// Tier 1: Method arguments // Tier 1: Method arguments
if (!empty($publicKey) && !empty($secretKey)) { if (!empty($publicKey) && !empty($secretKey)) {
return [$publicKey, $secretKey]; return [$publicKey, $secretKey];
@ -1714,29 +1716,50 @@ class Unsandbox {
return [$this->defaultPublicKey, $this->defaultSecretKey]; return [$this->defaultPublicKey, $this->defaultSecretKey];
} }
// Tier 2: Environment variables // Tier 2: Explicit account index (--account N flag or constructor accountIndex != 0)
// Resolve the effective account index: explicit arg > UNSANDBOX_ACCOUNT env > $this->accountIndex
$effectiveIndex = null;
if ($accountIndex !== null && $accountIndex >= 0) {
$effectiveIndex = $accountIndex;
} elseif ($this->accountIndexExplicit) {
// --account flag was used on CLI (may be 0, so can't rely on != 0 check)
$effectiveIndex = $this->accountIndex;
} else {
$envAccount = getenv('UNSANDBOX_ACCOUNT');
if ($envAccount !== false && $envAccount !== '') {
$effectiveIndex = (int)$envAccount;
} elseif ($this->accountIndex !== 0) {
$effectiveIndex = $this->accountIndex;
}
}
if ($effectiveIndex !== null) {
$unsandboxDir = $this->getUnsandboxDir();
$creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $effectiveIndex);
if ($creds !== null) {
return $creds;
}
$creds = $this->loadCredentialsFromCsv('./accounts.csv', $effectiveIndex);
if ($creds !== null) {
return $creds;
}
}
// Tier 3: Environment variables
$envPk = getenv('UNSANDBOX_PUBLIC_KEY'); $envPk = getenv('UNSANDBOX_PUBLIC_KEY');
$envSk = getenv('UNSANDBOX_SECRET_KEY'); $envSk = getenv('UNSANDBOX_SECRET_KEY');
if (!empty($envPk) && !empty($envSk)) { if (!empty($envPk) && !empty($envSk)) {
return [$envPk, $envSk]; return [$envPk, $envSk];
} }
// Determine account index // Tier 4: Default CSV lookup (account 0)
$accountIndex = $this->accountIndex;
$envAccount = getenv('UNSANDBOX_ACCOUNT');
if ($envAccount !== false && $envAccount !== '') {
$accountIndex = (int)$envAccount;
}
// Tier 3: ~/.unsandbox/accounts.csv
$unsandboxDir = $this->getUnsandboxDir(); $unsandboxDir = $this->getUnsandboxDir();
$creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $accountIndex); $creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', 0);
if ($creds !== null) { if ($creds !== null) {
return $creds; return $creds;
} }
// Tier 4: ./accounts.csv $creds = $this->loadCredentialsFromCsv('./accounts.csv', 0);
$creds = $this->loadCredentialsFromCsv('./accounts.csv', $accountIndex);
if ($creds !== null) { if ($creds !== null) {
return $creds; return $creds;
} }
@ -1744,9 +1767,9 @@ class Unsandbox {
throw new CredentialsException( throw new CredentialsException(
"No credentials found. Please provide via:\n" . "No credentials found. Please provide via:\n" .
" 1. Method arguments (publicKey, secretKey)\n" . " 1. Method arguments (publicKey, secretKey)\n" .
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" . " 2. --account N flag or UNSANDBOX_ACCOUNT env var (CSV row N)\n" .
" 3. ~/.unsandbox/accounts.csv\n" . " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" .
" 4. ./accounts.csv" " 4. ~/.unsandbox/accounts.csv or ./accounts.csv (row 0)"
); );
} }
@ -2138,6 +2161,10 @@ class Unsandbox {
if (!empty($opts['secret_key'])) { if (!empty($opts['secret_key'])) {
$this->defaultSecretKey = $opts['secret_key']; $this->defaultSecretKey = $opts['secret_key'];
} }
if ($opts['account'] !== null) {
$this->accountIndex = $opts['account'];
$this->accountIndexExplicit = true;
}
// Determine the command // Determine the command
if (empty($args)) { if (empty($args)) {
@ -2209,6 +2236,7 @@ class Unsandbox {
'vcpu' => 1, 'vcpu' => 1,
'yes' => false, 'yes' => false,
'help' => false, 'help' => false,
'account' => null,
]; ];
$args = []; $args = [];
@ -2255,6 +2283,11 @@ class Unsandbox {
$opts['yes'] = true; $opts['yes'] = true;
} elseif ($arg === '-h' || $arg === '--help') { } elseif ($arg === '-h' || $arg === '--help') {
$opts['help'] = true; $opts['help'] = true;
} elseif ($arg === '--account') {
$i++;
if (isset($argv[$i])) {
$opts['account'] = (int)$argv[$i];
}
} elseif (strpos($arg, '-') !== 0) { } elseif (strpos($arg, '-') !== 0) {
$args[] = $arg; $args[] = $arg;
} }

View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Integration test for --account N credential selection in the PHP SDK CLI.
#
# Tests that --account N selects the correct row from accounts.csv,
# taking priority over UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UN_PHP="${SCRIPT_DIR}/../src/un.php"
PASS=0
FAIL=0
SKIP=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
skip() { echo "SKIP: $1"; SKIP=$((SKIP + 1)); }
# Require real credentials to run meaningful tests
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
skip "UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set - cannot run account flag tests"
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped"
exit 0
fi
REAL_PK="${UNSANDBOX_PUBLIC_KEY}"
REAL_SK="${UNSANDBOX_SECRET_KEY}"
GARBAGE_PK="unsb-pk-0000-0000-0000-garbage"
GARBAGE_SK="unsb-sk-00000-00000-00000-garbage"
# Create a temporary HOME with accounts.csv: row 0 = garbage, row 1 = real creds
TMPHOME="$(mktemp -d)"
trap 'rm -rf "${TMPHOME}"' EXIT
mkdir -p "${TMPHOME}/.unsandbox"
printf '%s,%s\n' "${GARBAGE_PK}" "${GARBAGE_SK}" > "${TMPHOME}/.unsandbox/accounts.csv"
printf '%s,%s\n' "${REAL_PK}" "${REAL_SK}" >> "${TMPHOME}/.unsandbox/accounts.csv"
# Test 1: --account 1 with garbage env vars should load row 1 (real creds) and succeed
echo "Test 1: --account 1 ignores garbage env vars and uses CSV row 1 (real creds)"
output=$(HOME="${TMPHOME}" \
UNSANDBOX_PUBLIC_KEY="${GARBAGE_PK}" \
UNSANDBOX_SECRET_KEY="${GARBAGE_SK}" \
php "${UN_PHP}" --account 1 key 2>&1) && rc=0 || rc=$?
if [ $rc -eq 0 ]; then
pass "Test 1: --account 1 succeeded with real creds from CSV row 1"
elif echo "${output}" | grep -qi "401\|unauthorized\|forbidden"; then
fail "Test 1: got auth error despite real creds at row 1 (output: ${output})"
else
fail "Test 1: unexpected failure (rc=${rc}, output: ${output})"
fi
# Test 2: --account 0 with real env vars should load row 0 (garbage creds) and get 401
echo "Test 2: --account 0 overrides real env vars and uses CSV row 0 (garbage creds)"
output=$(HOME="${TMPHOME}" \
UNSANDBOX_PUBLIC_KEY="${REAL_PK}" \
UNSANDBOX_SECRET_KEY="${REAL_SK}" \
php "${UN_PHP}" --account 0 key 2>&1) && rc=0 || rc=$?
if echo "${output}" | grep -qi "401\|unauthorized\|forbidden\|authentication\|credentials"; then
pass "Test 2: got expected auth rejection for garbage creds at row 0"
elif [ $rc -eq 3 ]; then
# Exit code 3 = CredentialsException (e.g., empty key) - also acceptable
pass "Test 2: got credentials exception for garbage creds at row 0 (rc=3)"
else
fail "Test 2: expected 401/auth error but got rc=${rc}, output: ${output}"
fi
# Test 3: No --account flag with real env vars should succeed (env vars take priority over CSV row 0)
echo "Test 3: no --account flag with real env vars should succeed"
output=$(HOME="${TMPHOME}" \
UNSANDBOX_PUBLIC_KEY="${REAL_PK}" \
UNSANDBOX_SECRET_KEY="${REAL_SK}" \
php "${UN_PHP}" key 2>&1) && rc=0 || rc=$?
if [ $rc -eq 0 ]; then
pass "Test 3: succeeded using env vars when no --account flag set"
elif echo "${output}" | grep -qi "401\|unauthorized\|forbidden"; then
fail "Test 3: got unexpected auth error with real env vars (output: ${output})"
else
fail "Test 3: unexpected failure (rc=${rc}, output: ${output})"
fi
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped"
if [ $FAIL -gt 0 ]; then
exit 1
fi
exit 0

View file

@ -95,42 +95,52 @@ def _resolve_credentials(
Resolve credentials from 4-tier priority system. Resolve credentials from 4-tier priority system.
Priority: Priority:
1. Function arguments 1. Function arguments (public_key, secret_key)
2. Environment variables 2. Explicit account_index (>= 0) -> load from accounts.csv
3. ~/.unsandbox/accounts.csv 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ./accounts.csv 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env)
""" """
# Tier 1: Function arguments # Tier 1: Function arguments
if public_key and secret_key: if public_key and secret_key:
return (public_key, secret_key) return (public_key, secret_key)
# Tier 2: Environment variables # Tier 2: Explicit account_index overrides env vars
if account_index is not None and account_index >= 0:
unsandbox_dir = _get_unsandbox_dir()
creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", account_index)
if creds:
return creds
creds = _load_credentials_from_csv(Path("accounts.csv"), account_index)
if creds:
return creds
raise CredentialsError(
f"No credentials found for account index {account_index} in accounts.csv"
)
# Tier 3: Environment variables
env_pk = os.environ.get("UNSANDBOX_PUBLIC_KEY") env_pk = os.environ.get("UNSANDBOX_PUBLIC_KEY")
env_sk = os.environ.get("UNSANDBOX_SECRET_KEY") env_sk = os.environ.get("UNSANDBOX_SECRET_KEY")
if env_pk and env_sk: if env_pk and env_sk:
return (env_pk, env_sk) return (env_pk, env_sk)
# Determine account index # Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or index 0)
if account_index is None: default_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0"))
account_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0"))
# Tier 3: ~/.unsandbox/accounts.csv
unsandbox_dir = _get_unsandbox_dir() unsandbox_dir = _get_unsandbox_dir()
creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", account_index) creds = _load_credentials_from_csv(unsandbox_dir / "accounts.csv", default_index)
if creds: if creds:
return creds return creds
# Tier 4: ./accounts.csv creds = _load_credentials_from_csv(Path("accounts.csv"), default_index)
creds = _load_credentials_from_csv(Path("accounts.csv"), account_index)
if creds: if creds:
return creds return creds
raise CredentialsError( raise CredentialsError(
"No credentials found. Please provide via:\n" "No credentials found. Please provide via:\n"
" 1. Function arguments (public_key, secret_key)\n" " 1. Function arguments (public_key, secret_key)\n"
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" " 2. --account N (select row from accounts.csv)\n"
" 3. ~/.unsandbox/accounts.csv\n" " 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n"
" 4. ./accounts.csv" " 4. ~/.unsandbox/accounts.csv\n"
" 5. ./accounts.csv"
) )
@ -2584,6 +2594,8 @@ Examples:
metavar="N", help="vCPU count (1-8, default: 1)") metavar="N", help="vCPU count (1-8, default: 1)")
parser.add_argument("-y", "--yes", action="store_true", parser.add_argument("-y", "--yes", action="store_true",
help="Skip confirmation prompts") help="Skip confirmation prompts")
parser.add_argument("--account", type=int, metavar="N",
help="Select account row N from accounts.csv (overrides env vars)")
# Subcommands # Subcommands
subparsers = parser.add_subparsers(dest="command", help="Commands") subparsers = parser.add_subparsers(dest="command", help="Commands")
@ -2765,7 +2777,7 @@ def cli_main():
# Resolve credentials # Resolve credentials
try: try:
public_key, secret_key = _resolve_credentials( public_key, secret_key = _resolve_credentials(
args.public_key, args.secret_key args.public_key, args.secret_key, args.account
) )
except CredentialsError as e: except CredentialsError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)

View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Integration test: --account N flag must take priority over env vars
# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UN_PY="$SCRIPT_DIR/../src/un.py"
# Skip if credentials not set
if [[ -z "${UNSANDBOX_PUBLIC_KEY:-}" || -z "${UNSANDBOX_SECRET_KEY:-}" ]]; then
echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set"
exit 0
fi
REAL_PK="$UNSANDBOX_PUBLIC_KEY"
REAL_SK="$UNSANDBOX_SECRET_KEY"
GARBAGE_PK="unsb-pk-0000-0000-0000-0000"
GARBAGE_SK="unsb-sk-00000-00000-00000-00000"
TMPHOME="$(mktemp -d)"
trap 'rm -rf "$TMPHOME"' EXIT
UNSANDBOX_DIR="$TMPHOME/.unsandbox"
mkdir -p "$UNSANDBOX_DIR"
chmod 700 "$UNSANDBOX_DIR"
# accounts.csv: row 0 = garbage, row 1 = real creds
cat > "$UNSANDBOX_DIR/accounts.csv" <<EOF
$GARBAGE_PK,$GARBAGE_SK
$REAL_PK,$REAL_SK
EOF
chmod 600 "$UNSANDBOX_DIR/accounts.csv"
PASS=0
FAIL=0
run_test() {
local name="$1"
local expected_exit="$2"
shift 2
local output
local actual_exit=0
output="$(HOME="$TMPHOME" "$@" 2>&1)" || actual_exit=$?
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
echo "PASS: $name"
PASS=$((PASS + 1))
else
echo "FAIL: $name (expected exit $expected_exit, got $actual_exit)"
echo " output: $output"
FAIL=$((FAIL + 1))
fi
}
# Test 1: --account 1 with garbage env vars -> should succeed (exit 0)
# --account 1 selects the real creds from row 1, ignoring garbage env vars
run_test \
"--account 1 overrides garbage env vars" \
0 \
env UNSANDBOX_PUBLIC_KEY="$GARBAGE_PK" UNSANDBOX_SECRET_KEY="$GARBAGE_SK" \
python3 "$UN_PY" --account 1 key
# Test 2: --account 0 with real env vars -> should fail auth (exit 3)
# --account 0 selects garbage creds from row 0, ignoring real env vars
run_test \
"--account 0 overrides real env vars (expects 401)" \
3 \
env UNSANDBOX_PUBLIC_KEY="$REAL_PK" UNSANDBOX_SECRET_KEY="$REAL_SK" \
python3 "$UN_PY" --account 0 key
# Test 3: no --account with real env vars -> should succeed (exit 0)
run_test \
"no --account uses env vars" \
0 \
env UNSANDBOX_PUBLIC_KEY="$REAL_PK" UNSANDBOX_SECRET_KEY="$REAL_SK" \
python3 "$UN_PY" key
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [[ "$FAIL" -gt 0 ]]; then
exit 1
fi

View file

@ -36,9 +36,10 @@
# #
# Authentication Priority (4-tier): # Authentication Priority (4-tier):
# 1. Method arguments (public_key:, secret_key:) # 1. Method arguments (public_key:, secret_key:)
# 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) # 2. --account N flag (accounts.csv row N, overrides env vars)
# 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) # 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
# 4. Local directory (./accounts.csv, line 0 by default) # 4. Config file (~/.unsandbox/accounts.csv, line 0 by default)
# 5. Local directory (./accounts.csv, line 0 by default)
# #
# Request Authentication (HMAC-SHA256): # Request Authentication (HMAC-SHA256):
# Authorization: Bearer <public_key> # Authorization: Bearer <public_key>
@ -1415,7 +1416,11 @@ module Un
class << self class << self
attr_accessor :last_error_value attr_accessor :last_error_value
# CLI-set account index (-1 means not specified). When >= 0, takes
# priority over env vars in resolve_credentials.
attr_accessor :cli_account_index
end end
@cli_account_index = -1
# Get the SDK version string # Get the SDK version string
# #
@ -1562,41 +1567,53 @@ module Un
# #
# Priority: # Priority:
# 1. Method arguments # 1. Method arguments
# 2. Environment variables # 2. account_index >= 0 (explicit --account N flag) → CSV row N
# 3. ~/.unsandbox/accounts.csv # 3. Environment variables
# 4. ./accounts.csv # 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env)
# #
# @param public_key [String, nil] Explicit public key # @param public_key [String, nil] Explicit public key
# @param secret_key [String, nil] Explicit secret key # @param secret_key [String, nil] Explicit secret key
# @param account_index [Integer, nil] Account index for CSV files # @param account_index [Integer, nil] Account index for CSV files (-1 means not specified)
# @return [Array<String>] [public_key, secret_key] # @return [Array<String>] [public_key, secret_key]
# @raise [CredentialsError] If no credentials found # @raise [CredentialsError] If no credentials found
def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil) def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil)
# Tier 1: Method arguments # Tier 1: Method arguments
return [public_key, secret_key] if public_key && secret_key return [public_key, secret_key] if public_key && secret_key
# Tier 2: Environment variables # Resolve effective account_index: parameter takes precedence, then CLI-set value
effective_index = account_index
effective_index = Un.cli_account_index if effective_index.nil?
# Tier 2: Explicit account_index (--account N) → load from CSV row N before env vars
if effective_index && effective_index >= 0
creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), effective_index)
return creds if creds
creds = load_credentials_from_csv('accounts.csv', effective_index)
return creds if creds
end
# Tier 3: Environment variables
env_pk = ENV['UNSANDBOX_PUBLIC_KEY'] env_pk = ENV['UNSANDBOX_PUBLIC_KEY']
env_sk = ENV['UNSANDBOX_SECRET_KEY'] env_sk = ENV['UNSANDBOX_SECRET_KEY']
return [env_pk, env_sk] if env_pk && env_sk return [env_pk, env_sk] if env_pk && env_sk
# Determine account index # Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or row 0)
account_index ||= ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i default_index = ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i
# Tier 3: ~/.unsandbox/accounts.csv creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), default_index)
creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), account_index)
return creds if creds return creds if creds
# Tier 4: ./accounts.csv creds = load_credentials_from_csv('accounts.csv', default_index)
creds = load_credentials_from_csv('accounts.csv', account_index)
return creds if creds return creds if creds
raise CredentialsError, <<~MSG raise CredentialsError, <<~MSG
No credentials found. Please provide via: No credentials found. Please provide via:
1. Method arguments (public_key:, secret_key:) 1. Method arguments (public_key:, secret_key:)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) 2. --account N flag (CSV row N)
3. ~/.unsandbox/accounts.csv 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ./accounts.csv 4. ~/.unsandbox/accounts.csv
5. ./accounts.csv
MSG MSG
end end
@ -1913,6 +1930,7 @@ module Un
file_paths: [], file_paths: [],
public_key: nil, public_key: nil,
secret_key: nil, secret_key: nil,
account_index: -1,
network: 'zerotrust', network: 'zerotrust',
vcpu: 1, vcpu: 1,
yes: false, yes: false,
@ -1920,6 +1938,19 @@ module Un
output: nil output: nil
} }
# Pre-scan for --account N before subcommand dispatch so it works in any position.
# The flag will also be consumed by parse_global_options inside each sub-handler.
ARGV.each_with_index do |arg, i|
if arg == '--account' && ARGV[i + 1] =~ /\A-?\d+\z/
options[:account_index] = ARGV[i + 1].to_i
break
elsif arg =~ /\A--account=(-?\d+)\z/
options[:account_index] = Regexp.last_match(1).to_i
break
end
end
Un.cli_account_index = options[:account_index]
# Check for subcommands first # Check for subcommands first
if ARGV.empty? if ARGV.empty?
cli_show_help cli_show_help
@ -1990,6 +2021,7 @@ module Un
-o, --output DIR Output directory for artifacts -o, --output DIR Output directory for artifacts
-p, --public-key KEY API public key -p, --public-key KEY API public key
-k, --secret-key KEY API secret key -k, --secret-key KEY API secret key
--account N Use accounts.csv row N (overrides env vars)
-n, --network MODE Network mode: zerotrust or semitrusted -n, --network MODE Network mode: zerotrust or semitrusted
-v, --vcpu N vCPU count (1-8) -v, --vcpu N vCPU count (1-8)
-y, --yes Skip confirmation prompts -y, --yes Skip confirmation prompts
@ -2031,6 +2063,9 @@ module Un
opts.on('-k', '--secret-key KEY', 'API secret key') do |v| opts.on('-k', '--secret-key KEY', 'API secret key') do |v|
options[:secret_key] = v options[:secret_key] = v
end end
opts.on('--account N', Integer, 'Use credentials from accounts.csv row N') do |v|
options[:account_index] = v
end
opts.on('-n', '--network MODE', 'Network mode') do |v| opts.on('-n', '--network MODE', 'Network mode') do |v|
options[:network] = v options[:network] = v
end end

View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Integration test: --account N flag must take priority over env vars
#
# Requires: UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY (real credentials)
# Run: bash tests/test_account_flag.sh
#
# The defect this guards against: resolve_credentials() checked env vars before
# account_index, so --account N was silently ignored when env vars existed.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UN_RB="$SCRIPT_DIR/../src/un.rb"
RED='\033[31m'
GREEN='\033[32m'
NC='\033[0m'
pass=0
fail=0
check() {
local desc="$1" result="$2"
if [ "$result" = "pass" ]; then
printf " ${GREEN}${NC} %s\n" "$desc"
pass=$((pass + 1))
else
printf " ${RED}${NC} %s\n" "$desc"
fail=$((fail + 1))
fi
}
# Require real credentials to be available
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
echo "SKIP: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set"
exit 0
fi
if [ ! -f "$UN_RB" ]; then
echo "FAIL: un.rb not found at $UN_RB"
exit 1
fi
REAL_PK="$UNSANDBOX_PUBLIC_KEY"
REAL_SK="$UNSANDBOX_SECRET_KEY"
# Temporary HOME with accounts.csv:
# index 0: garbage credentials (will always 401)
# index 1: real credentials (will succeed)
TMPHOME="$(mktemp -d)"
mkdir -p "$TMPHOME/.unsandbox"
trap 'rm -rf "$TMPHOME"' EXIT
cat > "$TMPHOME/.unsandbox/accounts.csv" <<CSV
unsb-pk-fake-0000-0000-0000,unsb-sk-fake0-00000-00000-00000
${REAL_PK},${REAL_SK}
CSV
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "INTEGRATION: --account flag priority test"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# --- Test 1: --account 1 uses CSV row 1 (real creds), ignoring garbage env vars ---
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY=unsb-pk-fake-0000-0000-0000 \
UNSANDBOX_SECRET_KEY=unsb-sk-fake0-00000-00000-00000 \
ruby "$UN_RB" --account 1 key 2>&1 || true)
if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials found"; then
check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "pass"
else
check "--account 1 uses CSV row 1 (real creds) over garbage env vars" "fail"
echo " output: $OUT"
fi
# --- Test 2: --account 0 uses CSV row 0 (garbage creds) → 401, even with real env vars ---
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
ruby "$UN_RB" --account 0 key 2>&1 || true)
if echo "$OUT" | grep -qi "401\|unauthorized\|invalid\|error\|Valid: false"; then
check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "pass"
else
check "--account 0 uses CSV row 0 (garbage creds) despite real env vars, 401 as expected" "fail"
echo " output: $OUT"
fi
# --- Test 3: no --account flag, real env vars → env vars win over garbage CSV row 0 ---
OUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
ruby "$UN_RB" key 2>&1 || true)
if ! echo "$OUT" | grep -qi "401\|unauthorized\|invalid_credential\|No credentials found"; then
check "No --account flag: env vars used, succeeds" "pass"
else
check "No --account flag: env vars used, succeeds" "fail"
echo " output: $OUT"
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf "Passed: ${GREEN}%d${NC} Failed: ${RED}%d${NC}\n" "$pass" "$fail"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[ "$fail" -eq 0 ]

View file

@ -6,7 +6,7 @@
// use un::{Credentials, execute_code, resolve_credentials}; // use un::{Credentials, execute_code, resolve_credentials};
// //
// // Resolve credentials (4-tier priority) // // Resolve credentials (4-tier priority)
// let creds = resolve_credentials(None, None)?; // let creds = resolve_credentials(None, None, None)?;
// //
// // Execute code synchronously // // Execute code synchronously
// let result = execute_code("python", r#"print("hello")"#, &creds)?; // let result = execute_code("python", r#"print("hello")"#, &creds)?;
@ -702,13 +702,15 @@ fn load_credentials_from_csv(path: &PathBuf, account_index: usize) -> Option<Cre
/// ///
/// # Priority /// # Priority
/// 1. Function arguments (if both provided) /// 1. Function arguments (if both provided)
/// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) /// 2. account_index Some(n) → load from accounts.csv row n (before env vars)
/// 3. ~/.unsandbox/accounts.csv /// 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
/// 4. ./accounts.csv /// 4. ~/.unsandbox/accounts.csv (default row via UNSANDBOX_ACCOUNT or 0)
/// 5. ./accounts.csv (default row via UNSANDBOX_ACCOUNT or 0)
/// ///
/// # Arguments /// # Arguments
/// * `public_key` - Optional public key from function argument /// * `public_key` - Optional public key from function argument
/// * `secret_key` - Optional secret key from function argument /// * `secret_key` - Optional secret key from function argument
/// * `account_index` - Optional explicit account row (0-based) to select from CSV
/// ///
/// # Returns /// # Returns
/// Credentials if found, UnsandboxError::NoCredentials otherwise /// Credentials if found, UnsandboxError::NoCredentials otherwise
@ -716,17 +718,22 @@ fn load_credentials_from_csv(path: &PathBuf, account_index: usize) -> Option<Cre
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// // Use environment variables or config file /// // Use environment variables or config file
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // Use explicit credentials /// // Use explicit credentials
/// let creds = resolve_credentials( /// let creds = resolve_credentials(
/// Some("unsb-pk-xxxx"), /// Some("unsb-pk-xxxx"),
/// Some("unsb-sk-xxxx") /// Some("unsb-sk-xxxx"),
/// None,
/// )?; /// )?;
///
/// // Select account row 1 from CSV (overrides env vars)
/// let creds = resolve_credentials(None, None, Some(1))?;
/// ``` /// ```
pub fn resolve_credentials( pub fn resolve_credentials(
public_key: Option<&str>, public_key: Option<&str>,
secret_key: Option<&str>, secret_key: Option<&str>,
account_index: Option<usize>,
) -> Result<Credentials> { ) -> Result<Credentials> {
// Tier 1: Function arguments // Tier 1: Function arguments
if let (Some(pk), Some(sk)) = (public_key, secret_key) { if let (Some(pk), Some(sk)) = (public_key, secret_key) {
@ -735,7 +742,21 @@ pub fn resolve_credentials(
} }
} }
// Tier 2: Environment variables // Tier 2: Explicit account index → load from CSV before consulting env vars
if let Some(idx) = account_index {
if let Some(dir) = get_unsandbox_dir() {
let csv_path = dir.join("accounts.csv");
if let Some(creds) = load_credentials_from_csv(&csv_path, idx) {
return Ok(creds);
}
}
let local_csv = PathBuf::from("accounts.csv");
if let Some(creds) = load_credentials_from_csv(&local_csv, idx) {
return Ok(creds);
}
}
// Tier 3: Environment variables
let env_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok(); let env_pk = env::var("UNSANDBOX_PUBLIC_KEY").ok();
let env_sk = env::var("UNSANDBOX_SECRET_KEY").ok(); let env_sk = env::var("UNSANDBOX_SECRET_KEY").ok();
if let (Some(pk), Some(sk)) = (env_pk, env_sk) { if let (Some(pk), Some(sk)) = (env_pk, env_sk) {
@ -744,23 +765,23 @@ pub fn resolve_credentials(
} }
} }
// Determine account index // Determine default account index from env
let account_index: usize = env::var("UNSANDBOX_ACCOUNT") let default_index: usize = env::var("UNSANDBOX_ACCOUNT")
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(0); .unwrap_or(0);
// Tier 3: ~/.unsandbox/accounts.csv // Tier 4: ~/.unsandbox/accounts.csv
if let Some(dir) = get_unsandbox_dir() { if let Some(dir) = get_unsandbox_dir() {
let csv_path = dir.join("accounts.csv"); let csv_path = dir.join("accounts.csv");
if let Some(creds) = load_credentials_from_csv(&csv_path, account_index) { if let Some(creds) = load_credentials_from_csv(&csv_path, default_index) {
return Ok(creds); return Ok(creds);
} }
} }
// Tier 4: ./accounts.csv // Tier 5: ./accounts.csv
let local_csv = PathBuf::from("accounts.csv"); let local_csv = PathBuf::from("accounts.csv");
if let Some(creds) = load_credentials_from_csv(&local_csv, account_index) { if let Some(creds) = load_credentials_from_csv(&local_csv, default_index) {
return Ok(creds); return Ok(creds);
} }
@ -1065,7 +1086,7 @@ fn save_languages_cache(languages: &[String]) {
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let result = execute_code("python", r#"print("Hello, World!")"#, &creds)?; /// let result = execute_code("python", r#"print("Hello, World!")"#, &creds)?;
/// println!("Output: {}", result.output); /// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code); /// println!("Exit code: {}", result.exit_code);
@ -1422,7 +1443,7 @@ pub fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Res
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let image = image_publish("service", "svc-abc123", "my-app-image", Some("Production app v1.0"), &creds)?; /// let image = image_publish("service", "svc-abc123", "my-app-image", Some("Production app v1.0"), &creds)?;
/// println!("Published image: {}", image.image_id); /// println!("Published image: {}", image.image_id);
/// ``` /// ```
@ -1461,7 +1482,7 @@ pub fn image_publish(
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // List owned images (default) /// // List owned images (default)
/// let my_images = list_images(None, &creds)?; /// let my_images = list_images(None, &creds)?;
@ -1550,7 +1571,7 @@ pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // Make image public /// // Make image public
/// set_image_visibility("img-abc123", "public", &creds)?; /// set_image_visibility("img-abc123", "public", &creds)?;
@ -1665,7 +1686,7 @@ pub fn transfer_image(image_id: &str, to_api_key: &str, creds: &Credentials) ->
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // Spawn with default options /// // Spawn with default options
/// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?; /// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?;
@ -1745,7 +1766,7 @@ pub fn clone_image(
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let sessions = list_sessions(&creds)?; /// let sessions = list_sessions(&creds)?;
/// for session in sessions { /// for session in sessions {
/// println!("{}: {} ({})", session.session_id, session.container_name, session.status); /// println!("{}: {} ({})", session.session_id, session.container_name, session.status);
@ -1781,7 +1802,7 @@ pub fn get_session(session_id: &str, creds: &Credentials) -> Result<Session> {
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // Create a basic bash session /// // Create a basic bash session
/// let session = create_session("bash", &creds, None)?; /// let session = create_session("bash", &creds, None)?;
@ -1907,7 +1928,7 @@ pub fn unboost_session(session_id: &str, creds: &Credentials) -> Result<Session>
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let result = shell_session("session-123", "ls -la", &creds)?; /// let result = shell_session("session-123", "ls -la", &creds)?;
/// println!("Output: {}", result.output); /// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code); /// println!("Exit code: {}", result.exit_code);
@ -1934,7 +1955,7 @@ pub fn shell_session(session_id: &str, command: &str, creds: &Credentials) -> Re
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let services = list_services(&creds)?; /// let services = list_services(&creds)?;
/// for service in services { /// for service in services {
/// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url); /// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url);
@ -1959,7 +1980,7 @@ pub fn list_services(creds: &Credentials) -> Result<Vec<Service>> {
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // Create a simple web service /// // Create a simple web service
/// let service = create_service( /// let service = create_service(
@ -2317,7 +2338,7 @@ pub fn redeploy_service(
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let service = resize_service("service-123", 4, &creds)?; /// let service = resize_service("service-123", 4, &creds)?;
/// println!("Service now has {} vCPUs", service.vcpu); /// println!("Service now has {} vCPUs", service.vcpu);
/// ``` /// ```
@ -2341,7 +2362,7 @@ pub fn resize_service(service_id: &str, vcpu: u32, creds: &Credentials) -> Resul
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let result = execute_in_service("service-123", "ls -la /app", &creds)?; /// let result = execute_in_service("service-123", "ls -la /app", &creds)?;
/// println!("Output: {}", result.output); /// println!("Output: {}", result.output);
/// ``` /// ```
@ -2372,7 +2393,7 @@ pub fn execute_in_service(
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let result = validate_keys(&creds)?; /// let result = validate_keys(&creds)?;
/// if result.valid { /// if result.valid {
/// println!("Keys valid for account: {}", result.account_id); /// println!("Keys valid for account: {}", result.account_id);
@ -2432,7 +2453,7 @@ pub fn validate_keys(creds: &Credentials) -> Result<KeysValid> {
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// let result = image("A sunset over mountains", &creds, None)?; /// let result = image("A sunset over mountains", &creds, None)?;
/// for img in result.images { /// for img in result.images {
/// println!("Image: {}", img); /// println!("Image: {}", img);
@ -2502,7 +2523,7 @@ pub struct LogsEntry {
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// // Fetch last 100 lines from all sources /// // Fetch last 100 lines from all sources
/// let opts = LogsFetchOptions { /// let opts = LogsFetchOptions {
@ -2568,7 +2589,7 @@ pub type LogCallback = fn(source: &str, line: &str);
/// ///
/// # Examples /// # Examples
/// ```ignore /// ```ignore
/// let creds = resolve_credentials(None, None)?; /// let creds = resolve_credentials(None, None, None)?;
/// ///
/// fn handle_log(source: &str, line: &str) { /// fn handle_log(source: &str, line: &str) {
/// println!("[{}] {}", source, line); /// println!("[{}] {}", source, line);
@ -2766,6 +2787,7 @@ struct CliOptions {
output_dir: Option<String>, // -o, --output output_dir: Option<String>, // -o, --output
public_key: Option<String>, // -p, --public-key public_key: Option<String>, // -p, --public-key
secret_key: Option<String>, // -k, --secret-key secret_key: Option<String>, // -k, --secret-key
account_index: Option<usize>, // --account N
network: Option<String>, // -n, --network network: Option<String>, // -n, --network
vcpu: Option<u32>, // -v, --vcpu vcpu: Option<u32>, // -v, --vcpu
yes: bool, // -y, --yes yes: bool, // -y, --yes
@ -2867,6 +2889,7 @@ GLOBAL OPTIONS:
-o, --output DIR Output directory for artifacts -o, --output DIR Output directory for artifacts
-p, --public-key KEY API public key -p, --public-key KEY API public key
-k, --secret-key KEY API secret key -k, --secret-key KEY API secret key
--account N Select account row N from accounts.csv (overrides env vars)
-n, --network MODE Network mode: zerotrust or semitrusted -n, --network MODE Network mode: zerotrust or semitrusted
-v, --vcpu N vCPU count (1-8) -v, --vcpu N vCPU count (1-8)
-y, --yes Skip confirmation prompts -y, --yes Skip confirmation prompts
@ -3014,6 +3037,14 @@ fn parse_args(args: &[String]) -> CliOptions {
i += 1; i += 1;
} }
} }
"--account" => {
if i + 1 < args.len() {
opts.account_index = args[i + 1].parse().ok();
i += 2;
} else {
i += 1;
}
}
"-n" | "--network" => { "-n" | "--network" => {
if i + 1 < args.len() { if i + 1 < args.len() {
opts.network = Some(args[i + 1].clone()); opts.network = Some(args[i + 1].clone());
@ -3358,6 +3389,7 @@ fn get_credentials(opts: &CliOptions) -> Result<Credentials> {
resolve_credentials( resolve_credentials(
opts.public_key.as_deref(), opts.public_key.as_deref(),
opts.secret_key.as_deref(), opts.secret_key.as_deref(),
opts.account_index,
) )
} }

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Test --account N credential selection in the Rust sync SDK CLI.
#
# Tests:
# 1. --account 1 selects row 1 from accounts.csv (garbage env vars present)
# 2. --account 0 selects row 0 (garbage creds), returns 401
# 3. No --account flag falls through to env vars (real creds), succeeds
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
SDK_DIR="$SCRIPT_DIR/.."
BINARY="$SDK_DIR/target/debug/un"
# ---------------------------------------------------------------------------
# Skip if real credentials are not available
# ---------------------------------------------------------------------------
if [ -z "${UNSANDBOX_PUBLIC_KEY:-}" ] || [ -z "${UNSANDBOX_SECRET_KEY:-}" ]; then
echo "SKIP: UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY not set"
exit 0
fi
REAL_PK="$UNSANDBOX_PUBLIC_KEY"
REAL_SK="$UNSANDBOX_SECRET_KEY"
# ---------------------------------------------------------------------------
# Build the binary if it doesn't already exist
# ---------------------------------------------------------------------------
if [ ! -f "$BINARY" ]; then
echo "Building Rust SDK..."
cd "$SDK_DIR"
PATH="$HOME/.cargo/bin:$PATH" cargo build 2>&1
fi
if [ ! -f "$BINARY" ]; then
echo "FAIL: binary not found at $BINARY after build"
exit 1
fi
# ---------------------------------------------------------------------------
# Set up a temporary HOME with accounts.csv
# row 0: garbage credentials
# row 1: real credentials
# ---------------------------------------------------------------------------
TMPHOME="$(mktemp -d)"
trap 'rm -rf "$TMPHOME"' EXIT
mkdir -p "$TMPHOME/.unsandbox"
printf 'unsb-pk-garbage-0000,unsb-sk-garbage-0000000000000000\n%s,%s\n' \
"$REAL_PK" "$REAL_SK" \
> "$TMPHOME/.unsandbox/accounts.csv"
PASS=0
FAIL=0
run_key() {
# Returns the exit code and stdout/stderr of `un key`
HOME="$TMPHOME" "$BINARY" "$@" key 2>&1
}
# ---------------------------------------------------------------------------
# Test 1: --account 1 picks real creds from row 1, even when env has garbage
# ---------------------------------------------------------------------------
echo "Test 1: --account 1 selects row 1 (real creds) despite garbage env vars"
OUTPUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="unsb-pk-garbage-env" \
UNSANDBOX_SECRET_KEY="unsb-sk-garbage-env-0000000000000" \
"$BINARY" --account 1 key 2>&1 || true)
if echo "$OUTPUT" | grep -qi "API keys valid"; then
echo " PASS"
PASS=$((PASS + 1))
else
echo " FAIL: expected 'API keys valid', got:"
echo "$OUTPUT" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
# ---------------------------------------------------------------------------
# Test 2: --account 0 picks garbage creds from row 0, should get 401
# ---------------------------------------------------------------------------
echo "Test 2: --account 0 selects row 0 (garbage creds), expect auth failure"
OUTPUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
"$BINARY" --account 0 key 2>&1 || true)
if echo "$OUTPUT" | grep -qiE "401|unauthorized|invalid|invalid key|keys invalid"; then
echo " PASS"
PASS=$((PASS + 1))
else
echo " FAIL: expected auth failure (401/unauthorized/invalid), got:"
echo "$OUTPUT" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
# ---------------------------------------------------------------------------
# Test 3: No --account flag, env vars contain real creds → succeed
# ---------------------------------------------------------------------------
echo "Test 3: no --account flag, env vars hold real creds"
OUTPUT=$(HOME="$TMPHOME" \
UNSANDBOX_PUBLIC_KEY="$REAL_PK" \
UNSANDBOX_SECRET_KEY="$REAL_SK" \
"$BINARY" key 2>&1 || true)
if echo "$OUTPUT" | grep -qi "API keys valid"; then
echo " PASS"
PASS=$((PASS + 1))
else
echo " FAIL: expected 'API keys valid', got:"
echo "$OUTPUT" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0