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
10 changes: 9 additions & 1 deletion apps/cli/src/backends/claude/claudeLocalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,16 @@ export async function claudeLocalLauncher(
const resolvedAgentMode = resolveSessionModeOverrideFromMetadataSnapshot({
metadata: metadataSnapshot,
});
// Use spawnPermissionMode as a floor: if the per-turn lastPermissionMode
// has been clobbered to 'default' by remote messages but the session was
// originally started with a non-default mode (e.g. yolo), preserve the
// launch intent so the local Claude process respects it.
const effectivePermissionMode =
session.lastPermissionMode === 'default' && session.spawnPermissionMode !== 'default'
? session.spawnPermissionMode
: session.lastPermissionMode;
session.claudeArgs = upsertClaudePermissionModeArgs(session.claudeArgs, {
permissionMode: session.lastPermissionMode,
permissionMode: effectivePermissionMode,
agentModeId: resolvedAgentMode ? resolvedAgentMode.modeId : null,
});

Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/backends/claude/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export async function loop(opts: LoopOptions): Promise<number> {
precomputedMcpBridge: opts.precomputedMcpBridge ?? null,
});
session.claudeCodeExperimentalAgentTeamsEnabled = opts.claudeCodeExperimentalAgentTeamsEnabled === true;
session.spawnPermissionMode = opts.permissionMode ?? 'default';

// Seed permission mode without blocking on transcript fetches.
// The session's metadata snapshot is already available locally, and for fresh sessions
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/backends/claude/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ describe('Session', () => {
}
});

it('exposes spawnPermissionMode that is independent of lastPermissionMode', () => {
const client = createSessionClientStub();
const session = createSession(client);

try {
expect(session.spawnPermissionMode).toBe('default');

session.spawnPermissionMode = 'yolo';
session.setLastPermissionMode('default', 200);
expect(session.lastPermissionMode).toBe('default');
expect(session.spawnPermissionMode).toBe('yolo');
} finally {
session.cleanup();
}
});

