diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2eb83bd2..bc171c8d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -96,6 +96,33 @@ pnpm test:watch - **Strict mode** enabled - **JSX:** react-jsx (for Ink/React dashboard) +## Output Mode vs Interaction Mode + +The CLI separates two axes: + +| Axis | Question | API | +| -------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------- | +| **Output mode** | How should output be formatted? | `isJsonMode()` from `src/utils/output.ts` | +| **Interaction mode** | Who is driving the CLI? | `isHumanMode()`, `isAgentMode()`, `isCiMode()`, `isPromptAllowed()` from `src/utils/interaction-mode.ts` | + +Guidelines for new code: + +- Use `isJsonMode()` **only** to choose between structured JSON and human-formatted output. Do not use it to decide whether to prompt, open a browser, or skip a confirmation. +- Use `isPromptAllowed()` (== `isHumanMode()`) before any clack prompt or interactive flow. +- Use `isAgentMode()` to add agent-specific recovery hints, manual-fallback wording, or host-execution warnings. +- Use `isCiMode()` to refuse browser-based flows and to prefer terse failures over recovery handoff text. +- For destructive operations, require an explicit `--yes`/`--force` flag whenever `!isPromptAllowed()` regardless of output mode. +- For `auth_required` and other deterministic failures, attach recovery metadata via `src/utils/recovery-hints.ts` so agents can parse `error.recovery.hints[]`. + +Legacy compatibility — do not regress these: + +- `WORKOS_NO_PROMPT=1` keeps mapping to agent interaction behavior **and** JSON output (legacy alias). +- `WORKOS_FORCE_TTY=1` only affects output mode (forces human). It must not change interaction mode. +- Non-TTY stdout still defaults output to JSON and interaction to agent. +- `isNonInteractiveEnvironment()` from `src/utils/environment.ts` is a thin wrapper over `!isHumanMode()` kept for backward compatibility. Prefer the explicit interaction-mode predicates in new code. + +The full backwards-compat matrix lives in `src/utils/mode-compatibility.spec.ts`. + ## Making Changes ### Adding a New Framework diff --git a/README.md b/README.md index 77257b5a..60c36fb9 100644 --- a/README.md +++ b/README.md @@ -584,7 +584,18 @@ workos --help --json | jq '.commands[].name' ## Scripting & Automation -The CLI auto-detects non-TTY environments (piped output, CI, coding agents) and switches to machine-friendly behavior. No flags required — just pipe it. +The CLI separates **output mode** from **interaction mode**: + +- `--json` (or non-TTY auto-detection) controls **output formatting** only. +- `--mode human|agent|ci` (or `WORKOS_MODE=...`) controls **interaction behavior** — prompts, browser launch, host trust, destructive confirmation. + +For coding agents, set both axes explicitly: + +```bash +WORKOS_MODE=agent workos doctor --json --skip-ai +``` + +The CLI also auto-detects non-TTY environments (piped output, CI, coding agents) and falls back to machine-friendly defaults. No flags are required — just pipe it — but explicit mode is recommended for agents. ### JSON Output @@ -605,6 +616,30 @@ workos org list 2>&1 # → { "error": { "code": "no_api_key", "message": "No API key configured..." } } ``` +### Agent Mode + +When a coding agent drives the CLI, set agent mode explicitly so behavior is deterministic regardless of TTY: + +```bash +WORKOS_MODE=agent workos doctor --json --skip-ai +WORKOS_MODE=agent workos install --api-key ... --client-id ... +``` + +In agent mode the CLI: + +- Never prompts. Missing required arguments fail with structured errors instead of opening prompts. +- Treats browser launch as best-effort. Auth flows always print the manual URL and code. +- Probes host capabilities (home directory, keychain, browser launch). Host failures emit a `HOST_EXECUTION_UNTRUSTED` issue from `workos doctor` so agents can recognize sandboxed runs. +- Requires explicit confirmation flags (e.g. `--yes`, `--force`) for destructive operations. + +In `ci` mode the CLI additionally refuses browser-based auth flows and prefers terse failures over recovery handoff text. + +Legacy compatibility: + +- `WORKOS_NO_PROMPT=1` continues to work and is treated as agent interaction behavior plus JSON output. +- `WORKOS_FORCE_TTY=1` continues to force human **output** mode but does not change interaction mode. +- Non-TTY without an explicit mode still defaults output to JSON and interaction to agent. + ### Headless Installer In non-TTY, the installer streams progress as NDJSON (one JSON object per line): @@ -632,8 +667,9 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n | ------------------------ | --------------------------------------------------------- | | `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | | `WORKOS_API_BASE_URL` | Override API base URL (set automatically by `workos dev`) | -| `WORKOS_NO_PROMPT=1` | Force non-interactive mode + JSON output | -| `WORKOS_FORCE_TTY=1` | Force interactive mode even when piped | +| `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` | +| `WORKOS_NO_PROMPT=1` | Legacy alias: agent interaction behavior + JSON output | +| `WORKOS_FORCE_TTY=1` | Force human (non-JSON) **output** mode even when piped | | `WORKOS_TELEMETRY=false` | Disable telemetry | ### Command Discovery diff --git a/src/bin.ts b/src/bin.ts index 39508862..213a4379 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -27,22 +27,43 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { process.exit(1); } -import { isNonInteractiveEnvironment } from './utils/environment.js'; -import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.js'; +import { + InvalidInteractionModeError, + isPromptAllowed, + resolveInteractionMode, + setInteractionMode, +} from './utils/interaction-mode.js'; +import { + resolveEffectiveOutputMode, + resolveOutputMode, + setOutputMode, + isJsonMode, + outputJson, + exitWithError, +} from './utils/output.js'; import clack from './utils/clack.js'; import { registerSubcommand } from './utils/register-subcommand.js'; // Resolve output mode early from raw argv (before yargs parses) const rawArgs = hideBin(process.argv); const hasJsonFlag = rawArgs.includes('--json'); -setOutputMode(resolveOutputMode(hasJsonFlag)); +const baseOutputMode = resolveOutputMode(hasJsonFlag); +setOutputMode(baseOutputMode); +try { + const interaction = resolveInteractionMode({ argv: rawArgs }); + setInteractionMode(interaction); + setOutputMode(resolveEffectiveOutputMode(baseOutputMode, interaction)); +} catch (error) { + if (error instanceof InvalidInteractionModeError) { + exitWithError({ code: 'invalid_mode', message: error.message }); + } + throw error; +} // Intercept --help --json before yargs parses (yargs exits on --help) if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { - const { buildCommandTree } = await import('./utils/help-json.js'); - const commandAliases: Record = { org: 'organization' }; - const rawCommand = rawArgs.find((a) => !a.startsWith('-')); - const command = rawCommand ? (commandAliases[rawCommand] ?? rawCommand) : undefined; + const { buildCommandTree, extractHelpJsonCommand } = await import('./utils/help-json.js'); + const command = extractHelpJsonCommand(rawArgs); outputJson(buildCommandTree(command)); process.exit(0); } @@ -171,8 +192,8 @@ const installerOptions = { }, }; -// Check for updates (blocks up to 500ms, skip in JSON mode to keep stdout clean) -if (!isJsonMode()) await checkForUpdates(); +// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean) +if (!isJsonMode() && isPromptAllowed()) await checkForUpdates(); yargs(rawArgs) .parserConfiguration({ 'populate--': true }) @@ -183,6 +204,12 @@ yargs(rawArgs) describe: 'Output results as JSON (auto-enabled in non-TTY)', global: true, }) + .option('mode', { + type: 'string', + choices: ['human', 'agent', 'ci'] as const, + describe: 'Interaction mode: human, coding agent, or CI automation', + global: true, + }) .middleware(async (argv) => { // Warn about unclaimed environments before management commands. // Excluded: auth/claim/install/dashboard handle their own credential flows; @@ -395,7 +422,7 @@ yargs(rawArgs) 'Switch active environment', (y) => y.positional('name', { type: 'string', describe: 'Environment name' }), async (argv) => { - if (!argv.name && isNonInteractiveEnvironment()) { + if (!argv.name && !isPromptAllowed()) { exitWithError({ code: 'missing_args', message: 'Environment name required. Usage: workos env switch ', @@ -2370,8 +2397,8 @@ yargs(rawArgs) 'WorkOS AuthKit CLI', (yargs) => yargs.options(insecureStorageOption), async (argv) => { - // Non-TTY: show help - if (isNonInteractiveEnvironment()) { + // Non-human modes: show help instead of prompting + if (!isPromptAllowed()) { yargs(rawArgs).showHelp(); return; } diff --git a/src/commands/api/index.spec.ts b/src/commands/api/index.spec.ts index 8d16f840..0840de35 100644 --- a/src/commands/api/index.spec.ts +++ b/src/commands/api/index.spec.ts @@ -1,4 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { Catalog } from './catalog.js'; import type { ApiResponse } from './request.js'; @@ -69,12 +72,8 @@ vi.mock('../../utils/clack.js', () => ({ }, })); -vi.mock('../../utils/environment.js', () => ({ - isNonInteractiveEnvironment: vi.fn(() => false), -})); - const { setOutputMode } = await import('../../utils/output.js'); -const { isNonInteractiveEnvironment } = await import('../../utils/environment.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../../utils/interaction-mode.js'); const { runApiInteractive, runApiLs, runApiRequest } = await import('./index.js'); function buildResponse(overrides: Partial = {}): ApiResponse { @@ -94,6 +93,7 @@ describe('runApiInteractive', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; stderrOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -112,11 +112,11 @@ describe('runApiInteractive', () => { setOutputMode('human'); }); - it('prints usage instructions when stdin/stdout is non-interactive', async () => { + it('prints usage instructions when interaction mode is agent', async () => { setOutputMode('human'); - vi.mocked(isNonInteractiveEnvironment).mockReturnValueOnce(true); - await runApiInteractive(); - expect(consoleOutput.join('\n')).toContain('Interactive mode requires a TTY'); + setInteractionMode({ mode: 'agent', source: 'env' }); + await expect(runApiInteractive()).rejects.toThrow(/__exit__:1/); + expect(stderrOutput.join('\n')).toContain('Interactive API mode requires human mode'); }); it('emits a structured tty_required error in JSON mode when non-interactive', async () => { @@ -138,10 +138,8 @@ describe('runApiInteractive', () => { it('refuses to enter interactive mode in JSON mode even when a TTY is present', async () => { setOutputMode('json'); - // Default mock returns false (TTY present); JSON mode must short-circuit - // before isNonInteractiveEnvironment() is even called. + // Default interaction mode is human; JSON mode must still short-circuit. await expect(runApiInteractive()).rejects.toThrow(/__exit__:1/); - expect(isNonInteractiveEnvironment).not.toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(1); expect(consoleOutput).toEqual([]); const errorLine = stderrOutput.find((line) => { @@ -161,6 +159,7 @@ describe('runApiLs', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -215,6 +214,7 @@ describe('runApiRequest', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; stderrOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -367,14 +367,14 @@ describe('runApiRequest', () => { expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', body: '' })); }); - it('refuses mutating requests without --yes in non-interactive human mode', async () => { + it('refuses mutating requests without --yes in agent mode with human output', async () => { setOutputMode('human'); - vi.mocked(isNonInteractiveEnvironment).mockReturnValueOnce(true); + setInteractionMode({ mode: 'agent', source: 'env' }); await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:1/); expect(exitSpy).toHaveBeenCalledWith(1); expect(mockApiRequest).not.toHaveBeenCalled(); expect(mockConfirm).not.toHaveBeenCalled(); - expect(stderrOutput.some((l) => l.includes('Refusing to POST'))).toBe(true); + expect(stderrOutput.some((l) => l.includes('agent mode require --yes'))).toBe(true); }); it('exits with confirmation_required in JSON mode when a mutating request lacks --yes', async () => { @@ -392,6 +392,74 @@ describe('runApiRequest', () => { } }); expect(errorLine).toBeDefined(); + const parsed = JSON.parse(errorLine!); + expect(parsed.error.recovery.hints[0].command).toBe("workos api /organizations --method POST '--data={}' --yes"); + }); + + it('uses equals-form data flags so leading hyphens are preserved in confirmation recovery commands', async () => { + setOutputMode('json'); + await expect(runApiRequest('/organizations', { method: 'POST', data: '-x' })).rejects.toThrow(/__exit__:1/); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'confirmation_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + const parsed = JSON.parse(errorLine!); + expect(parsed.error.recovery.hints[0].command).toBe('workos api /organizations --method POST --data=-x --yes'); + }); + + it('preserves --file in confirmation recovery commands', async () => { + setOutputMode('json'); + const dir = mkdtempSync(join(tmpdir(), 'workos-api-')); + const file = join(dir, 'body.json'); + writeFileSync(file, '{"name":"Acme"}'); + try { + await expect(runApiRequest('/organizations', { method: 'PATCH', file })).rejects.toThrow(/__exit__:1/); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'confirmation_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + const parsed = JSON.parse(errorLine!); + expect(parsed.error.recovery.hints[0].command).toBe( + `workos api /organizations --method PATCH --file=${file} --yes`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('omits confirmation recovery commands for stdin bodies', async () => { + setOutputMode('json'); + const stdinBody = (async function* () { + yield Buffer.from('{"name":"Acme"}'); + })(); + const originalStdin = process.stdin; + Object.defineProperty(process, 'stdin', { value: stdinBody, configurable: true }); + try { + await expect(runApiRequest('/organizations', { method: 'POST', file: '-' })).rejects.toThrow(/__exit__:1/); + } finally { + Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true }); + } + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'confirmation_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + const parsed = JSON.parse(errorLine!); + expect(parsed.error.recovery.hints[0].command).toBeUndefined(); }); it('exits with empty_stdin_body when --file - is used and stdin is empty', async () => { diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index a81675a0..0f60b932 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -4,7 +4,9 @@ import { loadCatalog, endpointsByTag } from './catalog.js'; import { apiRequest } from './request.js'; import { resolveApiBaseUrl } from '../../lib/api-key.js'; import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js'; -import { isNonInteractiveEnvironment } from '../../utils/environment.js'; +import { isCiMode, isPromptAllowed } from '../../utils/interaction-mode.js'; +import { confirmationRecovery } from '../../utils/recovery-hints.js'; +import { formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; import { colorMethod, printResponse } from './format.js'; export { colorMethod } from './format.js'; @@ -35,17 +37,12 @@ export async function runApiInteractive(options?: { apiKey?: string }): Promise< }); } - if (isNonInteractiveEnvironment()) { - console.log( - 'Interactive mode requires a TTY.\n\n' + - 'Usage:\n' + - ' workos api Make an API request\n' + - ' workos api ls [filter] List available endpoints\n' + - '\nExample:\n' + - ' workos api /user_management/users\n' + - ' workos api ls users', - ); - return; + if (!isPromptAllowed()) { + exitWithError({ + code: 'tty_required', + message: + 'Interactive API mode requires human mode. Usage: workos api or workos api ls [filter]. Example: workos api /user_management/users', + }); } const { apiInteractive } = await import('./interactive.js'); @@ -126,16 +123,23 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions } if (MUTATING_METHODS.has(method) && !options.yes) { + const confirmCommand = buildConfirmationCommand(endpoint, method, options); + if (!isPromptAllowed()) { + exitWithError({ + code: 'confirmation_required', + message: isCiMode() + ? `Mutating requests in CI mode require --yes. Refusing to ${method} ${endpoint}.` + : `Mutating requests in agent mode require --yes. Refusing to ${method} ${endpoint}.`, + recovery: confirmationRecovery(confirmCommand), + }); + } if (isJsonMode()) { exitWithError({ code: 'confirmation_required', message: 'Mutating requests in JSON mode require --yes to keep stdout machine-readable.', + recovery: confirmationRecovery(confirmCommand), }); } - if (isNonInteractiveEnvironment()) { - console.error(`Refusing to ${method} ${endpoint} without --yes in a non-interactive environment.`); - process.exit(1); - } const clack = (await import('../../utils/clack.js')).default; console.log(`\n${chalk.yellow('About to')} ${method} ${endpoint}`); if (hasBody) prettyPrint(body); @@ -165,6 +169,25 @@ function normalizePath(path: string): string { return path; } +function buildConfirmationCommand(endpoint: string, method: string, options: ApiCommandOptions): string | undefined { + if (options.apiKey || options.file === '-') { + return undefined; + } + + const args = ['api', endpoint, '--method', method]; + if (options.data !== undefined) { + args.push(`--data=${options.data}`); + } + if (options.file) { + args.push(`--file=${options.file}`); + } + if (options.include) { + args.push('--include'); + } + args.push('--yes'); + return formatWorkOSCommandArgs(args); +} + async function resolveBody(options: ApiCommandOptions): Promise { if (options.data !== undefined) return options.data; if (options.file) { diff --git a/src/commands/claim.spec.ts b/src/commands/claim.spec.ts index d5f63464..58605d02 100644 --- a/src/commands/claim.spec.ts +++ b/src/commands/claim.spec.ts @@ -40,6 +40,13 @@ vi.mock('../utils/output.js', () => ({ exitWithError: (...args: unknown[]) => mockExitWithError(...args), })); +const mockIsAgentMode = vi.fn(() => false); +const mockIsCiMode = vi.fn(() => false); +vi.mock('../utils/interaction-mode.js', () => ({ + isAgentMode: () => mockIsAgentMode(), + isCiMode: () => mockIsCiMode(), +})); + // Mock helper-functions vi.mock('../lib/helper-functions.js', () => ({ sleep: vi.fn((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), @@ -73,6 +80,8 @@ describe('claim command', () => { beforeEach(() => { vi.clearAllMocks(); jsonMode = false; + mockIsAgentMode.mockReturnValue(false); + mockIsCiMode.mockReturnValue(false); vi.useFakeTimers({ shouldAdvanceTime: true }); }); @@ -194,6 +203,32 @@ describe('claim command', () => { expect(mockSpinner.start).not.toHaveBeenCalled(); }); + it('refuses claim browser flow in CI mode', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }); + mockIsUnclaimedEnvironment.mockReturnValue(true); + mockCreateClaimNonce.mockResolvedValueOnce({ + nonce: 'nonce_abc123', + alreadyClaimed: false, + }); + mockIsCiMode.mockReturnValue(true); + + await expect(runClaim()).rejects.toThrow('exitWithError'); + + expect(mockExitWithError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'unsupported_in_ci', + details: expect.objectContaining({ claimUrl: expect.stringContaining('nonce_abc123') }), + }), + ); + expect(mockOpen).not.toHaveBeenCalled(); + }); + it('outputs JSON for already-claimed in JSON mode', async () => { jsonMode = true; mockGetActiveEnvironment.mockReturnValue({ @@ -394,10 +429,7 @@ describe('claim command', () => { }); // Poll returns claimed immediately mockCreateClaimNonce.mockResolvedValueOnce({ alreadyClaimed: true }); - // Browser open throws synchronously (open() is called without await) - mockOpen.mockImplementationOnce(() => { - throw new Error('No browser available'); - }); + mockOpen.mockRejectedValueOnce(new Error('No browser available')); const claimPromise = runClaim(); await vi.advanceTimersByTimeAsync(6_000); diff --git a/src/commands/claim.ts b/src/commands/claim.ts index 30f36c3c..871ff484 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -10,8 +10,10 @@ import open from 'opn'; import clack from '../utils/clack.js'; import { getActiveEnvironment, isUnclaimedEnvironment, markEnvironmentClaimed } from '../lib/config-store.js'; import { createClaimNonce, UnclaimedEnvApiError } from '../lib/unclaimed-env-api.js'; +import { observeHostFailure } from '../lib/host-probe.js'; import { logInfo, logError } from '../utils/debug.js'; import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; +import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; import { sleep } from '../lib/helper-functions.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; @@ -61,12 +63,29 @@ export async function runClaim(): Promise { return; } + if (isCiMode()) { + exitWithError({ + code: 'unsupported_in_ci', + message: 'Environment claim requires opening the claim URL outside CI.', + details: { claimUrl, nonce: result.nonce }, + }); + } + clack.log.info(`Open this URL to claim your environment:\n\n ${claimUrl}`); try { - open(claimUrl, { wait: false }); - clack.log.info('Browser opened automatically'); + await open(claimUrl, { wait: false }); + if (isAgentMode()) { + clack.log.info('Browser launch attempted. If it did not open on the host, use the URL above.'); + } else { + clack.log.info('Browser opened automatically'); + } } catch (openError) { + observeHostFailure('browser-launch', openError, { + operation: 'open', + target: claimUrl, + label: 'environment claim browser', + }); logError('[claim] Failed to open browser:', openError instanceof Error ? openError.message : String(openError)); clack.log.info('Could not open browser — open the URL above manually.'); } diff --git a/src/commands/connection.spec.ts b/src/commands/connection.spec.ts index f76a7257..13fe7997 100644 --- a/src/commands/connection.spec.ts +++ b/src/commands/connection.spec.ts @@ -24,13 +24,8 @@ vi.mock('../utils/clack.js', () => ({ }, })); -// Mock environment detection -vi.mock('../utils/environment.js', () => ({ - isNonInteractiveEnvironment: vi.fn(() => false), -})); - const { setOutputMode } = await import('../utils/output.js'); -const { isNonInteractiveEnvironment } = await import('../utils/environment.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const { runConnectionList, runConnectionGet, runConnectionDelete } = await import('./connection.js'); @@ -51,6 +46,7 @@ describe('connection commands', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; stderrOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -66,6 +62,7 @@ describe('connection commands', () => { afterEach(() => { vi.restoreAllMocks(); setOutputMode('human'); + resetInteractionModeForTests(); }); describe('runConnectionList', () => { @@ -151,13 +148,14 @@ describe('connection commands', () => { expect(mockSdk.sso.deleteConnection).not.toHaveBeenCalled(); }); - it('requires --force in non-interactive mode', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + it('requires --force in agent mode', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }); await expect(runConnectionDelete('conn_01ABC', {}, 'sk_test')).rejects.toThrow('process.exit(1)'); expect(mockSdk.sso.deleteConnection).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/src/commands/connection.ts b/src/commands/connection.ts index 45081f26..7053f080 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -4,7 +4,7 @@ import { createWorkOSClient } from '../lib/workos-client.js'; import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import clack from '../utils/clack.js'; const handleApiError = createApiErrorHandler('Connection'); @@ -99,10 +99,12 @@ export async function runConnectionDelete( baseUrl?: string, ): Promise { if (!options.force) { - if (isNonInteractiveEnvironment()) { + if (!isPromptAllowed()) { exitWithError({ code: 'confirmation_required', - message: 'Destructive operation requires --force flag in non-interactive mode.', + message: isCiMode() + ? 'Destructive operation requires --force flag in CI mode.' + : 'Destructive operation requires --force flag in agent mode.', }); } diff --git a/src/commands/debug.spec.ts b/src/commands/debug.spec.ts index 2f656eb2..96312059 100644 --- a/src/commands/debug.spec.ts +++ b/src/commands/debug.spec.ts @@ -64,10 +64,10 @@ vi.mock('../utils/clack.js', () => ({ }, })); -// Mock environment -const mockIsNonInteractive = vi.fn(() => false); -vi.mock('../utils/environment.js', () => ({ - isNonInteractiveEnvironment: () => mockIsNonInteractive(), +// Mock interaction mode +const mockIsPromptAllowed = vi.fn(() => true); +vi.mock('../utils/interaction-mode.js', () => ({ + isPromptAllowed: () => mockIsPromptAllowed(), })); const { runDebugState, runDebugReset, runDebugSimulate, runDebugToken, runDebugEnv } = await import('./debug.js'); @@ -101,6 +101,7 @@ describe('debug commands', () => { beforeEach(() => { vi.clearAllMocks(); jsonMode = false; + mockIsPromptAllowed.mockReturnValue(true); consoleOutput = []; consoleErrors = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -277,11 +278,11 @@ describe('debug commands', () => { expect(mockClearConfig).toHaveBeenCalled(); }); - it('errors in non-interactive mode without --force', async () => { - mockIsNonInteractive.mockReturnValue(true); + it('errors in agent/CI mode without --force', async () => { + mockIsPromptAllowed.mockReturnValue(false); await expect(runDebugReset({ force: false, credentialsOnly: false, configOnly: false })).rejects.toThrow( - 'Use --force to reset in non-interactive mode', + 'Use --force to reset in agent or CI mode', ); }); diff --git a/src/commands/debug.ts b/src/commands/debug.ts index c523d402..1ca9bc6b 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -18,7 +18,7 @@ import { diagnoseConfig, } from '../lib/config-store.js'; import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { isPromptAllowed } from '../utils/interaction-mode.js'; function maskSecret(value: string | undefined): string | undefined { if (!value) return undefined; @@ -193,10 +193,10 @@ export async function runDebugReset({ const targets = [clearCreds && 'credentials', clearConf && 'config'].filter(Boolean).join(' and '); if (!force) { - if (isNonInteractiveEnvironment()) { + if (!isPromptAllowed()) { exitWithError({ code: 'non_interactive_reset', - message: 'Use --force to reset in non-interactive mode', + message: 'Use --force to reset in agent or CI mode', }); } @@ -322,8 +322,9 @@ interface EnvVarInfo { const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ { name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' }, + { name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' }, { name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' }, - { name: 'WORKOS_NO_PROMPT', effect: 'Forces non-interactive/JSON mode' }, + { name: 'WORKOS_NO_PROMPT', effect: 'Legacy compatibility alias for agent interaction behavior and JSON output' }, { name: 'WORKOS_TELEMETRY', effect: 'Set to "false" to disable telemetry' }, { name: 'WORKOS_API_URL', effect: 'Overrides API base URL (default: https://api.workos.com)' }, { name: 'WORKOS_DASHBOARD_URL', effect: 'Overrides dashboard URL (default: https://dashboard.workos.com)' }, diff --git a/src/commands/directory.spec.ts b/src/commands/directory.spec.ts index d09e7c65..c45cfdf7 100644 --- a/src/commands/directory.spec.ts +++ b/src/commands/directory.spec.ts @@ -26,13 +26,8 @@ vi.mock('../utils/clack.js', () => ({ }, })); -// Mock environment detection -vi.mock('../utils/environment.js', () => ({ - isNonInteractiveEnvironment: vi.fn(() => false), -})); - const { setOutputMode } = await import('../utils/output.js'); -const { isNonInteractiveEnvironment } = await import('../utils/environment.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const { runDirectoryList, runDirectoryGet, runDirectoryDelete, runDirectoryListUsers, runDirectoryListGroups } = await import('./directory.js'); @@ -81,6 +76,7 @@ describe('directory commands', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; stderrOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -96,6 +92,7 @@ describe('directory commands', () => { afterEach(() => { vi.restoreAllMocks(); setOutputMode('human'); + resetInteractionModeForTests(); }); describe('runDirectoryList', () => { @@ -181,13 +178,14 @@ describe('directory commands', () => { expect(mockSdk.directorySync.deleteDirectory).not.toHaveBeenCalled(); }); - it('requires --force in non-interactive mode', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + it('requires --force in agent mode', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code})`); }); await expect(runDirectoryDelete('directory_01ABC', {}, 'sk_test')).rejects.toThrow('process.exit(1)'); expect(mockSdk.directorySync.deleteDirectory).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/src/commands/directory.ts b/src/commands/directory.ts index 095304ec..2ca66ac3 100644 --- a/src/commands/directory.ts +++ b/src/commands/directory.ts @@ -3,7 +3,7 @@ import { createWorkOSClient } from '../lib/workos-client.js'; import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import clack from '../utils/clack.js'; const handleApiError = createApiErrorHandler('Directory'); @@ -92,10 +92,12 @@ export async function runDirectoryDelete( baseUrl?: string, ): Promise { if (!options.force) { - if (isNonInteractiveEnvironment()) { + if (!isPromptAllowed()) { exitWithError({ code: 'confirmation_required', - message: 'Destructive operation requires --force flag in non-interactive mode.', + message: isCiMode() + ? 'Destructive operation requires --force flag in CI mode.' + : 'Destructive operation requires --force flag in agent mode.', }); } diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index 978a67a9..8f2de03b 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -42,6 +42,7 @@ vi.mock('node:os', async (importOriginal) => { const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js'); const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const clack = (await import('../utils/clack.js')).default; // Spy on process.exit @@ -53,11 +54,14 @@ describe('env commands', () => { beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), 'env-cmd-test-')); setInsecureConfigStorage(true); + resetInteractionModeForTests(); vi.clearAllMocks(); }); afterEach(() => { clearConfig(); + resetInteractionModeForTests(); + setOutputMode('human'); try { rmdirSync(join(testDir, '.workos'), { recursive: true }); } catch {} @@ -103,6 +107,33 @@ describe('env commands', () => { it('rejects invalid environment name', async () => { await expect(runEnvAdd({ name: 'INVALID NAME', apiKey: 'sk_test' })).rejects.toThrow('process.exit'); }); + + it('requires name and API key in agent mode without prompting', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + await expect(runEnvAdd({ name: 'prod' })).rejects.toThrow('process.exit'); + expect(clack.text).not.toHaveBeenCalled(); + }); + + it('requires name and API key in CI mode without prompting', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + await expect(runEnvAdd({ name: 'prod' })).rejects.toThrow('process.exit'); + expect(clack.text).not.toHaveBeenCalled(); + }); + + it('does not include placeholder commands in missing-args recovery metadata', async () => { + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvAdd({ name: 'prod' })).rejects.toThrow('process.exit'); + const parsed = JSON.parse(errorSpy.mock.calls[0][0]); + expect(parsed.error.recovery.hints[0]).toEqual({ + description: 'Provide environment name and API key as positional arguments.', + }); + } finally { + errorSpy.mockRestore(); + } + }); }); describe('runEnvRemove', () => { diff --git a/src/commands/env.ts b/src/commands/env.ts index ef26f361..cff97105 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -3,7 +3,9 @@ import clack from '../utils/clack.js'; import { getConfig, saveConfig, isUnclaimedEnvironment } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; +import { missingArgsRecovery } from '../utils/recovery-hints.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; @@ -33,8 +35,16 @@ export async function runEnvAdd(options: { if (nameError) { exitWithError({ code: 'invalid_args', message: nameError }); } - } else if (isNonInteractiveEnvironment()) { - exitWithError({ code: 'missing_args', message: 'Name and API key required in non-interactive mode' }); + } else if (!isPromptAllowed()) { + exitWithError({ + code: 'missing_args', + message: isAgentMode() + ? `Name and API key required in agent mode. Example: ${formatWorkOSCommand('env add staging sk_test_xxx --client-id client_xxx')}` + : isCiMode() + ? 'Name and API key required in CI mode.' + : 'Name and API key required when prompting is unavailable.', + recovery: missingArgsRecovery(undefined, 'Provide environment name and API key as positional arguments.'), + }); } else { // Interactive mode const nameResult = await clack.text({ diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts index eaf1c705..dd877bad 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -3,6 +3,25 @@ import { mkdtempSync, rmdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +const mockOpen = vi.fn(); +vi.mock('opn', () => ({ default: (...args: unknown[]) => mockOpen(...args) })); + +class MockDeviceAuthTimeoutError extends Error {} +const mockRequestDeviceCode = vi.fn(); +const mockPollForToken = vi.fn(); +vi.mock('../lib/device-auth.js', () => ({ + requestDeviceCode: (...args: unknown[]) => mockRequestDeviceCode(...args), + pollForToken: (...args: unknown[]) => mockPollForToken(...args), + DeviceAuthTimeoutError: MockDeviceAuthTimeoutError, +})); + +const mockExitWithAuthRequired = vi.fn(() => { + throw new Error('auth_required'); +}); +vi.mock('../utils/exit-codes.js', () => ({ + exitWithAuthRequired: (...args: unknown[]) => mockExitWithAuthRequired(...args), +})); + // Mock debug utilities vi.mock('../utils/debug.js', () => ({ logInfo: vi.fn(), @@ -58,20 +77,42 @@ vi.mock('node:os', async (importOriginal) => { }); const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); -const { provisionStagingEnvironment, installSkillsAfterLogin } = await import('./login.js'); +const { provisionStagingEnvironment, installSkillsAfterLogin, runLogin } = await import('./login.js'); const { autoInstallSkills } = await import('./install-skill.js'); const { isJsonMode } = await import('../utils/output.js'); +const { clearCredentials, setInsecureStorage } = await import('../lib/credentials.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const clackMod = await import('../utils/clack.js'); describe('login', () => { beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), 'login-test-')); setInsecureConfigStorage(true); + setInsecureStorage(true); + resetInteractionModeForTests(); + clearCredentials(); vi.clearAllMocks(); + mockOpen.mockResolvedValue({}); + mockRequestDeviceCode.mockResolvedValue({ + verification_uri: 'https://auth.example.com/device', + verification_uri_complete: 'https://auth.example.com/device?code=ABCD', + user_code: 'ABCD-EFGH', + device_code: 'device_123', + interval: 1, + }); + mockPollForToken.mockResolvedValue({ + accessToken: 'access_token', + expiresAt: Date.now() + 3600000, + userId: 'user_123', + email: 'user@example.com', + refreshToken: 'refresh_token', + }); }); afterEach(() => { + clearCredentials(); clearConfig(); + resetInteractionModeForTests(); try { rmdirSync(join(testDir, '.workos'), { recursive: true }); } catch {} @@ -203,6 +244,29 @@ describe('login', () => { }); }); + describe('runLogin', () => { + it('refuses browser/device auth in CI mode', async () => { + setInteractionMode({ mode: 'ci', source: 'env' }); + + await expect(runLogin()).rejects.toThrow('auth_required'); + + expect(mockExitWithAuthRequired).toHaveBeenCalledWith(expect.stringContaining('CI mode')); + expect(mockRequestDeviceCode).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it('prints manual fallback and attempts browser launch in agent mode', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + const infoSpy = vi.mocked(clackMod.default.log.info); + + await runLogin(); + + expect(mockRequestDeviceCode).toHaveBeenCalledOnce(); + expect(mockOpen).toHaveBeenCalledWith('https://auth.example.com/device?code=ABCD', { wait: false }); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('manual URL')); + }); + }); + describe('installSkillsAfterLogin', () => { it('invokes autoInstallSkills', async () => { vi.mocked(autoInstallSkills).mockResolvedValueOnce(null); diff --git a/src/commands/login.ts b/src/commands/login.ts index 8c1a392e..517268a2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,7 +11,10 @@ import type { CliConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { autoInstallSkills } from './install-skill.js'; import { isJsonMode } from '../utils/output.js'; +import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js'; +import { observeHostFailure } from '../lib/host-probe.js'; /** * Best-effort skill install after a successful auth-login. @@ -110,6 +113,12 @@ export async function runLogin(): Promise { } } + if (isCiMode()) { + exitWithAuthRequired( + 'Browser authentication is not available in CI mode. Set WORKOS_API_KEY or configure credentials before running in CI.', + ); + } + const authkitDomain = getAuthkitDomain(); clack.log.step('Starting authentication...'); @@ -128,10 +137,19 @@ export async function runLogin(): Promise { console.log(`\nEnter code: ${deviceAuth.user_code}\n`); try { - open(deviceAuth.verification_uri_complete); - clack.log.info('Browser opened automatically'); - } catch { - // User can open manually + await open(deviceAuth.verification_uri_complete, { wait: false }); + if (isAgentMode()) { + clack.log.info('Browser launch attempted. If it did not open on the host, use the manual URL and code above.'); + } else { + clack.log.info('Browser opened automatically'); + } + } catch (error) { + observeHostFailure('browser-launch', error, { + operation: 'open', + target: deviceAuth.verification_uri_complete, + label: 'auth login browser', + }); + clack.log.info('Could not open browser — open the URL above manually.'); } const spinner = clack.spinner(); diff --git a/src/doctor/checks/auth-patterns.spec.ts b/src/doctor/checks/auth-patterns.spec.ts index 06173600..4ec7e81f 100644 --- a/src/doctor/checks/auth-patterns.spec.ts +++ b/src/doctor/checks/auth-patterns.spec.ts @@ -648,6 +648,31 @@ describe('checkAuthPatterns', () => { expect(result.findings.find((f) => f.code === 'API_KEY_IN_SOURCE')).toBeDefined(); }); + it('no finding when WorkOS keys appear in test files', async () => { + writeFixtureFile(testDir, 'src/config.spec.ts', 'const apiKey = "sk_test_abc123def456";'); + writeFixtureFile(testDir, 'src/config.test.ts', 'const apiKey = "sk_live_abc123def456";'); + const result = await checkAuthPatterns( + makeOptions(testDir), + makeFramework({ name: 'Express' }), + makeEnv(), + makeSdk({ name: '@workos-inc/node', isAuthKit: false }), + ); + expect(result.findings.find((f) => f.code === 'API_KEY_IN_SOURCE')).toBeUndefined(); + }); + + it('no finding when WorkOS keys appear in test, eval, or fixture directories', async () => { + writeFixtureFile(testDir, 'tests/evals/env-loader.ts', 'const apiKey = "sk_test_abc123def456";'); + writeFixtureFile(testDir, 'src/__fixtures__/config.ts', 'const apiKey = "sk_live_abc123def456";'); + writeFixtureFile(testDir, 'src/__tests__/config.ts', 'const apiKey = "sk_test_abcdef123456";'); + const result = await checkAuthPatterns( + makeOptions(testDir), + makeFramework({ name: 'Express' }), + makeEnv(), + makeSdk({ name: '@workos-inc/node', isAuthKit: false }), + ); + expect(result.findings.find((f) => f.code === 'API_KEY_IN_SOURCE')).toBeUndefined(); + }); + it('no finding when key is only in .env file', async () => { writeFixtureFile(testDir, '.env', 'WORKOS_API_KEY=sk_test_abc123def456'); writeFixtureFile(testDir, 'src/app.ts', 'const key = process.env.WORKOS_API_KEY;'); diff --git a/src/doctor/checks/auth-patterns.ts b/src/doctor/checks/auth-patterns.ts index fbfd8f2c..018a704a 100644 --- a/src/doctor/checks/auth-patterns.ts +++ b/src/doctor/checks/auth-patterns.ts @@ -481,6 +481,14 @@ function checkMissingApiHostname(ctx: CheckContext): AuthPatternFinding[] { // --- Cross-language checks (run for ALL projects, not just JS/AuthKit) --- const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|py|rb|go|java|kt|php|cs|swift|dart)$/; +const SECRET_SOURCE_IGNORED_SEGMENTS = new Set(['__fixtures__', '__tests__', 'evals', 'fixtures', 'test', 'tests']); + +function isIgnoredSecretSourcePath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, '/'); + const parts = normalized.split('/'); + if (parts.some((part) => SECRET_SOURCE_IGNORED_SEGMENTS.has(part))) return true; + return /\.(spec|test)\.[^.]+$/.test(normalized); +} function checkApiKeyInSource(ctx: CheckContext): AuthPatternFinding[] { const API_KEY_PATTERN = /sk_(test|live)_[A-Za-z0-9]{10,}/; @@ -488,6 +496,8 @@ function checkApiKeyInSource(ctx: CheckContext): AuthPatternFinding[] { const findings: AuthPatternFinding[] = []; for (const file of sourceFiles) { + const relativePath = relative(ctx.installDir, file); + if (isIgnoredSecretSourcePath(relativePath)) continue; const content = readFileSafe(file); if (!content) continue; if (API_KEY_PATTERN.test(content)) { @@ -495,7 +505,7 @@ function checkApiKeyInSource(ctx: CheckContext): AuthPatternFinding[] { code: 'API_KEY_IN_SOURCE', severity: 'error', message: `WorkOS API key hardcoded in source file`, - filePath: relative(ctx.installDir, file), + filePath: relativePath, remediation: 'Move the API key to an environment variable (WORKOS_API_KEY) and load it from .env or your secret manager.', }); diff --git a/src/doctor/checks/host-execution.spec.ts b/src/doctor/checks/host-execution.spec.ts new file mode 100644 index 00000000..f7f02433 --- /dev/null +++ b/src/doctor/checks/host-execution.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../utils/interaction-mode.js', () => ({ + getInteractionMode: vi.fn(), +})); + +vi.mock('../../lib/host-probe.js', () => ({ + runHostProbe: vi.fn(), +})); + +import { checkHostExecution } from './host-execution.js'; +import { getInteractionMode } from '../../utils/interaction-mode.js'; +import { runHostProbe } from '../../lib/host-probe.js'; + +describe('checkHostExecution', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('passes without probing in human interaction mode', async () => { + vi.mocked(getInteractionMode).mockReturnValue({ mode: 'human', source: 'default' }); + + const result = await checkHostExecution(); + + expect(result).toEqual({ mode: 'interactive', ok: true, failures: [] }); + expect(runHostProbe).not.toHaveBeenCalled(); + }); + + it('passes when agent-mode host state is reachable', async () => { + vi.mocked(getInteractionMode).mockReturnValue({ mode: 'agent', source: 'env' }); + vi.mocked(runHostProbe).mockResolvedValue({ ok: true, failures: [] }); + + const result = await checkHostExecution(); + + expect(result).toEqual({ mode: 'non-interactive', ok: true, failures: [], warning: undefined }); + expect(runHostProbe).toHaveBeenCalledOnce(); + }); + + it('warns when agent-mode host state is blocked', async () => { + vi.mocked(getInteractionMode).mockReturnValue({ mode: 'agent', source: 'env' }); + vi.mocked(runHostProbe).mockResolvedValue({ + ok: false, + failures: [ + { + capability: 'home-fs', + detail: 'EACCES: permission denied', + operation: 'write', + target: '/Users/test/.workos', + label: 'WorkOS home directory', + }, + ], + }); + + const result = await checkHostExecution(); + + expect(result.ok).toBe(false); + expect(result.warning).toContain('host shell'); + expect(result.failures[0]).toMatchObject({ + capability: 'home-fs', + operation: 'write', + label: 'WorkOS home directory', + }); + }); + + it('warns when CI-mode host state is blocked', async () => { + vi.mocked(getInteractionMode).mockReturnValue({ mode: 'ci', source: 'ci_env' }); + vi.mocked(runHostProbe).mockResolvedValue({ + ok: false, + failures: [{ capability: 'keychain', detail: 'interaction is not allowed' }], + }); + + const result = await checkHostExecution(); + + expect(result).toMatchObject({ + mode: 'non-interactive', + ok: false, + warning: expect.stringContaining('host shell'), + }); + expect(runHostProbe).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/doctor/checks/host-execution.ts b/src/doctor/checks/host-execution.ts new file mode 100644 index 00000000..1c35154d --- /dev/null +++ b/src/doctor/checks/host-execution.ts @@ -0,0 +1,26 @@ +import { getInteractionMode } from '../../utils/interaction-mode.js'; +import { runHostProbe } from '../../lib/host-probe.js'; +import type { HostExecutionInfo } from '../types.js'; + +const HOST_EXECUTION_WARNING = + 'This may be a sandboxed run. Re-run the command on the host shell before trusting auth, config, or API failures.'; + +export async function checkHostExecution(): Promise { + const interactionMode = getInteractionMode(); + + if (interactionMode.mode === 'human') { + return { + mode: 'interactive', + ok: true, + failures: [], + }; + } + + const probe = await runHostProbe(); + return { + mode: 'non-interactive', + ok: probe.ok, + failures: probe.failures, + warning: probe.ok ? undefined : HOST_EXECUTION_WARNING, + }; +} diff --git a/src/doctor/index.ts b/src/doctor/index.ts index 7c3cacb5..5ff26d9a 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -4,6 +4,7 @@ import { checkRuntime } from './checks/runtime.js'; import { checkLanguage } from './checks/language.js'; import { checkEnvironment } from './checks/environment.js'; import { checkConnectivity } from './checks/connectivity.js'; +import { checkHostExecution } from './checks/host-execution.js'; import { checkDashboardSettings, compareRedirectUris } from './checks/dashboard.js'; import { checkAuthPatterns } from './checks/auth-patterns.js'; import { checkAiAnalysis } from './checks/ai-analysis.js'; @@ -13,6 +14,7 @@ import { detectIssues } from './issues.js'; import { formatReport } from './output.js'; import { formatReportAsJson } from './json-output.js'; import { copyToClipboard } from './clipboard.js'; +import { getInteractionMode } from '../utils/interaction-mode.js'; import Chalk from 'chalk'; import type { DoctorOptions, DoctorReport, SkillsRefreshResult } from './types.js'; @@ -65,17 +67,20 @@ export async function maybeRefreshSkills( } export async function runDoctor(options: DoctorOptions): Promise { + const interactionMode = getInteractionMode(); + // Environment check first - loads project's .env/.env.local files // Must run before connectivity so the resolved base URL is available const { info: environment, raw: envRaw } = checkEnvironment(options); // Run remaining checks concurrently - const [sdk, framework, runtime, connectivity, language] = await Promise.all([ + const [sdk, framework, runtime, connectivity, language, hostExecution] = await Promise.all([ checkSdk(options), checkFramework(options), checkRuntime(options), checkConnectivity(options, environment.baseUrl ?? 'https://api.workos.com'), checkLanguage(options.installDir), + checkHostExecution(), ]); let skills = (await checkSkills()) ?? undefined; @@ -93,12 +98,14 @@ export async function runDoctor(options: DoctorOptions): Promise { const earlyIssues = detectIssues({ version: DOCTOR_VERSION, timestamp: '', + interactionMode, project: { path: options.installDir, packageManager: runtime.packageManager }, sdk, language, runtime, framework, environment, + hostExecution, connectivity, skills, }); @@ -129,6 +136,7 @@ export async function runDoctor(options: DoctorOptions): Promise { const partialReport = { version: DOCTOR_VERSION, timestamp: new Date().toISOString(), + interactionMode, project: { path: options.installDir, packageManager: runtime.packageManager, @@ -138,6 +146,7 @@ export async function runDoctor(options: DoctorOptions): Promise { runtime, framework, environment, + hostExecution, connectivity, credentialValidation: dashboardResult.credentialValidation, dashboardSettings: dashboardResult.settings ?? undefined, diff --git a/src/doctor/issues.spec.ts b/src/doctor/issues.spec.ts new file mode 100644 index 00000000..74c29f1e --- /dev/null +++ b/src/doctor/issues.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { detectIssues } from './issues.js'; +import type { DoctorReport } from './types.js'; + +function baseReport(): Omit { + return { + version: '1.0.0', + timestamp: '2026-01-01T00:00:00.000Z', + interactionMode: { mode: 'agent', source: 'env' }, + project: { path: '/tmp/app', packageManager: 'pnpm' }, + sdk: { + name: '@workos-inc/node', + version: '1.0.0', + latest: '1.0.0', + outdated: false, + isAuthKit: false, + language: 'javascript', + }, + language: { name: 'JavaScript/TypeScript', manifestFile: 'package.json' }, + runtime: { nodeVersion: 'v22.0.0', packageManager: 'pnpm', packageManagerVersion: '10.0.0' }, + framework: { name: 'Next.js', version: '15.0.0' }, + environment: { + apiKeyConfigured: true, + apiKeyType: 'staging', + clientId: 'client_123', + redirectUri: 'http://localhost:3000/callback', + cookieDomain: null, + baseUrl: 'https://api.workos.com', + }, + hostExecution: { mode: 'interactive', ok: true, failures: [] }, + connectivity: { apiReachable: true, latencyMs: 42, tlsValid: true }, + }; +} + +describe('detectIssues', () => { + it('adds a warning when host execution is untrusted', () => { + const report = baseReport(); + report.hostExecution = { + mode: 'non-interactive', + ok: false, + warning: 'This may be a sandboxed run.', + failures: [ + { + capability: 'keychain', + detail: 'EACCES: permission denied', + operation: 'read', + target: 'workos-cli/config', + label: 'config keychain entry', + }, + ], + }; + + const issues = detectIssues(report); + + expect(issues).toContainEqual( + expect.objectContaining({ + code: 'HOST_EXECUTION_UNTRUSTED', + severity: 'warning', + remediation: expect.stringContaining('Agent/CI host execution is untrusted'), + details: { failures: report.hostExecution.failures }, + }), + ); + }); +}); diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts index d653251e..d2781e7d 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -58,6 +58,12 @@ export const ISSUE_DEFINITIONS = { remediation: 'Ensure WORKOS_CLIENT_ID matches the environment for your API key', docsUrl: 'https://dashboard.workos.com/configuration', }, + HOST_EXECUTION_UNTRUSTED: { + severity: 'warning' as const, + message: 'Host-only WorkOS state may be unavailable in this shell', + remediation: + 'Agent/CI host execution is untrusted. Re-run this command on the host shell before trusting auth, config, or API failures.', + }, }; export function detectIssues(report: Omit): Issue[] { @@ -97,6 +103,14 @@ export function detectIssues(report: Omit): issues.push({ code: 'COOKIE_DOMAIN_NOT_SET', ...ISSUE_DEFINITIONS.COOKIE_DOMAIN_NOT_SET }); } + if (!report.hostExecution.ok) { + issues.push({ + code: 'HOST_EXECUTION_UNTRUSTED', + ...ISSUE_DEFINITIONS.HOST_EXECUTION_UNTRUSTED, + details: { failures: report.hostExecution.failures }, + }); + } + // Connectivity issues if (!report.connectivity.apiReachable && !report.connectivity.error?.includes('Skipped')) { issues.push({ diff --git a/src/doctor/json-output.spec.ts b/src/doctor/json-output.spec.ts new file mode 100644 index 00000000..c374f928 --- /dev/null +++ b/src/doctor/json-output.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { formatReportAsJson } from './json-output.js'; +import type { DoctorReport } from './types.js'; + +function report(): DoctorReport { + return { + version: '1.0.0', + timestamp: '2026-01-01T00:00:00.000Z', + interactionMode: { mode: 'agent', source: 'env' }, + project: { path: '/tmp/app', packageManager: 'pnpm' }, + sdk: { + name: '@workos-inc/node', + version: '1.0.0', + latest: '1.0.0', + outdated: false, + isAuthKit: false, + language: 'javascript', + }, + language: { name: 'JavaScript/TypeScript', manifestFile: 'package.json' }, + runtime: { nodeVersion: 'v22.0.0', packageManager: 'pnpm', packageManagerVersion: '10.0.0' }, + framework: { name: 'Next.js', version: '15.0.0' }, + environment: { + apiKeyConfigured: true, + apiKeyType: 'staging', + clientId: 'client_123', + redirectUri: 'http://localhost:3000/callback', + cookieDomain: null, + baseUrl: 'https://api.workos.com', + }, + hostExecution: { mode: 'non-interactive', ok: true, failures: [] }, + connectivity: { apiReachable: true, latencyMs: 42, tlsValid: true }, + issues: [], + summary: { errors: 0, warnings: 0, healthy: true }, + }; +} + +describe('formatReportAsJson', () => { + it('includes top-level interaction mode', () => { + const json = JSON.parse(formatReportAsJson(report())); + + expect(json.interactionMode).toEqual({ mode: 'agent', source: 'env' }); + expect(json.hostExecution).toMatchObject({ mode: 'non-interactive', ok: true }); + }); +}); diff --git a/src/doctor/output.spec.ts b/src/doctor/output.spec.ts new file mode 100644 index 00000000..2e647065 --- /dev/null +++ b/src/doctor/output.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest'; +import { formatInteractionModeSource, formatReport } from './output.js'; +import type { DoctorReport } from './types.js'; + +function report(overrides: Partial = {}): DoctorReport { + return { + version: '1.0.0', + timestamp: '2026-01-01T00:00:00.000Z', + interactionMode: { mode: 'agent', source: 'env' }, + project: { path: '/tmp/app', packageManager: 'pnpm' }, + sdk: { + name: '@workos-inc/node', + version: '1.0.0', + latest: '1.0.0', + outdated: false, + isAuthKit: false, + language: 'javascript', + }, + language: { name: 'JavaScript/TypeScript', manifestFile: 'package.json' }, + runtime: { nodeVersion: 'v22.0.0', packageManager: 'pnpm', packageManagerVersion: '10.0.0' }, + framework: { name: 'Next.js', version: '15.0.0' }, + environment: { + apiKeyConfigured: true, + apiKeyType: 'staging', + clientId: 'client_123', + redirectUri: 'http://localhost:3000/callback', + cookieDomain: null, + baseUrl: 'https://api.workos.com', + }, + hostExecution: { mode: 'non-interactive', ok: true, failures: [] }, + connectivity: { apiReachable: true, latencyMs: 42, tlsValid: true }, + issues: [], + summary: { errors: 0, warnings: 0, healthy: true }, + ...overrides, + }; +} + +describe('doctor output', () => { + it('formats interaction mode sources for human output', () => { + expect(formatInteractionModeSource('flag')).toBe('--mode'); + expect(formatInteractionModeSource('env')).toBe('WORKOS_MODE'); + expect(formatInteractionModeSource('workos_no_prompt')).toBe('WORKOS_NO_PROMPT'); + expect(formatInteractionModeSource('ci_env')).toBe('CI environment'); + expect(formatInteractionModeSource('agent_env')).toBe('agent environment'); + expect(formatInteractionModeSource('non_tty')).toBe('non-TTY'); + expect(formatInteractionModeSource('default')).toBe('default'); + }); + + it('shows interaction mode and source in human report output', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + formatReport(report()); + + const output = log.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output).toContain('Interaction Mode'); + expect(output).toContain('Mode: agent (WORKOS_MODE)'); + expect(output).toContain('Agent/CI context, host state reachable'); + + log.mockRestore(); + }); + + it('shows agent/CI host trust warning summary without verbose mode', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + formatReport( + report({ + hostExecution: { + mode: 'non-interactive', + ok: false, + failures: [ + { + capability: 'home-fs', + detail: 'EACCES: permission denied', + label: 'WorkOS home directory', + target: '/Users/test/.workos', + }, + ], + }, + }), + ); + + const output = log.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output).toContain('Agent/CI context, host state may be unavailable'); + expect(output).toContain('WorkOS home directory (/Users/test/.workos)'); + expect(output).not.toContain('EACCES: permission denied'); + + log.mockRestore(); + }); +}); diff --git a/src/doctor/output.ts b/src/doctor/output.ts index ff9ddac9..d078e52d 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -1,6 +1,7 @@ import Chalk from 'chalk'; import { homedir } from 'os'; import { createAgents } from '../commands/install-skill.js'; +import type { InteractionModeSource } from '../utils/interaction-mode.js'; import type { DoctorReport, Issue } from './types.js'; import { renderSummaryBox, type SummaryBoxItem } from '../utils/summary-box.js'; import type { LockExpression } from '../utils/lock-art.js'; @@ -51,6 +52,32 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi ); console.log(` Base URL: ${report.environment.baseUrl} ${Chalk.green('✓')}`); + // Interaction Mode + console.log(''); + console.log('Interaction Mode'); + console.log( + ` Mode: ${report.interactionMode.mode} (${formatInteractionModeSource(report.interactionMode.source)})`, + ); + + // Host Execution + console.log(''); + console.log('Host Execution'); + if (report.hostExecution.mode === 'interactive') { + console.log(` Shell: ${Chalk.green('✓')} Interactive host shell`); + } else if (report.hostExecution.ok) { + console.log(` Shell: ${Chalk.green('✓')} Agent/CI context, host state reachable`); + } else { + console.log(` Shell: ${Chalk.yellow('!')} Agent/CI context, host state may be unavailable`); + for (const failure of report.hostExecution.failures) { + const label = failure.label ?? failure.capability; + const target = failure.target ? ` (${failure.target})` : ''; + console.log(` ${Chalk.yellow('!')} ${label}${target}`); + if (options?.verbose) { + console.log(` ${Chalk.dim(failure.detail)}`); + } + } + } + // Connectivity & Credential Validation console.log(''); console.log('Connectivity'); @@ -234,6 +261,25 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi console.log(''); } +export function formatInteractionModeSource(source: InteractionModeSource): string { + switch (source) { + case 'flag': + return '--mode'; + case 'env': + return 'WORKOS_MODE'; + case 'workos_no_prompt': + return 'WORKOS_NO_PROMPT'; + case 'ci_env': + return 'CI environment'; + case 'agent_env': + return 'agent environment'; + case 'non_tty': + return 'non-TTY'; + case 'default': + return 'default'; + } +} + function formatIssue(issue: Issue): void { const icon = issue.severity === 'error' ? Chalk.red('✗') : Chalk.yellow('!'); const color = issue.severity === 'error' ? Chalk.red : Chalk.yellow; diff --git a/src/doctor/types.ts b/src/doctor/types.ts index d87c0efd..c27b267c 100644 --- a/src/doctor/types.ts +++ b/src/doctor/types.ts @@ -1,3 +1,5 @@ +import type { InteractionModeInfo } from '../utils/interaction-mode.js'; + export type IssueSeverity = 'error' | 'warning'; export interface Issue { @@ -67,6 +69,21 @@ export interface ConnectivityInfo { error?: string; } +export interface HostExecutionFailure { + capability: string; + detail: string; + operation?: string; + target?: string; + label?: string; +} + +export interface HostExecutionInfo { + mode: 'interactive' | 'non-interactive'; + ok: boolean; + failures: HostExecutionFailure[]; + warning?: string; +} + export interface DashboardSettings { redirectUris: string[]; authMethods: string[]; @@ -136,6 +153,7 @@ export interface SkillsRefreshResult { export interface DoctorReport { version: string; timestamp: string; + interactionMode: InteractionModeInfo; project: { path: string; packageManager: string | null; @@ -145,6 +163,7 @@ export interface DoctorReport { runtime: RuntimeInfo; framework: FrameworkInfo; environment: EnvironmentInfo; + hostExecution: HostExecutionInfo; connectivity: ConnectivityInfo; dashboardSettings?: DashboardSettings; dashboardError?: string; diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 22539096..fa48f623 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -14,6 +14,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { logWarn } from '../utils/debug.js'; +import { observeHostFailure } from './host-probe.js'; interface BaseEnvironmentConfig { name: string; @@ -72,10 +73,16 @@ function fileExists(): boolean { function readFromFile(): CliConfig | null { if (!fileExists()) return null; + const filePath = getConfigFilePath(); try { - const content = fs.readFileSync(getConfigFilePath(), 'utf-8'); + const content = fs.readFileSync(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { + observeHostFailure('home-fs', error, { + operation: 'read', + target: filePath, + label: 'config fallback file', + }); logWarn('Failed to read config file:', error); return null; } @@ -83,17 +90,37 @@ function readFromFile(): CliConfig | null { function writeToFile(config: CliConfig): void { const dir = getConfigDir(); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const filePath = getConfigFilePath(); + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(filePath, JSON.stringify(config, null, 2), { + mode: 0o600, + }); + } catch (error) { + observeHostFailure('home-fs', error, { + operation: 'write', + target: filePath, + label: 'config fallback file', + }); + throw error; } - fs.writeFileSync(getConfigFilePath(), JSON.stringify(config, null, 2), { - mode: 0o600, - }); } function deleteFile(): void { + const filePath = getConfigFilePath(); if (fileExists()) { - fs.unlinkSync(getConfigFilePath()); + try { + fs.unlinkSync(filePath); + } catch (error) { + observeHostFailure('home-fs', error, { + operation: 'delete', + target: filePath, + label: 'config fallback file', + }); + throw error; + } } } @@ -109,6 +136,11 @@ function readFromKeyring(): CliConfig | null { return JSON.parse(data); } catch (error) { logWarn('Failed to read config from keyring:', error); + observeHostFailure('keychain', error, { + operation: 'read', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'config keychain entry', + }); return null; } } @@ -120,6 +152,11 @@ function writeToKeyring(config: CliConfig): boolean { return true; } catch (error) { logWarn('Failed to write config to keyring:', error); + observeHostFailure('keychain', error, { + operation: 'write', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'config keychain entry', + }); return false; } } @@ -132,6 +169,11 @@ function deleteFromKeyring(): void { const msg = error instanceof Error ? error.message : String(error); if (!msg.includes('not found') && !msg.includes('No such')) { logWarn('Failed to delete config from keyring:', error); + observeHostFailure('keychain', error, { + operation: 'delete', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'config keychain entry', + }); } } } diff --git a/src/lib/credential-proxy.ts b/src/lib/credential-proxy.ts index 480a617c..606e0b99 100644 --- a/src/lib/credential-proxy.ts +++ b/src/lib/credential-proxy.ts @@ -11,6 +11,7 @@ import { getCredentials, updateTokens, type Credentials } from './credentials.js import { analytics } from '../utils/analytics.js'; import { refreshAccessToken } from './token-refresh-client.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; +import { observeHostFailure } from './host-probe.js'; export interface RefreshConfig { /** AuthKit domain for refresh endpoint */ @@ -228,6 +229,11 @@ export async function startCredentialProxy(options: CredentialProxyOptions): Pro attempts++; tryListen(0); // Try random port } else { + observeHostFailure('localhost-bind', err, { + operation: 'listen', + target: `127.0.0.1:${p}`, + label: 'credential proxy', + }); reject(err); } }); @@ -420,7 +426,14 @@ export async function startClaimTokenProxy(options: { }); const port = await new Promise((resolve, reject) => { - server.once('error', (err) => reject(err)); + server.once('error', (err) => { + observeHostFailure('localhost-bind', err, { + operation: 'listen', + target: '127.0.0.1:0', + label: 'claim token proxy', + }); + reject(err); + }); server.listen(0, '127.0.0.1', () => { const addr = server.address(); if (addr && typeof addr === 'object') resolve(addr.port); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 169c4484..af4dd5e6 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -11,6 +11,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { logWarn } from '../utils/debug.js'; +import { observeHostFailure } from './host-probe.js'; export interface StagingCache { clientId: string; @@ -53,10 +54,16 @@ function fileExists(): boolean { function readFromFile(): Credentials | null { if (!fileExists()) return null; + const filePath = getCredentialsPath(); try { - const content = fs.readFileSync(getCredentialsPath(), 'utf-8'); + const content = fs.readFileSync(filePath, 'utf-8'); return JSON.parse(content); } catch (error) { + observeHostFailure('home-fs', error, { + operation: 'read', + target: filePath, + label: 'credential fallback file', + }); logWarn('Failed to read credentials file:', error); return null; } @@ -64,17 +71,37 @@ function readFromFile(): Credentials | null { function writeToFile(creds: Credentials): void { const dir = getCredentialsDir(); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const filePath = getCredentialsPath(); + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(filePath, JSON.stringify(creds, null, 2), { + mode: 0o600, + }); + } catch (error) { + observeHostFailure('home-fs', error, { + operation: 'write', + target: filePath, + label: 'credential fallback file', + }); + throw error; } - fs.writeFileSync(getCredentialsPath(), JSON.stringify(creds, null, 2), { - mode: 0o600, - }); } function deleteFile(): void { + const filePath = getCredentialsPath(); if (fileExists()) { - fs.unlinkSync(getCredentialsPath()); + try { + fs.unlinkSync(filePath); + } catch (error) { + observeHostFailure('home-fs', error, { + operation: 'delete', + target: filePath, + label: 'credential fallback file', + }); + throw error; + } } } @@ -94,6 +121,11 @@ function readFromKeyring(): Credentials | null { } catch (error) { const msg = error instanceof Error ? error.message : String(error); logWarn(`[credential-store] keyring read failed: ${msg}`); + observeHostFailure('keychain', error, { + operation: 'read', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'credential keychain entry', + }); return null; } } @@ -106,6 +138,11 @@ function writeToKeyring(creds: Credentials): boolean { } catch (error) { const msg = error instanceof Error ? error.message : String(error); logWarn(`[credential-store] keyring write failed: ${msg}`); + observeHostFailure('keychain', error, { + operation: 'write', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'credential keychain entry', + }); return false; } } @@ -118,6 +155,11 @@ function deleteFromKeyring(): void { const msg = error instanceof Error ? error.message : String(error); if (!msg.includes('not found') && !msg.includes('No such')) { logWarn('Failed to delete from keyring:', error); + observeHostFailure('keychain', error, { + operation: 'delete', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'credential keychain entry', + }); } } } diff --git a/src/lib/ensure-auth.spec.ts b/src/lib/ensure-auth.spec.ts index 38f7a9b0..81b7d5a7 100644 --- a/src/lib/ensure-auth.spec.ts +++ b/src/lib/ensure-auth.spec.ts @@ -42,12 +42,6 @@ vi.mock('./settings.js', () => ({ })), })); -// Mock environment detection -const mockIsNonInteractive = vi.fn(() => false); -vi.mock('../utils/environment.js', () => ({ - isNonInteractiveEnvironment: () => mockIsNonInteractive(), -})); - // Mock exit codes — must throw to halt execution like the real process.exit() class AuthRequiredExit extends Error { constructor() { @@ -75,6 +69,7 @@ vi.mock('./token-refresh-client.js', () => ({ // Import after mocks are set up const { saveCredentials, getCredentials, setInsecureStorage, hasCredentials } = await import('./credentials.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const { ensureAuthenticated } = await import('./ensure-auth.js'); describe('ensure-auth', () => { @@ -85,6 +80,7 @@ describe('ensure-auth', () => { vi.clearAllMocks(); // Force file-based storage for these tests setInsecureStorage(true); + resetInteractionModeForTests(); }); afterEach(() => { @@ -350,24 +346,24 @@ describe('ensure-auth', () => { }); }); - describe('non-TTY mode', () => { + describe('agent mode', () => { beforeEach(() => { - mockIsNonInteractive.mockReturnValue(true); + setInteractionMode({ mode: 'agent', source: 'env' }); }); afterEach(() => { - mockIsNonInteractive.mockReturnValue(false); + resetInteractionModeForTests(); }); - it('exits with auth required when no credentials in non-TTY', async () => { - // No credentials saved, non-TTY mode + it('exits with auth required when no credentials in agent mode', async () => { + // No credentials saved, agent mode await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); expect(mockExitWithAuthRequired).toHaveBeenCalled(); expect(mockRunLogin).not.toHaveBeenCalled(); }); - it('still refreshes tokens silently in non-TTY', async () => { + it('still refreshes tokens silently in agent mode', async () => { saveCredentials(expiredAccessCreds); mockRefreshAccessToken.mockResolvedValue({ @@ -385,7 +381,7 @@ describe('ensure-auth', () => { expect(mockRunLogin).not.toHaveBeenCalled(); }); - it('exits with auth required when refresh fails in non-TTY', async () => { + it('exits with auth required when refresh fails in agent mode', async () => { saveCredentials(expiredAccessCreds); mockRefreshAccessToken.mockResolvedValue({ @@ -399,6 +395,29 @@ describe('ensure-auth', () => { expect(mockExitWithAuthRequired).toHaveBeenCalled(); expect(mockRunLogin).not.toHaveBeenCalled(); }); + + it('uses agent-specific host-shell auth guidance', async () => { + await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); + + expect(mockExitWithAuthRequired).toHaveBeenCalledWith(expect.stringContaining('host shell')); + }); + }); + + describe('CI mode', () => { + beforeEach(() => { + setInteractionMode({ mode: 'ci', source: 'env' }); + }); + + afterEach(() => { + resetInteractionModeForTests(); + }); + + it('uses CI-specific auth guidance', async () => { + await expect(ensureAuthenticated()).rejects.toThrow(AuthRequiredExit); + + expect(mockExitWithAuthRequired).toHaveBeenCalledWith(expect.stringContaining('CI')); + expect(mockRunLogin).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 5d5f3086..b40323c4 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -7,9 +7,10 @@ import { refreshAccessToken } from './token-refresh-client.js'; import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { runLogin } from '../commands/login.js'; import { logInfo } from '../utils/debug.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { exitWithAuthRequired } from '../utils/exit-codes.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; +import { warnIfSandboxed } from './host-probe.js'; export interface EnsureAuthResult { /** Whether auth is now valid */ @@ -30,6 +31,23 @@ export interface EnsureAuthResult { * @returns Result indicating what actions were taken * @throws Error if login fails or refresh fails unexpectedly */ +function exitForAuthRequired(message?: string): never { + if (isCiMode()) { + exitWithAuthRequired( + message ?? 'Not authenticated. Set WORKOS_API_KEY or configure credentials before running in CI.', + ); + } + + if (isAgentMode()) { + exitWithAuthRequired( + message ?? + `Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, + ); + } + + exitWithAuthRequired(message); +} + export async function ensureAuthenticated(): Promise { const result: EnsureAuthResult = { authenticated: false, @@ -37,12 +55,14 @@ export async function ensureAuthenticated(): Promise { tokenRefreshed: false, }; + await warnIfSandboxed(); + // Case 1: No credentials or invalid credentials const creds = getCredentials(); if (!creds) { clearCredentials(); // Clean up any corrupt/empty files - if (isNonInteractiveEnvironment()) { - exitWithAuthRequired(); + if (!isPromptAllowed()) { + exitForAuthRequired(); } logInfo('[ensure-auth] No valid credentials found, triggering login'); await runLogin(); @@ -77,9 +97,11 @@ export async function ensureAuthenticated(): Promise { // Refresh failed - check if it's recoverable if (refreshResult.errorType === 'invalid_grant') { clearCredentials(); - if (isNonInteractiveEnvironment()) { - exitWithAuthRequired( - `Session expired. Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal to re-authenticate.`, + if (!isPromptAllowed()) { + exitForAuthRequired( + isCiMode() + ? 'Session expired. Refresh credentials before running in CI, or set WORKOS_API_KEY.' + : `Session expired. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, ); } logInfo('[ensure-auth] Refresh token expired, triggering login'); @@ -90,9 +112,11 @@ export async function ensureAuthenticated(): Promise { } // Network or server error - keep credentials intact for retry - if (isNonInteractiveEnvironment()) { - exitWithAuthRequired( - `Authentication refresh failed (${refreshResult.errorType}). Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal.`, + if (!isPromptAllowed()) { + exitForAuthRequired( + isCiMode() + ? `Authentication refresh failed (${refreshResult.errorType}). Refresh credentials before running in CI, or set WORKOS_API_KEY.` + : `Authentication refresh failed (${refreshResult.errorType}). Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, ); } logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`); @@ -105,9 +129,11 @@ export async function ensureAuthenticated(): Promise { // Case 4: No refresh token available — clear stale creds, must login clearCredentials(); - if (isNonInteractiveEnvironment()) { - exitWithAuthRequired( - `Session expired. Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal to re-authenticate.`, + if (!isPromptAllowed()) { + exitForAuthRequired( + isCiMode() + ? 'Session expired. Refresh credentials before running in CI, or set WORKOS_API_KEY.' + : `Session expired. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, ); } logInfo('[ensure-auth] No refresh token, triggering login'); diff --git a/src/lib/host-probe.spec.ts b/src/lib/host-probe.spec.ts new file mode 100644 index 00000000..24af43e1 --- /dev/null +++ b/src/lib/host-probe.spec.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/debug.js', () => ({ + logWarn: vi.fn(), + logVisibleWarn: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock('../utils/interaction-mode.js', () => ({ + isAgentMode: vi.fn(), + isCiMode: vi.fn(), +})); + +vi.mock('node:os', () => ({ + default: { homedir: () => '/tmp/host-probe-test' }, + homedir: () => '/tmp/host-probe-test', +})); + +vi.mock('node:fs', () => { + const promises = { + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + }; + return { + default: { promises }, + promises, + }; +}); + +const keyringMock = vi.hoisted(() => ({ + getPassword: vi.fn(() => null), +})); + +vi.mock('@napi-rs/keyring', () => ({ + Entry: class { + getPassword(): string | null { + return keyringMock.getPassword(); + } + }, +})); + +import { _resetProbeState, runHostProbe, warnIfSandboxed, observeHostFailure } from './host-probe.js'; +import { logInfo, logVisibleWarn } from '../utils/debug.js'; +import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { promises as fs } from 'node:fs'; + +describe('host-probe', () => { + beforeEach(() => { + _resetProbeState(); + vi.resetAllMocks(); + keyringMock.getPassword.mockReturnValue(null); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + vi.mocked(isAgentMode).mockReturnValue(false); + vi.mocked(isCiMode).mockReturnValue(false); + }); + + describe('runHostProbe', () => { + it('returns ok when home-fs and keychain succeed', async () => { + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('treats a "not found" keychain error as healthy', async () => { + keyringMock.getPassword.mockImplementation(() => { + throw new Error('Item not found in keyring'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('detects home-fs failure', async () => { + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EPERM: operation not permitted'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(false); + expect(result.failures).toContainEqual(expect.objectContaining({ capability: 'home-fs' })); + }); + + it('does not flag non-permission home-fs errors as sandbox failures', async () => { + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('reports success even when unlink cleanup fails', async () => { + vi.mocked(fs.unlink).mockRejectedValue(new Error('EACCES: permission denied')); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('always attempts to unlink the probe file after a successful write', async () => { + await runHostProbe(); + expect(vi.mocked(fs.unlink)).toHaveBeenCalledTimes(1); + }); + + it('detects keychain failure on permission error', async () => { + keyringMock.getPassword.mockImplementation(() => { + throw new Error('EACCES: keychain unavailable'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(false); + expect(result.failures).toContainEqual(expect.objectContaining({ capability: 'keychain' })); + }); + + it('does not flag non-permission keychain errors as sandbox failures', async () => { + keyringMock.getPassword.mockImplementation(() => { + throw new Error('The user canceled the Keychain Services operation'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('caches the result across calls', async () => { + const first = await runHostProbe(); + const second = await runHostProbe(); + expect(first).toBe(second); + }); + }); + + describe('warnIfSandboxed', () => { + it('warns in agent mode when probe fails', async () => { + vi.mocked(isAgentMode).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + await warnIfSandboxed(); + expect(logVisibleWarn).toHaveBeenCalledWith( + expect.stringContaining('unavailable'), + expect.stringContaining('host shell'), + ); + }); + + it('does not warn in human mode', async () => { + vi.mocked(isAgentMode).mockReturnValue(false); + vi.mocked(isCiMode).mockReturnValue(false); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES'); + }); + + await warnIfSandboxed(); + expect(logVisibleWarn).not.toHaveBeenCalled(); + }); + + it('warns at most once per session', async () => { + vi.mocked(isAgentMode).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EPERM'); + }); + + await warnIfSandboxed(); + const callCount = vi.mocked(logVisibleWarn).mock.calls.length; + await warnIfSandboxed(); + expect(vi.mocked(logVisibleWarn).mock.calls.length).toBe(callCount); + }); + + it('does not warn on a healthy host (no false positive when probe entry is absent)', async () => { + vi.mocked(isAgentMode).mockReturnValue(true); + keyringMock.getPassword.mockImplementation(() => { + throw new Error('No such password in the keyring'); + }); + + await warnIfSandboxed(); + expect(logVisibleWarn).not.toHaveBeenCalled(); + }); + + it('warns in CI mode when probe fails', async () => { + vi.mocked(isCiMode).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + await warnIfSandboxed(); + expect(logVisibleWarn).toHaveBeenCalledWith( + expect.stringContaining('unavailable'), + expect.stringContaining('host shell'), + ); + }); + }); + + describe('observeHostFailure', () => { + it('warns on permission errors in agent mode', () => { + vi.mocked(isAgentMode).mockReturnValue(true); + observeHostFailure('keychain', new Error('EPERM: operation not permitted'), { + operation: 'read', + target: 'workos-cli/credentials', + label: 'credential keychain entry', + }); + expect(logVisibleWarn).toHaveBeenCalledWith( + expect.stringContaining('keychain'), + expect.stringContaining('host shell'), + ); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('credential keychain entry')); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('operation=read')); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('target=workos-cli/credentials')); + }); + + it('warns on browser launch errors in agent mode', () => { + vi.mocked(isAgentMode).mockReturnValue(true); + observeHostFailure('browser-launch', new Error('No browser available'), { + operation: 'open', + target: 'https://example.com', + label: 'auth login browser', + }); + expect(logVisibleWarn).toHaveBeenCalledWith( + expect.stringContaining('browser-launch'), + expect.stringContaining('host shell'), + ); + }); + + it('ignores non-permission errors', () => { + vi.mocked(isAgentMode).mockReturnValue(true); + observeHostFailure('keychain', new Error('JSON parse error')); + expect(logVisibleWarn).not.toHaveBeenCalled(); + }); + + it('does not match unrelated words containing "sandbox" as a substring', () => { + vi.mocked(isAgentMode).mockReturnValue(true); + observeHostFailure('keychain', new Error('failed to update sandboxes table: schema mismatch')); + expect(logVisibleWarn).not.toHaveBeenCalled(); + }); + + it('does not warn twice even for different capabilities', () => { + vi.mocked(isAgentMode).mockReturnValue(true); + observeHostFailure('keychain', new Error('EPERM')); + const callCount = vi.mocked(logVisibleWarn).mock.calls.length; + observeHostFailure('home-fs', new Error('EACCES')); + expect(vi.mocked(logVisibleWarn).mock.calls.length).toBe(callCount); + }); + + it('does not double-warn across proactive and reactive paths', async () => { + vi.mocked(isAgentMode).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES'); + }); + + await warnIfSandboxed(); + const callCount = vi.mocked(logVisibleWarn).mock.calls.length; + observeHostFailure('keychain', new Error('EPERM')); + expect(vi.mocked(logVisibleWarn).mock.calls.length).toBe(callCount); + }); + }); +}); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts new file mode 100644 index 00000000..8054db6d --- /dev/null +++ b/src/lib/host-probe.ts @@ -0,0 +1,194 @@ +/** + * Host capability probes for non-interactive / sandboxed environments. + * + * When the CLI runs inside an AI agent sandbox (Claude Code, Codex, Cursor), + * the keyring, home directory, network, or browser may be unavailable. + * These helpers detect that situation and emit a single actionable warning + * per session instead of letting opaque EPERM errors confuse the agent. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { Entry } from '@napi-rs/keyring'; +import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { logInfo, logVisibleWarn } from '../utils/debug.js'; + +export type HostCapability = 'home-fs' | 'keychain' | 'network' | 'browser-launch' | 'localhost-bind'; +export type HostOperation = 'read' | 'write' | 'delete' | 'connect' | 'open' | 'listen'; + +export interface HostCapabilityDetails { + operation?: HostOperation; + target?: string; + label?: string; +} + +export interface ProbeFailure extends HostCapabilityDetails { + capability: HostCapability; + detail: string; +} + +export interface ProbeResult { + ok: boolean; + failures: ProbeFailure[]; +} + +let warnedThisSession = false; +let cachedProbe: ProbeResult | undefined; + +const KEYCHAIN_SERVICE = 'workos-cli'; +const KEYCHAIN_PROBE_ACCOUNT = 'probe'; + +const PERMISSION_PATTERNS = [ + /\bEPERM\b/i, + /\bEACCES\b/i, + /operation not permitted/i, + /permission denied/i, + /\bsandboxd?\b/i, + /interaction is not allowed/i, + /access denied/i, +]; + +function isPermissionError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return PERMISSION_PATTERNS.some((p) => p.test(msg)); +} + +function isLikelyHostFailure(capability: HostCapability, error: unknown): boolean { + if (capability === 'browser-launch' || capability === 'localhost-bind') { + return true; + } + + return isPermissionError(error); +} + +function isMissingEntryError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return msg.includes('not found') || msg.includes('No such'); +} + +async function probeHomeFs(): Promise { + const dir = path.join(os.homedir(), '.workos'); + const probePath = path.join(dir, `.probe-${process.pid}-${crypto.randomUUID()}`); + + try { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(probePath, new Date().toISOString(), { mode: 0o600 }); + return null; + } catch (error) { + // Only treat permission-class errors as sandbox indicators. Transient + // errors like ENOSPC/EIO would otherwise produce a misleading "sandboxed + // environment" warning. Mirrors the gating in observeHostFailure(). + if (!isPermissionError(error)) return null; + const detail = error instanceof Error ? error.message : String(error); + return { capability: 'home-fs', detail, operation: 'write', target: dir, label: 'WorkOS home directory' }; + } finally { + // Best-effort cleanup so a successful write never leaves an orphan file + // behind. Ignore unlink failures: if the file was never created the + // unlink will fail with ENOENT, and any other failure is unrelated to + // the probe's purpose (which is checking write access, not delete). + await fs.unlink(probePath).catch(() => {}); + } +} + +function probeKeychain(): ProbeFailure | null { + try { + const entry = new Entry(KEYCHAIN_SERVICE, KEYCHAIN_PROBE_ACCOUNT); + entry.getPassword(); + return null; + } catch (error) { + // A "not found" / "No such" error means the keychain is reachable but the + // probe entry simply doesn't exist — that's a healthy state, not a failure. + if (isMissingEntryError(error)) { + return null; + } + // Only treat permission-class errors as sandbox indicators. A user-canceled + // macOS prompt or a transient keyring daemon hiccup would otherwise produce + // a misleading "sandboxed environment" warning. Mirrors probeHomeFs() and + // observeHostFailure(). + if (!isPermissionError(error)) return null; + const detail = error instanceof Error ? error.message : String(error); + return { + capability: 'keychain', + detail, + operation: 'read', + target: `${KEYCHAIN_SERVICE}/${KEYCHAIN_PROBE_ACCOUNT}`, + label: 'WorkOS keychain probe', + }; + } +} + +export function formatHostProbeFailure(failure: ProbeFailure): string { + const parts = [failure.label ?? failure.capability]; + if (failure.operation) parts.push(`operation=${failure.operation}`); + if (failure.target) parts.push(`target=${failure.target}`); + parts.push(`error=${failure.detail}`); + return parts.join(', '); +} + +function formatHostFailureContext(capability: HostCapability, details: HostCapabilityDetails, detail: string): string { + return formatHostProbeFailure({ capability, ...details, detail }); +} + +export async function runHostProbe(): Promise { + if (cachedProbe) return cachedProbe; + + const failures: ProbeFailure[] = []; + + const fsResult = await probeHomeFs(); + if (fsResult) failures.push(fsResult); + + const keychainResult = probeKeychain(); + if (keychainResult) failures.push(keychainResult); + + cachedProbe = { ok: failures.length === 0, failures }; + return cachedProbe; +} + +function shouldWarnForHostTrust(): boolean { + return isAgentMode() || isCiMode(); +} + +export async function warnIfSandboxed(): Promise { + if (warnedThisSession) return; + if (!shouldWarnForHostTrust()) return; + + const probe = await runHostProbe(); + if (probe.ok) return; + + warnedThisSession = true; + + const caps = probe.failures.map((f) => f.capability).join(', '); + logVisibleWarn( + `Host capabilities may be unavailable (${caps}). This may be a sandboxed environment.`, + 'Re-run this command on the host shell before trusting auth or API failures.', + ); + + for (const f of probe.failures) { + logInfo(`[host-probe] ${formatHostProbeFailure(f)}`); + } +} + +export function observeHostFailure( + capability: HostCapability, + error: unknown, + details: HostCapabilityDetails = {}, +): void { + if (warnedThisSession) return; + if (!shouldWarnForHostTrust()) return; + if (!isLikelyHostFailure(capability, error)) return; + + warnedThisSession = true; + + const detail = error instanceof Error ? error.message : String(error); + logVisibleWarn( + `Host capability "${capability}" failed (${detail}). This may be a sandboxed environment.`, + 'Re-run this command on the host shell before trusting auth or API failures.', + ); + logInfo(`[host-probe] ${formatHostFailureContext(capability, details, detail)}`); +} + +export function _resetProbeState(): void { + cachedProbe = undefined; + warnedThisSession = false; +} diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index f0b9b82a..69971fad 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -8,7 +8,8 @@ import { CLIAdapter } from './adapters/cli-adapter.js'; import { DashboardAdapter } from './adapters/dashboard-adapter.js'; import type { InstallerAdapter } from './adapters/types.js'; import type { InstallerOptions } from '../utils/types.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { getInteractionMode, isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { getOutputMode, isJsonMode, resolveEffectiveOutputMode, setOutputMode } from '../utils/output.js'; import type { InstallerMachineContext, DetectionOutput, @@ -51,6 +52,7 @@ import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; +import { observeHostFailure } from './host-probe.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; async function runIntegrationInstallerFn(integration: Integration, options: InstallerOptions): Promise { @@ -206,8 +208,14 @@ export async function runWithCore(options: InstallerOptions): Promise { } }; + const nonHumanMode = isAgentMode() || isCiMode(); + if (nonHumanMode && !isJsonMode()) { + setOutputMode(resolveEffectiveOutputMode(getOutputMode(), getInteractionMode())); + } + const headlessMode = nonHumanMode && isJsonMode(); + let adapter: InstallerAdapter; - if (isNonInteractiveEnvironment()) { + if (headlessMode) { const { HeadlessAdapter } = await import('./adapters/headless-adapter.js'); adapter = new HeadlessAdapter({ emitter, @@ -370,8 +378,13 @@ export async function runWithCore(options: InstallerOptions): Promise { // Open browser try { const { default: openFn } = await import('opn'); - await openFn(deviceAuth.verification_uri_complete); - } catch { + await openFn(deviceAuth.verification_uri_complete, { wait: false }); + } catch (error) { + observeHostFailure('browser-launch', error, { + operation: 'open', + target: deviceAuth.verification_uri_complete, + label: 'installer device auth browser', + }); // User can open manually } @@ -493,7 +506,13 @@ export async function runWithCore(options: InstallerOptions): Promise { inspectUrl = msg; console.log = originalLog; console.log(`Opening XState inspector: ${inspectUrl}`); - void open(inspectUrl); + void open(inspectUrl).catch((error: unknown) => { + observeHostFailure('browser-launch', error, { + operation: 'open', + target: inspectUrl, + label: 'XState inspector browser', + }); + }); } else { originalLog.apply(console, args); } @@ -514,8 +533,9 @@ export async function runWithCore(options: InstallerOptions): Promise { await adapter.start(); - // Start telemetry session - const mode = isNonInteractiveEnvironment() ? 'headless' : augmentedOptions.dashboard ? 'tui' : 'cli'; + // Start telemetry session. Analytics currently accepts cli/tui/headless only, + // so agent and CI mode both report through the existing headless bucket. + const mode = headlessMode ? 'headless' : augmentedOptions.dashboard ? 'tui' : 'cli'; analytics.sessionStart(mode, getVersion()); let installerStatus: 'success' | 'error' | 'cancelled' = 'success'; diff --git a/src/utils/command-invocation.spec.ts b/src/utils/command-invocation.spec.ts index e390b780..cf5b665d 100644 --- a/src/utils/command-invocation.spec.ts +++ b/src/utils/command-invocation.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatWorkOSCommand, getWorkOSCommand } from './command-invocation.js'; +import { formatWorkOSCommand, formatWorkOSCommandArgs, getWorkOSCommand, shellQuoteArg } from './command-invocation.js'; describe('command invocation helpers', () => { it('uses workos for regular global/local invocations', () => { @@ -13,4 +13,15 @@ describe('command invocation helpers', () => { it('formats commands with the detected invocation', () => { expect(formatWorkOSCommand('auth login', { npm_command: 'exec' })).toBe('npx workos@latest auth login'); }); + + it('quotes shell arguments that contain JSON or shell metacharacters', () => { + expect(shellQuoteArg('{"name":"Acme"}')).toBe('\'{"name":"Acme"}\''); + expect(shellQuoteArg("O'Hara")).toBe("'O'\\''Hara'"); + }); + + it('formats commands from argv-like parts', () => { + expect(formatWorkOSCommandArgs(['api', '/organizations', '--data', '{"name":"Acme"}'], {})).toBe( + 'workos api /organizations --data \'{"name":"Acme"}\'', + ); + }); }); diff --git a/src/utils/command-invocation.ts b/src/utils/command-invocation.ts index 90e2f032..c1d0db33 100644 --- a/src/utils/command-invocation.ts +++ b/src/utils/command-invocation.ts @@ -17,3 +17,14 @@ export function getWorkOSCommand(env: NodeJS.ProcessEnv = process.env): string { export function formatWorkOSCommand(args: string, env: NodeJS.ProcessEnv = process.env): string { return `${getWorkOSCommand(env)} ${args}`; } + +export function shellQuoteArg(arg: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(arg)) { + return arg; + } + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +export function formatWorkOSCommandArgs(args: string[], env: NodeJS.ProcessEnv = process.env): string { + return [getWorkOSCommand(env), ...args.map(shellQuoteArg)].join(' '); +} diff --git a/src/utils/debug.spec.ts b/src/utils/debug.spec.ts index c1e07199..67f8c696 100644 --- a/src/utils/debug.spec.ts +++ b/src/utils/debug.spec.ts @@ -88,6 +88,53 @@ describe('debug logging', () => { expect(content).toContain('❌ ERROR: test error'); }); + it('writes visible warnings to stderr before log initialization', async () => { + vi.resetModules(); + vi.doMock('os', async () => { + const actual = await vi.importActual('os'); + return { ...actual, homedir: () => testDir }; + }); + vi.doMock('./clack.js', () => ({ + default: { log: { info: vi.fn() } }, + })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { getLogFilePath, logVisibleWarn } = await import('./debug.js'); + + logVisibleWarn('sandbox warning', 'host shell'); + + expect(getLogFilePath()).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('sandbox warning host shell')); + } finally { + errorSpy.mockRestore(); + } + }); + + it('suppresses visible warnings in JSON mode', async () => { + vi.resetModules(); + vi.doMock('os', async () => { + const actual = await vi.importActual('os'); + return { ...actual, homedir: () => testDir }; + }); + vi.doMock('./clack.js', () => ({ + default: { log: { info: vi.fn() } }, + })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { setOutputMode } = await import('./output.js'); + setOutputMode('json'); + const { logVisibleWarn } = await import('./debug.js'); + + logVisibleWarn('sandbox warning', 'host shell'); + + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + it('getLogFilePath returns null before init', async () => { vi.resetModules(); vi.doMock('os', async () => { diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 0fa9433f..1eadd815 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import { prepareMessage } from './logging.js'; import { redactCredentials } from './redact.js'; import clack from './clack.js'; +import { isJsonMode } from './output.js'; let debugEnabled = false; let sessionLogPath: string | null = null; @@ -61,12 +62,12 @@ export function getLogFilePath(): string | null { return sessionLogPath; } -function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown[]): void { +function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown[]): string { const redactedArgs = args.map((a) => (typeof a === 'object' && a !== null ? redactCredentials(a) : a)); const msg = redactedArgs.map((a) => prepareMessage(a)).join(' '); // Write to console if debug enabled - if (debugEnabled) { + if (debugEnabled && !isJsonMode()) { const color = level === 'ERROR' ? chalk.red : level === 'WARN' ? chalk.yellow : chalk.dim; clack.log.info(color(`${emoji} ${msg}`)); } @@ -80,6 +81,8 @@ function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown // Ignore write failures } } + + return msg; } export function logInfo(...args: unknown[]): void { @@ -90,12 +93,19 @@ export function logWarn(...args: unknown[]): void { writeLog('WARN', '⚠️ ', args); } +export function logVisibleWarn(...args: unknown[]): void { + const msg = writeLog('WARN', '⚠️ ', args); + if (!debugEnabled && !isJsonMode()) { + console.error(chalk.yellow(`⚠️ ${msg}`)); + } +} + export function logError(...args: unknown[]): void { writeLog('ERROR', '❌', args); } export function debug(...args: unknown[]): void { - if (!debugEnabled) return; + if (!debugEnabled || isJsonMode()) return; const msg = args.map((a) => prepareMessage(a)).join(' '); clack.log.info(chalk.dim(msg)); } diff --git a/src/utils/environment.spec.ts b/src/utils/environment.spec.ts new file mode 100644 index 00000000..b955bbcd --- /dev/null +++ b/src/utils/environment.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { isNonInteractiveEnvironment } from './environment.js'; +import { resetInteractionModeForTests, setInteractionMode } from './interaction-mode.js'; + +describe('environment', () => { + beforeEach(() => { + resetInteractionModeForTests(); + }); + + describe('isNonInteractiveEnvironment', () => { + it('returns false in human interaction mode', () => { + setInteractionMode({ mode: 'human', source: 'default' }); + expect(isNonInteractiveEnvironment()).toBe(false); + }); + + it('returns true in agent interaction mode', () => { + setInteractionMode({ mode: 'agent', source: 'workos_no_prompt' }); + expect(isNonInteractiveEnvironment()).toBe(true); + }); + + it('returns true in CI interaction mode', () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + expect(isNonInteractiveEnvironment()).toBe(true); + }); + + it('does not inspect WORKOS_FORCE_TTY directly', () => { + process.env.WORKOS_FORCE_TTY = '1'; + setInteractionMode({ mode: 'agent', source: 'non_tty' }); + expect(isNonInteractiveEnvironment()).toBe(true); + delete process.env.WORKOS_FORCE_TTY; + }); + }); +}); diff --git a/src/utils/environment.ts b/src/utils/environment.ts index bab2488e..aac7e501 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -1,28 +1,16 @@ import { getPackageDotJson } from './clack-utils.js'; import type { InstallerOptions } from './types.js'; import fg from 'fast-glob'; -import { IS_DEV } from '../lib/constants.js'; - +import { isHumanMode } from './interaction-mode.js'; + +/** + * Compatibility wrapper for legacy call sites. + * + * Interaction mode now owns prompt/browser behavior. Keep this helper while + * call sites migrate, but do not reimplement env/TTY detection here. + */ export function isNonInteractiveEnvironment(): boolean { - // WORKOS_NO_PROMPT forces non-interactive regardless of TTY - if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') { - return true; - } - - // WORKOS_FORCE_TTY forces interactive regardless of TTY - if (process.env.WORKOS_FORCE_TTY === '1' || process.env.WORKOS_FORCE_TTY === 'true') { - return false; - } - - if (IS_DEV) { - return false; - } - - if (!process.stdout.isTTY || !process.stderr.isTTY) { - return true; - } - - return false; + return !isHumanMode(); } export function readEnvironment(): Record { diff --git a/src/utils/exit-codes.spec.ts b/src/utils/exit-codes.spec.ts index 1178f1b6..efd7fd45 100644 --- a/src/utils/exit-codes.spec.ts +++ b/src/utils/exit-codes.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('./output.js', () => ({ outputError: vi.fn(), @@ -6,6 +6,7 @@ vi.mock('./output.js', () => ({ const { outputError } = await import('./output.js'); const { ExitCode, exitWithCode, exitWithAuthRequired } = await import('./exit-codes.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('./interaction-mode.js'); describe('exit-codes', () => { beforeEach(() => { @@ -47,6 +48,8 @@ describe('exit-codes', () => { }); describe('exitWithAuthRequired', () => { + afterEach(() => resetInteractionModeForTests()); + it('exits with code 4 and auth_required error', () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); exitWithAuthRequired(); @@ -63,5 +66,27 @@ describe('exit-codes', () => { ); exitSpy.mockRestore(); }); + + it('attaches agent-mode recovery hints by default', () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithAuthRequired(); + const call = vi.mocked(outputError).mock.calls.at(-1)![0]; + expect(call.recovery?.hints[0]).toMatchObject({ + command: expect.stringContaining('auth login'), + hostShellRequired: true, + }); + exitSpy.mockRestore(); + }); + + it('attaches CI-mode recovery hints when in CI', () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + exitWithAuthRequired(); + const call = vi.mocked(outputError).mock.calls.at(-1)![0]; + expect(call.recovery?.hints[0].description).toMatch(/WORKOS_API_KEY/); + expect(call.recovery?.hints[0].command).toBeUndefined(); + exitSpy.mockRestore(); + }); }); }); diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index cbd49bd6..b2283b38 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -7,8 +7,10 @@ * 4 = Authentication required */ -import { outputError } from './output.js'; +import { outputError, type StructuredError } from './output.js'; import { formatWorkOSCommand } from './command-invocation.js'; +import { authLoginRecovery } from './recovery-hints.js'; +import { getInteractionMode } from './interaction-mode.js'; export const ExitCode = { SUCCESS: 0, @@ -20,19 +22,25 @@ export const ExitCode = { export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; /** Exit with a specific code, optionally writing a structured error first. */ -export function exitWithCode(code: ExitCodeValue, error?: { code: string; message: string }): never { +export function exitWithCode(code: ExitCodeValue, error?: StructuredError): never { if (error) { outputError(error); } process.exit(code); } -/** Convenience: exit with code 4 and auth-required error. */ -export function exitWithAuthRequired(message?: string): never { +/** + * Convenience: exit with code 4 and auth-required error. + * + * Recovery hints are inferred from interaction mode unless explicitly provided. + */ +export function exitWithAuthRequired(message?: string, options?: { recovery?: StructuredError['recovery'] }): never { + const mode = getInteractionMode().mode; exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', message: message ?? `Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal, or set WORKOS_API_KEY.`, + recovery: options?.recovery ?? authLoginRecovery({ mode }), }); } diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index e548ec28..6362eae0 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -4,9 +4,31 @@ vi.mock('../lib/settings.js', () => ({ getVersion: vi.fn(() => '0.7.3'), })); -const { buildCommandTree } = await import('./help-json.js'); +const { buildCommandTree, extractHelpJsonCommand } = await import('./help-json.js'); describe('help-json', () => { + describe('extractHelpJsonCommand()', () => { + it('extracts a direct command', () => { + expect(extractHelpJsonCommand(['doctor', '--help', '--json'])).toBe('doctor'); + }); + + it('skips --mode values before the command', () => { + expect(extractHelpJsonCommand(['--mode', 'agent', 'doctor', '--help', '--json'])).toBe('doctor'); + }); + + it('skips --mode= values before the command', () => { + expect(extractHelpJsonCommand(['--mode=agent', 'doctor', '--help', '--json'])).toBe('doctor'); + }); + + it('returns undefined when only global flags are present', () => { + expect(extractHelpJsonCommand(['--mode', 'agent', '--help', '--json'])).toBeUndefined(); + }); + + it('resolves command aliases', () => { + expect(extractHelpJsonCommand(['org', '--help', '--json'])).toBe('organization'); + }); + }); + describe('buildCommandTree() — full tree', () => { it('returns root with name "workos"', () => { const tree = buildCommandTree(); @@ -57,6 +79,15 @@ describe('help-json', () => { expect(jsonOpt!.default).toBe(false); }); + it('includes global interaction mode option with choices', () => { + const tree = buildCommandTree(); + const opts = (tree as { options: { name: string; type: string; choices?: string[] }[] }).options; + const modeOpt = opts.find((o) => o.name === 'mode'); + expect(modeOpt).toBeDefined(); + expect(modeOpt!.type).toBe('string'); + expect(modeOpt!.choices).toEqual(['human', 'agent', 'ci']); + }); + it('output is valid JSON-serializable', () => { const tree = buildCommandTree(); const json = JSON.stringify(tree); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index f35a6c9f..ce66f5f2 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1311,6 +1311,14 @@ const globalOptions: OptionSchema[] = [ default: false, hidden: false, }, + { + name: 'mode', + type: 'string', + description: 'Interaction mode: human, coding agent, or CI automation', + required: false, + choices: ['human', 'agent', 'ci'], + hidden: false, + }, { name: 'help', type: 'boolean', description: 'Show help', required: false, alias: 'h', hidden: false }, { name: 'version', type: 'boolean', description: 'Show version number', required: false, alias: 'v', hidden: false }, ]; @@ -1319,6 +1327,35 @@ const globalOptions: OptionSchema[] = [ // Public API // --------------------------------------------------------------------------- +const commandAliases: Record = { org: 'organization' }; +const helpJsonCommandNames = new Set([ + ...commands.map((command) => command.name.split(' ')[0]), + ...Object.keys(commandAliases), +]); + +/** + * Extract the requested command from raw argv before yargs parses --help. + * + * This intentionally matches only known command names so option values from + * global flags like `--mode agent` are not mistaken for commands. + */ +export function extractHelpJsonCommand(argv: string[]): string | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--mode') { + i += 1; + continue; + } + if (arg.startsWith('--mode=')) { + continue; + } + if (!arg.startsWith('-') && helpJsonCommandNames.has(arg)) { + return commandAliases[arg] ?? arg; + } + } + return undefined; +} + /** * Build a machine-readable command tree for --help --json output. * diff --git a/src/utils/interaction-mode.spec.ts b/src/utils/interaction-mode.spec.ts new file mode 100644 index 00000000..aba6b725 --- /dev/null +++ b/src/utils/interaction-mode.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + InvalidInteractionModeError, + getInteractionMode, + isAgentMode, + isCiMode, + isHumanMode, + isPromptAllowed, + resetInteractionModeForTests, + resolveInteractionMode, + setInteractionMode, +} from './interaction-mode.js'; + +describe('interaction-mode', () => { + beforeEach(() => { + resetInteractionModeForTests(); + }); + + describe('resolveInteractionMode', () => { + it('returns explicit --mode value from separate argv tokens', () => { + expect( + resolveInteractionMode({ argv: ['--mode', 'agent'], env: {}, stdoutIsTTY: true, stderrIsTTY: true }), + ).toEqual({ + mode: 'agent', + source: 'flag', + }); + }); + + it('returns explicit --mode value from equals syntax', () => { + expect(resolveInteractionMode({ argv: ['--mode=ci'], env: {}, stdoutIsTTY: true, stderrIsTTY: true })).toEqual({ + mode: 'ci', + source: 'flag', + }); + }); + + it('--mode beats WORKOS_MODE', () => { + expect( + resolveInteractionMode({ + argv: ['--mode', 'agent'], + env: { WORKOS_MODE: 'ci' }, + stdoutIsTTY: true, + stderrIsTTY: true, + }), + ).toEqual({ mode: 'agent', source: 'flag' }); + }); + + it('WORKOS_MODE beats WORKOS_NO_PROMPT', () => { + expect( + resolveInteractionMode({ + env: { WORKOS_MODE: 'human', WORKOS_NO_PROMPT: '1' }, + stdoutIsTTY: false, + stderrIsTTY: false, + }), + ).toEqual({ mode: 'human', source: 'env' }); + }); + + it('WORKOS_NO_PROMPT=true maps to agent compatibility mode', () => { + expect( + resolveInteractionMode({ env: { WORKOS_NO_PROMPT: 'true' }, stdoutIsTTY: true, stderrIsTTY: true }), + ).toEqual({ + mode: 'agent', + source: 'workos_no_prompt', + }); + }); + + it('CI markers beat agent markers when no explicit mode is set', () => { + expect( + resolveInteractionMode({ + env: { CI: 'true', WORKOS_AGENT: '1' }, + stdoutIsTTY: true, + stderrIsTTY: true, + }), + ).toEqual({ mode: 'ci', source: 'ci_env' }); + }); + + it('detects agent markers', () => { + expect(resolveInteractionMode({ env: { WORKOS_AGENT: '1' }, stdoutIsTTY: true, stderrIsTTY: true })).toEqual({ + mode: 'agent', + source: 'agent_env', + }); + }); + + it('non-TTY maps to agent after env marker checks', () => { + expect(resolveInteractionMode({ env: {}, stdoutIsTTY: false, stderrIsTTY: true })).toEqual({ + mode: 'agent', + source: 'non_tty', + }); + }); + + it('TTY with no markers defaults to human', () => { + expect(resolveInteractionMode({ env: {}, stdoutIsTTY: true, stderrIsTTY: true })).toEqual({ + mode: 'human', + source: 'default', + }); + }); + + it('WORKOS_FORCE_TTY does not affect interaction mode', () => { + expect( + resolveInteractionMode({ env: { WORKOS_FORCE_TTY: '1' }, stdoutIsTTY: false, stderrIsTTY: false }), + ).toEqual({ + mode: 'agent', + source: 'non_tty', + }); + }); + + it('throws for invalid --mode values', () => { + expect(() => resolveInteractionMode({ argv: ['--mode', 'robot'], env: {} })).toThrow(InvalidInteractionModeError); + }); + + it('throws for missing --mode values', () => { + expect(() => resolveInteractionMode({ argv: ['--mode'], env: {} })).toThrow(InvalidInteractionModeError); + }); + + it('throws for invalid WORKOS_MODE values', () => { + expect(() => resolveInteractionMode({ env: { WORKOS_MODE: 'robot' } })).toThrow(InvalidInteractionModeError); + }); + }); + + describe('process-level state', () => { + it('sets and gets interaction mode', () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + expect(getInteractionMode()).toEqual({ mode: 'agent', source: 'env' }); + expect(isAgentMode()).toBe(true); + expect(isHumanMode()).toBe(false); + expect(isCiMode()).toBe(false); + expect(isPromptAllowed()).toBe(false); + }); + + it('defaults to human mode', () => { + expect(getInteractionMode()).toEqual({ mode: 'human', source: 'default' }); + expect(isHumanMode()).toBe(true); + expect(isPromptAllowed()).toBe(true); + }); + }); +}); diff --git a/src/utils/interaction-mode.ts b/src/utils/interaction-mode.ts new file mode 100644 index 00000000..b983a17f --- /dev/null +++ b/src/utils/interaction-mode.ts @@ -0,0 +1,149 @@ +export type InteractionMode = 'human' | 'agent' | 'ci'; + +export type InteractionModeSource = + | 'flag' + | 'env' + | 'workos_no_prompt' + | 'ci_env' + | 'agent_env' + | 'non_tty' + | 'default'; + +export interface InteractionModeInfo { + mode: InteractionMode; + source: InteractionModeSource; +} + +export interface ResolveInteractionModeOptions { + argv?: string[]; + env?: NodeJS.ProcessEnv; + stdoutIsTTY?: boolean; + stderrIsTTY?: boolean; +} + +const VALID_MODES: InteractionMode[] = ['human', 'agent', 'ci']; + +let currentMode: InteractionModeInfo = { mode: 'human', source: 'default' }; + +export class InvalidInteractionModeError extends Error { + constructor( + public readonly value: string | undefined, + public readonly source: 'flag' | 'env', + ) { + const label = source === 'flag' ? '--mode' : 'WORKOS_MODE'; + super(`${label} must be one of: ${VALID_MODES.join(', ')}`); + this.name = 'InvalidInteractionModeError'; + } +} + +function isTruthy(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true'; +} + +function parseModeValue(value: string | undefined, source: 'flag' | 'env'): InteractionMode { + const normalized = value?.toLowerCase(); + if (normalized && VALID_MODES.includes(normalized as InteractionMode)) { + return normalized as InteractionMode; + } + throw new InvalidInteractionModeError(value, source); +} + +function parseModeFromArgv(argv: string[]): InteractionMode | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--mode') { + const value = argv[i + 1]; + if (!value || value.startsWith('-')) { + throw new InvalidInteractionModeError(value, 'flag'); + } + return parseModeValue(value, 'flag'); + } + if (arg.startsWith('--mode=')) { + return parseModeValue(arg.slice('--mode='.length), 'flag'); + } + } + + return undefined; +} + +function hasCiMarker(env: NodeJS.ProcessEnv): boolean { + return ( + isTruthy(env.CI) || + isTruthy(env.GITHUB_ACTIONS) || + isTruthy(env.GITLAB_CI) || + isTruthy(env.CIRCLECI) || + isTruthy(env.BUILDKITE) || + isTruthy(env.TF_BUILD) + ); +} + +function hasAgentMarker(env: NodeJS.ProcessEnv): boolean { + return ( + isTruthy(env.WORKOS_AGENT) || + isTruthy(env.CLAUDECODE) || + isTruthy(env.CLAUDE_CODE) || + isTruthy(env.CURSOR_AGENT) || + isTruthy(env.CODEX_SANDBOX) || + env.CURSOR_TRACE_ID !== undefined + ); +} + +export function resolveInteractionMode(options: ResolveInteractionModeOptions = {}): InteractionModeInfo { + const argv = options.argv ?? []; + const env = options.env ?? process.env; + + const flagMode = parseModeFromArgv(argv); + if (flagMode) return { mode: flagMode, source: 'flag' }; + + if (env.WORKOS_MODE !== undefined) { + return { mode: parseModeValue(env.WORKOS_MODE, 'env'), source: 'env' }; + } + + if (isTruthy(env.WORKOS_NO_PROMPT)) { + return { mode: 'agent', source: 'workos_no_prompt' }; + } + + if (hasCiMarker(env)) { + return { mode: 'ci', source: 'ci_env' }; + } + + if (hasAgentMarker(env)) { + return { mode: 'agent', source: 'agent_env' }; + } + + const stdoutIsTTY = options.stdoutIsTTY ?? process.stdout.isTTY; + const stderrIsTTY = options.stderrIsTTY ?? process.stderr.isTTY; + if (!stdoutIsTTY || !stderrIsTTY) { + return { mode: 'agent', source: 'non_tty' }; + } + + return { mode: 'human', source: 'default' }; +} + +export function setInteractionMode(info: InteractionModeInfo): void { + currentMode = info; +} + +export function getInteractionMode(): InteractionModeInfo { + return currentMode; +} + +export function isHumanMode(): boolean { + return currentMode.mode === 'human'; +} + +export function isAgentMode(): boolean { + return currentMode.mode === 'agent'; +} + +export function isCiMode(): boolean { + return currentMode.mode === 'ci'; +} + +export function isPromptAllowed(): boolean { + return isHumanMode(); +} + +export function resetInteractionModeForTests(): void { + currentMode = { mode: 'human', source: 'default' }; +} diff --git a/src/utils/mode-compatibility.spec.ts b/src/utils/mode-compatibility.spec.ts new file mode 100644 index 00000000..e379659d --- /dev/null +++ b/src/utils/mode-compatibility.spec.ts @@ -0,0 +1,165 @@ +/** + * Backcompat matrix tests for Phase 6. + * + * Encodes the contract's commitment to resolving output mode and interaction mode + * separately, then coercing the effective output mode to JSON for explicit + * agent/CI modes so headless streams are not prefixed by human-only output. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolveEffectiveOutputMode, resolveOutputMode } from './output.js'; +import { resolveInteractionMode } from './interaction-mode.js'; + +describe('mode compatibility matrix', () => { + const originalEnv = process.env; + const originalIsTTY = process.stdout.isTTY; + const originalStderrIsTTY = process.stderr.isTTY; + const interactionEnvKeys = ['WORKOS_MODE', 'WORKOS_NO_PROMPT', 'CI', 'GITHUB_ACTIONS', 'WORKOS_AGENT'] as const; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.WORKOS_FORCE_TTY; + delete process.env.WORKOS_NO_PROMPT; + delete process.env.WORKOS_MODE; + delete process.env.CI; + delete process.env.GITHUB_ACTIONS; + delete process.env.WORKOS_AGENT; + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: originalStderrIsTTY, writable: true }); + process.env = originalEnv; + }); + + function getInteractionTestEnv(): NodeJS.ProcessEnv { + return Object.fromEntries( + interactionEnvKeys.flatMap((key) => { + const value = process.env[key]; + return value === undefined ? [] : [[key, value]]; + }), + ); + } + + // Each row asserts that output and interaction modes resolve independently. + type Row = { + name: string; + setup: () => { argv?: string[]; jsonFlag?: boolean }; + expectOutput: 'human' | 'json'; + expectMode: 'human' | 'agent' | 'ci'; + expectSource: 'flag' | 'env' | 'workos_no_prompt' | 'ci_env' | 'agent_env' | 'non_tty' | 'default'; + }; + + const rows: Row[] = [ + { + name: '--json alone keeps interaction mode at default human', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, writable: true }); + return { jsonFlag: true }; + }, + expectOutput: 'json', + expectMode: 'human', + expectSource: 'default', + }, + { + name: 'non-TTY stdout maps output to json and interaction to agent', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: undefined, writable: true }); + return {}; + }, + expectOutput: 'json', + expectMode: 'agent', + expectSource: 'non_tty', + }, + { + name: 'WORKOS_NO_PROMPT=1 maps output to json and interaction to agent (legacy compatibility)', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, writable: true }); + process.env.WORKOS_NO_PROMPT = '1'; + return {}; + }, + expectOutput: 'json', + expectMode: 'agent', + expectSource: 'workos_no_prompt', + }, + { + name: 'WORKOS_FORCE_TTY=1 forces human output but does not change interaction mode (non-TTY)', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: undefined, writable: true }); + process.env.WORKOS_FORCE_TTY = '1'; + return {}; + }, + expectOutput: 'human', + // Interaction mode still resolves to agent because non-TTY is the only + // remaining signal; WORKOS_FORCE_TTY must not silently flip interaction. + expectMode: 'agent', + expectSource: 'non_tty', + }, + { + name: 'WORKOS_MODE=human in non-TTY keeps interaction human but output stays json', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: undefined, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: undefined, writable: true }); + process.env.WORKOS_MODE = 'human'; + return {}; + }, + expectOutput: 'json', + expectMode: 'human', + expectSource: 'env', + }, + { + name: 'WORKOS_MODE=agent with TTY coerces effective output to json', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, writable: true }); + process.env.WORKOS_MODE = 'agent'; + return {}; + }, + expectOutput: 'json', + expectMode: 'agent', + expectSource: 'env', + }, + { + name: 'CI marker beats agent marker without explicit override', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, writable: true }); + process.env.CI = 'true'; + process.env.WORKOS_AGENT = '1'; + return {}; + }, + expectOutput: 'json', + expectMode: 'ci', + expectSource: 'ci_env', + }, + { + name: '--mode agent beats CI markers', + setup: () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: true, writable: true }); + process.env.CI = 'true'; + process.env.GITHUB_ACTIONS = 'true'; + return { argv: ['--mode', 'agent'] }; + }, + expectOutput: 'json', + expectMode: 'agent', + expectSource: 'flag', + }, + ]; + + for (const row of rows) { + it(row.name, () => { + const { argv = [], jsonFlag } = row.setup(); + const interaction = resolveInteractionMode({ argv, env: getInteractionTestEnv() }); + expect(resolveEffectiveOutputMode(resolveOutputMode(jsonFlag), interaction)).toBe(row.expectOutput); + expect(interaction).toEqual({ + mode: row.expectMode, + source: row.expectSource, + }); + }); + } +}); diff --git a/src/utils/output.spec.ts b/src/utils/output.spec.ts index 43296cbb..125e7eec 100644 --- a/src/utils/output.spec.ts +++ b/src/utils/output.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const { resolveOutputMode, + resolveEffectiveOutputMode, setOutputMode, getOutputMode, isJsonMode, @@ -18,6 +19,7 @@ describe('output', () => { beforeEach(() => { process.env = { ...originalEnv }; delete process.env.WORKOS_FORCE_TTY; + delete process.env.WORKOS_NO_PROMPT; setOutputMode('human'); }); @@ -42,6 +44,12 @@ describe('output', () => { expect(resolveOutputMode()).toBe('json'); }); + it('returns json when WORKOS_NO_PROMPT is set for legacy output compatibility', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + process.env.WORKOS_NO_PROMPT = '1'; + expect(resolveOutputMode()).toBe('json'); + }); + it('returns human when stdout is a TTY and no flags', () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); expect(resolveOutputMode()).toBe('human'); @@ -53,6 +61,20 @@ describe('output', () => { }); }); + describe('resolveEffectiveOutputMode', () => { + it('keeps human output for human interaction mode', () => { + expect(resolveEffectiveOutputMode('human', { mode: 'human', source: 'default' })).toBe('human'); + }); + + it('forces JSON output for explicit agent mode', () => { + expect(resolveEffectiveOutputMode('human', { mode: 'agent', source: 'env' })).toBe('json'); + }); + + it('preserves non-TTY output compatibility decisions', () => { + expect(resolveEffectiveOutputMode('human', { mode: 'agent', source: 'non_tty' })).toBe('human'); + }); + }); + describe('setOutputMode / getOutputMode / isJsonMode', () => { it('sets and gets output mode', () => { setOutputMode('json'); @@ -102,6 +124,44 @@ describe('output', () => { expect(spy.mock.calls[0][0]).toContain('something failed'); spy.mockRestore(); }); + + it('serializes recovery metadata in json mode', () => { + setOutputMode('json'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputError({ + code: 'auth_required', + message: 'Not authenticated.', + recovery: { + hints: [ + { description: 'Authenticate on host shell.', command: 'workos auth login', hostShellRequired: true }, + ], + }, + }); + const output = JSON.parse(spy.mock.calls[0][0]); + expect(output.error.recovery.hints[0]).toEqual({ + description: 'Authenticate on host shell.', + command: 'workos auth login', + hostShellRequired: true, + }); + spy.mockRestore(); + }); + + it('prints the first recovery hint in human mode without dumping JSON', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputError({ + code: 'confirmation_required', + message: 'Refusing to DELETE.', + recovery: { + hints: [{ description: 'Re-run with --yes.', command: 'workos api /x --method DELETE --yes' }], + }, + }); + const lines = spy.mock.calls.map((c) => c[0]); + expect(lines.some((l: string) => l.includes('Refusing to DELETE'))).toBe(true); + expect(lines.some((l: string) => l.includes('workos api /x --method DELETE --yes'))).toBe(true); + expect(lines.every((l: string) => !l.startsWith('{'))).toBe(true); + spy.mockRestore(); + }); }); describe('outputSuccess', () => { diff --git a/src/utils/output.ts b/src/utils/output.ts index fcd58ce7..6b6aba26 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,13 +1,15 @@ /** * Output mode system for non-TTY / JSON support. * - * Resolves once at startup, drives all output formatting. + * Resolves once at startup, drives output formatting only. * In JSON mode: structured JSON to stdout, structured errors to stderr. * In human mode: chalk-formatted output (existing behavior). */ import chalk from 'chalk'; import { formatTable, type TableColumn } from './table.js'; +import type { RecoveryHints } from './recovery-hints.js'; +import type { InteractionModeInfo } from './interaction-mode.js'; export type OutputMode = 'human' | 'json'; @@ -18,9 +20,10 @@ let currentMode: OutputMode = 'human'; * * Priority: * 1. Explicit --json flag - * 2. WORKOS_FORCE_TTY env var → human - * 3. Non-TTY auto-detection → json - * 4. Default → human + * 2. WORKOS_FORCE_TTY env var → human output compatibility + * 3. WORKOS_NO_PROMPT legacy compatibility → json + * 4. Non-TTY auto-detection → json + * 5. Default → human */ export function resolveOutputMode(jsonFlag?: boolean): OutputMode { if (jsonFlag) return 'json'; @@ -30,6 +33,13 @@ export function resolveOutputMode(jsonFlag?: boolean): OutputMode { return 'human'; } +export function resolveEffectiveOutputMode(mode: OutputMode, interaction: InteractionModeInfo): OutputMode { + if (interaction.mode === 'human' || interaction.source === 'non_tty') { + return mode; + } + return 'json'; +} + export function setOutputMode(mode: OutputMode): void { currentMode = mode; if (mode === 'json') { @@ -74,12 +84,30 @@ export function outputSuccess( } } +export interface StructuredError { + code: string; + message: string; + details?: unknown; + /** + * Optional structured recovery metadata for agents. + * + * Only include for deterministic recovery paths. Human output prints the + * first hint as a follow-up line; JSON output serializes the full structure. + */ + recovery?: RecoveryHints; +} + /** Write a structured error to stderr. */ -export function outputError(error: { code: string; message: string; details?: unknown }): void { +export function outputError(error: StructuredError): void { if (currentMode === 'json') { console.error(JSON.stringify({ error })); } else { console.error(chalk.red(error.message)); + const firstHint = error.recovery?.hints[0]; + if (firstHint) { + const suffix = firstHint.command ? ` Run: ${firstHint.command}` : ''; + console.error(chalk.dim(`→ ${firstHint.description}${suffix}`)); + } } } @@ -105,7 +133,7 @@ export function outputTable(columns: TableColumn[], rows: string[][], rawData?: } /** Exit with a structured error. Writes error then exits with code 1. */ -export function exitWithError(error: { code: string; message: string; details?: unknown }): never { +export function exitWithError(error: StructuredError): never { outputError(error); process.exit(1); } diff --git a/src/utils/recovery-hints.spec.ts b/src/utils/recovery-hints.spec.ts new file mode 100644 index 00000000..fa199186 --- /dev/null +++ b/src/utils/recovery-hints.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { authLoginRecovery, confirmationRecovery, missingArgsRecovery } from './recovery-hints.js'; + +describe('recovery-hints', () => { + describe('authLoginRecovery', () => { + it('CI mode prefers env credentials and marks login as host-shell-required', () => { + const recovery = authLoginRecovery({ mode: 'ci', env: {} }); + expect(recovery.hints[0]).toMatchObject({ + description: expect.stringContaining('WORKOS_API_KEY'), + }); + expect(recovery.hints[0].command).toBeUndefined(); + expect(recovery.hints[1]).toMatchObject({ + command: 'workos auth login', + hostShellRequired: true, + }); + }); + + it('agent mode recommends host-shell login first and env var second', () => { + const recovery = authLoginRecovery({ mode: 'agent', env: {} }); + expect(recovery.hints[0]).toMatchObject({ + command: 'workos auth login', + hostShellRequired: true, + }); + expect(recovery.hints[1].description).toMatch(/WORKOS_API_KEY/); + expect(recovery.hints[1].command).toBeUndefined(); + }); + + it('human mode recommends device login without host-shell flag', () => { + const recovery = authLoginRecovery({ mode: 'human', env: {} }); + expect(recovery.hints[0]).toMatchObject({ + command: 'workos auth login', + }); + expect(recovery.hints[0].hostShellRequired).toBeUndefined(); + }); + + it('uses npx invocation when called via npm exec', () => { + const recovery = authLoginRecovery({ + mode: 'agent', + env: { npm_command: 'exec' }, + }); + expect(recovery.hints[0].command).toBe('npx workos@latest auth login'); + }); + }); + + describe('confirmationRecovery', () => { + it('returns a single deterministic command hint', () => { + const recovery = confirmationRecovery('workos api /resource --method DELETE --yes'); + expect(recovery.hints).toHaveLength(1); + expect(recovery.hints[0].command).toBe('workos api /resource --method DELETE --yes'); + }); + + it('omits command when the rerun cannot be safely reconstructed', () => { + const recovery = confirmationRecovery(); + expect(recovery.hints).toEqual([{ description: 'Re-run with explicit confirmation.' }]); + }); + }); + + describe('missingArgsRecovery', () => { + it('attaches the example command and description', () => { + const recovery = missingArgsRecovery('workos env add prod sk_test_xxx', 'Pass name and api key'); + expect(recovery.hints).toEqual([ + { description: 'Pass name and api key', command: 'workos env add prod sk_test_xxx' }, + ]); + }); + + it('omits placeholder commands', () => { + const recovery = missingArgsRecovery(undefined, 'Pass name and api key'); + expect(recovery.hints).toEqual([{ description: 'Pass name and api key' }]); + }); + }); +}); diff --git a/src/utils/recovery-hints.ts b/src/utils/recovery-hints.ts new file mode 100644 index 00000000..4c4fb0b8 --- /dev/null +++ b/src/utils/recovery-hints.ts @@ -0,0 +1,104 @@ +/** + * Recovery hints for structured CLI errors. + * + * Recovery metadata is consumed by coding agents through JSON stderr output. + * Hints describe the deterministic next step the caller can take to fix the + * failure: the exact command to run, whether host shell access is required, + * and (optionally) related docs links. + * + * Keep hints conservative: only include `command` when the next action is + * unambiguous and safe. When the correct next step depends on user intent, + * provide `description` only. + */ + +import type { InteractionMode } from './interaction-mode.js'; +import { formatWorkOSCommand } from './command-invocation.js'; + +export interface RecoveryHint { + /** Human-readable next step for both humans and agents. */ + description: string; + /** Exact command to run, when deterministic. */ + command?: string; + /** True when the command must run on the user's host shell, not in the current sandbox. */ + hostShellRequired?: boolean; + /** Optional documentation URL. */ + docsUrl?: string; + /** Optional markdown-capable documentation URL. Only set when route support is verified. */ + docsMarkdownUrl?: string; +} + +export interface RecoveryHints { + hints: RecoveryHint[]; +} + +/** Build mode-aware recovery hints for `auth_required` errors. */ +export function authLoginRecovery(options: { mode: InteractionMode; env?: NodeJS.ProcessEnv }): RecoveryHints { + const env = options.env ?? process.env; + const loginCommand = formatWorkOSCommand('auth login', env); + + if (options.mode === 'ci') { + return { + hints: [ + { + description: 'Set WORKOS_API_KEY in the CI environment.', + }, + { + description: 'Or refresh stored credentials before the CI run.', + command: loginCommand, + hostShellRequired: true, + }, + ], + }; + } + + if (options.mode === 'agent') { + return { + hints: [ + { + description: "Authenticate on the user's host shell.", + command: loginCommand, + hostShellRequired: true, + }, + { + description: 'Or set WORKOS_API_KEY before invoking the CLI.', + }, + ], + }; + } + + return { + hints: [ + { + description: 'Authenticate via browser-based device login.', + command: loginCommand, + }, + { + description: 'Or set WORKOS_API_KEY.', + }, + ], + }; +} + +/** Build a `confirmation_required` recovery hint, attaching a command only when the exact rerun is known. */ +export function confirmationRecovery(command?: string): RecoveryHints { + return { + hints: [ + { + description: 'Re-run with explicit confirmation.', + ...(command && { command }), + }, + ], + }; +} + +/** Build a `missing_args` recovery hint, attaching a command only when it is directly runnable. */ +export function missingArgsRecovery(command: string | undefined, description: string): RecoveryHints { + return { + hints: [ + { + description, + ...(command && { command }), + }, + ], + }; +}