From 7e7bec56d5ba5f1115ff5af91310da2d8e122c0f Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 22 Jun 2026 13:14:25 -0400 Subject: [PATCH] Add way for infisical to switch accounts when signed into wrong account --- AGENTS.md | 3 + README.md | 29 +++ .../tests/unit/infisical-account.test.ts | 229 ++++++++++++++++++ apps/agent-worker/tsconfig.json | 2 +- scripts/export-env | 1 + scripts/infisical-account | 177 ++++++++++++++ 6 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 apps/agent-worker/tests/unit/infisical-account.test.ts create mode 100755 scripts/infisical-account diff --git a/AGENTS.md b/AGENTS.md index 7146b81..6fc7b34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,9 @@ - Local `dev` and `staging` come only from Infisical via `scripts/with-env`; it never falls back to `.env*`. - Run `infisical login` and `infisical init` before local development. +- `scripts/export-env` enforces `.local/infisical.env` when multiple local + Infisical accounts are logged in. Put `INFISICAL_EMAIL=you@example.com` there + for this project and do not commit it. - Machine-generated values belong in `.local/.generated.env`; never put the generated Convex admin key in shared Infisical. - `scripts/sync-convex-env ` copies Authentik, GitHub App, diff --git a/README.md b/README.md index 1ba36d7..4559e8b 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,35 @@ native simulator. This project is currently private, so this section is a reference for what the application expects rather than public setup documentation. +
+Local Infisical account selection + +Local `dev` and `staging` commands export secrets through Infisical. Spoon runs +`scripts/infisical-account ensure` from `scripts/export-env` before exporting so +machines logged into multiple Infisical accounts do not accidentally use the +wrong organization. + +If your machine has only one local Infisical account, no extra setup is needed. +If it has multiple accounts, create this ignored local file: + +```sh +mkdir -p .local +printf "INFISICAL_EMAIL=me@gbrown.org\n" > .local/infisical.env +``` + +Log into each needed account once with `infisical login`. You can inspect local +profiles without printing tokens: + +```sh +jq '.loggedInUsers[] | {email, domain}' ~/.infisical/infisical-config.json +``` + +`.local/infisical.env` supports only `INFISICAL_EMAIL=...` and must not be +committed. CI is unchanged; it uses injected environment files/secrets and must +not call Infisical. + +
+
Public Next variables diff --git a/apps/agent-worker/tests/unit/infisical-account.test.ts b/apps/agent-worker/tests/unit/infisical-account.test.ts new file mode 100644 index 0000000..22a27d9 --- /dev/null +++ b/apps/agent-worker/tests/unit/infisical-account.test.ts @@ -0,0 +1,229 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +type TestWorkspace = { + binDir: string; + homeDir: string; + localFile: string; + projectDir: string; +}; + +const scriptPath = fileURLToPath( + new URL('../../../../scripts/infisical-account', import.meta.url), +); + +let workspaces: TestWorkspace[] = []; + +const createWorkspace = async (): Promise => { + const root = await realpathTemp(); + const homeDir = path.join(root, 'home'); + const projectDir = path.join(root, 'project'); + const binDir = path.join(root, 'bin'); + const localFile = path.join(projectDir, '.local', 'infisical.env'); + + await mkdir(path.join(homeDir, '.infisical'), { recursive: true }); + await mkdir(path.dirname(localFile), { recursive: true }); + await mkdir(binDir, { recursive: true }); + + const fakeInfisical = path.join(binDir, 'infisical'); + await writeFile(fakeInfisical, '#!/usr/bin/env sh\nexit 0\n'); + await chmod(fakeInfisical, 0o755); + + const workspace = { binDir, homeDir, localFile, projectDir }; + workspaces.push(workspace); + return workspace; +}; + +const realpathTemp = async (): Promise => { + const base = path.join(tmpdir(), 'spoon-infisical-account-'); + const { mkdtemp } = await import('node:fs/promises'); + return mkdtemp(base); +}; + +const configPath = (workspace: TestWorkspace) => + path.join(workspace.homeDir, '.infisical', 'infisical-config.json'); + +const writeConfig = async ( + workspace: TestWorkspace, + config: Record | string, +) => { + const content = + typeof config === 'string' ? config : `${JSON.stringify(config, null, 2)}\n`; + await writeFile(configPath(workspace), content); +}; + +const readConfig = async ( + workspace: TestWorkspace, +): Promise> => + JSON.parse(await readFile(configPath(workspace), 'utf8')) as Record< + string, + unknown + >; + +const envFor = (workspace: TestWorkspace): NodeJS.ProcessEnv => ({ + ...process.env, + HOME: workspace.homeDir, + PATH: `${workspace.binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`, + SPOON_INFISICAL_LOCAL_FILE: workspace.localFile, +}); + +const runEnsure = (workspace: TestWorkspace) => + spawnSync(scriptPath, ['ensure'], { + encoding: 'utf8', + env: envFor(workspace), + }); + +const writeLocalEmail = async (workspace: TestWorkspace, emailLine: string) => { + await mkdir(path.dirname(workspace.localFile), { recursive: true }); + await writeFile(workspace.localFile, `${emailLine}\n`); +}; + +const twoAccountConfig = { + loggedInUsers: [ + { email: 'work@example.com', domain: 'https://app.infisical.com' }, + { email: 'home@example.com', domain: 'https://infisical.gbrown.org' }, + ], + loggedInUserEmail: 'work@example.com', + LoggedInUserDomain: 'https://app.infisical.com', +}; + +beforeEach(() => { + workspaces = []; +}); + +afterEach(async () => { + await Promise.all( + workspaces.map((workspace) => + rm(path.dirname(workspace.homeDir), { force: true, recursive: true }), + ), + ); +}); + +describe('infisical-account', () => { + test('single account no-ops without local file', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, { + loggedInUsers: [ + { email: 'work@example.com', domain: 'https://app.infisical.com' }, + ], + loggedInUserEmail: 'work@example.com', + LoggedInUserDomain: 'https://app.infisical.com', + }); + + const result = runEnsure(workspace); + const config = await readConfig(workspace); + + expect(result.status).toBe(0); + expect(config.loggedInUserEmail).toBe('work@example.com'); + expect(config.LoggedInUserDomain).toBe('https://app.infisical.com'); + }); + + test('multiple accounts require local project config', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, twoAccountConfig); + + const result = runEnsure(workspace); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('.local/infisical.env'); + expect(result.stderr).toContain('work@example.com'); + expect(result.stderr).toContain('home@example.com'); + }); + + test('multiple accounts switch to configured email', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, twoAccountConfig); + await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com'); + + const result = runEnsure(workspace); + const config = await readConfig(workspace); + + expect(result.status).toBe(0); + expect(config.loggedInUserEmail).toBe('home@example.com'); + expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org'); + }); + + test('configured email missing from local accounts fails clearly', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, twoAccountConfig); + await writeLocalEmail(workspace, 'INFISICAL_EMAIL=missing@example.com'); + + const result = runEnsure(workspace); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + 'not logged in locally: missing@example.com', + ); + }); + + test.each([ + 'INFISICAL_EMAIL="home@example.com"', + "INFISICAL_EMAIL='home@example.com'", + ])('quoted email parses correctly: %s', async (line) => { + const workspace = await createWorkspace(); + await writeConfig(workspace, twoAccountConfig); + await writeLocalEmail(workspace, line); + + const result = runEnsure(workspace); + const config = await readConfig(workspace); + + expect(result.status).toBe(0); + expect(config.loggedInUserEmail).toBe('home@example.com'); + }); + + test('empty email fails clearly', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, twoAccountConfig); + await writeLocalEmail(workspace, 'INFISICAL_EMAIL='); + + const result = runEnsure(workspace); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + '.local/infisical.env must contain INFISICAL_EMAIL', + ); + }); + + test('corrupt config fails clearly', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, '{not-json'); + + const result = runEnsure(workspace); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + 'Infisical config is invalid or missing loggedInUsers', + ); + }); + + test('concurrent ensure calls do not corrupt config', async () => { + const workspace = await createWorkspace(); + await writeConfig(workspace, twoAccountConfig); + await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com'); + + const run = () => + new Promise<{ status: number | null; stderr: string }>((resolve) => { + const child = spawn(scriptPath, ['ensure'], { env: envFor(workspace) }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + child.on('close', (status) => { + resolve({ status, stderr }); + }); + }); + + const [first, second] = await Promise.all([run(), run()]); + const config = await readConfig(workspace); + + expect(first).toEqual({ status: 0, stderr: '' }); + expect(second).toEqual({ status: 0, stderr: '' }); + expect(config.loggedInUserEmail).toBe('home@example.com'); + expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org'); + }); +}); diff --git a/apps/agent-worker/tsconfig.json b/apps/agent-worker/tsconfig.json index eba6a0f..c400508 100644 --- a/apps/agent-worker/tsconfig.json +++ b/apps/agent-worker/tsconfig.json @@ -4,6 +4,6 @@ "lib": ["ES2022", "DOM"], "types": ["node"] }, - "include": ["src", "eslint.config.ts", "vitest.config.ts"], + "include": ["src", "tests", "eslint.config.ts", "vitest.config.ts"], "exclude": ["node_modules"] } diff --git a/scripts/export-env b/scripts/export-env index 3132ef9..e75d416 100755 --- a/scripts/export-env +++ b/scripts/export-env @@ -22,6 +22,7 @@ fi [ -f "$ROOT_DIR/.infisical.json" ] || { echo "export-env: run 'infisical init' in this repository." >&2; exit 1; } command -v infisical >/dev/null 2>&1 || { echo "export-env: Infisical CLI is required." >&2; exit 1; } +"$ROOT_DIR/scripts/infisical-account" ensure (cd "$ROOT_DIR" && infisical export --env="$INFISICAL_ENV" --format=dotenv --silent) || { echo "export-env: failed to export '$INFISICAL_ENV'; check login and project access." >&2 diff --git a/scripts/infisical-account b/scripts/infisical-account new file mode 100755 index 0000000..4ea04c9 --- /dev/null +++ b/scripts/infisical-account @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +LOCAL_FILE="${SPOON_INFISICAL_LOCAL_FILE:-$ROOT_DIR/.local/infisical.env}" +INFISICAL_DIR="${HOME}/.infisical" +CONFIG_FILE="$INFISICAL_DIR/infisical-config.json" +LOCK_FILE="$INFISICAL_DIR/.switch.lock" + +usage() { + printf 'usage: infisical-account \n' >&2 + exit 2 +} + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + printf 'infisical-account: required command not found: %s\n' "$command_name" >&2 + exit 1 + fi +} + +require_dependencies() { + require_command infisical + require_command jq + require_command flock +} + +require_config() { + if [[ ! -f "$CONFIG_FILE" ]]; then + printf 'infisical-account: Infisical config not found at ~/.infisical/infisical-config.json.\n' >&2 + printf 'Run: infisical login\n' >&2 + exit 1 + fi +} + +logged_in_user_count() { + jq -er ' + if (.loggedInUsers | type) == "array" then + .loggedInUsers | length + else + error("loggedInUsers missing") + end + ' "$CONFIG_FILE" 2>/dev/null || { + printf 'infisical-account: Infisical config is invalid or missing loggedInUsers.\n' >&2 + printf 'Run: infisical login\n' >&2 + exit 1 + } +} + +print_logged_in_accounts() { + jq -r '(.loggedInUsers // [])[] | " - \(.email)"' "$CONFIG_FILE" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +strip_matching_quotes() { + local value="$1" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} + +read_project_email() { + if [[ ! -f "$LOCAL_FILE" ]]; then + printf 'infisical-account: multiple Infisical users are logged in, but .local/infisical.env is missing.\n\n' >&2 + printf 'Create it with:\n' >&2 + printf ' mkdir -p .local\n' >&2 + printf ' printf "INFISICAL_EMAIL=you@example.com\\n" > .local/infisical.env\n\n' >&2 + printf 'Logged-in accounts:\n' >&2 + print_logged_in_accounts >&2 + exit 1 + fi + + local email="" + local line key value + while IFS= read -r line || [[ -n "$line" ]]; do + line="$(trim "$line")" + case "$line" in + ''|'#'*) continue ;; + esac + key="$(trim "${line%%=*}")" + value="${line#*=}" + if [[ "$key" == "$line" ]]; then + continue + fi + value="$(strip_matching_quotes "$(trim "$value")")" + if [[ "$key" == "INFISICAL_EMAIL" ]]; then + email="$value" + break + fi + done < "$LOCAL_FILE" + + if [[ -z "$email" ]]; then + printf 'infisical-account: .local/infisical.env must contain INFISICAL_EMAIL=you@example.com.\n' >&2 + exit 1 + fi + + printf '%s' "$email" +} + +domain_for_email() { + local email="$1" + jq -er --arg email "$email" ' + first((.loggedInUsers // [])[] | select(.email == $email) | .domain) + ' "$CONFIG_FILE" +} + +switch_to_email() { + local email="$1" + local domain + domain="$(domain_for_email "$email")" || { + printf 'infisical-account: configured Infisical user is not logged in locally: %s\n' "$email" >&2 + printf 'Run: infisical login\n' >&2 + exit 1 + } + + local tmp + tmp="$(mktemp "$INFISICAL_DIR/infisical-config.XXXXXX")" + jq --arg email "$email" --arg domain "$domain" ' + .loggedInUserEmail = $email + | .LoggedInUserDomain = $domain + ' "$CONFIG_FILE" > "$tmp" + chmod 600 "$tmp" + mv "$tmp" "$CONFIG_FILE" +} + +ensure_account() { + require_dependencies + require_config + + ( + flock 9 + local count + count="$(logged_in_user_count)" + if [[ "$count" -eq 0 ]]; then + printf 'infisical-account: no local Infisical users are logged in.\n' >&2 + printf 'Run: infisical login\n' >&2 + exit 1 + fi + if [[ "$count" -eq 1 ]]; then + exit 0 + fi + + local email + email="$(read_project_email)" + switch_to_email "$email" + ) 9>"$LOCK_FILE" +} + +status() { + ensure_account + local count configured active + count="$(logged_in_user_count)" + configured="not required" + if [[ -f "$LOCAL_FILE" ]]; then + configured="$(read_project_email 2>/dev/null || printf 'invalid')" + fi + active="$(jq -r '.loggedInUserEmail // "unknown"' "$CONFIG_FILE")" + + printf 'Infisical accounts logged in: %s\n' "$count" + printf 'Configured project email: %s\n' "$configured" + printf 'Active account after ensure: %s\n' "$active" +} + +case "${1:-}" in + ensure) ensure_account ;; + status) status ;; + *) usage ;; +esac