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-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
@echo ""
@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

View file

@ -396,7 +396,14 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli
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_sk = getenv("UNSANDBOX_SECRET_KEY");
@ -416,16 +423,12 @@ static UnsandboxCredentials* get_credentials(const char *cli_pk, const char *cli
return creds;
}
// Priority 3: Config file (~/.unsandbox/accounts.csv)
// Use account_index from --account flag, or UNSANDBOX_ACCOUNT env var, or default to 0
int csv_index = account_index;
if (csv_index < 0) {
const char *env_account = getenv("UNSANDBOX_ACCOUNT");
if (env_account && strlen(env_account) > 0) {
csv_index = atoi(env_account);
} else {
csv_index = 0;
}
// Priority 4: Config file (~/.unsandbox/accounts.csv)
// Use UNSANDBOX_ACCOUNT env var, or default to account 0
int csv_index = 0;
const char *env_account = getenv("UNSANDBOX_ACCOUNT");
if (env_account && strlen(env_account) > 0) {
csv_index = atoi(env_account);
}
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.
func NewClientFromEnv() (*Client, error) {
creds, err := ResolveCredentials("", "")
creds, err := ResolveCredentials("", "", -1)
if err != nil {
return nil, err
}

View file

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

View file

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

View file

@ -25,11 +25,12 @@
* // Snapshot operations
* String snapshotId = Un.sessionSnapshot(sessionId, publicKey, secretKey, "my-snapshot", false);
*
* Authentication Priority (4-tier):
* Authentication Priority (5-tier):
* 1. Method arguments (publicKey, secretKey)
* 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
* 3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
* 4. Local directory (./accounts.csv, line 0 by default)
* 2. --account N flag / accountIndex >= 0 (load row N from accounts.csv)
* 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
* 4. Config file (~/.unsandbox/accounts.csv, line 0 by default)
* 5. Local directory (./accounts.csv, line 0 by default)
*
* Request Authentication (HMAC-SHA256):
* Authorization: Bearer <public_key>
@ -175,38 +176,58 @@ public class Un {
}
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
if (publicKey != null && !publicKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) {
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 envSk = System.getenv("UNSANDBOX_SECRET_KEY");
if (envPk != null && !envPk.isEmpty() && envSk != null && !envSk.isEmpty()) {
return new String[]{envPk, envSk};
}
// Determine account index
int accountIndex = 0;
// Determine account index from env (default 0)
int csvIndex = 0;
String accountEnv = System.getenv("UNSANDBOX_ACCOUNT");
if (accountEnv != null && !accountEnv.isEmpty()) {
try {
accountIndex = Integer.parseInt(accountEnv);
csvIndex = Integer.parseInt(accountEnv);
} catch (NumberFormatException e) {
// Use default
}
}
// Tier 3: ~/.unsandbox/accounts.csv
// Tier 4: ~/.unsandbox/accounts.csv
Path unsandboxDir = getUnsandboxDir();
String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), accountIndex);
String[] creds = loadCredentialsFromCsv(unsandboxDir.resolve("accounts.csv"), csvIndex);
if (creds != null) {
return creds;
}
// Tier 4: ./accounts.csv
creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), accountIndex);
// Tier 5: ./accounts.csv
creds = loadCredentialsFromCsv(Paths.get("accounts.csv"), csvIndex);
if (creds != null) {
return creds;
}
@ -214,9 +235,10 @@ public class Un {
throw new CredentialsException(
"No credentials found. Please provide via:\n" +
" 1. Method arguments (publicKey, secretKey)\n" +
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" +
" 3. ~/.unsandbox/accounts.csv\n" +
" 4. ./accounts.csv"
" 2. --account N flag (load row N from accounts.csv)\n" +
" 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" +
" 4. ~/.unsandbox/accounts.csv\n" +
" 5. ./accounts.csv"
);
}
@ -2860,6 +2882,7 @@ public class Un {
String language = null;
String networkMode = "zerotrust";
int vcpu = 1;
int accountIndex = -1;
List<String> envVars = new ArrayList<>();
List<String> files = new ArrayList<>();
List<String> positionalArgs = new ArrayList<>();
@ -2871,6 +2894,18 @@ public class Un {
if (arg.equals("-h") || arg.equals("--help")) {
showHelp = true;
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")) {
if (i + 1 >= args.length) {
System.err.println("Error: -s/--shell requires an argument");
@ -2936,6 +2971,14 @@ public class Un {
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
switch (command) {
case "session":
@ -2982,6 +3025,7 @@ public class Un {
System.out.println(" -f, --file FILE Add input file to /tmp/");
System.out.println(" -p, --public-key KEY API public 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(" -v, --vcpu N vCPU count (1-8)");
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:
* 1. Function arguments
* 2. Environment variables (Node.js)
* 3. localStorage (Browser)
* 4. ~/.unsandbox/accounts.csv (Node.js)
* 5. ./accounts.csv (Node.js)
* 2. accountIndex >= 0 load from accounts.csv row N
* 3. Environment variables (Node.js)
* 4. localStorage (Browser)
* 5. ~/.unsandbox/accounts.csv (default row, Node.js)
* 6. ./accounts.csv (default row, Node.js)
*/
function resolveCredentials(publicKey, secretKey, accountIndex) {
// Tier 1: Function arguments
@ -342,7 +343,26 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
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) {
const envPk = process.env.UNSANDBOX_PUBLIC_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) {
const storageCreds = loadCredentialsFromStorage();
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) {
// Determine account index
if (accountIndex === undefined) {
accountIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
}
const defaultIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
// Tier 4: ~/.unsandbox/accounts.csv
// Tier 5: ~/.unsandbox/accounts.csv
try {
const unsandboxDir = getUnsandboxDir();
const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex);
const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), defaultIndex);
if (creds) {
return creds;
}
@ -377,8 +394,8 @@ function resolveCredentials(publicKey, secretKey, accountIndex) {
// Continue to next tier
}
// Tier 5: ./accounts.csv
const creds = loadCredentialsFromCsv('accounts.csv', accountIndex);
// Tier 6: ./accounts.csv
const creds = loadCredentialsFromCsv('accounts.csv', defaultIndex);
if (creds) {
return creds;
}
@ -2193,6 +2210,7 @@ function parseArgs(args) {
output: null,
publicKey: null,
secretKey: null,
accountIndex: undefined,
network: 'zerotrust',
vcpu: 1,
yes: false,
@ -2322,6 +2340,9 @@ function parseArgs(args) {
} else if (arg === '-k' || arg === '--secret-key') {
result.secretKey = args[++i];
i++;
} else if (arg === '--account') {
result.accountIndex = parseInt(args[++i], 10);
i++;
} else if (arg === '-n' || arg === '--network') {
result.network = args[++i];
i++;
@ -2509,8 +2530,7 @@ function formatTable(items, columns) {
* Handle session commands.
*/
async function handleSession(opts) {
const pk = opts.publicKey;
const sk = opts.secretKey;
let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
// List sessions
if (opts.list) {
@ -2593,8 +2613,7 @@ async function handleSession(opts) {
* Handle service commands.
*/
async function handleService(opts) {
const pk = opts.publicKey;
const sk = opts.secretKey;
let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
// Handle env subcommand
if (opts.subcommand === 'env') {
@ -2829,8 +2848,7 @@ async function handleService(opts) {
* Handle snapshot commands.
*/
async function handleSnapshot(opts) {
const pk = opts.publicKey;
const sk = opts.secretKey;
let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
// List snapshots
if (opts.list) {
@ -2898,8 +2916,7 @@ async function handleSnapshot(opts) {
* Handle image command.
*/
async function handleImage(opts) {
const pk = opts.publicKey;
const sk = opts.secretKey;
let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
// List images
if (opts.list) {
@ -3002,13 +3019,13 @@ async function handleImage(opts) {
* Handle key command.
*/
async function handleKey(opts) {
const [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
try {
const result = await validateKeys(opts.publicKey, opts.secretKey);
const result = await validateKeys(pk, sk);
console.log('API Key Status:');
console.log(JSON.stringify(result, null, 2));
} catch (err) {
// 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('Key validation endpoint returned error - key may still be valid.');
}
@ -3018,7 +3035,8 @@ async function handleKey(opts) {
* Handle languages command.
*/
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) {
// Output as JSON array
@ -3035,8 +3053,7 @@ async function handleLanguages(opts) {
* Handle execute command (default).
*/
async function handleExecute(opts) {
const pk = opts.publicKey;
const sk = opts.secretKey;
let [pk, sk] = resolveCredentials(opts.publicKey, opts.secretKey, opts.accountIndex);
let code;
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 $defaultSecretKey = null;
private int $accountIndex = 0;
private bool $accountIndexExplicit = false;
/**
* Create a new Unsandbox client.
@ -1693,17 +1694,18 @@ class Unsandbox {
* Resolve credentials from 4-tier priority system.
*
* Priority:
* 1. Method arguments
* 2. Environment variables
* 3. ~/.unsandbox/accounts.csv
* 4. ./accounts.csv
* 1. Method arguments / constructor defaults
* 2. $accountIndex >= 0 load from accounts.csv row N
* 3. Environment variables (UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY)
* 4. Default CSV lookup (account 0)
*
* @param string|null $publicKey Public 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]
* @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
if (!empty($publicKey) && !empty($secretKey)) {
return [$publicKey, $secretKey];
@ -1714,29 +1716,50 @@ class Unsandbox {
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');
$envSk = getenv('UNSANDBOX_SECRET_KEY');
if (!empty($envPk) && !empty($envSk)) {
return [$envPk, $envSk];
}
// Determine account index
$accountIndex = $this->accountIndex;
$envAccount = getenv('UNSANDBOX_ACCOUNT');
if ($envAccount !== false && $envAccount !== '') {
$accountIndex = (int)$envAccount;
}
// Tier 3: ~/.unsandbox/accounts.csv
// Tier 4: Default CSV lookup (account 0)
$unsandboxDir = $this->getUnsandboxDir();
$creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $accountIndex);
$creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', 0);
if ($creds !== null) {
return $creds;
}
// Tier 4: ./accounts.csv
$creds = $this->loadCredentialsFromCsv('./accounts.csv', $accountIndex);
$creds = $this->loadCredentialsFromCsv('./accounts.csv', 0);
if ($creds !== null) {
return $creds;
}
@ -1744,9 +1767,9 @@ class Unsandbox {
throw new CredentialsException(
"No credentials found. Please provide via:\n" .
" 1. Method arguments (publicKey, secretKey)\n" .
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" .
" 3. ~/.unsandbox/accounts.csv\n" .
" 4. ./accounts.csv"
" 2. --account N flag or UNSANDBOX_ACCOUNT env var (CSV row N)\n" .
" 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" .
" 4. ~/.unsandbox/accounts.csv or ./accounts.csv (row 0)"
);
}
@ -2138,6 +2161,10 @@ class Unsandbox {
if (!empty($opts['secret_key'])) {
$this->defaultSecretKey = $opts['secret_key'];
}
if ($opts['account'] !== null) {
$this->accountIndex = $opts['account'];
$this->accountIndexExplicit = true;
}
// Determine the command
if (empty($args)) {
@ -2209,6 +2236,7 @@ class Unsandbox {
'vcpu' => 1,
'yes' => false,
'help' => false,
'account' => null,
];
$args = [];
@ -2255,6 +2283,11 @@ class Unsandbox {
$opts['yes'] = true;
} elseif ($arg === '-h' || $arg === '--help') {
$opts['help'] = true;
} elseif ($arg === '--account') {
$i++;
if (isset($argv[$i])) {
$opts['account'] = (int)$argv[$i];
}
} elseif (strpos($arg, '-') !== 0) {
$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.
Priority:
1. Function arguments
2. Environment variables
3. ~/.unsandbox/accounts.csv
4. ./accounts.csv
1. Function arguments (public_key, secret_key)
2. Explicit account_index (>= 0) -> load from accounts.csv
3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env)
"""
# Tier 1: Function arguments
if public_key and 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_sk = os.environ.get("UNSANDBOX_SECRET_KEY")
if env_pk and env_sk:
return (env_pk, env_sk)
# Determine account index
if account_index is None:
account_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0"))
# Tier 3: ~/.unsandbox/accounts.csv
# Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or index 0)
default_index = int(os.environ.get("UNSANDBOX_ACCOUNT", "0"))
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:
return creds
# Tier 4: ./accounts.csv
creds = _load_credentials_from_csv(Path("accounts.csv"), account_index)
creds = _load_credentials_from_csv(Path("accounts.csv"), default_index)
if creds:
return creds
raise CredentialsError(
"No credentials found. Please provide via:\n"
" 1. Function arguments (public_key, secret_key)\n"
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n"
" 3. ~/.unsandbox/accounts.csv\n"
" 4. ./accounts.csv"
" 2. --account N (select row from accounts.csv)\n"
" 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n"
" 4. ~/.unsandbox/accounts.csv\n"
" 5. ./accounts.csv"
)
@ -2584,6 +2594,8 @@ Examples:
metavar="N", help="vCPU count (1-8, default: 1)")
parser.add_argument("-y", "--yes", action="store_true",
help="Skip confirmation prompts")
parser.add_argument("--account", type=int, metavar="N",
help="Select account row N from accounts.csv (overrides env vars)")
# Subcommands
subparsers = parser.add_subparsers(dest="command", help="Commands")
@ -2765,7 +2777,7 @@ def cli_main():
# Resolve credentials
try:
public_key, secret_key = _resolve_credentials(
args.public_key, args.secret_key
args.public_key, args.secret_key, args.account
)
except CredentialsError as e:
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):
# 1. Method arguments (public_key:, secret_key:)
# 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
# 3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
# 4. Local directory (./accounts.csv, line 0 by default)
# 2. --account N flag (accounts.csv row N, overrides env vars)
# 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
# 4. Config file (~/.unsandbox/accounts.csv, line 0 by default)
# 5. Local directory (./accounts.csv, line 0 by default)
#
# Request Authentication (HMAC-SHA256):
# Authorization: Bearer <public_key>
@ -1415,7 +1416,11 @@ module Un
class << self
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
@cli_account_index = -1
# Get the SDK version string
#
@ -1562,41 +1567,53 @@ module Un
#
# Priority:
# 1. Method arguments
# 2. Environment variables
# 3. ~/.unsandbox/accounts.csv
# 4. ./accounts.csv
# 2. account_index >= 0 (explicit --account N flag) → CSV row N
# 3. Environment variables
# 4. Default CSV lookup (account 0 or UNSANDBOX_ACCOUNT env)
#
# @param public_key [String, nil] Explicit public 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]
# @raise [CredentialsError] If no credentials found
def resolve_credentials(public_key = nil, secret_key = nil, account_index = nil)
# Tier 1: Method arguments
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_sk = ENV['UNSANDBOX_SECRET_KEY']
return [env_pk, env_sk] if env_pk && env_sk
# Determine account index
account_index ||= ENV.fetch('UNSANDBOX_ACCOUNT', '0').to_i
# Tier 4: Default CSV lookup (UNSANDBOX_ACCOUNT env or row 0)
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'), account_index)
creds = load_credentials_from_csv(File.join(unsandbox_dir, 'accounts.csv'), default_index)
return creds if creds
# Tier 4: ./accounts.csv
creds = load_credentials_from_csv('accounts.csv', account_index)
creds = load_credentials_from_csv('accounts.csv', default_index)
return creds if creds
raise CredentialsError, <<~MSG
No credentials found. Please provide via:
1. Method arguments (public_key:, secret_key:)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
3. ~/.unsandbox/accounts.csv
4. ./accounts.csv
2. --account N flag (CSV row N)
3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ~/.unsandbox/accounts.csv
5. ./accounts.csv
MSG
end
@ -1913,6 +1930,7 @@ module Un
file_paths: [],
public_key: nil,
secret_key: nil,
account_index: -1,
network: 'zerotrust',
vcpu: 1,
yes: false,
@ -1920,6 +1938,19 @@ module Un
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
if ARGV.empty?
cli_show_help
@ -1990,6 +2021,7 @@ module Un
-o, --output DIR Output directory for artifacts
-p, --public-key KEY API public 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
-v, --vcpu N vCPU count (1-8)
-y, --yes Skip confirmation prompts
@ -2031,6 +2063,9 @@ module Un
opts.on('-k', '--secret-key KEY', 'API secret key') do |v|
options[:secret_key] = v
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|
options[:network] = v
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};
//
// // Resolve credentials (4-tier priority)
// let creds = resolve_credentials(None, None)?;
// let creds = resolve_credentials(None, None, None)?;
//
// // Execute code synchronously
// 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
/// 1. Function arguments (if both provided)
/// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
/// 3. ~/.unsandbox/accounts.csv
/// 4. ./accounts.csv
/// 2. account_index Some(n) → load from accounts.csv row n (before env vars)
/// 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
/// 4. ~/.unsandbox/accounts.csv (default row via UNSANDBOX_ACCOUNT or 0)
/// 5. ./accounts.csv (default row via UNSANDBOX_ACCOUNT or 0)
///
/// # Arguments
/// * `public_key` - Optional public 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
/// Credentials if found, UnsandboxError::NoCredentials otherwise
@ -716,17 +718,22 @@ fn load_credentials_from_csv(path: &PathBuf, account_index: usize) -> Option<Cre
/// # Examples
/// ```ignore
/// // Use environment variables or config file
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // Use explicit credentials
/// let creds = resolve_credentials(
/// 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(
public_key: Option<&str>,
secret_key: Option<&str>,
account_index: Option<usize>,
) -> Result<Credentials> {
// Tier 1: Function arguments
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_sk = env::var("UNSANDBOX_SECRET_KEY").ok();
if let (Some(pk), Some(sk)) = (env_pk, env_sk) {
@ -744,23 +765,23 @@ pub fn resolve_credentials(
}
}
// Determine account index
let account_index: usize = env::var("UNSANDBOX_ACCOUNT")
// Determine default account index from env
let default_index: usize = env::var("UNSANDBOX_ACCOUNT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// Tier 3: ~/.unsandbox/accounts.csv
// Tier 4: ~/.unsandbox/accounts.csv
if let Some(dir) = get_unsandbox_dir() {
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);
}
}
// Tier 4: ./accounts.csv
// Tier 5: ./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);
}
@ -1065,7 +1086,7 @@ fn save_languages_cache(languages: &[String]) {
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let result = execute_code("python", r#"print("Hello, World!")"#, &creds)?;
/// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code);
@ -1422,7 +1443,7 @@ pub fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Res
///
/// # Examples
/// ```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)?;
/// println!("Published image: {}", image.image_id);
/// ```
@ -1461,7 +1482,7 @@ pub fn image_publish(
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // List owned images (default)
/// let my_images = list_images(None, &creds)?;
@ -1550,7 +1571,7 @@ pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result<LxdImage> {
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // Make image public
/// 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
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // Spawn with default options
/// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?;
@ -1745,7 +1766,7 @@ pub fn clone_image(
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let sessions = list_sessions(&creds)?;
/// for session in sessions {
/// 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
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // Create a basic bash session
/// let session = create_session("bash", &creds, None)?;
@ -1907,7 +1928,7 @@ pub fn unboost_session(session_id: &str, creds: &Credentials) -> Result<Session>
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let result = shell_session("session-123", "ls -la", &creds)?;
/// println!("Output: {}", result.output);
/// println!("Exit code: {}", result.exit_code);
@ -1934,7 +1955,7 @@ pub fn shell_session(session_id: &str, command: &str, creds: &Credentials) -> Re
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let services = list_services(&creds)?;
/// for service in services {
/// println!("{}: {} ({}) - {}", service.service_id, service.name, service.status, service.url);
@ -1959,7 +1980,7 @@ pub fn list_services(creds: &Credentials) -> Result<Vec<Service>> {
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // Create a simple web service
/// let service = create_service(
@ -2317,7 +2338,7 @@ pub fn redeploy_service(
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let service = resize_service("service-123", 4, &creds)?;
/// println!("Service now has {} vCPUs", service.vcpu);
/// ```
@ -2341,7 +2362,7 @@ pub fn resize_service(service_id: &str, vcpu: u32, creds: &Credentials) -> Resul
///
/// # Examples
/// ```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)?;
/// println!("Output: {}", result.output);
/// ```
@ -2372,7 +2393,7 @@ pub fn execute_in_service(
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let result = validate_keys(&creds)?;
/// if result.valid {
/// println!("Keys valid for account: {}", result.account_id);
@ -2432,7 +2453,7 @@ pub fn validate_keys(creds: &Credentials) -> Result<KeysValid> {
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
/// let result = image("A sunset over mountains", &creds, None)?;
/// for img in result.images {
/// println!("Image: {}", img);
@ -2502,7 +2523,7 @@ pub struct LogsEntry {
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// // Fetch last 100 lines from all sources
/// let opts = LogsFetchOptions {
@ -2568,7 +2589,7 @@ pub type LogCallback = fn(source: &str, line: &str);
///
/// # Examples
/// ```ignore
/// let creds = resolve_credentials(None, None)?;
/// let creds = resolve_credentials(None, None, None)?;
///
/// fn handle_log(source: &str, line: &str) {
/// println!("[{}] {}", source, line);
@ -2766,6 +2787,7 @@ struct CliOptions {
output_dir: Option<String>, // -o, --output
public_key: Option<String>, // -p, --public-key
secret_key: Option<String>, // -k, --secret-key
account_index: Option<usize>, // --account N
network: Option<String>, // -n, --network
vcpu: Option<u32>, // -v, --vcpu
yes: bool, // -y, --yes
@ -2867,6 +2889,7 @@ GLOBAL OPTIONS:
-o, --output DIR Output directory for artifacts
-p, --public-key KEY API public 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
-v, --vcpu N vCPU count (1-8)
-y, --yes Skip confirmation prompts
@ -3014,6 +3037,14 @@ fn parse_args(args: &[String]) -> CliOptions {
i += 1;
}
}
"--account" => {
if i + 1 < args.len() {
opts.account_index = args[i + 1].parse().ok();
i += 2;
} else {
i += 1;
}
}
"-n" | "--network" => {
if i + 1 < args.len() {
opts.network = Some(args[i + 1].clone());
@ -3358,6 +3389,7 @@ fn get_credentials(opts: &CliOptions) -> Result<Credentials> {
resolve_credentials(
opts.public_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