From 68c937833e9de801f41bfbf3b949e33793957839 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 16:20:44 -0700 Subject: [PATCH 1/5] 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 30a43b173468555dbf9db1a6ac2d5b17ddbe6f61 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 2/5] 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 089e0350715d90ec9e068803956cc96dd1db57e3 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 3/5] 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 7f9e0e6a13da42fe1c7c0349b180fa7b65910b8e 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 4/5] 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 1046ad381095bbcebda2fc38c00379b062fdc667 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 5/5] 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 }; }