it('does not bump permissionModeUpdatedAt when permission mode does not change', () => {
const metadataUpdates: Metadata[] = [];
const client = createSessionClientStub({
Expand Down
7 changes: 7 additions & 0 deletions apps/cli/src/backends/claude/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ export class Session {
| null = null;
private happierMcpBridgePromise: Promise<NonNullable<Session['happierMcpBridge']>> | null = null;

/**
* Permission mode from the initial session spawn (CLI --dangerously-skip-permissions or mobile picker).
* Set once at session creation, never overwritten by per-message or metadata updates.
* Used as a floor when constructing local spawn args so the launch intent is never lost.
*/
spawnPermissionMode: PermissionMode = 'default';

/**
* Last known permission mode for this session, derived from message metadata / permission responses.
* Used to carry permission settings across remote ↔ local mode switches.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import Fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import { enableMonitoring } from './enableMonitoring';
import { createLightSqliteHarness } from '@/testkit/lightSqliteHarness';

describe('enableMonitoring', () => {
it('reports service as happier-server in /health responses', async () => {
const harness = await createLightSqliteHarness({
let harness: Awaited<ReturnType<typeof createLightSqliteHarness>>;

beforeAll(async () => {
harness = await createLightSqliteHarness({
tempDirPrefix: 'happier-server-health-',
initAuth: false,
initEncrypt: false,
initFiles: false,
});
const app = Fastify();
});

afterAll(async () => {
await harness?.close().catch(() => {});
});

it('reports service as happier-server in /health responses', async () => {
const app = Fastify();
try {
enableMonitoring(app as any);
await app.ready();
Expand All @@ -24,7 +32,24 @@ describe('enableMonitoring', () => {
expect(body.service).toBe('happier-server');
} finally {
await app.close().catch(() => {});
await harness.close().catch(() => {});
}
});

it('returns full response body shape { status, timestamp, service } when database is healthy', async () => {
const app = Fastify();
try {
enableMonitoring(app as any);
await app.ready();

const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = res.json() as { status?: string; timestamp?: string; service?: string };
expect(body.status).toBe('ok');
expect(body.service).toBe('happier-server');
expect(typeof body.timestamp).toBe('string');
expect(Number.isNaN(new Date(body.timestamp!).getTime())).toBe(false);
} finally {
await app.close().catch(() => {});
}
});
});
31 changes: 31 additions & 0 deletions apps/server/sources/app/api/utils/enableMonitoring.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';

const mockQueryRaw = vi.fn();

vi.mock('@/storage/db', () => ({
db: { $queryRaw: mockQueryRaw },
}));

describe('enableMonitoring (unit)', () => {
it('returns 503 with database connectivity error in body when database query fails', async () => {
mockQueryRaw.mockRejectedValueOnce(new Error('SQLITE_CANTOPEN: cannot open database'));

const { enableMonitoring } = await import('./enableMonitoring');
const app = Fastify({ logger: false }) as any;

try {
enableMonitoring(app);
await app.ready();

const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(503);
const body = res.json() as { status?: string; service?: string; error?: string };
expect(body.status).toBe('error');
expect(body.service).toBe('happier-server');
expect(body.error).toBe('Database connectivity failed');
} finally {
await app.close().catch(() => {});
}
});
});
22 changes: 22 additions & 0 deletions apps/ui/scripts/prepareTauriSidecar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import { dirname, join } from 'node:path';
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

const MCP_DEV_CAPABILITY = {
$schema: '../gen/schemas/desktop-schema.json',
identifier: 'mcp-dev',
description: 'enables the MCP bridge plugin in dev/debug builds',
windows: ['main'],
permissions: ['mcp-bridge:default'],
};

export async function ensureTauriMcpDevCapability({
srcTauriDir = join(uiDir, 'src-tauri'),
mkdirImpl = mkdir,
writeFileImpl = writeFile,
} = {}) {
const capabilitiesDir = join(srcTauriDir, 'capabilities');
await mkdirImpl(capabilitiesDir, { recursive: true });
const targetPath = join(capabilitiesDir, 'mcp-dev.json');
await writeFileImpl(targetPath, JSON.stringify(MCP_DEV_CAPABILITY, null, 2) + '\n', 'utf8');
return targetPath;
}

import { ensureWorkspacePackagesBuiltForComponent as ensureWorkspacePackagesBuiltForComponentDefault } from '../../stack/scripts/utils/proc/pm.mjs';

function normalizeTargetTriple(rawValue) {
Expand Down Expand Up @@ -118,11 +138,13 @@ export async function prepareTauriSidecar({
ensureWorkspacePackagesBuiltForComponent = ensureWorkspacePackagesBuiltForComponentDefault,
ensureTauriSidecarEntrypointFileImpl = ensureTauriSidecarEntrypointFile,
ensureTauriSidecarRuntimeFilesImpl = ensureTauriSidecarRuntimeFiles,
ensureTauriMcpDevCapabilityImpl = ensureTauriMcpDevCapability,
spawnSyncImpl = spawnSync,
} = {}) {
await ensureWorkspacePackagesBuiltForComponent(uiDir, { quiet: false, env });
await ensureWorkspacePackagesBuiltForComponent(bootstrapDir, { quiet: false, env });
await ensureTauriWatcherIgnoreFile();
await ensureTauriMcpDevCapabilityImpl();

const bunTarget = resolveBunTargetForTauriBuildEnv(env);
const nextEnv = {
Expand Down
47 changes: 39 additions & 8 deletions apps/ui/scripts/prepareTauriSidecar.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ensureTauriWatcherIgnoreFile,
ensureTauriSidecarEntrypointFile,
ensureTauriSidecarRuntimeFiles,
ensureTauriMcpDevCapability,
resolveBunTargetForTauriBuildEnv,
resolveTauriWatcherIgnoreContent,
} from './prepareTauriSidecar.mjs';
Expand Down Expand Up @@ -53,6 +54,9 @@ test('prepareTauriSidecar builds app workspace dependencies before compiling hse
calls.push(['entrypoint', options]);
return join(options.srcTauriDir, 'binaries', 'hsetup.js');
};
const ensureTauriMcpDevCapabilityImpl = async () => {
calls.push(['mcp-dev']);
};
const spawnSyncImpl = (command, args, options) => {
calls.push(['spawn', command, args, options]);
return { status: 0 };
Expand All @@ -65,27 +69,52 @@ test('prepareTauriSidecar builds app workspace dependencies before compiling hse
ensureWorkspacePackagesBuiltForComponent,
ensureTauriSidecarRuntimeFilesImpl,
ensureTauriSidecarEntrypointFileImpl,
ensureTauriMcpDevCapabilityImpl,
spawnSyncImpl,
});

assert.equal(result, 0);
assert.equal(calls[0][0], 'ensure');
assert.match(String(calls[0][1]), /apps\/ui$/);
assert.match(String(calls[0][1]), /apps[/\\]ui$/);
assert.equal(calls[1][0], 'ensure');
assert.match(String(calls[1][1]), /apps\/bootstrap$/);
assert.equal(calls[2][0], 'spawn');
assert.equal(calls[2][1], 'yarn');
assert.deepEqual(calls[2][2], ['-s', 'workspace', '@happier-dev/bootstrap', 'build:binary']);
assert.equal(calls[2][3].env.HAPPIER_BUN_TARGET, 'bun-darwin-arm64');
assert.equal(calls[3][0], 'runtime');
assert.equal(calls[4][0], 'entrypoint');
assert.match(String(calls[1][1]), /apps[/\\]bootstrap$/);
assert.equal(calls[2][0], 'mcp-dev');
assert.equal(calls[3][0], 'spawn');
assert.equal(calls[3][1], 'yarn');
assert.deepEqual(calls[3][2], ['-s', 'workspace', '@happier-dev/bootstrap', 'build:binary']);
assert.equal(calls[3][3].env.HAPPIER_BUN_TARGET, 'bun-darwin-arm64');
assert.equal(calls[4][0], 'runtime');
assert.equal(calls[5][0], 'entrypoint');
});

test('ensureTauriMcpDevCapability writes mcp-dev.json with correct content', async () => {
const srcTauriDir = await mkdtemp(join(tmpdir(), 'happier-tauri-mcp-dev-'));

const targetPath = await ensureTauriMcpDevCapability({ srcTauriDir });

assert.equal(targetPath, join(srcTauriDir, 'capabilities', 'mcp-dev.json'));
const capability = JSON.parse(await readFile(targetPath, 'utf8'));
assert.equal(capability.identifier, 'mcp-dev');
assert.deepEqual(capability.windows, ['main']);
assert.ok(capability.permissions.includes('mcp-bridge:default'));
});

test('ensureTauriMcpDevCapability is idempotent when run multiple times', async () => {
const srcTauriDir = await mkdtemp(join(tmpdir(), 'happier-tauri-mcp-dev-idem-'));

await ensureTauriMcpDevCapability({ srcTauriDir });
await ensureTauriMcpDevCapability({ srcTauriDir });

const capability = JSON.parse(await readFile(join(srcTauriDir, 'capabilities', 'mcp-dev.json'), 'utf8'));
assert.equal(capability.identifier, 'mcp-dev');
});

test('prepareTauriSidecar invokes Yarn via a Windows-safe shell so yarn.cmd can be resolved', async () => {
const calls = [];
const ensureWorkspacePackagesBuiltForComponent = async () => {};
const ensureTauriSidecarRuntimeFilesImpl = async () => [];
const ensureTauriSidecarEntrypointFileImpl = async (options) => join(options.srcTauriDir, 'binaries', 'hsetup.js');
const ensureTauriMcpDevCapabilityImpl = async () => {};
const spawnSyncImpl = (command, args, options) => {
calls.push(['spawn', command, args, options]);
return { status: 0 };
Expand All @@ -99,6 +128,7 @@ test('prepareTauriSidecar invokes Yarn via a Windows-safe shell so yarn.cmd can
ensureWorkspacePackagesBuiltForComponent,
ensureTauriSidecarRuntimeFilesImpl,
ensureTauriSidecarEntrypointFileImpl,
ensureTauriMcpDevCapabilityImpl,
spawnSyncImpl,
});

Expand Down Expand Up @@ -168,6 +198,7 @@ test('prepareTauriSidecar propagates spawn errors', async () => {
await assert.rejects(() => prepareTauriSidecar({
env: {},
ensureWorkspacePackagesBuiltForComponent: async () => {},
ensureTauriMcpDevCapabilityImpl: async () => {},
spawnSyncImpl: () => ({ error: boom }),
}), /spawn failed/);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ afterEach(() => {
resetRestoreRouteTestState();
});
describe('/restore (web desktop)', () => {
it('defaults to the show-QR restore flow when the web environment is not mobile-like', async () => {
it('shows the camera scanner on web desktop when camera API is available', async () => {
vi.stubGlobal('navigator', {
maxTouchPoints: 0,
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
Expand All @@ -72,8 +72,8 @@ describe('/restore (web desktop)', () => {
try {
screen = await renderScreen(<Screen />);
await act(async () => {});
const qrView = screen.findAllByType('div').filter((node) => node.props['data-testid'] === 'RestoreQrView');
expect(qrView).toHaveLength(1);
const scannerView = screen.findAllByType('div').filter((node) => node.props['data-testid'] === 'RestoreScanComputerQrView');
expect(scannerView).toHaveLength(1);
} finally {
await act(async () => {
screen?.tree.unmount();
Expand Down
1 change: 1 addition & 0 deletions apps/ui/sources/__tests__/routes/_layout.init.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ vi.mock('expo-notifications', () => ({

vi.mock('@expo/vector-icons', () => ({
FontAwesome: { font: {} },
Ionicons: { font: {} },
}));

vi.mock('@/auth/storage/tokenStorage', () => ({
Expand Down
9 changes: 3 additions & 6 deletions apps/ui/sources/app/(app)/restore/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import * as React from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { Platform } from 'react-native';

import { isRunningOnMac } from '@/utils/platform/platform';
import { RestoreQrView } from '@/components/account/restore/RestoreQrView';
import { RestoreScanComputerQrView } from '@/components/account/restore/RestoreScanComputerQrView';
import { isWebQrScannerSupported } from '@/utils/platform/qrScannerSupport';
import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics';

export default function RestoreIndex() {
const { width, height } = useWindowDimensions();
const isNativePhone = (Platform.OS === 'ios' || Platform.OS === 'android') && !isRunningOnMac();
const isWebPhoneWithCamera =
Platform.OS === 'web' && isWebQrScannerSupported() && isWebMobileLikeQrScannerHost({ width, height });
const showScannerFirst = isNativePhone || isWebPhoneWithCamera;
const webHasCamera = Platform.OS === 'web' && isWebQrScannerSupported();
const showScannerFirst = isNativePhone || webHasCamera;

return showScannerFirst ? <RestoreScanComputerQrView /> : <RestoreQrView />;
}
3 changes: 2 additions & 1 deletion apps/ui/sources/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as SplashScreen from 'expo-splash-screen';
import * as Fonts from 'expo-font';
import { Asset } from 'expo-asset';
import * as Notifications from 'expo-notifications';
import { FontAwesome } from '@expo/vector-icons';
import { FontAwesome, Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
PUSH_NOTIFICATION_ACTION_IDS,
Expand Down Expand Up @@ -570,6 +570,7 @@ async function loadFonts() {
'BricolageGrotesque-Bold': require('@/assets/fonts/BricolageGrotesque-Bold.ttf'),

...FontAwesome.font,
...Ionicons.font,
};

// On web, expo-font uses FontFaceObserver with a hard-coded ~6s timeout. In practice, this
Expand Down
4 changes: 1 addition & 3 deletions apps/ui/sources/components/qr/QrCodeScannerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { t } from '@/text';
import { Typography } from '@/constants/Typography';
import { isRunningOnMac } from '@/utils/platform/platform';
import { isWebQrScannerSupported } from '@/utils/platform/qrScannerSupport';
import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics';

const stylesheet = StyleSheet.create((theme) => ({
root: {
Expand Down Expand Up @@ -115,8 +114,7 @@ export const QrCodeScannerView = React.memo(function QrCodeScannerView(props: Qr
const canUseCamera = React.useMemo(() => {
if (isRunningOnMac()) return false;
if (Platform.OS !== 'web') return true;
if (!isWebQrScannerSupported()) return false;
return isWebMobileLikeQrScannerHost({ width, height });
return isWebQrScannerSupported();
}, [height, width]);
Comment on lines 114 to 118
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stale useMemo dependency array after removing dimension-based check

isRunningOnMac(), Platform.OS, and isWebQrScannerSupported() are all constant for the lifetime of the component, so canUseCamera never changes on window resize. The [height, width] dependency array is now stale — it causes the memo to recompute on every window resize even though neither value is used in the computation.

Suggested change
const canUseCamera = React.useMemo(() => {
if (isRunningOnMac()) return false;
if (Platform.OS !== 'web') return true;
if (!isWebQrScannerSupported()) return false;
return isWebMobileLikeQrScannerHost({ width, height });
return isWebQrScannerSupported();
}, [height, width]);
const canUseCamera = React.useMemo(() => {
if (isRunningOnMac()) return false;
if (Platform.OS !== 'web') return true;
return isWebQrScannerSupported();
}, []);


React.useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('useConnectAccount (scanner lifecycle)', () => {
it('navigates to the in-app QR scanner on phone-sized web', async () => {
screenState.platformOS = 'web';
screenState.windowDimensions = { width: 360, height: 800 };
vi.stubGlobal('navigator', { maxTouchPoints: 5, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)' } as any);
vi.stubGlobal('navigator', { maxTouchPoints: 5, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)', mediaDevices: { getUserMedia: vi.fn() } } as any);

const { useConnectAccount } = await import('./useConnectAccount');

Expand Down
Loading
Loading