From f222b975f85a73263a7712a6cbb71d88f93be9af Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 16:20:44 -0700 Subject: [PATCH 01/10] feat: add host-capability probes for sandboxed environments When the CLI runs inside an AI agent sandbox (Claude Code, Codex, Cursor), the keyring and home directory may be unavailable. Instead of letting opaque EPERM errors confuse the agent, we now: 1. Proactively probe home-fs and keychain on first auth check in non-interactive mode (warnIfSandboxed in ensure-auth) 2. Reactively observe permission errors in keyring read/write calls in both credential-store and config-store (observeHostFailure) 3. Emit a single actionable warning per session pointing the user to re-run on the host shell --- src/lib/config-store.ts | 3 + src/lib/credential-store.ts | 3 + src/lib/ensure-auth.ts | 3 + src/lib/host-probe.spec.ts | 131 ++++++++++++++++++++++++++++++++++++ src/lib/host-probe.ts | 128 +++++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 src/lib/host-probe.spec.ts create mode 100644 src/lib/host-probe.ts diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 22539096..3e6b695b 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; @@ -109,6 +110,7 @@ function readFromKeyring(): CliConfig | null { return JSON.parse(data); } catch (error) { logWarn('Failed to read config from keyring:', error); + observeHostFailure('keychain', error); return null; } } @@ -120,6 +122,7 @@ function writeToKeyring(config: CliConfig): boolean { return true; } catch (error) { logWarn('Failed to write config to keyring:', error); + observeHostFailure('keychain', error); return false; } } diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 169c4484..add7de12 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; @@ -94,6 +95,7 @@ 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); return null; } } @@ -106,6 +108,7 @@ 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); return false; } } diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 5d5f3086..d0f769bb 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -10,6 +10,7 @@ import { logInfo } from '../utils/debug.js'; import { isNonInteractiveEnvironment } from '../utils/environment.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 */ @@ -37,6 +38,8 @@ export async function ensureAuthenticated(): Promise { tokenRefreshed: false, }; + warnIfSandboxed(); + // Case 1: No credentials or invalid credentials const creds = getCredentials(); if (!creds) { diff --git a/src/lib/host-probe.spec.ts b/src/lib/host-probe.spec.ts new file mode 100644 index 00000000..c5a36c80 --- /dev/null +++ b/src/lib/host-probe.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/debug.js', () => ({ + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock('../utils/environment.js', () => ({ + isNonInteractiveEnvironment: vi.fn(), +})); + +vi.mock('node:os', () => ({ + default: { homedir: () => '/tmp/host-probe-test' }, + homedir: () => '/tmp/host-probe-test', +})); + +vi.mock('node:fs', () => ({ + default: { + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), + }, + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +vi.mock('@napi-rs/keyring', () => ({ + Entry: class { + getPassword() { + return null; + } + }, +})); + +import { _resetProbeState, runHostProbe, warnIfSandboxed, observeHostFailure } from './host-probe.js'; +import { logWarn } from '../utils/debug.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import fs from 'node:fs'; + +describe('host-probe', () => { + beforeEach(() => { + _resetProbeState(); + vi.clearAllMocks(); + }); + + describe('runHostProbe', () => { + it('returns ok when home-fs and keychain succeed', () => { + const result = runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('detects home-fs failure', () => { + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('EPERM: operation not permitted'); + }); + + const result = runHostProbe(); + expect(result.ok).toBe(false); + expect(result.failures).toContainEqual(expect.objectContaining({ capability: 'home-fs' })); + }); + + it('caches the result across calls', () => { + const first = runHostProbe(); + const second = runHostProbe(); + expect(first).toBe(second); + }); + }); + + describe('warnIfSandboxed', () => { + it('warns in non-interactive mode when probe fails', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + warnIfSandboxed(); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining('unavailable'), + expect.stringContaining('host shell'), + ); + }); + + it('does not warn in interactive mode', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('EACCES'); + }); + + warnIfSandboxed(); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it('warns at most once per session', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('EPERM'); + }); + + warnIfSandboxed(); + const callCount = vi.mocked(logWarn).mock.calls.length; + warnIfSandboxed(); + expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); + }); + }); + + describe('observeHostFailure', () => { + it('warns on permission errors in non-interactive mode', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + observeHostFailure('keychain', new Error('EPERM: operation not permitted')); + expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('keychain'), expect.stringContaining('host shell')); + }); + + it('ignores non-permission errors', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + observeHostFailure('keychain', new Error('JSON parse error')); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it('does not warn twice even for different capabilities', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + observeHostFailure('keychain', new Error('EPERM')); + const callCount = vi.mocked(logWarn).mock.calls.length; + observeHostFailure('home-fs', new Error('EACCES')); + expect(vi.mocked(logWarn).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..0ab31299 --- /dev/null +++ b/src/lib/host-probe.ts @@ -0,0 +1,128 @@ +/** + * 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 fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { logWarn, logInfo } from '../utils/debug.js'; + +export type HostCapability = 'home-fs' | 'keychain' | 'network' | 'browser-launch'; + +export interface ProbeFailure { + capability: HostCapability; + detail: string; +} + +export interface ProbeResult { + ok: boolean; + failures: ProbeFailure[]; +} + +let warnedThisSession = false; +let cachedProbe: ProbeResult | undefined; + +const PERMISSION_PATTERNS = [ + /\bEPERM\b/i, + /\bEACCES\b/i, + /operation not permitted/i, + /permission denied/i, + /sandbox/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 probeHomeFs(): ProbeFailure | null { + const dir = path.join(os.homedir(), '.workos'); + const probePath = path.join(dir, `.probe-${process.pid}-${randomUUID()}`); + + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(probePath, new Date().toISOString(), { mode: 0o600 }); + fs.unlinkSync(probePath); + return null; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { capability: 'home-fs', detail }; + } +} + +function probeKeychain(): ProbeFailure | null { + try { + const { Entry } = require('@napi-rs/keyring'); + const entry = new Entry('workos-cli', 'probe'); + entry.getPassword(); + return null; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { capability: 'keychain', detail }; + } +} + +export function runHostProbe(): ProbeResult { + if (cachedProbe) return cachedProbe; + + const failures: ProbeFailure[] = []; + + const fsResult = probeHomeFs(); + if (fsResult) failures.push(fsResult); + + const keychainResult = probeKeychain(); + if (keychainResult) failures.push(keychainResult); + + cachedProbe = { ok: failures.length === 0, failures }; + return cachedProbe; +} + +export function warnIfSandboxed(): void { + if (warnedThisSession) return; + if (!isNonInteractiveEnvironment()) return; + + const probe = runHostProbe(); + if (probe.ok) return; + + warnedThisSession = true; + + const caps = probe.failures.map((f) => f.capability).join(', '); + logWarn( + `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] ${f.capability}: ${f.detail}`); + } +} + +export function observeHostFailure(capability: HostCapability, error: unknown): void { + if (warnedThisSession) return; + if (!isNonInteractiveEnvironment()) return; + if (!isPermissionError(error)) return; + + warnedThisSession = true; + + const detail = error instanceof Error ? error.message : String(error); + logWarn( + `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.', + ); +} + +export function _resetProbeState(): void { + cachedProbe = undefined; + warnedThisSession = false; +} From 98b86b566e80f1d9c8677eefd72d303d487c5194 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 23:58:29 +0000 Subject: [PATCH 02/10] fix(host-probe): correct keychain probe and use ESM/async APIs - probeKeychain() no longer reports the keychain as failed when the probe entry is simply absent. A 'not found' / 'No such' error from @napi-rs/keyring now indicates a healthy keychain (matches the existing pattern in deleteFromKeyring in config-store and credential-store), so non-interactive runs on healthy hosts no longer emit false-positive sandbox warnings. - Replace require('@napi-rs/keyring') with a static ES import. The package has 'type: module', so the previous require() threw ReferenceError at runtime and caused probeKeychain to always fail. - Switch probeHomeFs to node:fs/promises and make runHostProbe and warnIfSandboxed async, per the project's no-sync-fs guideline. - Tighten the /sandbox/i permission pattern to /\bsandboxd?\b/i so unrelated error messages containing 'sandbox' as a substring don't trigger sandbox warnings. Updates the spec to drive the async API and adds coverage for the healthy-keychain (entry-absent) and substring-collision cases. Co-Authored-By: nick.nisi@workos.com --- src/lib/ensure-auth.ts | 2 +- src/lib/host-probe.spec.ts | 121 +++++++++++++++++++++++++++---------- src/lib/host-probe.ts | 34 +++++++---- 3 files changed, 110 insertions(+), 47 deletions(-) diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index d0f769bb..a3b3f270 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -38,7 +38,7 @@ export async function ensureAuthenticated(): Promise { tokenRefreshed: false, }; - warnIfSandboxed(); + await warnIfSandboxed(); // Case 1: No credentials or invalid credentials const creds = getCredentials(); diff --git a/src/lib/host-probe.spec.ts b/src/lib/host-probe.spec.ts index c5a36c80..03dcd509 100644 --- a/src/lib/host-probe.spec.ts +++ b/src/lib/host-probe.spec.ts @@ -14,23 +14,26 @@ vi.mock('node:os', () => ({ homedir: () => '/tmp/host-probe-test', })); -vi.mock('node:fs', () => ({ - default: { - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), - }, - existsSync: vi.fn(() => true), - mkdirSync: vi.fn(), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), +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() { - return null; + getPassword(): string | null { + return keyringMock.getPassword(); } }, })); @@ -38,73 +41,107 @@ vi.mock('@napi-rs/keyring', () => ({ import { _resetProbeState, runHostProbe, warnIfSandboxed, observeHostFailure } from './host-probe.js'; import { logWarn } from '../utils/debug.js'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; describe('host-probe', () => { beforeEach(() => { _resetProbeState(); - vi.clearAllMocks(); + vi.resetAllMocks(); + keyringMock.getPassword.mockReturnValue(null); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); }); describe('runHostProbe', () => { - it('returns ok when home-fs and keychain succeed', () => { - const result = 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', () => { - vi.mocked(fs.writeFileSync).mockImplementation(() => { + it('detects home-fs failure', async () => { + vi.mocked(fs.writeFile).mockImplementation(() => { throw new Error('EPERM: operation not permitted'); }); - const result = runHostProbe(); + const result = await runHostProbe(); expect(result.ok).toBe(false); expect(result.failures).toContainEqual(expect.objectContaining({ capability: 'home-fs' })); }); - it('caches the result across calls', () => { - const first = runHostProbe(); - const second = runHostProbe(); + 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('caches the result across calls', async () => { + const first = await runHostProbe(); + const second = await runHostProbe(); expect(first).toBe(second); }); }); describe('warnIfSandboxed', () => { - it('warns in non-interactive mode when probe fails', () => { + it('warns in non-interactive mode when probe fails', async () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); - vi.mocked(fs.writeFileSync).mockImplementation(() => { + vi.mocked(fs.writeFile).mockImplementation(() => { throw new Error('EACCES: permission denied'); }); - warnIfSandboxed(); + await warnIfSandboxed(); expect(logWarn).toHaveBeenCalledWith( expect.stringContaining('unavailable'), expect.stringContaining('host shell'), ); }); - it('does not warn in interactive mode', () => { + it('does not warn in interactive mode', async () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(false); - vi.mocked(fs.writeFileSync).mockImplementation(() => { + vi.mocked(fs.writeFile).mockImplementation(() => { throw new Error('EACCES'); }); - warnIfSandboxed(); + await warnIfSandboxed(); expect(logWarn).not.toHaveBeenCalled(); }); - it('warns at most once per session', () => { + it('warns at most once per session', async () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); - vi.mocked(fs.writeFileSync).mockImplementation(() => { + vi.mocked(fs.writeFile).mockImplementation(() => { throw new Error('EPERM'); }); - warnIfSandboxed(); + await warnIfSandboxed(); const callCount = vi.mocked(logWarn).mock.calls.length; - warnIfSandboxed(); + await warnIfSandboxed(); expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); }); + + it('does not warn on a healthy host (no false positive when probe entry is absent)', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + keyringMock.getPassword.mockImplementation(() => { + throw new Error('No such password in the keyring'); + }); + + await warnIfSandboxed(); + expect(logWarn).not.toHaveBeenCalled(); + }); }); describe('observeHostFailure', () => { @@ -120,6 +157,12 @@ describe('host-probe', () => { expect(logWarn).not.toHaveBeenCalled(); }); + it('does not match unrelated words containing "sandbox" as a substring', () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + observeHostFailure('keychain', new Error('failed to update sandboxes table: schema mismatch')); + expect(logWarn).not.toHaveBeenCalled(); + }); + it('does not warn twice even for different capabilities', () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); observeHostFailure('keychain', new Error('EPERM')); @@ -127,5 +170,17 @@ describe('host-probe', () => { observeHostFailure('home-fs', new Error('EACCES')); expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); }); + + it('does not double-warn across proactive and reactive paths', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES'); + }); + + await warnIfSandboxed(); + const callCount = vi.mocked(logWarn).mock.calls.length; + observeHostFailure('keychain', new Error('EPERM')); + expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); + }); }); }); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index 0ab31299..f40cd960 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -7,10 +7,11 @@ * per session instead of letting opaque EPERM errors confuse the agent. */ -import fs from 'node:fs'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { randomUUID } from 'node:crypto'; +import { Entry } from '@napi-rs/keyring'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; import { logWarn, logInfo } from '../utils/debug.js'; @@ -34,7 +35,7 @@ const PERMISSION_PATTERNS = [ /\bEACCES\b/i, /operation not permitted/i, /permission denied/i, - /sandbox/i, + /\bsandboxd?\b/i, /interaction is not allowed/i, /access denied/i, ]; @@ -44,16 +45,19 @@ function isPermissionError(error: unknown): boolean { return PERMISSION_PATTERNS.some((p) => p.test(msg)); } -function probeHomeFs(): ProbeFailure | null { +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}-${randomUUID()}`); try { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(probePath, new Date().toISOString(), { mode: 0o600 }); - fs.unlinkSync(probePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(probePath, new Date().toISOString(), { mode: 0o600 }); + await fs.unlink(probePath); return null; } catch (error) { const detail = error instanceof Error ? error.message : String(error); @@ -63,22 +67,26 @@ function probeHomeFs(): ProbeFailure | null { function probeKeychain(): ProbeFailure | null { try { - const { Entry } = require('@napi-rs/keyring'); const entry = new Entry('workos-cli', 'probe'); 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; + } const detail = error instanceof Error ? error.message : String(error); return { capability: 'keychain', detail }; } } -export function runHostProbe(): ProbeResult { +export async function runHostProbe(): Promise { if (cachedProbe) return cachedProbe; const failures: ProbeFailure[] = []; - const fsResult = probeHomeFs(); + const fsResult = await probeHomeFs(); if (fsResult) failures.push(fsResult); const keychainResult = probeKeychain(); @@ -88,11 +96,11 @@ export function runHostProbe(): ProbeResult { return cachedProbe; } -export function warnIfSandboxed(): void { +export async function warnIfSandboxed(): Promise { if (warnedThisSession) return; if (!isNonInteractiveEnvironment()) return; - const probe = runHostProbe(); + const probe = await runHostProbe(); if (probe.ok) return; warnedThisSession = true; From 8baef982bb0308967a322ceef35f43e13a3ef31e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 00:08:03 +0000 Subject: [PATCH 03/10] fix(host-probe): use global crypto.randomUUID() per CLAUDE.md Co-Authored-By: nick.nisi@workos.com --- src/lib/host-probe.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index f40cd960..5c17c584 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -10,7 +10,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { randomUUID } from 'node:crypto'; import { Entry } from '@napi-rs/keyring'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; import { logWarn, logInfo } from '../utils/debug.js'; @@ -52,7 +51,7 @@ function isMissingEntryError(error: unknown): boolean { async function probeHomeFs(): Promise { const dir = path.join(os.homedir(), '.workos'); - const probePath = path.join(dir, `.probe-${process.pid}-${randomUUID()}`); + const probePath = path.join(dir, `.probe-${process.pid}-${crypto.randomUUID()}`); try { await fs.mkdir(dir, { recursive: true, mode: 0o700 }); From 4ce635ddca96f72512fb2c865582e3c0dbae655e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 00:14:35 +0000 Subject: [PATCH 04/10] fix(host-probe): only flag permission errors as sandbox failures probeHomeFs previously treated every fs error as a sandbox indicator, so transient errors like ENOSPC or EIO would emit a misleading "sandboxed environment" warning. Gate the catch block on isPermissionError so it stays consistent with observeHostFailure. Co-Authored-By: nick.nisi@workos.com --- src/lib/host-probe.spec.ts | 10 ++++++++++ src/lib/host-probe.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/src/lib/host-probe.spec.ts b/src/lib/host-probe.spec.ts index 03dcd509..2434b6ec 100644 --- a/src/lib/host-probe.spec.ts +++ b/src/lib/host-probe.spec.ts @@ -80,6 +80,16 @@ describe('host-probe', () => { 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('detects keychain failure on permission error', async () => { keyringMock.getPassword.mockImplementation(() => { throw new Error('EACCES: keychain unavailable'); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index 5c17c584..e256ac3c 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -59,6 +59,10 @@ async function probeHomeFs(): Promise { await fs.unlink(probePath); 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 }; } From a9b122a74757f11721c71111bbcbcfd1cf6af26a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 00:19:20 +0000 Subject: [PATCH 05/10] fix(host-probe): gate probeKeychain on permission errors probeKeychain previously treated any non-missing-entry keychain error as a sandbox indicator. A user-canceled macOS keychain prompt or a transient keyring daemon error would therefore produce a misleading "sandboxed environment" warning on healthy hosts. Mirror probeHomeFs and observeHostFailure by ignoring non-permission errors. Co-Authored-By: nick.nisi@workos.com --- src/lib/host-probe.spec.ts | 10 ++++++++++ src/lib/host-probe.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/src/lib/host-probe.spec.ts b/src/lib/host-probe.spec.ts index 2434b6ec..c794179a 100644 --- a/src/lib/host-probe.spec.ts +++ b/src/lib/host-probe.spec.ts @@ -100,6 +100,16 @@ describe('host-probe', () => { 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(); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index e256ac3c..7b6c15a7 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -79,6 +79,11 @@ function probeKeychain(): ProbeFailure | null { 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 }; } From 9df6a118136bb2d330e18fccff7af24148ed79e3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 00:12:02 +0000 Subject: [PATCH 06/10] fix(host-probe): clean up probe file in finally block Move fs.unlink into a finally block with best-effort error handling so a successful writeFile never leaves an orphan probe file in ~/.workos when unlink itself fails. The probe is checking write access; cleanup should not affect the result. Co-Authored-By: nick.nisi@workos.com --- src/lib/host-probe.spec.ts | 13 +++++++++++++ src/lib/host-probe.ts | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/host-probe.spec.ts b/src/lib/host-probe.spec.ts index c794179a..a4982587 100644 --- a/src/lib/host-probe.spec.ts +++ b/src/lib/host-probe.spec.ts @@ -90,6 +90,19 @@ describe('host-probe', () => { 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'); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index 7b6c15a7..5d639ee9 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -56,7 +56,6 @@ async function probeHomeFs(): Promise { try { await fs.mkdir(dir, { recursive: true, mode: 0o700 }); await fs.writeFile(probePath, new Date().toISOString(), { mode: 0o600 }); - await fs.unlink(probePath); return null; } catch (error) { // Only treat permission-class errors as sandbox indicators. Transient @@ -65,6 +64,12 @@ async function probeHomeFs(): Promise { if (!isPermissionError(error)) return null; const detail = error instanceof Error ? error.message : String(error); return { capability: 'home-fs', detail }; + } 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(() => {}); } } From 97aaaeb82e8b25747ec8cdbd514a255c9ed23b63 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 8 May 2026 18:25:02 -0700 Subject: [PATCH 07/10] Add host execution doctor diagnostics --- src/commands/claim.ts | 6 +++ src/commands/login.ts | 8 ++- src/doctor/checks/auth-patterns.spec.ts | 25 ++++++++++ src/doctor/checks/auth-patterns.ts | 12 ++++- src/doctor/checks/host-execution.spec.ts | 63 ++++++++++++++++++++++++ src/doctor/checks/host-execution.ts | 26 ++++++++++ src/doctor/index.ts | 6 ++- src/doctor/issues.spec.ts | 62 +++++++++++++++++++++++ src/doctor/issues.ts | 13 +++++ src/doctor/output.ts | 19 +++++++ src/doctor/types.ts | 16 ++++++ src/lib/config-store.ts | 57 +++++++++++++++++---- src/lib/credential-proxy.ts | 15 +++++- src/lib/credential-store.ts | 57 +++++++++++++++++---- src/lib/host-probe.spec.ts | 52 +++++++++++++------ src/lib/host-probe.ts | 63 +++++++++++++++++++----- src/lib/run-with-core.ts | 16 +++++- src/utils/debug.spec.ts | 23 +++++++++ src/utils/debug.ts | 11 ++++- 19 files changed, 500 insertions(+), 50 deletions(-) create mode 100644 src/doctor/checks/host-execution.spec.ts create mode 100644 src/doctor/checks/host-execution.ts create mode 100644 src/doctor/issues.spec.ts diff --git a/src/commands/claim.ts b/src/commands/claim.ts index 30f36c3c..fea4e240 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -10,6 +10,7 @@ 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 { sleep } from '../lib/helper-functions.js'; @@ -67,6 +68,11 @@ export async function runClaim(): Promise { open(claimUrl, { wait: false }); 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/login.ts b/src/commands/login.ts index 8c1a392e..303ee6c6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -12,6 +12,7 @@ import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { autoInstallSkills } from './install-skill.js'; import { isJsonMode } from '../utils/output.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. @@ -130,7 +131,12 @@ export async function runLogin(): Promise { try { open(deviceAuth.verification_uri_complete); clack.log.info('Browser opened automatically'); - } catch { + } catch (error) { + observeHostFailure('browser-launch', error, { + operation: 'open', + target: deviceAuth.verification_uri_complete, + label: 'auth login browser', + }); // User can open manually } 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..24552315 --- /dev/null +++ b/src/doctor/checks/host-execution.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../utils/environment.js', () => ({ + isNonInteractiveEnvironment: vi.fn(), +})); + +vi.mock('../../lib/host-probe.js', () => ({ + runHostProbe: vi.fn(), +})); + +import { checkHostExecution } from './host-execution.js'; +import { isNonInteractiveEnvironment } from '../../utils/environment.js'; +import { runHostProbe } from '../../lib/host-probe.js'; + +describe('checkHostExecution', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('passes without probing in an interactive host shell', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(false); + + const result = await checkHostExecution(); + + expect(result).toEqual({ mode: 'interactive', ok: true, failures: [] }); + expect(runHostProbe).not.toHaveBeenCalled(); + }); + + it('passes when non-interactive host state is reachable', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(runHostProbe).mockResolvedValue({ ok: true, failures: [] }); + + const result = await checkHostExecution(); + + expect(result).toEqual({ mode: 'non-interactive', ok: true, failures: [], warning: undefined }); + }); + + it('warns when non-interactive host state is blocked', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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', + }); + }); +}); diff --git a/src/doctor/checks/host-execution.ts b/src/doctor/checks/host-execution.ts new file mode 100644 index 00000000..1339f9a0 --- /dev/null +++ b/src/doctor/checks/host-execution.ts @@ -0,0 +1,26 @@ +import { isNonInteractiveEnvironment } from '../../utils/environment.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 nonInteractive = isNonInteractiveEnvironment(); + + if (!nonInteractive) { + 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..f317e38a 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'; @@ -70,12 +71,13 @@ export async function runDoctor(options: DoctorOptions): Promise { 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; @@ -99,6 +101,7 @@ export async function runDoctor(options: DoctorOptions): Promise { runtime, framework, environment, + hostExecution, connectivity, skills, }); @@ -138,6 +141,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..43b66f60 --- /dev/null +++ b/src/doctor/issues.spec.ts @@ -0,0 +1,62 @@ +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', + 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', + details: { failures: report.hostExecution.failures }, + }), + ); + }); +}); diff --git a/src/doctor/issues.ts b/src/doctor/issues.ts index d653251e..65b36d38 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -58,6 +58,11 @@ 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: 'Re-run this command on the host shell before trusting auth, config, or API failures', + }, }; export function detectIssues(report: Omit): Issue[] { @@ -97,6 +102,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/output.ts b/src/doctor/output.ts index ff9ddac9..61c02556 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -51,6 +51,25 @@ export function formatReport(report: DoctorReport, options?: FormatOptions): voi ); console.log(` Base URL: ${report.environment.baseUrl} ${Chalk.green('✓')}`); + // 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('✓')} Non-interactive, host state reachable`); + } else { + console.log(` Shell: ${Chalk.yellow('!')} Non-interactive, 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'); diff --git a/src/doctor/types.ts b/src/doctor/types.ts index d87c0efd..2e987085 100644 --- a/src/doctor/types.ts +++ b/src/doctor/types.ts @@ -67,6 +67,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[]; @@ -145,6 +160,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 3e6b695b..fa48f623 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -73,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; } @@ -84,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; + } } } @@ -110,7 +136,11 @@ function readFromKeyring(): CliConfig | null { return JSON.parse(data); } catch (error) { logWarn('Failed to read config from keyring:', error); - observeHostFailure('keychain', error); + observeHostFailure('keychain', error, { + operation: 'read', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'config keychain entry', + }); return null; } } @@ -122,7 +152,11 @@ function writeToKeyring(config: CliConfig): boolean { return true; } catch (error) { logWarn('Failed to write config to keyring:', error); - observeHostFailure('keychain', error); + observeHostFailure('keychain', error, { + operation: 'write', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'config keychain entry', + }); return false; } } @@ -135,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 add7de12..af4dd5e6 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -54,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; } @@ -65,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; + } } } @@ -95,7 +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); + observeHostFailure('keychain', error, { + operation: 'read', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'credential keychain entry', + }); return null; } } @@ -108,7 +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); + observeHostFailure('keychain', error, { + operation: 'write', + target: `${SERVICE_NAME}/${ACCOUNT_NAME}`, + label: 'credential keychain entry', + }); return false; } } @@ -121,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/host-probe.spec.ts b/src/lib/host-probe.spec.ts index a4982587..d2b60b9f 100644 --- a/src/lib/host-probe.spec.ts +++ b/src/lib/host-probe.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('../utils/debug.js', () => ({ logWarn: vi.fn(), + logVisibleWarn: vi.fn(), logInfo: vi.fn(), })); @@ -39,7 +40,7 @@ vi.mock('@napi-rs/keyring', () => ({ })); import { _resetProbeState, runHostProbe, warnIfSandboxed, observeHostFailure } from './host-probe.js'; -import { logWarn } from '../utils/debug.js'; +import { logInfo, logVisibleWarn } from '../utils/debug.js'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; import { promises as fs } from 'node:fs'; @@ -138,7 +139,7 @@ describe('host-probe', () => { }); await warnIfSandboxed(); - expect(logWarn).toHaveBeenCalledWith( + expect(logVisibleWarn).toHaveBeenCalledWith( expect.stringContaining('unavailable'), expect.stringContaining('host shell'), ); @@ -151,7 +152,7 @@ describe('host-probe', () => { }); await warnIfSandboxed(); - expect(logWarn).not.toHaveBeenCalled(); + expect(logVisibleWarn).not.toHaveBeenCalled(); }); it('warns at most once per session', async () => { @@ -161,9 +162,9 @@ describe('host-probe', () => { }); await warnIfSandboxed(); - const callCount = vi.mocked(logWarn).mock.calls.length; + const callCount = vi.mocked(logVisibleWarn).mock.calls.length; await warnIfSandboxed(); - expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); + 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 () => { @@ -173,35 +174,58 @@ describe('host-probe', () => { }); await warnIfSandboxed(); - expect(logWarn).not.toHaveBeenCalled(); + expect(logVisibleWarn).not.toHaveBeenCalled(); }); }); describe('observeHostFailure', () => { it('warns on permission errors in non-interactive mode', () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); - observeHostFailure('keychain', new Error('EPERM: operation not permitted')); - expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('keychain'), expect.stringContaining('host shell')); + 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 non-interactive mode', () => { + vi.mocked(isNonInteractiveEnvironment).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(isNonInteractiveEnvironment).mockReturnValue(true); observeHostFailure('keychain', new Error('JSON parse error')); - expect(logWarn).not.toHaveBeenCalled(); + expect(logVisibleWarn).not.toHaveBeenCalled(); }); it('does not match unrelated words containing "sandbox" as a substring', () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); observeHostFailure('keychain', new Error('failed to update sandboxes table: schema mismatch')); - expect(logWarn).not.toHaveBeenCalled(); + expect(logVisibleWarn).not.toHaveBeenCalled(); }); it('does not warn twice even for different capabilities', () => { vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); observeHostFailure('keychain', new Error('EPERM')); - const callCount = vi.mocked(logWarn).mock.calls.length; + const callCount = vi.mocked(logVisibleWarn).mock.calls.length; observeHostFailure('home-fs', new Error('EACCES')); - expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); + expect(vi.mocked(logVisibleWarn).mock.calls.length).toBe(callCount); }); it('does not double-warn across proactive and reactive paths', async () => { @@ -211,9 +235,9 @@ describe('host-probe', () => { }); await warnIfSandboxed(); - const callCount = vi.mocked(logWarn).mock.calls.length; + const callCount = vi.mocked(logVisibleWarn).mock.calls.length; observeHostFailure('keychain', new Error('EPERM')); - expect(vi.mocked(logWarn).mock.calls.length).toBe(callCount); + expect(vi.mocked(logVisibleWarn).mock.calls.length).toBe(callCount); }); }); }); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index 5d639ee9..36595c96 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -12,11 +12,18 @@ import path from 'node:path'; import os from 'node:os'; import { Entry } from '@napi-rs/keyring'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; -import { logWarn, logInfo } from '../utils/debug.js'; +import { logInfo, logVisibleWarn } from '../utils/debug.js'; -export type HostCapability = 'home-fs' | 'keychain' | 'network' | 'browser-launch'; +export type HostCapability = 'home-fs' | 'keychain' | 'network' | 'browser-launch' | 'localhost-bind'; +export type HostOperation = 'read' | 'write' | 'delete' | 'connect' | 'open' | 'listen'; -export interface ProbeFailure { +export interface HostCapabilityDetails { + operation?: HostOperation; + target?: string; + label?: string; +} + +export interface ProbeFailure extends HostCapabilityDetails { capability: HostCapability; detail: string; } @@ -29,6 +36,9 @@ export interface ProbeResult { 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, @@ -44,6 +54,14 @@ function isPermissionError(error: unknown): boolean { 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'); @@ -63,7 +81,7 @@ async function probeHomeFs(): Promise { // 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 }; + 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 @@ -75,7 +93,7 @@ async function probeHomeFs(): Promise { function probeKeychain(): ProbeFailure | null { try { - const entry = new Entry('workos-cli', 'probe'); + const entry = new Entry(KEYCHAIN_SERVICE, KEYCHAIN_PROBE_ACCOUNT); entry.getPassword(); return null; } catch (error) { @@ -90,10 +108,28 @@ function probeKeychain(): ProbeFailure | null { // observeHostFailure(). if (!isPermissionError(error)) return null; const detail = error instanceof Error ? error.message : String(error); - return { capability: 'keychain', detail }; + 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; @@ -119,28 +155,33 @@ export async function warnIfSandboxed(): Promise { warnedThisSession = true; const caps = probe.failures.map((f) => f.capability).join(', '); - logWarn( + 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] ${f.capability}: ${f.detail}`); + logInfo(`[host-probe] ${formatHostProbeFailure(f)}`); } } -export function observeHostFailure(capability: HostCapability, error: unknown): void { +export function observeHostFailure( + capability: HostCapability, + error: unknown, + details: HostCapabilityDetails = {}, +): void { if (warnedThisSession) return; if (!isNonInteractiveEnvironment()) return; - if (!isPermissionError(error)) return; + if (!isLikelyHostFailure(capability, error)) return; warnedThisSession = true; const detail = error instanceof Error ? error.message : String(error); - logWarn( + 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 { diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index f0b9b82a..056d8196 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -51,6 +51,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 { @@ -371,7 +372,12 @@ export async function runWithCore(options: InstallerOptions): Promise { try { const { default: openFn } = await import('opn'); await openFn(deviceAuth.verification_uri_complete); - } catch { + } catch (error) { + observeHostFailure('browser-launch', error, { + operation: 'open', + target: deviceAuth.verification_uri_complete, + label: 'installer device auth browser', + }); // User can open manually } @@ -493,7 +499,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); } diff --git a/src/utils/debug.spec.ts b/src/utils/debug.spec.ts index c1e07199..a7af9157 100644 --- a/src/utils/debug.spec.ts +++ b/src/utils/debug.spec.ts @@ -88,6 +88,29 @@ 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('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..1ec09d1e 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -61,7 +61,7 @@ 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(' '); @@ -80,6 +80,8 @@ function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown // Ignore write failures } } + + return msg; } export function logInfo(...args: unknown[]): void { @@ -90,6 +92,13 @@ export function logWarn(...args: unknown[]): void { writeLog('WARN', '⚠️ ', args); } +export function logVisibleWarn(...args: unknown[]): void { + const msg = writeLog('WARN', '⚠️ ', args); + if (!debugEnabled) { + console.error(chalk.yellow(`⚠️ ${msg}`)); + } +} + export function logError(...args: unknown[]): void { writeLog('ERROR', '❌', args); } From 889c9319d2c30d97ca577da420641a8315a5b198 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 8 May 2026 20:06:00 -0700 Subject: [PATCH 08/10] Add first-class CLI interaction modes --- DEVELOPMENT.md | 27 ++++ README.md | 42 ++++++- src/bin.ts | 33 +++-- src/commands/api/index.spec.ts | 27 ++-- src/commands/api/index.ts | 36 +++--- src/commands/claim.spec.ts | 40 +++++- src/commands/claim.ts | 17 ++- src/commands/connection.spec.ts | 14 +-- src/commands/connection.ts | 8 +- src/commands/debug.spec.ts | 15 +-- src/commands/debug.ts | 9 +- src/commands/directory.spec.ts | 14 +-- src/commands/directory.ts | 8 +- src/commands/env.spec.ts | 15 +++ src/commands/env.ts | 15 ++- src/commands/login.spec.ts | 66 +++++++++- src/commands/login.ts | 18 ++- src/doctor/checks/host-execution.spec.ts | 36 ++++-- src/doctor/checks/host-execution.ts | 6 +- src/doctor/index.ts | 5 + src/doctor/issues.spec.ts | 2 + src/doctor/issues.ts | 3 +- src/doctor/json-output.spec.ts | 44 +++++++ src/doctor/output.spec.ts | 89 +++++++++++++ src/doctor/output.ts | 31 ++++- src/doctor/types.ts | 3 + src/lib/ensure-auth.spec.ts | 45 +++++-- src/lib/ensure-auth.ts | 47 +++++-- src/lib/host-probe.spec.ts | 51 +++++--- src/lib/host-probe.ts | 10 +- src/lib/run-with-core.ts | 11 +- src/utils/environment.spec.ts | 33 +++++ src/utils/environment.ts | 30 ++--- src/utils/exit-codes.spec.ts | 27 +++- src/utils/exit-codes.ts | 16 ++- src/utils/help-json.spec.ts | 33 ++++- src/utils/help-json.ts | 25 ++++ src/utils/interaction-mode.spec.ts | 135 ++++++++++++++++++++ src/utils/interaction-mode.ts | 149 ++++++++++++++++++++++ src/utils/mode-compatibility.spec.ts | 152 +++++++++++++++++++++++ src/utils/output.spec.ts | 45 +++++++ src/utils/output.ts | 32 ++++- src/utils/recovery-hints.spec.ts | 61 +++++++++ src/utils/recovery-hints.ts | 104 ++++++++++++++++ 44 files changed, 1444 insertions(+), 185 deletions(-) create mode 100644 src/doctor/json-output.spec.ts create mode 100644 src/doctor/output.spec.ts create mode 100644 src/utils/environment.spec.ts create mode 100644 src/utils/interaction-mode.spec.ts create mode 100644 src/utils/interaction-mode.ts create mode 100644 src/utils/mode-compatibility.spec.ts create mode 100644 src/utils/recovery-hints.spec.ts create mode 100644 src/utils/recovery-hints.ts 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..25d544fc 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 `WORKOS_NO_PROMPT=1`, 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..4792cd19 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -27,7 +27,12 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { process.exit(1); } -import { isNonInteractiveEnvironment } from './utils/environment.js'; +import { + InvalidInteractionModeError, + isPromptAllowed, + resolveInteractionMode, + setInteractionMode, +} from './utils/interaction-mode.js'; import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.js'; import clack from './utils/clack.js'; import { registerSubcommand } from './utils/register-subcommand.js'; @@ -36,13 +41,19 @@ import { registerSubcommand } from './utils/register-subcommand.js'; const rawArgs = hideBin(process.argv); const hasJsonFlag = rawArgs.includes('--json'); setOutputMode(resolveOutputMode(hasJsonFlag)); +try { + setInteractionMode(resolveInteractionMode({ argv: rawArgs })); +} 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); } @@ -183,6 +194,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 +412,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 +2387,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..dba9d2ab 100644 --- a/src/commands/api/index.spec.ts +++ b/src/commands/api/index.spec.ts @@ -69,12 +69,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 +90,7 @@ describe('runApiInteractive', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; stderrOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -112,11 +109,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 +135,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 +156,7 @@ describe('runApiLs', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -215,6 +211,7 @@ describe('runApiRequest', () => { beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); consoleOutput = []; stderrOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -367,14 +364,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 () => { diff --git a/src/commands/api/index.ts b/src/commands/api/index.ts index a81675a0..3f1b4d01 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 { formatWorkOSCommand } 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 = formatWorkOSCommand(`api ${endpoint} --method ${method} --yes`); + 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); 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 fea4e240..871ff484 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -13,6 +13,7 @@ import { createClaimNonce, UnclaimedEnvApiError } from '../lib/unclaimed-env-api 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'; @@ -62,11 +63,23 @@ 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', 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..df59eb73 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,13 @@ describe('env commands', () => { beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), 'env-cmd-test-')); setInsecureConfigStorage(true); + resetInteractionModeForTests(); vi.clearAllMocks(); }); afterEach(() => { clearConfig(); + resetInteractionModeForTests(); try { rmdirSync(join(testDir, '.workos'), { recursive: true }); } catch {} @@ -103,6 +106,18 @@ 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(); + }); }); describe('runEnvRemove', () => { diff --git a/src/commands/env.ts b/src/commands/env.ts index ef26f361..6fc8924c 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, 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,15 @@ 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()) { + const exampleCommand = formatWorkOSCommand('env add --client-id '); + exitWithError({ + code: 'missing_args', + message: isAgentMode() + ? 'Name and API key required in agent mode. Example: workos env add staging --client-id ' + : 'Name and API key required in CI mode.', + recovery: missingArgsRecovery(exampleCommand, '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 303ee6c6..517268a2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -11,6 +11,8 @@ 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'; @@ -111,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...'); @@ -129,15 +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'); + 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', }); - // User can open manually + clack.log.info('Could not open browser — open the URL above manually.'); } const spinner = clack.spinner(); diff --git a/src/doctor/checks/host-execution.spec.ts b/src/doctor/checks/host-execution.spec.ts index 24552315..f7f02433 100644 --- a/src/doctor/checks/host-execution.spec.ts +++ b/src/doctor/checks/host-execution.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -vi.mock('../../utils/environment.js', () => ({ - isNonInteractiveEnvironment: vi.fn(), +vi.mock('../../utils/interaction-mode.js', () => ({ + getInteractionMode: vi.fn(), })); vi.mock('../../lib/host-probe.js', () => ({ @@ -9,7 +9,7 @@ vi.mock('../../lib/host-probe.js', () => ({ })); import { checkHostExecution } from './host-execution.js'; -import { isNonInteractiveEnvironment } from '../../utils/environment.js'; +import { getInteractionMode } from '../../utils/interaction-mode.js'; import { runHostProbe } from '../../lib/host-probe.js'; describe('checkHostExecution', () => { @@ -17,8 +17,8 @@ describe('checkHostExecution', () => { vi.resetAllMocks(); }); - it('passes without probing in an interactive host shell', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(false); + it('passes without probing in human interaction mode', async () => { + vi.mocked(getInteractionMode).mockReturnValue({ mode: 'human', source: 'default' }); const result = await checkHostExecution(); @@ -26,17 +26,18 @@ describe('checkHostExecution', () => { expect(runHostProbe).not.toHaveBeenCalled(); }); - it('passes when non-interactive host state is reachable', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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 non-interactive host state is blocked', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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: [ @@ -60,4 +61,21 @@ describe('checkHostExecution', () => { 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 index 1339f9a0..1c35154d 100644 --- a/src/doctor/checks/host-execution.ts +++ b/src/doctor/checks/host-execution.ts @@ -1,4 +1,4 @@ -import { isNonInteractiveEnvironment } from '../../utils/environment.js'; +import { getInteractionMode } from '../../utils/interaction-mode.js'; import { runHostProbe } from '../../lib/host-probe.js'; import type { HostExecutionInfo } from '../types.js'; @@ -6,9 +6,9 @@ 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 nonInteractive = isNonInteractiveEnvironment(); + const interactionMode = getInteractionMode(); - if (!nonInteractive) { + if (interactionMode.mode === 'human') { return { mode: 'interactive', ok: true, diff --git a/src/doctor/index.ts b/src/doctor/index.ts index f317e38a..5ff26d9a 100644 --- a/src/doctor/index.ts +++ b/src/doctor/index.ts @@ -14,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'; @@ -66,6 +67,8 @@ 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); @@ -95,6 +98,7 @@ export async function runDoctor(options: DoctorOptions): Promise { const earlyIssues = detectIssues({ version: DOCTOR_VERSION, timestamp: '', + interactionMode, project: { path: options.installDir, packageManager: runtime.packageManager }, sdk, language, @@ -132,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, diff --git a/src/doctor/issues.spec.ts b/src/doctor/issues.spec.ts index 43b66f60..74c29f1e 100644 --- a/src/doctor/issues.spec.ts +++ b/src/doctor/issues.spec.ts @@ -6,6 +6,7 @@ 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', @@ -55,6 +56,7 @@ describe('detectIssues', () => { 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 65b36d38..d2781e7d 100644 --- a/src/doctor/issues.ts +++ b/src/doctor/issues.ts @@ -61,7 +61,8 @@ export const ISSUE_DEFINITIONS = { HOST_EXECUTION_UNTRUSTED: { severity: 'warning' as const, message: 'Host-only WorkOS state may be unavailable in this shell', - remediation: 'Re-run this command on the host shell before trusting auth, config, or API failures', + remediation: + 'Agent/CI host execution is untrusted. Re-run this command on the host shell before trusting auth, config, or API failures.', }, }; 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 61c02556..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,15 +52,22 @@ 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('✓')} Non-interactive, host state reachable`); + console.log(` Shell: ${Chalk.green('✓')} Agent/CI context, host state reachable`); } else { - console.log(` Shell: ${Chalk.yellow('!')} Non-interactive, host state may be unavailable`); + 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})` : ''; @@ -253,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 2e987085..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 { @@ -151,6 +153,7 @@ export interface SkillsRefreshResult { export interface DoctorReport { version: string; timestamp: string; + interactionMode: InteractionModeInfo; project: { path: string; packageManager: string | null; 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 a3b3f270..b40323c4 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -7,7 +7,7 @@ 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'; @@ -31,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, @@ -44,8 +61,8 @@ export async function ensureAuthenticated(): Promise { 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(); @@ -80,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'); @@ -93,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`); @@ -108,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 index d2b60b9f..24af43e1 100644 --- a/src/lib/host-probe.spec.ts +++ b/src/lib/host-probe.spec.ts @@ -6,8 +6,9 @@ vi.mock('../utils/debug.js', () => ({ logInfo: vi.fn(), })); -vi.mock('../utils/environment.js', () => ({ - isNonInteractiveEnvironment: vi.fn(), +vi.mock('../utils/interaction-mode.js', () => ({ + isAgentMode: vi.fn(), + isCiMode: vi.fn(), })); vi.mock('node:os', () => ({ @@ -41,7 +42,7 @@ vi.mock('@napi-rs/keyring', () => ({ import { _resetProbeState, runHostProbe, warnIfSandboxed, observeHostFailure } from './host-probe.js'; import { logInfo, logVisibleWarn } from '../utils/debug.js'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; import { promises as fs } from 'node:fs'; describe('host-probe', () => { @@ -52,6 +53,8 @@ describe('host-probe', () => { 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', () => { @@ -132,8 +135,8 @@ describe('host-probe', () => { }); describe('warnIfSandboxed', () => { - it('warns in non-interactive mode when probe fails', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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'); }); @@ -145,8 +148,9 @@ describe('host-probe', () => { ); }); - it('does not warn in interactive mode', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(false); + 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'); }); @@ -156,7 +160,7 @@ describe('host-probe', () => { }); it('warns at most once per session', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(isAgentMode).mockReturnValue(true); vi.mocked(fs.writeFile).mockImplementation(() => { throw new Error('EPERM'); }); @@ -168,7 +172,7 @@ describe('host-probe', () => { }); it('does not warn on a healthy host (no false positive when probe entry is absent)', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(isAgentMode).mockReturnValue(true); keyringMock.getPassword.mockImplementation(() => { throw new Error('No such password in the keyring'); }); @@ -176,11 +180,24 @@ describe('host-probe', () => { 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 non-interactive mode', () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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', @@ -195,8 +212,8 @@ describe('host-probe', () => { expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('target=workos-cli/credentials')); }); - it('warns on browser launch errors in non-interactive mode', () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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', @@ -209,19 +226,19 @@ describe('host-probe', () => { }); it('ignores non-permission errors', () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + 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(isNonInteractiveEnvironment).mockReturnValue(true); + 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(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(isAgentMode).mockReturnValue(true); observeHostFailure('keychain', new Error('EPERM')); const callCount = vi.mocked(logVisibleWarn).mock.calls.length; observeHostFailure('home-fs', new Error('EACCES')); @@ -229,7 +246,7 @@ describe('host-probe', () => { }); it('does not double-warn across proactive and reactive paths', async () => { - vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(isAgentMode).mockReturnValue(true); vi.mocked(fs.writeFile).mockImplementation(() => { throw new Error('EACCES'); }); diff --git a/src/lib/host-probe.ts b/src/lib/host-probe.ts index 36595c96..8054db6d 100644 --- a/src/lib/host-probe.ts +++ b/src/lib/host-probe.ts @@ -11,7 +11,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { Entry } from '@napi-rs/keyring'; -import { isNonInteractiveEnvironment } from '../utils/environment.js'; +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'; @@ -145,9 +145,13 @@ export async function runHostProbe(): Promise { return cachedProbe; } +function shouldWarnForHostTrust(): boolean { + return isAgentMode() || isCiMode(); +} + export async function warnIfSandboxed(): Promise { if (warnedThisSession) return; - if (!isNonInteractiveEnvironment()) return; + if (!shouldWarnForHostTrust()) return; const probe = await runHostProbe(); if (probe.ok) return; @@ -171,7 +175,7 @@ export function observeHostFailure( details: HostCapabilityDetails = {}, ): void { if (warnedThisSession) return; - if (!isNonInteractiveEnvironment()) return; + if (!shouldWarnForHostTrust()) return; if (!isLikelyHostFailure(capability, error)) return; warnedThisSession = true; diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 056d8196..92791925 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -8,7 +8,7 @@ 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 { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; import type { InstallerMachineContext, DetectionOutput, @@ -207,8 +207,10 @@ export async function runWithCore(options: InstallerOptions): Promise { } }; + const headlessMode = isAgentMode() || isCiMode(); + let adapter: InstallerAdapter; - if (isNonInteractiveEnvironment()) { + if (headlessMode) { const { HeadlessAdapter } = await import('./adapters/headless-adapter.js'); adapter = new HeadlessAdapter({ emitter, @@ -526,8 +528,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/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..58b6fbfa 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,23 @@ 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 { + const command = argv.find((arg) => !arg.startsWith('-') && helpJsonCommandNames.has(arg)); + return command ? (commandAliases[command] ?? command) : 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..118dbf42 --- /dev/null +++ b/src/utils/mode-compatibility.spec.ts @@ -0,0 +1,152 @@ +/** + * Backcompat matrix tests for Phase 6. + * + * Encodes the contract's commitment to keeping output mode and interaction mode + * separate while preserving legacy behavior of `--json`, non-TTY, `WORKOS_NO_PROMPT`, + * `WORKOS_FORCE_TTY`, and CI/agent markers. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolveOutputMode } from './output.js'; +import { resolveInteractionMode } from './interaction-mode.js'; + +describe('mode compatibility matrix', () => { + const originalEnv = process.env; + const originalIsTTY = process.stdout.isTTY; + + 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 }); + process.env = originalEnv; + }); + + // 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 keeps human output unless --json is passed', + 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: 'human', + 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: 'human', + 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: 'human', + expectMode: 'agent', + expectSource: 'flag', + }, + ]; + + for (const row of rows) { + it(row.name, () => { + const { argv = [], jsonFlag } = row.setup(); + expect(resolveOutputMode(jsonFlag)).toBe(row.expectOutput); + expect(resolveInteractionMode({ argv })).toEqual({ + mode: row.expectMode, + source: row.expectSource, + }); + }); + } +}); diff --git a/src/utils/output.spec.ts b/src/utils/output.spec.ts index 43296cbb..1efad9d8 100644 --- a/src/utils/output.spec.ts +++ b/src/utils/output.spec.ts @@ -18,6 +18,7 @@ describe('output', () => { beforeEach(() => { process.env = { ...originalEnv }; delete process.env.WORKOS_FORCE_TTY; + delete process.env.WORKOS_NO_PROMPT; setOutputMode('human'); }); @@ -42,6 +43,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'); @@ -102,6 +109,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..d30009a9 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,13 +1,14 @@ /** * 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'; export type OutputMode = 'human' | 'json'; @@ -18,9 +19,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'; @@ -74,12 +76,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 +125,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..36fe5568 --- /dev/null +++ b/src/utils/recovery-hints.spec.ts @@ -0,0 +1,61 @@ +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'); + }); + }); + + 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' }, + ]); + }); + }); +}); diff --git a/src/utils/recovery-hints.ts b/src/utils/recovery-hints.ts new file mode 100644 index 00000000..1c8fbc66 --- /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 when the exact command is known. */ +export function confirmationRecovery(command: string): RecoveryHints { + return { + hints: [ + { + description: 'Re-run with explicit confirmation.', + command, + }, + ], + }; +} + +/** Build a `missing_args` recovery hint with a deterministic command shape. */ +export function missingArgsRecovery(command: string, description: string): RecoveryHints { + return { + hints: [ + { + description, + command, + }, + ], + }; +} From e2210d83b4e5a9a18ccfd49889c1ee8c4fc08d83 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 12 May 2026 14:16:50 -0500 Subject: [PATCH 09/10] fix: address CodeRabbit review feedback - Use `{ wait: false }` for opn() in device auth to avoid blocking poll - Disambiguate error messages for CI vs generic non-prompt scenarios - Skip --mode values in help-json command extraction - Fix contradictory WORKOS_NO_PROMPT docs in README --- README.md | 2 +- src/commands/env.ts | 11 ++++++----- src/lib/run-with-core.ts | 11 ++++++++--- src/utils/help-json.ts | 16 ++++++++++++++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 25d544fc..60c36fb9 100644 --- a/README.md +++ b/README.md @@ -586,7 +586,7 @@ workos --help --json | jq '.commands[].name' The CLI separates **output mode** from **interaction mode**: -- `--json` (or `WORKOS_NO_PROMPT=1`, or non-TTY auto-detection) controls **output formatting** only. +- `--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: diff --git a/src/commands/env.ts b/src/commands/env.ts index 6fc8924c..cff97105 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -3,7 +3,7 @@ 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 { isAgentMode, isPromptAllowed } from '../utils/interaction-mode.js'; +import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { missingArgsRecovery } from '../utils/recovery-hints.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; @@ -36,13 +36,14 @@ export async function runEnvAdd(options: { exitWithError({ code: 'invalid_args', message: nameError }); } } else if (!isPromptAllowed()) { - const exampleCommand = formatWorkOSCommand('env add --client-id '); exitWithError({ code: 'missing_args', message: isAgentMode() - ? 'Name and API key required in agent mode. Example: workos env add staging --client-id ' - : 'Name and API key required in CI mode.', - recovery: missingArgsRecovery(exampleCommand, 'Provide environment name and API key as positional arguments.'), + ? `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 diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 92791925..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 { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { getInteractionMode, isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { getOutputMode, isJsonMode, resolveEffectiveOutputMode, setOutputMode } from '../utils/output.js'; import type { InstallerMachineContext, DetectionOutput, @@ -207,7 +208,11 @@ export async function runWithCore(options: InstallerOptions): Promise { } }; - const headlessMode = isAgentMode() || isCiMode(); + const nonHumanMode = isAgentMode() || isCiMode(); + if (nonHumanMode && !isJsonMode()) { + setOutputMode(resolveEffectiveOutputMode(getOutputMode(), getInteractionMode())); + } + const headlessMode = nonHumanMode && isJsonMode(); let adapter: InstallerAdapter; if (headlessMode) { @@ -373,7 +378,7 @@ export async function runWithCore(options: InstallerOptions): Promise { // Open browser try { const { default: openFn } = await import('opn'); - await openFn(deviceAuth.verification_uri_complete); + await openFn(deviceAuth.verification_uri_complete, { wait: false }); } catch (error) { observeHostFailure('browser-launch', error, { operation: 'open', diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 58b6fbfa..ce66f5f2 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1340,8 +1340,20 @@ const helpJsonCommandNames = new Set([ * global flags like `--mode agent` are not mistaken for commands. */ export function extractHelpJsonCommand(argv: string[]): string | undefined { - const command = argv.find((arg) => !arg.startsWith('-') && helpJsonCommandNames.has(arg)); - return command ? (commandAliases[command] ?? command) : 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; } /** From d17475877fc39383fae228382489c5ff95e023e4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 12 May 2026 14:40:52 -0500 Subject: [PATCH 10/10] feat: coerce output to JSON for explicit agent/CI modes and harden recovery hints - resolveEffectiveOutputMode forces JSON when WORKOS_MODE is explicitly agent or ci, preventing human-formatted noise in machine streams - Shell-safe confirmation commands via buildConfirmationCommand with proper quoting for --data JSON values - Omit recovery command when it cannot be deterministically rebuilt (stdin body, explicit API key) - Suppress human warnings (emoji/chalk) in JSON output mode - Skip update check when prompting is disallowed --- src/bin.ts | 20 ++++++-- src/commands/api/index.spec.ts | 71 ++++++++++++++++++++++++++++ src/commands/api/index.ts | 23 ++++++++- src/commands/env.spec.ts | 16 +++++++ src/utils/command-invocation.spec.ts | 13 ++++- src/utils/command-invocation.ts | 11 +++++ src/utils/debug.spec.ts | 24 ++++++++++ src/utils/debug.ts | 7 +-- src/utils/mode-compatibility.spec.ts | 33 +++++++++---- src/utils/output.spec.ts | 15 ++++++ src/utils/output.ts | 8 ++++ src/utils/recovery-hints.spec.ts | 10 ++++ src/utils/recovery-hints.ts | 12 ++--- 13 files changed, 236 insertions(+), 27 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 4792cd19..213a4379 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -33,16 +33,26 @@ import { resolveInteractionMode, setInteractionMode, } from './utils/interaction-mode.js'; -import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.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 { - setInteractionMode(resolveInteractionMode({ argv: rawArgs })); + const interaction = resolveInteractionMode({ argv: rawArgs }); + setInteractionMode(interaction); + setOutputMode(resolveEffectiveOutputMode(baseOutputMode, interaction)); } catch (error) { if (error instanceof InvalidInteractionModeError) { exitWithError({ code: 'invalid_mode', message: error.message }); @@ -182,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 }) diff --git a/src/commands/api/index.spec.ts b/src/commands/api/index.spec.ts index dba9d2ab..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'; @@ -389,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 3f1b4d01..0f60b932 100644 --- a/src/commands/api/index.ts +++ b/src/commands/api/index.ts @@ -6,7 +6,7 @@ import { resolveApiBaseUrl } from '../../lib/api-key.js'; import { exitWithError, isJsonMode, outputJson } from '../../utils/output.js'; import { isCiMode, isPromptAllowed } from '../../utils/interaction-mode.js'; import { confirmationRecovery } from '../../utils/recovery-hints.js'; -import { formatWorkOSCommand } from '../../utils/command-invocation.js'; +import { formatWorkOSCommandArgs } from '../../utils/command-invocation.js'; import { colorMethod, printResponse } from './format.js'; export { colorMethod } from './format.js'; @@ -123,7 +123,7 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions } if (MUTATING_METHODS.has(method) && !options.yes) { - const confirmCommand = formatWorkOSCommand(`api ${endpoint} --method ${method} --yes`); + const confirmCommand = buildConfirmationCommand(endpoint, method, options); if (!isPromptAllowed()) { exitWithError({ code: 'confirmation_required', @@ -169,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/env.spec.ts b/src/commands/env.spec.ts index df59eb73..8f2de03b 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -61,6 +61,7 @@ describe('env commands', () => { afterEach(() => { clearConfig(); resetInteractionModeForTests(); + setOutputMode('human'); try { rmdirSync(join(testDir, '.workos'), { recursive: true }); } catch {} @@ -118,6 +119,21 @@ describe('env commands', () => { 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/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 a7af9157..67f8c696 100644 --- a/src/utils/debug.spec.ts +++ b/src/utils/debug.spec.ts @@ -111,6 +111,30 @@ describe('debug logging', () => { } }); + 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 1ec09d1e..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; @@ -66,7 +67,7 @@ function writeLog(level: 'INFO' | 'WARN' | 'ERROR', emoji: string, args: unknown 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}`)); } @@ -94,7 +95,7 @@ export function logWarn(...args: unknown[]): void { export function logVisibleWarn(...args: unknown[]): void { const msg = writeLog('WARN', '⚠️ ', args); - if (!debugEnabled) { + if (!debugEnabled && !isJsonMode()) { console.error(chalk.yellow(`⚠️ ${msg}`)); } } @@ -104,7 +105,7 @@ export function logError(...args: unknown[]): void { } 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/mode-compatibility.spec.ts b/src/utils/mode-compatibility.spec.ts index 118dbf42..e379659d 100644 --- a/src/utils/mode-compatibility.spec.ts +++ b/src/utils/mode-compatibility.spec.ts @@ -1,18 +1,20 @@ /** * Backcompat matrix tests for Phase 6. * - * Encodes the contract's commitment to keeping output mode and interaction mode - * separate while preserving legacy behavior of `--json`, non-TTY, `WORKOS_NO_PROMPT`, - * `WORKOS_FORCE_TTY`, and CI/agent markers. + * 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 { resolveOutputMode } from './output.js'; +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 }; @@ -26,9 +28,19 @@ describe('mode compatibility matrix', () => { 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; @@ -100,14 +112,14 @@ describe('mode compatibility matrix', () => { expectSource: 'env', }, { - name: 'WORKOS_MODE=agent with TTY keeps human output unless --json is passed', + 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: 'human', + expectOutput: 'json', expectMode: 'agent', expectSource: 'env', }, @@ -120,7 +132,7 @@ describe('mode compatibility matrix', () => { process.env.WORKOS_AGENT = '1'; return {}; }, - expectOutput: 'human', + expectOutput: 'json', expectMode: 'ci', expectSource: 'ci_env', }, @@ -133,7 +145,7 @@ describe('mode compatibility matrix', () => { process.env.GITHUB_ACTIONS = 'true'; return { argv: ['--mode', 'agent'] }; }, - expectOutput: 'human', + expectOutput: 'json', expectMode: 'agent', expectSource: 'flag', }, @@ -142,8 +154,9 @@ describe('mode compatibility matrix', () => { for (const row of rows) { it(row.name, () => { const { argv = [], jsonFlag } = row.setup(); - expect(resolveOutputMode(jsonFlag)).toBe(row.expectOutput); - expect(resolveInteractionMode({ argv })).toEqual({ + 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 1efad9d8..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, @@ -60,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'); diff --git a/src/utils/output.ts b/src/utils/output.ts index d30009a9..6b6aba26 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -9,6 +9,7 @@ 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'; @@ -32,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') { diff --git a/src/utils/recovery-hints.spec.ts b/src/utils/recovery-hints.spec.ts index 36fe5568..fa199186 100644 --- a/src/utils/recovery-hints.spec.ts +++ b/src/utils/recovery-hints.spec.ts @@ -48,6 +48,11 @@ describe('recovery-hints', () => { 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', () => { @@ -57,5 +62,10 @@ describe('recovery-hints', () => { { 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 index 1c8fbc66..4c4fb0b8 100644 --- a/src/utils/recovery-hints.ts +++ b/src/utils/recovery-hints.ts @@ -79,25 +79,25 @@ export function authLoginRecovery(options: { mode: InteractionMode; env?: NodeJS }; } -/** Build a `confirmation_required` recovery hint when the exact command is known. */ -export function confirmationRecovery(command: string): RecoveryHints { +/** 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 && { command }), }, ], }; } -/** Build a `missing_args` recovery hint with a deterministic command shape. */ -export function missingArgsRecovery(command: string, description: string): RecoveryHints { +/** 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 && { command }), }, ], };