Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/lib/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/ensure-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -37,6 +38,8 @@ export async function ensureAuthenticated(): Promise<EnsureAuthResult> {
tokenRefreshed: false,
};

await warnIfSandboxed();

// Case 1: No credentials or invalid credentials
const creds = getCredentials();
if (!creds) {
Expand Down
206 changes: 206 additions & 0 deletions src/lib/host-probe.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading