diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 2253909..3e6b695 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 169c448..add7de1 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 5d5f308..a3b3f27 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, }; + await 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 0000000..c794179 --- /dev/null +++ b/src/lib/host-probe.spec.ts @@ -0,0 +1,206 @@ +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', () => { + const promises = { + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + }; + return { + default: { promises }, + promises, + }; +}); + +const keyringMock = vi.hoisted(() => ({ + getPassword: vi.fn(() => null), +})); + +vi.mock('@napi-rs/keyring', () => ({ + Entry: class { + getPassword(): string | null { + return keyringMock.getPassword(); + } + }, +})); + +import { _resetProbeState, runHostProbe, warnIfSandboxed, observeHostFailure } from './host-probe.js'; +import { logWarn } from '../utils/debug.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import { promises as fs } from 'node:fs'; + +describe('host-probe', () => { + beforeEach(() => { + _resetProbeState(); + vi.resetAllMocks(); + keyringMock.getPassword.mockReturnValue(null); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + }); + + describe('runHostProbe', () => { + it('returns ok when home-fs and keychain succeed', async () => { + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('treats a "not found" keychain error as healthy', async () => { + keyringMock.getPassword.mockImplementation(() => { + throw new Error('Item not found in keyring'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('detects home-fs failure', async () => { + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EPERM: operation not permitted'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(false); + expect(result.failures).toContainEqual(expect.objectContaining({ capability: 'home-fs' })); + }); + + it('does not flag non-permission home-fs errors as sandbox failures', async () => { + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('detects keychain failure on permission error', async () => { + keyringMock.getPassword.mockImplementation(() => { + throw new Error('EACCES: keychain unavailable'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(false); + expect(result.failures).toContainEqual(expect.objectContaining({ capability: 'keychain' })); + }); + + it('does not flag non-permission keychain errors as sandbox failures', async () => { + keyringMock.getPassword.mockImplementation(() => { + throw new Error('The user canceled the Keychain Services operation'); + }); + + const result = await runHostProbe(); + expect(result.ok).toBe(true); + expect(result.failures).toHaveLength(0); + }); + + it('caches the result across calls', async () => { + const first = await runHostProbe(); + const second = await runHostProbe(); + expect(first).toBe(second); + }); + }); + + describe('warnIfSandboxed', () => { + it('warns in non-interactive mode when probe fails', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + await warnIfSandboxed(); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining('unavailable'), + expect.stringContaining('host shell'), + ); + }); + + it('does not warn in interactive mode', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(false); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EACCES'); + }); + + await warnIfSandboxed(); + expect(logWarn).not.toHaveBeenCalled(); + }); + + it('warns at most once per session', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + vi.mocked(fs.writeFile).mockImplementation(() => { + throw new Error('EPERM'); + }); + + await warnIfSandboxed(); + const callCount = vi.mocked(logWarn).mock.calls.length; + 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', () => { + 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 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')); + const callCount = vi.mocked(logWarn).mock.calls.length; + 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 new file mode 100644 index 0000000..7b6c15a --- /dev/null +++ b/src/lib/host-probe.ts @@ -0,0 +1,144 @@ +/** + * Host capability probes for non-interactive / sandboxed environments. + * + * When the CLI runs inside an AI agent sandbox (Claude Code, Codex, Cursor), + * the keyring, home directory, network, or browser may be unavailable. + * These helpers detect that situation and emit a single actionable warning + * per session instead of letting opaque EPERM errors confuse the agent. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { Entry } from '@napi-rs/keyring'; +import { 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, + /\bsandboxd?\b/i, + /interaction is not allowed/i, + /access denied/i, +]; + +function isPermissionError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return PERMISSION_PATTERNS.some((p) => p.test(msg)); +} + +function isMissingEntryError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return msg.includes('not found') || msg.includes('No such'); +} + +async function probeHomeFs(): Promise { + const dir = path.join(os.homedir(), '.workos'); + const probePath = path.join(dir, `.probe-${process.pid}-${crypto.randomUUID()}`); + + try { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + await fs.writeFile(probePath, new Date().toISOString(), { mode: 0o600 }); + 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 }; + } +} + +function probeKeychain(): ProbeFailure | null { + try { + 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; + } + // 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 }; + } +} + +export async function runHostProbe(): Promise { + if (cachedProbe) return cachedProbe; + + const failures: ProbeFailure[] = []; + + const fsResult = await probeHomeFs(); + if (fsResult) failures.push(fsResult); + + const keychainResult = probeKeychain(); + if (keychainResult) failures.push(keychainResult); + + cachedProbe = { ok: failures.length === 0, failures }; + return cachedProbe; +} + +export async function warnIfSandboxed(): Promise { + if (warnedThisSession) return; + if (!isNonInteractiveEnvironment()) return; + + const probe = await 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; +}