+ Run Claude Code in the cloud using your own Claude subscription. No
+ macrod required.
+
+
+ {props.failed
+ ? 'Could not load Claude connection. Check that this workspace’s backend is running.'
+ : 'Loading Claude connection…'}
+
+ }
+ >
+
+ Browser connection is enabled only in the local demo backend.
+
+ }
+ >
+
+ {props.status?.ephemeral
+ ? 'Local demo: credentials stay in server memory. Reconnect after a backend restart.'
+ : 'Your connection is saved securely and survives backend restarts.'}{' '}
+ Macro never asks for your Claude password.
+
+
+
+
+ }
+ >
+ {(login) => (
+
+ )}
+
+
+
+
+
+ Ready. In Settings → Agents, choose Claude Cloud (demo) as the
+ agent’s harness. Disconnecting forgets Macro’s grant; it does not
+ revoke consent at Claude or stop an already-running cloud turn.
+
+
+
+
+ {props.error}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/claude-connection/core/connection.ts b/apps/web/src/features/claude-connection/core/connection.ts
new file mode 100644
index 00000000000..7d998229cae
--- /dev/null
+++ b/apps/web/src/features/claude-connection/core/connection.ts
@@ -0,0 +1,28 @@
+/** Safe connection metadata. Provider tokens are never sent to the browser. */
+export type ClaudeConnectionStatus = {
+ enabled: boolean;
+ connected: boolean;
+ ephemeral: boolean;
+};
+
+export type ClaudeLogin = {
+ attemptId: string;
+ authorizationUrl: string;
+ expiresIn: number;
+};
+
+/** Host-owned sign-in navigation, reserved synchronously during the click. */
+export type ClaudeSignIn = {
+ navigate: (url: string) => void;
+ close: () => void;
+};
+
+/** Narrow source contract; the controller does not know the query or transport library. */
+export type ClaudeConnectionSource = {
+ status: () => ClaudeConnectionStatus | undefined;
+ failed: () => boolean;
+ begin: () => Promise;
+ complete: (attemptId: string, code: string) => Promise;
+ disconnect: () => Promise;
+ refresh: () => Promise;
+};
diff --git a/apps/web/src/features/claude-connection/open-sign-in.test.ts b/apps/web/src/features/claude-connection/open-sign-in.test.ts
new file mode 100644
index 00000000000..0f4100e6431
--- /dev/null
+++ b/apps/web/src/features/claude-connection/open-sign-in.test.ts
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+
+import { isTauri } from '@core/util/platform';
+import { openExternalUrl } from '@core/util/url';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { openClaudeSignIn } from './open-sign-in';
+
+vi.mock('@core/util/platform', () => ({ isTauri: vi.fn(() => false) }));
+vi.mock('@core/util/url', () => ({ openExternalUrl: vi.fn() }));
+afterEach(() => vi.restoreAllMocks());
+
+describe('Claude sign-in navigation', () => {
+ it('reserves an isolated tab with no referrer and navigates it once ready', () => {
+ const popup = {
+ opener: window,
+ document: document.implementation.createHTMLDocument(),
+ closed: false,
+ location: { replace: vi.fn() },
+ close: vi.fn(),
+ };
+ const open = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as Window);
+ const signIn = openClaudeSignIn();
+ expect(open).toHaveBeenCalledWith('about:blank', '_blank');
+ expect(popup.opener).toBeNull();
+ expect(
+ popup.document
+ .querySelector('meta[name="referrer"]')
+ ?.getAttribute('content')
+ ).toBe('no-referrer');
+ signIn?.navigate('https://claude.com/consent');
+ expect(popup.location.replace).toHaveBeenCalledWith(
+ 'https://claude.com/consent'
+ );
+ signIn?.close();
+ expect(popup.close).toHaveBeenCalledOnce();
+ });
+ it('returns no reserved tab when blocked', () => {
+ vi.spyOn(window, 'open').mockReturnValue(null);
+ expect(openClaudeSignIn()).toBeUndefined();
+ });
+ it('uses the native external browser without opening a blank tab', () => {
+ vi.mocked(isTauri).mockReturnValueOnce(true);
+ const open = vi.spyOn(window, 'open');
+ const signIn = openClaudeSignIn();
+ expect(open).not.toHaveBeenCalled();
+ signIn?.navigate('https://claude.com/consent');
+ expect(openExternalUrl).toHaveBeenCalledWith('https://claude.com/consent');
+ });
+});
diff --git a/apps/web/src/features/claude-connection/open-sign-in.ts b/apps/web/src/features/claude-connection/open-sign-in.ts
new file mode 100644
index 00000000000..39b0b32a37c
--- /dev/null
+++ b/apps/web/src/features/claude-connection/open-sign-in.ts
@@ -0,0 +1,32 @@
+import { isTauri } from '@core/util/platform';
+import { openExternalUrl } from '@core/util/url';
+import type { ClaudeSignIn } from './core/connection';
+
+/** Reserve a browser tab before awaiting auth; native shells use the system browser. */
+export function openClaudeSignIn(): ClaudeSignIn | undefined {
+ if (isTauri()) return { navigate: openExternalUrl, close: () => {} };
+
+ let popup: Window | null = null;
+ try {
+ popup = window.open('about:blank', '_blank');
+ if (!popup) return;
+ popup.opener = null;
+ const referrer = popup.document.createElement('meta');
+ referrer.name = 'referrer';
+ referrer.content = 'no-referrer';
+ popup.document.head.append(referrer);
+ popup.document.title = 'Connecting to Claude…';
+ popup.document.body.textContent = 'Preparing Claude sign-in…';
+ const tab = popup;
+ return {
+ navigate: (url) => {
+ if (!tab.closed) tab.location.replace(url);
+ },
+ close: () => tab.close(),
+ };
+ } catch {
+ popup?.close();
+ // The settings form retains a normal link when opening a tab is blocked.
+ return;
+ }
+}
diff --git a/apps/web/src/features/claude-connection/primitives/connection.test.ts b/apps/web/src/features/claude-connection/primitives/connection.test.ts
new file mode 100644
index 00000000000..c0c935b643d
--- /dev/null
+++ b/apps/web/src/features/claude-connection/primitives/connection.test.ts
@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+import { createRoot, createSignal } from 'solid-js';
+import { describe, expect, it, vi } from 'vitest';
+import type {
+ ClaudeConnectionSource,
+ ClaudeConnectionStatus,
+} from '../core/connection';
+import { createClaudeConnection } from './connection';
+
+function setup() {
+ return createRoot((dispose) => {
+ const [status, setStatus] = createSignal({
+ enabled: true,
+ connected: false,
+ ephemeral: true,
+ });
+ const source: ClaudeConnectionSource = {
+ status,
+ failed: () => false,
+ begin: vi.fn(async () => ({
+ attemptId: 'attempt',
+ authorizationUrl: 'https://claude.com/consent',
+ expiresIn: 600,
+ })),
+ complete: vi.fn(async () => {
+ setStatus({ ...status(), connected: true });
+ }),
+ disconnect: vi.fn(async () => {
+ setStatus({ ...status(), connected: false });
+ }),
+ refresh: vi.fn(async () => {}),
+ };
+ const signIn = { navigate: vi.fn(), close: vi.fn() };
+ const openSignIn = vi.fn(() => signIn as typeof signIn | undefined);
+ return {
+ state: createClaudeConnection(source, openSignIn),
+ source,
+ dispose,
+ signIn,
+ openSignIn,
+ };
+ });
+}
+describe('Claude connection controller', () => {
+ it('reserves a tab synchronously and navigates after authorization starts', async () => {
+ const { state, source, dispose, signIn, openSignIn } = setup();
+ const pending = state.begin();
+ expect(openSignIn).toHaveBeenCalledOnce();
+ expect(openSignIn.mock.invocationCallOrder[0]).toBeLessThan(
+ vi.mocked(source.begin).mock.invocationCallOrder[0]
+ );
+ expect(signIn.navigate).not.toHaveBeenCalled();
+ await state.begin();
+ await pending;
+ expect(openSignIn).toHaveBeenCalledOnce();
+ expect(signIn.navigate).toHaveBeenCalledWith('https://claude.com/consent');
+ dispose();
+ expect(signIn.close).not.toHaveBeenCalled();
+ });
+ it('keeps the sign-in link available when a popup is blocked', async () => {
+ const { state, dispose, openSignIn } = setup();
+ openSignIn.mockReturnValue(undefined);
+ await state.begin();
+ expect(state.login()?.authorizationUrl).toBe('https://claude.com/consent');
+ expect(state.error()).toBe('');
+ dispose();
+ });
+ it('closes the reserved tab when authorization setup fails', async () => {
+ const { state, source, dispose, signIn } = setup();
+ vi.mocked(source.begin).mockRejectedValueOnce(
+ new Error('Could not start sign-in')
+ );
+ await state.begin();
+ expect(signIn.close).toHaveBeenCalledOnce();
+ expect(signIn.navigate).not.toHaveBeenCalled();
+ expect(state.error()).toBe('Could not start sign-in');
+ expect(state.busy()).toBe(false);
+ dispose();
+ });
+ it('closes a pending tab on unmount and ignores a late authorization URL', async () => {
+ const { state, source, dispose, signIn } = setup();
+ let resolve!: (
+ login: Awaited>
+ ) => void;
+ vi.mocked(source.begin).mockReturnValueOnce(
+ new Promise((done) => {
+ resolve = done;
+ })
+ );
+ const pending = state.begin();
+ dispose();
+ expect(signIn.close).toHaveBeenCalledOnce();
+ resolve({
+ attemptId: 'late',
+ authorizationUrl: 'https://claude.com/consent',
+ expiresIn: 600,
+ });
+ await pending;
+ expect(signIn.navigate).not.toHaveBeenCalled();
+ });
+ it('connects and immediately clears the one-time code, then disconnects', async () => {
+ const { state, source, dispose } = setup();
+ await state.begin();
+ expect(state.login()?.attemptId).toBe('attempt');
+ state.setCode('one-time#state');
+ const done = state.complete();
+ expect(state.code()).toBe('');
+ expect(state.busy()).toBe(true);
+ await done;
+ expect(source.complete).toHaveBeenCalledWith('attempt', 'one-time#state');
+ expect(state.status()?.connected).toBe(true);
+ expect(state.login()).toBeUndefined();
+ await state.disconnect();
+ expect(state.status()?.connected).toBe(false);
+ dispose();
+ });
+ it('shows an exchange error and requires a fresh attempt instead of replaying', async () => {
+ const { state, source, dispose } = setup();
+ vi.mocked(source.complete).mockRejectedValueOnce(new Error('Expired code'));
+ await state.begin();
+ state.setCode('expired#state');
+ await state.complete();
+ expect(state.error()).toBe('Expired code');
+ expect(state.code()).toBe('');
+ expect(state.login()).toBeUndefined();
+ expect(state.busy()).toBe(false);
+ await state.complete();
+ expect(source.complete).toHaveBeenCalledTimes(1);
+ dispose();
+ });
+ it('expires an unfinished attempt and cancels explicitly', async () => {
+ vi.useFakeTimers();
+ const { state, source, dispose } = setup();
+ await state.begin();
+ state.setCode('private#state');
+ vi.advanceTimersByTime(600_000);
+ expect(state.login()).toBeUndefined();
+ expect(state.code()).toBe('');
+ expect(state.error()).toContain('expired');
+ await state.begin();
+ await state.disconnect();
+ expect(source.disconnect).toHaveBeenCalledTimes(1);
+ expect(state.login()).toBeUndefined();
+ dispose();
+ vi.useRealTimers();
+ });
+});
diff --git a/apps/web/src/features/claude-connection/primitives/connection.ts b/apps/web/src/features/claude-connection/primitives/connection.ts
new file mode 100644
index 00000000000..50238603cf0
--- /dev/null
+++ b/apps/web/src/features/claude-connection/primitives/connection.ts
@@ -0,0 +1,117 @@
+import { createSignal, onCleanup } from 'solid-js';
+import type {
+ ClaudeConnectionSource,
+ ClaudeLogin,
+ ClaudeSignIn,
+} from '../core/connection';
+
+/** Owns consent UI state; the source owns transport and cache invalidation. */
+export function createClaudeConnection(
+ source: ClaudeConnectionSource,
+ openSignIn: () => ClaudeSignIn | undefined
+) {
+ const [login, setLogin] = createSignal();
+ const [code, setCode] = createSignal('');
+ const [busy, setBusy] = createSignal(false);
+ const [error, setError] = createSignal('');
+ let disposed = false;
+ let pendingSignIn: ClaudeSignIn | undefined;
+ let expiry: ReturnType | undefined;
+ const clear = () => {
+ clearTimeout(expiry);
+ setLogin(undefined);
+ setCode('');
+ };
+ onCleanup(() => {
+ disposed = true;
+ pendingSignIn?.close();
+ pendingSignIn = undefined;
+ clearTimeout(expiry);
+ setCode('');
+ });
+ const report = (error: unknown) =>
+ setError(
+ error instanceof Error
+ ? error.message
+ : 'Claude connection failed. Please try again.'
+ );
+
+ async function begin() {
+ if (busy()) return;
+ clear();
+ setBusy(true);
+ setError('');
+ try {
+ // Reserve the tab before the first await to retain the user's click activation.
+ pendingSignIn = openSignIn();
+ const next = await source.begin();
+ if (disposed) return;
+ setLogin(next);
+ expiry = setTimeout(() => {
+ clear();
+ setError('Sign-in expired. Start Connect Claude again.');
+ }, next.expiresIn * 1000);
+ pendingSignIn?.navigate(next.authorizationUrl);
+ pendingSignIn = undefined;
+ } catch (error) {
+ pendingSignIn?.close();
+ pendingSignIn = undefined;
+ if (!disposed) report(error);
+ } finally {
+ if (!disposed) setBusy(false);
+ }
+ }
+ async function complete() {
+ const current = login();
+ const pasted = code().trim();
+ if (busy() || !current || !pasted) return;
+ setCode('');
+ setBusy(true);
+ setError('');
+ try {
+ await source.complete(current.attemptId, pasted);
+ if (!disposed) clear();
+ } catch (error) {
+ if (!disposed) {
+ clear();
+ report(error);
+ }
+ } finally {
+ if (!disposed) setBusy(false);
+ }
+ }
+ async function disconnect() {
+ if (busy()) return;
+ clear();
+ setBusy(true);
+ setError('');
+ try {
+ await source.disconnect();
+ } catch (error) {
+ if (!disposed) report(error);
+ } finally {
+ if (!disposed) setBusy(false);
+ }
+ }
+ async function refresh() {
+ setError('');
+ try {
+ await source.refresh();
+ } catch (error) {
+ if (!disposed) report(error);
+ }
+ }
+ return {
+ status: source.status,
+ failed: source.failed,
+ login,
+ code,
+ setCode,
+ busy,
+ error,
+ begin,
+ complete,
+ disconnect,
+ refresh,
+ };
+}
diff --git a/apps/web/src/features/settings/Agents.test.tsx b/apps/web/src/features/settings/Agents.test.tsx
index aba8609caaa..a420849e4f8 100644
--- a/apps/web/src/features/settings/Agents.test.tsx
+++ b/apps/web/src/features/settings/Agents.test.tsx
@@ -22,6 +22,25 @@ import { createStore } from 'solid-js/store';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { Agents } from './Agents';
+const claudeFlag = vi.hoisted(() => ({ enabled: true }));
+vi.mock('@app/lib/analytics/posthog', () => ({
+ useFeatureFlag: (flag: { key: string }) => {
+ expect(flag.key).toBe('claude-cloud');
+ return () => ({ enabled: claudeFlag.enabled });
+ },
+}));
+
+vi.mock('@queries/claude-auth/connection', () => ({
+ useClaudeConnectionSource: () => ({
+ status: () => ({ enabled: true, connected: false, ephemeral: true }),
+ failed: () => false,
+ begin: vi.fn(),
+ complete: vi.fn(),
+ disconnect: vi.fn(),
+ refresh: vi.fn(),
+ }),
+}));
+
const [searchParams, updateSearchParams] = createStore<{
createAgent?: string;
}>({});
@@ -291,6 +310,30 @@ const MACROD_HARNESS = {
};
describe('Agents', () => {
+ it.each([false, true])(
+ 'gates Claude harness selection and discovery when enabled=%s',
+ (enabled) => {
+ claudeFlag.enabled = enabled;
+ modelMocks.queries['claude-cloud:'] = successfulModels([
+ { id: 'claude-default', name: 'Subscription default' },
+ ]);
+ try {
+ render(() => );
+ fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));
+ const option = screen.queryByRole('option', {
+ name: 'Claude Cloud (demo)',
+ });
+ expect(Boolean(option)).toBe(enabled);
+ const targets = vi.mocked(useAgentModelsQueries).mock.lastCall?.[0]();
+ expect(
+ targets?.some((target) => target.harness === 'claude-cloud')
+ ).toBe(enabled);
+ } finally {
+ claudeFlag.enabled = true;
+ }
+ }
+ );
+
it('opens the new-agent form from a link and clears the action on cancel', () => {
updateSearchParams({ createAgent: 'true' });
render(() => );
diff --git a/apps/web/src/features/settings/Agents.tsx b/apps/web/src/features/settings/Agents.tsx
index 18b33b301d1..0d06fe57d6c 100644
--- a/apps/web/src/features/settings/Agents.tsx
+++ b/apps/web/src/features/settings/Agents.tsx
@@ -1,7 +1,9 @@
+import { useFeatureFlag } from '@app/lib/analytics/posthog';
import { ModelCatalogPicker } from '@core/component/AI/component/input/ModelCatalogPicker';
import { isLargeModelCatalog } from '@core/component/AI/component/input/modelCatalog';
import { MODEL_PRETTYNAME, Model } from '@core/component/AI/constant/model';
import { toast } from '@core/component/Toast/Toast';
+import { claudeCloud } from '@core/constant/featureFlags';
import { MACRO_AGENT_BOT_ID } from '@core/constant/macroAgent';
import { useChannelsContext } from '@core/context/channels';
import { useUserId } from '@core/context/user';
@@ -93,6 +95,7 @@ const MACRO_AGENT: AgentSummary = {
/** Settings page for viewing and creating persistent agents. */
export function Agents() {
+ const claudeCloudFlag = useFeatureFlag(claudeCloud);
const [creating, setCreating] = createSignal(false);
const [searchParams, setSearchParams] = useSearchParams();
const creatingFromLink = () => searchParams.createAgent === 'true';
@@ -118,33 +121,43 @@ export function Agents() {
const harnessesQuery = useHarnessesQuery();
const connectedHarnesses = (): readonly ConnectedHarness[] => {
const harnesses = harnessesQuery.isSuccess ? harnessesQuery.data : [];
- return buildAgentModelTargets(cursorConnected(), harnesses).map(
- (target) => {
- if (target.harness === 'in-memory') return IN_MEMORY_HARNESS;
- if (target.harness === 'cursor') {
- return {
- id: 'cursor',
- name: 'Cursor',
- kind: 'builtin',
- target,
- };
- }
-
- const harness = harnesses.find(
- (candidate) => candidate.id === target.harnessId
- );
+ return buildAgentModelTargets(
+ cursorConnected(),
+ harnesses,
+ claudeCloudFlag().enabled
+ ).map((target) => {
+ if (target.harness === 'in-memory') return IN_MEMORY_HARNESS;
+ if (target.harness === 'claude-cloud') {
return {
- id: target.harnessId ?? '',
- name:
- harness?.owner.type === 'team'
- ? `${harness.name} · Team`
- : (harness?.name ?? 'macrod'),
- kind: 'macrod',
+ id: 'claude-cloud',
+ name: 'Claude Cloud (demo)',
+ kind: 'builtin',
target,
- connected: harness?.connected,
};
}
- );
+ if (target.harness === 'cursor') {
+ return {
+ id: 'cursor',
+ name: 'Cursor',
+ kind: 'builtin',
+ target,
+ };
+ }
+
+ const harness = harnesses.find(
+ (candidate) => candidate.id === target.harnessId
+ );
+ return {
+ id: target.harnessId ?? '',
+ name:
+ harness?.owner.type === 'team'
+ ? `${harness.name} · Team`
+ : (harness?.name ?? 'macrod'),
+ kind: 'macrod',
+ target,
+ connected: harness?.connected,
+ };
+ });
};
const channelOptions = createMemo(() =>
botAssignableChannelOptions(channelsContext.channels())
@@ -379,6 +392,7 @@ function summarizeAgent(
function harnessName(id: string): string {
if (id === 'in-memory') return 'In-memory';
if (id === 'cursor') return 'Cursor';
+ if (id === 'claude-cloud') return 'Claude Cloud (demo)';
// Any other id is a registered macrod harness uuid; if it is not in the
// connected list any more, the harness has been removed.
return 'Disconnected harness';
@@ -857,7 +871,14 @@ function AgentDialog(props: {
handleHarnessChange(event.currentTarget.value)
}
>
-
+
+ harness.id !== 'claude-cloud' ||
+ modelDataForHarness(harness.id)?.status ===
+ 'available'
+ )}
+ >
{(harness) => (
)}
diff --git a/apps/web/src/features/settings/Harness.test.tsx b/apps/web/src/features/settings/Harness.test.tsx
index f03246a3961..26f7934a805 100644
--- a/apps/web/src/features/settings/Harness.test.tsx
+++ b/apps/web/src/features/settings/Harness.test.tsx
@@ -29,6 +29,31 @@ vi.mock('./codex/views/CodexHarness', () => ({
CodexHarness: () => ,
}));
+const claudeFlag = vi.hoisted(() => ({ enabled: true, source: vi.fn() }));
+vi.mock('@app/lib/analytics/posthog', () => ({
+ useFeatureFlag: (flag: { key: string }) => {
+ expect(flag.key).toBe('claude-cloud');
+ return () => ({ enabled: claudeFlag.enabled });
+ },
+}));
+
+vi.mock('@core/context/user', () => ({
+ useUserId: () => () => 'macro|demo@example.com',
+}));
+vi.mock('@queries/claude-auth/connection', () => ({
+ useClaudeConnectionSource: () => {
+ claudeFlag.source();
+ return {
+ status: () => ({ enabled: true, connected: false, ephemeral: true }),
+ failed: () => false,
+ begin: vi.fn(),
+ complete: vi.fn(),
+ disconnect: vi.fn(),
+ refresh: vi.fn(),
+ };
+ },
+}));
+
const mocks = vi.hoisted(() => ({
status: {
isSuccess: true,
@@ -202,6 +227,21 @@ describe('Harness', () => {
}
);
+ it('does not mount Claude connection or fetch its status when the flag is off', () => {
+ claudeFlag.enabled = false;
+ claudeFlag.source.mockClear();
+ try {
+ render(() => );
+ expect(
+ screen.queryByRole('region', { name: 'Claude Cloud connection' })
+ ).toBeNull();
+ expect(claudeFlag.source).not.toHaveBeenCalled();
+ expect(screen.getByRole('heading', { name: 'Cursor' })).toBeTruthy();
+ } finally {
+ claudeFlag.enabled = true;
+ }
+ });
+
it.each(['success', 'error'] as const)(
'keeps settings visible while Cursor models load and after %s',
async (outcome) => {
@@ -278,11 +318,17 @@ describe('Harness', () => {
}
);
- it('shows the three configurable harness options', () => {
+ it('shows Claude with the Anthropic logo above Cursor in the harness list', () => {
render(() => );
expect(screen.getByRole('heading', { name: 'In-memory' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'Cursor' })).toBeTruthy();
+ const claude = screen.getByRole('heading', { name: 'Claude Cloud (demo)' });
+ const cursor = screen.getByRole('heading', { name: 'Cursor' });
+ expect(
+ claude.compareDocumentPosition(cursor) & Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy();
+ expect(screen.getByLabelText('Anthropic')).toBeTruthy();
expect(
screen.getByRole('heading', { name: 'Bring your own agent' })
).toBeTruthy();
diff --git a/apps/web/src/features/settings/Harness.tsx b/apps/web/src/features/settings/Harness.tsx
index 045b5b9c82a..694cb4ca06b 100644
--- a/apps/web/src/features/settings/Harness.tsx
+++ b/apps/web/src/features/settings/Harness.tsx
@@ -1,7 +1,9 @@
+import { useFeatureFlag } from '@app/lib/analytics/posthog';
import { useCodexAgentsAccess } from '@core/codex/flag';
import { ModelCatalogPicker } from '@core/component/AI/component/input/ModelCatalogPicker';
import { isLargeModelCatalog } from '@core/component/AI/component/input/modelCatalog';
import { toast } from '@core/component/Toast/Toast';
+import { claudeCloud } from '@core/constant/featureFlags';
import { ThrownResultError } from '@core/util/result';
import CursorIcon from '@icon/wide-cursor-ide.svg';
import ArrowUpRightIcon from '@phosphor/arrow-up-right.svg';
@@ -22,6 +24,7 @@ import type { Harness as RegisteredHarness } from '@service-storage/client';
import { useSearchParams } from '@solidjs/router';
import { Button, Dialog, Panel } from '@ui';
import { createSignal, For, onMount, Show } from 'solid-js';
+import { ClaudeConnection } from '../claude-connection/claude-connection';
import { CodexHarness } from './codex/views/CodexHarness';
import { HarnessPairingDialog } from './HarnessPairingDialog';
import { ConnectAction, HarnessIcon, StatusDot } from './integration-ui';
@@ -43,6 +46,7 @@ function lastConnectedText(harness: RegisteredHarness): string {
/** Settings UI for choosing and configuring the available agent harnesses. */
export function Harness() {
const canUseCodex = useCodexAgentsAccess();
+ const claudeCloudFlag = useFeatureFlag(claudeCloud);
const [cursorApiKey, setCursorApiKey] = createSignal('');
const cursorStatus = useCursorApiKeyStatusQuery();
const saveCursorApiKey = useSaveCursorApiKey();
@@ -177,6 +181,10 @@ export function Harness() {
+
+
+
+
diff --git a/apps/web/src/lib/core/constant/featureFlags.ts b/apps/web/src/lib/core/constant/featureFlags.ts
index 1d9f0b4f2c5..c83eb7aa85b 100644
--- a/apps/web/src/lib/core/constant/featureFlags.ts
+++ b/apps/web/src/lib/core/constant/featureFlags.ts
@@ -126,6 +126,13 @@ export const PROD_MODE_ENV = import.meta.env.MODE === 'production';
const onInDev = DEV_MODE_ENV || undefined;
+// Claude Cloud demo onboarding and harness/model discovery. Off until PostHog
+// enables it, including in dev; override locally with VITE_CLAUDE_CLOUD.
+export const claudeCloud = defineFlag({
+ key: 'claude-cloud',
+ env: 'CLAUDE_CLOUD',
+});
+
export const ENABLE_PDF_MODIFICATION_DATA_AUTOSAVE = defineFlag({
env: 'ENABLE_PDF_MODIFICATION_DATA_AUTOSAVE',
default: true,
diff --git a/apps/web/src/lib/queries/agent-session/session.ts b/apps/web/src/lib/queries/agent-session/session.ts
index 36f7007cd61..084212a4813 100644
--- a/apps/web/src/lib/queries/agent-session/session.ts
+++ b/apps/web/src/lib/queries/agent-session/session.ts
@@ -1,5 +1,5 @@
/**
- * Bounded snapshot poll for a Cursor session whose `external.url` is still
+ * Bounded snapshot poll for a cloud session whose `external.url` is still
* missing.
*
* Cursor mints that url inside the session's first prompt, and nothing
@@ -26,7 +26,7 @@ export const EXTERNAL_URL_POLL_ATTEMPTS = 15;
/**
* Re-read an agent session snapshot while `sessionId` is set. Callers pass
- * `undefined` when the session is not a Cursor bot, already has a url, or
+ * `undefined` when the session is not a cloud harness, already has a url, or
* has not loaded yet — the query disables rather than fetching.
*
* `gcTime: 0` drops the observer when the call site unmounts so reopening
diff --git a/apps/web/src/lib/queries/agents/models.test.tsx b/apps/web/src/lib/queries/agents/models.test.tsx
index cd2e086abc3..f7286b6ef0f 100644
--- a/apps/web/src/lib/queries/agents/models.test.tsx
+++ b/apps/web/src/lib/queries/agents/models.test.tsx
@@ -47,10 +47,11 @@ afterEach(() => {
describe('agent model discovery', () => {
it('constructs every available target in parallel without waiting for another target', async () => {
- const targets = buildAgentModelTargets(true, [
- { id: 'harness-a' },
- { id: 'harness-b' },
- ]);
+ const targets = buildAgentModelTargets(
+ true,
+ [{ id: 'harness-a' }, { id: 'harness-b' }],
+ true
+ );
const pending = new Promise(() => {});
vi.mocked(agentHarnessServiceClient.loadAgentModels).mockReturnValue(
pending
@@ -60,7 +61,7 @@ describe('agent model discovery', () => {
await vi.waitFor(() => {
expect(agentHarnessServiceClient.loadAgentModels).toHaveBeenCalledTimes(
- 4
+ 5
);
});
expect(
@@ -69,6 +70,7 @@ describe('agent model discovery', () => {
.mock.calls.map(([request]) => request)
).toEqual([
{ harness: 'in-memory' },
+ { harness: 'claude-cloud' },
{ harness: 'cursor' },
{ harness: 'macrod', harnessId: 'harness-a' },
{ harness: 'macrod', harnessId: 'harness-b' },
@@ -76,9 +78,27 @@ describe('agent model discovery', () => {
});
it('omits Cursor when it is not registered', () => {
- expect(buildAgentModelTargets(false, [{ id: 'harness-a' }])).toEqual([
+ expect(buildAgentModelTargets(false, [{ id: 'harness-a' }], true)).toEqual([
{ harness: 'in-memory' },
+ { harness: 'claude-cloud' },
{ harness: 'macrod', harnessId: 'harness-a' },
]);
});
+
+ it('does not query Claude when its feature flag is off', async () => {
+ vi.mocked(agentHarnessServiceClient.loadAgentModels).mockReturnValue(
+ new Promise(() => {})
+ );
+ renderHook(() =>
+ useAgentModelsQueries(() => buildAgentModelTargets(true, [], false))
+ );
+ await vi.waitFor(() =>
+ expect(agentHarnessServiceClient.loadAgentModels).toHaveBeenCalledTimes(2)
+ );
+ expect(
+ vi
+ .mocked(agentHarnessServiceClient.loadAgentModels)
+ .mock.calls.map(([request]) => request)
+ ).toEqual([{ harness: 'in-memory' }, { harness: 'cursor' }]);
+ });
});
diff --git a/apps/web/src/lib/queries/agents/models.ts b/apps/web/src/lib/queries/agents/models.ts
index ca980c84015..7c14c533ada 100644
--- a/apps/web/src/lib/queries/agents/models.ts
+++ b/apps/web/src/lib/queries/agents/models.ts
@@ -12,9 +12,11 @@ export type AgentModelTarget = LoadAgentModelsRequest;
/** Every model provider available to the agent dialog, in display order. */
export function buildAgentModelTargets(
cursorRegistered: boolean,
- harnesses: readonly Pick[]
+ harnesses: readonly Pick[],
+ claudeCloudEnabled: boolean
): AgentModelTarget[] {
const targets: AgentModelTarget[] = [{ harness: 'in-memory' }];
+ if (claudeCloudEnabled) targets.push({ harness: 'claude-cloud' });
if (cursorRegistered) targets.push({ harness: 'cursor' });
targets.push(
...harnesses.map(
diff --git a/apps/web/src/lib/queries/claude-auth/connection.ts b/apps/web/src/lib/queries/claude-auth/connection.ts
new file mode 100644
index 00000000000..9cabb3a28de
--- /dev/null
+++ b/apps/web/src/lib/queries/claude-auth/connection.ts
@@ -0,0 +1,42 @@
+import { throwOnErr } from '@core/util/result';
+import { claudeAuthClient } from '@service-agent-harness/claude-auth';
+import { useQuery, useQueryClient } from '@tanstack/solid-query';
+import type { ClaudeConnectionSource } from '../../../features/claude-connection/core/connection';
+import { claudeAuthKeys } from './keys';
+
+/** Only safe status is cached. One-time codes never enter a mutation cache. */
+export function useClaudeConnectionSource(
+ owner: () => string | undefined
+): ClaudeConnectionSource {
+ const client = useQueryClient();
+ const query = useQuery(() => ({
+ queryKey: claudeAuthKeys.status(owner()).queryKey,
+ queryFn: ({ signal }) => throwOnErr(() => claudeAuthClient.status(signal)),
+ enabled: !!owner(),
+ staleTime: 0,
+ gcTime: 0,
+ retry: false,
+ }));
+ const refresh = async () => {
+ await Promise.all([
+ client.invalidateQueries({ queryKey: claudeAuthKeys._def }),
+ client.invalidateQueries({
+ queryKey: ['agent-models', 'load', 'claude-cloud'],
+ }),
+ ]);
+ };
+ return {
+ status: () => (query.isSuccess ? query.data : undefined),
+ failed: () => query.isError,
+ begin: () => throwOnErr(() => claudeAuthClient.begin()),
+ complete: async (attemptId, code) => {
+ await throwOnErr(() => claudeAuthClient.complete(attemptId, code));
+ await refresh();
+ },
+ disconnect: async () => {
+ await throwOnErr(() => claudeAuthClient.disconnect());
+ await refresh();
+ },
+ refresh,
+ };
+}
diff --git a/apps/web/src/lib/queries/claude-auth/keys.ts b/apps/web/src/lib/queries/claude-auth/keys.ts
new file mode 100644
index 00000000000..18cfc28adb3
--- /dev/null
+++ b/apps/web/src/lib/queries/claude-auth/keys.ts
@@ -0,0 +1,4 @@
+import { createQueryKeys } from '@lukemorales/query-key-factory';
+export const claudeAuthKeys = createQueryKeys('claude-auth', {
+ status: (owner: string | undefined) => [owner],
+});
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts b/apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts
new file mode 100644
index 00000000000..0b5822a6227
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-agent-harness/claude-auth.ts
@@ -0,0 +1,66 @@
+import { SERVER_HOSTS } from '@core/constant/servers';
+import { fetchWithToken } from '@core/util/fetchWithToken';
+import type { ErrorResponseHandler } from '@core/util/safeFetch';
+
+// Mirrors the Rust /claude-auth DTOs. Neither response can contain provider credentials.
+export type ClaudeAuthStatus = {
+ enabled: boolean;
+ connected: boolean;
+ ephemeral: boolean;
+};
+export type ClaudeAuthStart = {
+ attemptId: string;
+ authorizationUrl: string;
+ expiresIn: number;
+};
+const host = `${SERVER_HOSTS['agent-harness']}/claude-auth`;
+
+const authError: ErrorResponseHandler = async (response) => {
+ const data: unknown = await response.json();
+ return {
+ code: response.status === 401 ? 'UNAUTHORIZED' : 'HTTP_ERROR',
+ message:
+ typeof data === 'object' &&
+ data !== null &&
+ 'message' in data &&
+ typeof data.message === 'string'
+ ? data.message
+ : 'Claude connection request failed. Start Connect Claude again.',
+ };
+};
+const writeOptions = {
+ headers: { 'Content-Type': 'application/json' },
+ cache: 'no-store' as const,
+ errorResponseHandler: authError,
+};
+
+export const claudeAuthClient = {
+ status(signal?: AbortSignal) {
+ return fetchWithToken(host, {
+ method: 'GET',
+ cache: 'no-store',
+ signal,
+ });
+ },
+ begin() {
+ return fetchWithToken(`${host}/start`, {
+ ...writeOptions,
+ method: 'POST',
+ body: '{}',
+ });
+ },
+ complete(attemptId: string, code: string) {
+ return fetchWithToken>(`${host}/complete`, {
+ ...writeOptions,
+ method: 'POST',
+ body: JSON.stringify({ attemptId, code }),
+ });
+ },
+ disconnect() {
+ return fetchWithToken>(host, {
+ ...writeOptions,
+ method: 'DELETE',
+ body: '{}',
+ });
+ },
+};
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts
index f36f0e1c3b5..79d3ab2e5ef 100644
--- a/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/client.ts
@@ -8,17 +8,21 @@ import type {
AgentSessionLogResponse,
AgentSessionQueueResponse,
AgentSessionResponse,
+ CompleteRequest,
ControlRequest,
ControlResponse,
CreateAgentSessionRequest,
CreateAgentSessionResponse,
EditQueuedActionRequest,
+ EmptyRequest,
LoadAgentModelsRequest,
LoadAgentModelsResponse,
PreviewAgentSessionsRequest,
PreviewAgentSessionsResponse,
RenameAgentSessionRequest,
SandboxSizeBody,
+ StartResponse,
+ StatusResponse,
} from './schemas';
/**
@@ -990,3 +994,197 @@ export const putAgentSessionSandboxSize = async (
headers: res.headers,
} as putAgentSessionSandboxSizeResponse;
};
+
+/**
+ * @summary Read connection status for the authenticated user only.
+ */
+export type statusResponse200 = {
+ data: StatusResponse;
+ status: 200;
+};
+
+export type statusResponse401 = {
+ data: void;
+ status: 401;
+};
+
+export type statusResponseSuccess = statusResponse200 & {
+ headers: Headers;
+};
+export type statusResponseError = statusResponse401 & {
+ headers: Headers;
+};
+
+export type statusResponse = statusResponseSuccess | statusResponseError;
+
+export const getStatusUrl = () => {
+ return `/claude-auth`;
+};
+
+export const status = async (
+ options?: RequestInit
+): Promise => {
+ const res = await fetch(getStatusUrl(), {
+ ...options,
+ method: 'GET',
+ });
+
+ const body = [204, 205, 304].includes(res.status) ? null : await res.text();
+
+ const data: statusResponse['data'] = body ? JSON.parse(body) : {};
+ return { data, status: res.status, headers: res.headers } as statusResponse;
+};
+
+/**
+ * @summary Forget only the authenticated user's grant and cancel pending consent.
+ */
+export type disconnectResponse204 = {
+ data: void;
+ status: 204;
+};
+
+export type disconnectResponse403 = {
+ data: void;
+ status: 403;
+};
+
+export type disconnectResponseSuccess = disconnectResponse204 & {
+ headers: Headers;
+};
+export type disconnectResponseError = disconnectResponse403 & {
+ headers: Headers;
+};
+
+export type disconnectResponse =
+ | disconnectResponseSuccess
+ | disconnectResponseError;
+
+export const getDisconnectUrl = () => {
+ return `/claude-auth`;
+};
+
+export const disconnect = async (
+ emptyRequest: EmptyRequest,
+ options?: RequestInit
+): Promise => {
+ const res = await fetch(getDisconnectUrl(), {
+ ...options,
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(emptyRequest),
+ });
+
+ const body = [204, 205, 304].includes(res.status) ? null : await res.text();
+
+ const data: disconnectResponse['data'] = body ? JSON.parse(body) : {};
+ return {
+ data,
+ status: res.status,
+ headers: res.headers,
+ } as disconnectResponse;
+};
+
+/**
+ * @summary Exchange one code; never return access or refresh tokens.
+ */
+export type completeResponse204 = {
+ data: void;
+ status: 204;
+};
+
+export type completeResponse400 = {
+ data: void;
+ status: 400;
+};
+
+export type completeResponse409 = {
+ data: void;
+ status: 409;
+};
+
+export type completeResponse502 = {
+ data: void;
+ status: 502;
+};
+
+export type completeResponseSuccess = completeResponse204 & {
+ headers: Headers;
+};
+export type completeResponseError = (
+ | completeResponse400
+ | completeResponse409
+ | completeResponse502
+) & {
+ headers: Headers;
+};
+
+export type completeResponse = completeResponseSuccess | completeResponseError;
+
+export const getCompleteUrl = () => {
+ return `/claude-auth/complete`;
+};
+
+export const complete = async (
+ completeRequest: CompleteRequest,
+ options?: RequestInit
+): Promise => {
+ const res = await fetch(getCompleteUrl(), {
+ ...options,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(completeRequest),
+ });
+
+ const body = [204, 205, 304].includes(res.status) ? null : await res.text();
+
+ const data: completeResponse['data'] = body ? JSON.parse(body) : {};
+ return { data, status: res.status, headers: res.headers } as completeResponse;
+};
+
+/**
+ * @summary Create an expiring PKCE challenge for the authenticated user.
+ */
+export type startResponse200 = {
+ data: StartResponse;
+ status: 200;
+};
+
+export type startResponse403 = {
+ data: void;
+ status: 403;
+};
+
+export type startResponse429 = {
+ data: void;
+ status: 429;
+};
+
+export type startResponseSuccess = startResponse200 & {
+ headers: Headers;
+};
+export type startResponseError = (startResponse403 | startResponse429) & {
+ headers: Headers;
+};
+
+export type startResponse = startResponseSuccess | startResponseError;
+
+export const getStartUrl = () => {
+ return `/claude-auth/start`;
+};
+
+export const start = async (
+ emptyRequest: EmptyRequest,
+ options?: RequestInit
+): Promise => {
+ const res = await fetch(getStartUrl(), {
+ ...options,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
+ body: JSON.stringify(emptyRequest),
+ });
+
+ const body = [204, 205, 304].includes(res.status) ? null : await res.text();
+
+ const data: startResponse['data'] = body ? JSON.parse(body) : {};
+ return { data, status: res.status, headers: res.headers } as startResponse;
+};
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts
new file mode 100644
index 00000000000..9e1e141d0ef
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/completeRequest.ts
@@ -0,0 +1,16 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * agent_harness_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * One-time manual code. Deliberately does not implement Debug.
+ */
+export interface CompleteRequest {
+ /** Server-issued handle; never an owner selected by the caller. */
+ attemptId: string;
+ /** Claude's complete code#state string, not an access token. */
+ code: string;
+}
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts
new file mode 100644
index 00000000000..722dd6e1749
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/emptyRequest.ts
@@ -0,0 +1,13 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * agent_harness_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * A JSON body is required on writes, including start/disconnect (no form-based CSRF).
+ */
+export interface EmptyRequest {
+ [key: string]: unknown;
+}
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts
index 07912c86c3e..d6094747679 100644
--- a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/index.ts
@@ -55,6 +55,7 @@ export * from './agentSessionResponseThreadChannelId';
export * from './agentSessionResponseThreadId';
export * from './agentSetModelAction';
export * from './botId';
+export * from './completeRequest';
export * from './controlRequest';
export * from './controlResponse';
export * from './controlStatusDto';
@@ -81,6 +82,7 @@ export * from './elicitationAnswerOneOfSeven';
export * from './elicitationAnswerOneOfSevenAction';
export * from './elicitationContentValue';
export * from './elicitationRequestId';
+export * from './emptyRequest';
export * from './externalSessionResponse';
export * from './externalSessionResponseName';
export * from './externalSessionResponseUrl';
@@ -109,4 +111,6 @@ export * from './sessionStatusDtoOneOfFiveKind';
export * from './sessionStatusDtoOneOfKind';
export * from './sessionStatusDtoOneOfThree';
export * from './sessionStatusDtoOneOfThreeKind';
+export * from './startResponse';
+export * from './statusResponse';
export * from './withAgentSessionId';
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts
index 15d05b1db33..0b1b76195e9 100644
--- a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/modelHarnessDto.ts
@@ -15,5 +15,6 @@ export type ModelHarnessDto =
export const ModelHarnessDto = {
'in-memory': 'in-memory',
cursor: 'cursor',
+ 'claude-cloud': 'claude-cloud',
macrod: 'macrod',
} as const;
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts
new file mode 100644
index 00000000000..3548c268b08
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/startResponse.ts
@@ -0,0 +1,21 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * agent_harness_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Public PKCE challenge and attempt handle; contains no verifier or provider tokens.
+ */
+export interface StartResponse {
+ /** Opaque owner-bound attempt handle. */
+ attemptId: string;
+ /** Claude-hosted consent page. */
+ authorizationUrl: string;
+ /**
+ * Attempt lifetime in seconds.
+ * @minimum 0
+ */
+ expiresIn: number;
+}
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts
new file mode 100644
index 00000000000..19a9af1c856
--- /dev/null
+++ b/apps/web/src/lib/service-clients/service-agent-harness/generated/schemas/statusResponse.ts
@@ -0,0 +1,18 @@
+/**
+ * Generated by orval v7.21.0 🍺
+ * Do not edit manually.
+ * agent_harness_service
+ * OpenAPI spec version: 0.1.0
+ */
+
+/**
+ * Safe connection metadata.
+ */
+export interface StatusResponse {
+ /** Whether the authenticated Macro user has connected. */
+ connected: boolean;
+ /** Whether this deployment supports browser connection. */
+ enabled: boolean;
+ /** Whether reconnecting after service restart is required. */
+ ephemeral: boolean;
+}
diff --git a/apps/web/src/lib/service-clients/service-agent-harness/openapi.json b/apps/web/src/lib/service-clients/service-agent-harness/openapi.json
index 0bb631f4158..f362574a52a 100644
--- a/apps/web/src/lib/service-clients/service-agent-harness/openapi.json
+++ b/apps/web/src/lib/service-clients/service-agent-harness/openapi.json
@@ -916,6 +916,137 @@
}
}
}
+ },
+ "/claude-auth": {
+ "get": {
+ "tags": ["claude-auth"],
+ "summary": "Read connection status for the authenticated user only.",
+ "operationId": "status",
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StatusResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthenticated"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "delete": {
+ "tags": ["claude-auth"],
+ "summary": "Forget only the authenticated user's grant and cancel pending consent.",
+ "operationId": "disconnect",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmptyRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "Disconnected"
+ },
+ "403": {
+ "description": "Disabled"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/claude-auth/complete": {
+ "post": {
+ "tags": ["claude-auth"],
+ "summary": "Exchange one code; never return access or refresh tokens.",
+ "operationId": "complete",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CompleteRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "Connected"
+ },
+ "400": {
+ "description": "Invalid code"
+ },
+ "409": {
+ "description": "Expired or replayed"
+ },
+ "502": {
+ "description": "Provider failed"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/claude-auth/start": {
+ "post": {
+ "tags": ["claude-auth"],
+ "summary": "Create an expiring PKCE challenge for the authenticated user.",
+ "operationId": "start",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmptyRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StartResponse"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Disabled"
+ },
+ "429": {
+ "description": "Too many attempts"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
}
},
"components": {
@@ -1376,6 +1507,22 @@
"BotId": {
"type": "string"
},
+ "CompleteRequest": {
+ "type": "object",
+ "description": "One-time manual code. Deliberately does not implement Debug.",
+ "required": ["attemptId", "code"],
+ "properties": {
+ "attemptId": {
+ "type": "string",
+ "description": "Server-issued handle; never an owner selected by the caller."
+ },
+ "code": {
+ "type": "string",
+ "description": "Claude's complete code#state string, not an access token."
+ }
+ },
+ "additionalProperties": false
+ },
"ControlRequest": {
"allOf": [
{
@@ -1588,6 +1735,11 @@
],
"description": "The JSON-RPC id of an agent's `elicitation/create` request, carried whole\nso the answer echoes exactly what the agent sent.\n\nAgents pick these, not us: Claude Code counts from `0`, others use\nstrings. `null` is not a legal id for a request that expects a response,\nso it is not representable here."
},
+ "EmptyRequest": {
+ "type": "object",
+ "description": "A JSON body is required on writes, including start/disconnect (no form-based CSRF).",
+ "additionalProperties": false
+ },
"ExternalSessionResponse": {
"type": "object",
"description": "The provider-side identity of an externally-served session.",
@@ -1668,7 +1820,7 @@
"ModelHarnessDto": {
"type": "string",
"description": "Harness names accepted by the model discovery endpoint.",
- "enum": ["in-memory", "cursor", "macrod"]
+ "enum": ["in-memory", "cursor", "claude-cloud", "macrod"]
},
"PreviewAgentSessionsRequest": {
"type": "object",
@@ -1819,6 +1971,46 @@
],
"description": "Transport representation of a session's status, mirroring\n[`SessionStatus`]."
},
+ "StartResponse": {
+ "type": "object",
+ "description": "Public PKCE challenge and attempt handle; contains no verifier or provider tokens.",
+ "required": ["attemptId", "authorizationUrl", "expiresIn"],
+ "properties": {
+ "attemptId": {
+ "type": "string",
+ "description": "Opaque owner-bound attempt handle."
+ },
+ "authorizationUrl": {
+ "type": "string",
+ "description": "Claude-hosted consent page."
+ },
+ "expiresIn": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Attempt lifetime in seconds.",
+ "minimum": 0
+ }
+ }
+ },
+ "StatusResponse": {
+ "type": "object",
+ "description": "Safe connection metadata.",
+ "required": ["enabled", "connected", "ephemeral"],
+ "properties": {
+ "connected": {
+ "type": "boolean",
+ "description": "Whether the authenticated Macro user has connected."
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether this deployment supports browser connection."
+ },
+ "ephemeral": {
+ "type": "boolean",
+ "description": "Whether reconnecting after service restart is required."
+ }
+ }
+ },
"WithAgentSessionId": {
"type": "object",
"description": "Just a session id, for the preview variants that carry nothing else.\n\nClients deserialize this, so both derives are used.",
diff --git a/crates/agent_harness/Cargo.toml b/crates/agent_harness/Cargo.toml
index ba42d0510fc..bbfed59cb47 100644
--- a/crates/agent_harness/Cargo.toml
+++ b/crates/agent_harness/Cargo.toml
@@ -5,6 +5,7 @@ publish = false
version = "0.1.0"
[dependencies]
+claude_cloud_agents = { path = "../claude_cloud_agents" }
async-trait = { workspace = true }
codex_cloud_agents = { path = "../codex_cloud_agents", features = ["postgres"] }
codex_connection = { path = "../codex_connection" }
diff --git a/crates/agent_harness/src/domain/claude.rs b/crates/agent_harness/src/domain/claude.rs
new file mode 100644
index 00000000000..5157ff55805
--- /dev/null
+++ b/crates/agent_harness/src/domain/claude.rs
@@ -0,0 +1,155 @@
+//! Claude conversation lifecycle through owner-bound provider and session ports.
+use crate::domain::error::{HarnessError, Result};
+use agent_session::domain::{
+ model::{AgentSessionId, ExternalSession},
+ ports::{AgentSessionRepo, ExternalSessionRepo},
+};
+use claude_cloud_agents::domain::{
+ model::{Error, SessionId},
+ ports::{CloudLifecycle, CloudProvider},
+ service::Session,
+};
+use std::sync::Arc;
+
+/// Provider identity in the shared external-session store.
+pub const PROVIDER: &str = "claude-cloud";
+const PENDING: &str = "claude-cloud-create-pending";
+
+fn cloud_error(error: Error) -> HarnessError {
+ HarnessError::Container(error.to_string())
+}
+
+/// Lifecycle policy shared by every agent configured with the Claude harness.
+pub struct ClaudeSessions